diff --git a/docs/components/agent-profiles.md b/docs/components/agent-profiles.md index 32e2fd23..afe0b4e1 100644 --- a/docs/components/agent-profiles.md +++ b/docs/components/agent-profiles.md @@ -11,8 +11,8 @@ ## What a profile is -A profile is a named preset for one runtime: display name, optional model, -optional reasoning effort (free text with per-runtime suggestions), execution +A profile is a named preset for one runtime: display name, optional model and +reasoning effort (both accept free text with per-runtime suggestions), execution mode (Standard / Unrestricted), and a launch placement (New Tab or New Split with a direction). By default a profile is **argv-only**: launching it is exactly like typing `claude`/`codex` with those flags yourself — same login, @@ -62,9 +62,9 @@ sign-in moment — the agent's own TUI walks through login and the credentials land inside the profile home. Prowl never reads or copies them; use **Reveal Profile Files** to manage skills and instruction files there yourself. -Deleting a bound profile asks first: **Remove Profile** keeps the folder on -disk, **Remove and Trash Files** moves it to the Trash (never `rm`). Pure -presets are removed with no file operations at all. +Removing any profile asks first. Pure presets have no file operations. A bound +profile offers **Remove Profile** to keep its folder on disk and **Remove and +Trash Files** to move the folder to the Trash (never `rm`). ## Advanced extra arguments @@ -76,9 +76,10 @@ about what it can prove: recognized bypass flags (`--yolo`, when the picker says Standard; any other extra argument (including `--sandbox`/`--ask-for-approval`/`-c` overrides) shows a neutral "effective execution mode follows your extra arguments" note instead of claiming -Standard — the semantics belong to your command line. The **Launch Preview** at the bottom of the editor shows -the exact rendered invocation — including the env prefix for bound profiles — -using the same rendering as the real launch. +Standard — the semantics belong to your command line. +The editor's first section is **Launch Preview**. It shows the exact rendered +invocation — including the env prefix for bound profiles — using the same +rendering as the real launch. ## Where things live on disk diff --git a/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift b/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift index e78375cd..1386f2cc 100644 --- a/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift +++ b/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift @@ -13,6 +13,9 @@ nonisolated protocol AgentRuntimeAdapter: Sendable { /// free-form value stays accepted as a literal argument; an unknown value /// fails at CLI startup, visibly in the new surface. var reasoningEffortSuggestions: [String] { get } + /// Known model values offered as editor suggestions. Custom values still pass + /// through as literal model arguments, so provider-specific deployments work. + var modelSuggestions: [String] { get } func observe(arguments: [String]) -> AgentLaunchObservation func makeStartInvocation(_ request: AgentStartRequest) throws -> AgentInvocation @@ -26,6 +29,7 @@ nonisolated protocol AgentRuntimeAdapter: Sendable { nonisolated extension AgentRuntimeAdapter { var supportsAccountIsolation: Bool { accountHomeEnvironmentVariable != nil } + var modelSuggestions: [String] { [] } } nonisolated enum AgentExecutionMode: String, Codable, Equatable, Sendable { @@ -224,6 +228,7 @@ nonisolated private struct CodexRuntimeAdapter: AgentRuntimeAdapter { let displayName = "Codex" let accountHomeEnvironmentVariable: String? = "CODEX_HOME" let reasoningEffortSuggestions = ["minimal", "low", "medium", "high", "xhigh"] + let modelSuggestions = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.3-codex-spark"] func observe(arguments: [String]) -> AgentLaunchObservation { let explicitlyBypassesSandbox = @@ -285,6 +290,7 @@ nonisolated private struct ClaudeCodeRuntimeAdapter: AgentRuntimeAdapter { let displayName = "Claude Code" let accountHomeEnvironmentVariable: String? = "CLAUDE_CONFIG_DIR" let reasoningEffortSuggestions = ["low", "medium", "high"] + let modelSuggestions = ["fable", "opus", "sonnet", "haiku", "opusplan"] func observe(arguments: [String]) -> AgentLaunchObservation { let explicitlyBypassesPermissions = diff --git a/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift b/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift index 265c2e85..d7f2af37 100644 --- a/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift +++ b/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift @@ -69,16 +69,10 @@ struct AgentProfileEditorFeature { return .send(.delegate(.profileEdited(state.profile))) case .removeTapped: - // The confirmation gate keys on the *disk fact*, not the current - // binding intent: a profile that was bound, launched (home created), - // then unbound still owns credentials on disk — deleting it silently - // would orphan them with no UI path back. - guard state.profile.bindsDedicatedHome || homeClient.homeExists(state.profile.id) else { - // Pure presets with no home on disk: removal performs zero file - // operations by construction. - return .send(.delegate(.removeProfile(state.profile.id, trashFiles: false))) - } - state.alert = Self.removalAlert(profile: state.profile) + // A previously bound profile can still own credentials on disk after + // being unbound, so its removal must preserve the trash choice. + let hasProfileHome = state.profile.bindsDedicatedHome || homeClient.homeExists(state.profile.id) + state.alert = Self.removalAlert(profile: state.profile, hasProfileHome: hasProfileHome) return .none case .revealProfileFiles: @@ -155,23 +149,26 @@ struct AgentProfileEditorFeature { } } - static func removalAlert(profile: AgentProfile) -> AlertState { + static func removalAlert(profile: AgentProfile, hasProfileHome: Bool) -> AlertState { AlertState { TextState("Remove “\(profile.name)”?") } actions: { - ButtonState(action: .removeKeepingFiles) { + ButtonState(role: .destructive, action: .removeKeepingFiles) { TextState("Remove Profile") } - ButtonState(role: .destructive, action: .removeTrashingFiles) { - TextState("Remove and Trash Files") + if hasProfileHome { + ButtonState(role: .destructive, action: .removeTrashingFiles) { + TextState("Remove and Trash Files") + } } ButtonState(role: .cancel) { TextState("Cancel") } } message: { TextState( - "This profile has its own home with login credentials and files. " - + "“Remove Profile” keeps them on disk; “Remove and Trash Files” moves the folder to the Trash." + hasProfileHome + ? "“Remove Profile” keeps the profile folder on disk; “Remove and Trash Files” moves it to the Trash." + : "This removes the profile from Prowl. No files will be deleted." ) } } diff --git a/supacode/Features/Settings/Views/AgentProfileEditorView.swift b/supacode/Features/Settings/Views/AgentProfileEditorView.swift index 47b3f03d..a497430e 100644 --- a/supacode/Features/Settings/Views/AgentProfileEditorView.swift +++ b/supacode/Features/Settings/Views/AgentProfileEditorView.swift @@ -8,6 +8,7 @@ struct AgentProfileEditorView: View { var body: some View { Form { + launchPreviewSection profileSection advancedSection removalSection @@ -26,12 +27,18 @@ struct AgentProfileEditorView: View { Text(AgentRuntimeAdapterRegistry.displayName(for: runtime.agent)).tag(runtime) } } - optionalTextRow( + suggestedTextRow( title: "Model", prompt: "Runtime default", - text: $store.profile.model + text: $store.profile.model, + suggestions: modelSuggestions + ) + suggestedTextRow( + title: "Reasoning Effort", + prompt: "Runtime default", + text: $store.profile.reasoningEffort, + suggestions: effortSuggestions ) - effortRow Picker("Execution Mode", selection: $store.profile.executionMode) { Text("Standard").tag(AgentExecutionMode.standard) Text("Unrestricted").tag(AgentExecutionMode.unrestricted) @@ -95,7 +102,6 @@ struct AgentProfileEditorView: View { } .help("Open the profile's home folder in Finder") } - launchPreview } } @@ -110,31 +116,11 @@ struct AgentProfileEditorView: View { } } - private var effortRow: some View { - HStack { - optionalTextRow( - title: "Reasoning Effort", - prompt: "Runtime default", - text: $store.profile.reasoningEffort - ) - Menu { - ForEach(effortSuggestions, id: \.self) { suggestion in - Button(suggestion) { $store.profile.reasoningEffort.wrappedValue = suggestion } - } - Button("Runtime Default") { $store.profile.reasoningEffort.wrappedValue = nil } - } label: { - Image(systemName: "chevron.up.chevron.down") - .accessibilityLabel("Effort suggestions") - } - .menuStyle(.borderlessButton) - .fixedSize() - .help("Pick a known effort level, or type any value") - } - } - - private var launchPreview: some View { - VStack(alignment: .leading, spacing: 4) { - Text("Launch Preview") + private var launchPreviewSection: some View { + Section("Launch Preview") { + Text("Prowl will execute this exact command.") + .font(.caption) + .foregroundStyle(.secondary) Text(previewText) .font(.callout.monospaced()) .foregroundStyle(.secondary) @@ -148,16 +134,55 @@ struct AgentProfileEditorView: View { prompt: String, text: Binding ) -> some View { - TextField( - title, - text: Binding( - get: { text.wrappedValue ?? "" }, - set: { value in - let trimmed = value.trimmingCharacters(in: .whitespaces) - text.wrappedValue = trimmed.isEmpty ? nil : value + LabeledContent(title) { + TextField("", text: optionalTextBinding(for: text), prompt: Text(prompt)) + } + } + + private func suggestedTextRow( + title: String, + prompt: String, + text: Binding, + suggestions: [String] + ) -> some View { + LabeledContent(title) { + HStack(spacing: 4) { + TextField("", text: optionalTextBinding(for: text), prompt: Text(prompt)) + Picker("", selection: suggestionIndex(for: text, suggestions: suggestions)) { + Text("Runtime Default").tag(0) + ForEach(suggestions.indices, id: \.self) { index in + Text(suggestions[index]).tag(index + 1) + } } - ), - prompt: Text(prompt) + .labelsHidden() + .frame(width: 28) + .accessibilityLabel("\(title) suggestions") + .help("Pick a known value, or type any value.") + } + } + } + + private func optionalTextBinding(for text: Binding) -> Binding { + Binding( + get: { text.wrappedValue ?? "" }, + set: { value in + let trimmed = value.trimmingCharacters(in: .whitespaces) + text.wrappedValue = trimmed.isEmpty ? nil : value + } + ) + } + + private func suggestionIndex(for text: Binding, suggestions: [String]) -> Binding { + Binding( + get: { + guard let value = text.wrappedValue, let index = suggestions.firstIndex(of: value) else { + return 0 + } + return index + 1 + }, + set: { index in + text.wrappedValue = index == 0 ? nil : suggestions[index - 1] + } ) } @@ -173,4 +198,8 @@ struct AgentProfileEditorView: View { AgentRuntimeAdapterRegistry.adapter(for: store.profile.runtime.agent)?.reasoningEffortSuggestions ?? [] } + + private var modelSuggestions: [String] { + AgentRuntimeAdapterRegistry.adapter(for: store.profile.runtime.agent)?.modelSuggestions ?? [] + } } diff --git a/supacodeTests/AgentProfileEditorFeatureTests.swift b/supacodeTests/AgentProfileEditorFeatureTests.swift index d0332d15..dc98e483 100644 --- a/supacodeTests/AgentProfileEditorFeatureTests.swift +++ b/supacodeTests/AgentProfileEditorFeatureTests.swift @@ -60,7 +60,7 @@ struct AgentProfileEditorFeatureTests { } await store.send(.removeTapped) { - $0.alert = AgentProfileEditorFeature.removalAlert(profile: bound) + $0.alert = AgentProfileEditorFeature.removalAlert(profile: bound, hasProfileHome: true) } await store.send(.alert(.presented(.removeTrashingFiles))) { $0.alert = nil @@ -79,7 +79,7 @@ struct AgentProfileEditorFeatureTests { } await store.send(.removeTapped) { - $0.alert = AgentProfileEditorFeature.removalAlert(profile: unbound) + $0.alert = AgentProfileEditorFeature.removalAlert(profile: unbound, hasProfileHome: true) } await store.send(.alert(.presented(.removeKeepingFiles))) { $0.alert = nil @@ -87,14 +87,18 @@ struct AgentProfileEditorFeatureTests { await store.receive(\.delegate.removeProfile) } - @Test(.dependencies) func removingPurePresetSkipsConfirmation() async { + @Test(.dependencies) func removingPurePresetRequiresConfirmation() async { let preset = AgentProfile(name: "Claude", runtime: .claude) let store = TestStore(initialState: AgentProfileEditorFeature.State(profile: preset)) { AgentProfileEditorFeature() } - // No confirmation and no file operations for a preset with no home. - await store.send(.removeTapped) + await store.send(.removeTapped) { + $0.alert = AgentProfileEditorFeature.removalAlert(profile: preset, hasProfileHome: false) + } + await store.send(.alert(.presented(.removeKeepingFiles))) { + $0.alert = nil + } await store.receive(\.delegate.removeProfile) } diff --git a/supacodeTests/AgentRuntimeAdapterTests.swift b/supacodeTests/AgentRuntimeAdapterTests.swift index 0b9b3283..37d7cc51 100644 --- a/supacodeTests/AgentRuntimeAdapterTests.swift +++ b/supacodeTests/AgentRuntimeAdapterTests.swift @@ -99,6 +99,14 @@ struct AgentRuntimeAdapterTests { #expect(claude?.reasoningEffortSuggestions.contains("high") == true) } + @Test func modelSuggestionsMatchEachRuntime() { + let codex = AgentRuntimeAdapterRegistry.adapter(for: .codex) + #expect(codex?.modelSuggestions == ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.3-codex-spark"]) + + let claude = AgentRuntimeAdapterRegistry.adapter(for: .claude) + #expect(claude?.modelSuggestions == ["fable", "opus", "sonnet", "haiku", "opusplan"]) + } + @Test func launchConfigurationDecodesLegacyPayloadWithoutNewFields() throws { let legacy = Data(#"{"model":"gpt-5.4","executionMode":"standard"}"#.utf8) let configuration = try JSONDecoder().decode(AgentLaunchConfiguration.self, from: legacy) -- 2.51.2 From ce531b9ec1a10ce5f71dc7bffba3410b6135e730 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 31 Jul 2026 13:43:58 +0900 Subject: [PATCH 2/8] Adjust agent profile picker spacing --- supacode/Features/Settings/Views/AgentProfileEditorView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/supacode/Features/Settings/Views/AgentProfileEditorView.swift b/supacode/Features/Settings/Views/AgentProfileEditorView.swift index a497430e..e7c0fb60 100644 --- a/supacode/Features/Settings/Views/AgentProfileEditorView.swift +++ b/supacode/Features/Settings/Views/AgentProfileEditorView.swift @@ -148,6 +148,7 @@ struct AgentProfileEditorView: View { LabeledContent(title) { HStack(spacing: 4) { TextField("", text: optionalTextBinding(for: text), prompt: Text(prompt)) + .padding(.trailing, 4) Picker("", selection: suggestionIndex(for: text, suggestions: suggestions)) { Text("Runtime Default").tag(0) ForEach(suggestions.indices, id: \.self) { index in -- 2.51.2 From 855b6d819b66af434539c4580642c8ad335dd536 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 31 Jul 2026 14:13:43 +0900 Subject: [PATCH 3/8] Pad agent profile picker buttons --- supacode/Features/Settings/Views/AgentProfileEditorView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode/Features/Settings/Views/AgentProfileEditorView.swift b/supacode/Features/Settings/Views/AgentProfileEditorView.swift index e7c0fb60..00b1266b 100644 --- a/supacode/Features/Settings/Views/AgentProfileEditorView.swift +++ b/supacode/Features/Settings/Views/AgentProfileEditorView.swift @@ -148,7 +148,6 @@ struct AgentProfileEditorView: View { LabeledContent(title) { HStack(spacing: 4) { TextField("", text: optionalTextBinding(for: text), prompt: Text(prompt)) - .padding(.trailing, 4) Picker("", selection: suggestionIndex(for: text, suggestions: suggestions)) { Text("Runtime Default").tag(0) ForEach(suggestions.indices, id: \.self) { index in @@ -157,6 +156,7 @@ struct AgentProfileEditorView: View { } .labelsHidden() .frame(width: 28) + .padding(.trailing, 4) .accessibilityLabel("\(title) suggestions") .help("Pick a known value, or type any value.") } -- 2.51.2 From ebab5f31bb7b424250d20d1dc9d3ed6e39ab9a50 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 31 Jul 2026 14:22:34 +0900 Subject: [PATCH 4/8] Refresh agent runtime suggestions --- .../Domain/AgentRuntime/AgentRuntimeAdapter.swift | 13 +++++++++---- supacodeTests/AgentRuntimeAdapterTests.swift | 11 ++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift b/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift index 1386f2cc..2849519a 100644 --- a/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift +++ b/supacode/Domain/AgentRuntime/AgentRuntimeAdapter.swift @@ -227,8 +227,8 @@ nonisolated private struct CodexRuntimeAdapter: AgentRuntimeAdapter { let agent: DetectedAgent = .codex let displayName = "Codex" let accountHomeEnvironmentVariable: String? = "CODEX_HOME" - let reasoningEffortSuggestions = ["minimal", "low", "medium", "high", "xhigh"] - let modelSuggestions = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.3-codex-spark"] + let reasoningEffortSuggestions = ["low", "medium", "high", "xhigh", "max"] + let modelSuggestions = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] func observe(arguments: [String]) -> AgentLaunchObservation { let explicitlyBypassesSandbox = @@ -289,8 +289,13 @@ nonisolated private struct ClaudeCodeRuntimeAdapter: AgentRuntimeAdapter { let agent: DetectedAgent = .claude let displayName = "Claude Code" let accountHomeEnvironmentVariable: String? = "CLAUDE_CONFIG_DIR" - let reasoningEffortSuggestions = ["low", "medium", "high"] - let modelSuggestions = ["fable", "opus", "sonnet", "haiku", "opusplan"] + let reasoningEffortSuggestions = ["low", "medium", "high", "xhigh", "max"] + let modelSuggestions = [ + "claude-fable-5", + "claude-opus-5", + "claude-sonnet-5", + "claude-haiku-4-5-20251001", + ] func observe(arguments: [String]) -> AgentLaunchObservation { let explicitlyBypassesPermissions = diff --git a/supacodeTests/AgentRuntimeAdapterTests.swift b/supacodeTests/AgentRuntimeAdapterTests.swift index 37d7cc51..44192349 100644 --- a/supacodeTests/AgentRuntimeAdapterTests.swift +++ b/supacodeTests/AgentRuntimeAdapterTests.swift @@ -99,12 +99,17 @@ struct AgentRuntimeAdapterTests { #expect(claude?.reasoningEffortSuggestions.contains("high") == true) } - @Test func modelSuggestionsMatchEachRuntime() { + @Test func runtimeSuggestionsMatchCurrentCapabilities() { let codex = AgentRuntimeAdapterRegistry.adapter(for: .codex) - #expect(codex?.modelSuggestions == ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.3-codex-spark"]) + #expect(codex?.modelSuggestions == ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) + #expect(codex?.reasoningEffortSuggestions == ["low", "medium", "high", "xhigh", "max"]) let claude = AgentRuntimeAdapterRegistry.adapter(for: .claude) - #expect(claude?.modelSuggestions == ["fable", "opus", "sonnet", "haiku", "opusplan"]) + #expect( + claude?.modelSuggestions + == ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"] + ) + #expect(claude?.reasoningEffortSuggestions == ["low", "medium", "high", "xhigh", "max"]) } @Test func launchConfigurationDecodesLegacyPayloadWithoutNewFields() throws { -- 2.51.2 From 86e630bb8a31dd173c8bc405d9edc54553e2031a Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 31 Jul 2026 14:39:49 +0900 Subject: [PATCH 5/8] Reset runtime-specific agent profile settings --- docs/components/agent-profiles.md | 4 ++++ .../Reducer/AgentProfileEditorFeature.swift | 12 ++++++++++++ .../Views/AgentProfileEditorView.swift | 8 +++++++- .../AgentProfileEditorFeatureTests.swift | 18 ++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/components/agent-profiles.md b/docs/components/agent-profiles.md index afe0b4e1..fac24026 100644 --- a/docs/components/agent-profiles.md +++ b/docs/components/agent-profiles.md @@ -46,6 +46,10 @@ sidebar remains available. Adding a profile opens the same editor immediately. Changing another Settings sidebar section leaves the editor and opens that section's root. +Changing a profile's **Agent** resets its Model, Reasoning Effort, and Extra +Arguments to the new runtime defaults. Those values are runtime-specific; add +new values after choosing the destination agent. + **Recommended** resolves in three tiers: the repo's **Default Agent Profile** (Repo Settings) → the last profile explicitly launched in this repo → the first enabled profile in the Settings list order. Each tier only matches an diff --git a/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift b/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift index d7f2af37..cb858235 100644 --- a/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift +++ b/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift @@ -25,6 +25,7 @@ struct AgentProfileEditorFeature { enum Action: BindableAction { case task + case runtimeChanged(AgentProfileRuntime) case binding(BindingAction) case removeTapped case revealProfileFiles @@ -57,6 +58,17 @@ struct AgentProfileEditorFeature { refreshHomeStatus(&state) return .none + case .runtimeChanged(let runtime): + guard state.profile.runtime != runtime else { return .none } + // Model, effort, and literal CLI arguments belong to the selected + // runtime. Keep only cross-runtime profile settings on a switch. + state.profile.runtime = runtime + state.profile.model = nil + state.profile.reasoningEffort = nil + state.profile.extraArguments = "" + refreshHomeStatus(&state) + return .send(.delegate(.profileEdited(state.profile))) + case .binding: // `.unrestricted` is never applied silently: the change reverts until // the user explicitly confirms it (docs-ai 053). diff --git a/supacode/Features/Settings/Views/AgentProfileEditorView.swift b/supacode/Features/Settings/Views/AgentProfileEditorView.swift index 00b1266b..8f838152 100644 --- a/supacode/Features/Settings/Views/AgentProfileEditorView.swift +++ b/supacode/Features/Settings/Views/AgentProfileEditorView.swift @@ -22,7 +22,13 @@ struct AgentProfileEditorView: View { private var profileSection: some View { Section("Profile") { TextField("Name", text: $store.profile.name) - Picker("Agent", selection: $store.profile.runtime) { + Picker( + "Agent", + selection: Binding( + get: { store.profile.runtime }, + set: { store.send(.runtimeChanged($0)) } + ) + ) { ForEach(AgentProfileRuntime.allCases) { runtime in Text(AgentRuntimeAdapterRegistry.displayName(for: runtime.agent)).tag(runtime) } diff --git a/supacodeTests/AgentProfileEditorFeatureTests.swift b/supacodeTests/AgentProfileEditorFeatureTests.swift index dc98e483..d44bfd54 100644 --- a/supacodeTests/AgentProfileEditorFeatureTests.swift +++ b/supacodeTests/AgentProfileEditorFeatureTests.swift @@ -22,6 +22,24 @@ struct AgentProfileEditorFeatureTests { await store.receive(\.delegate.profileEdited) } + @Test(.dependencies) func changingRuntimeClearsRuntimeSpecificConfiguration() async { + var profile = AgentProfile(name: "Codex", runtime: .codex) + profile.model = "gpt-5.6-sol" + profile.reasoningEffort = "xhigh" + profile.extraArguments = "--search" + let store = TestStore(initialState: AgentProfileEditorFeature.State(profile: profile)) { + AgentProfileEditorFeature() + } + + await store.send(.runtimeChanged(.claude)) { + $0.profile.runtime = .claude + $0.profile.model = nil + $0.profile.reasoningEffort = nil + $0.profile.extraArguments = "" + } + await store.receive(\.delegate.profileEdited) + } + @Test(.dependencies) func unrestrictedRequiresExplicitConfirmation() async { let profile = AgentProfile(name: "Codex", runtime: .codex) let storage = SettingsTestStorage() -- 2.51.2 From bae258df80a76076271b12dfa8df6c35ecbc693d Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 31 Jul 2026 15:14:22 +0900 Subject: [PATCH 6/8] Add customizable agent profile icons --- docs-ai/053-agent-profiles/000-plan.md | 3 + .../053-agent-profiles/003-profile-icons.md | 66 ++++++++++++++++++ docs/components/agent-profiles.md | 17 +++-- .../Domain/AgentProfile/AgentProfile.swift | 7 +- .../CommandPalette/CommandPaletteItem.swift | 5 +- .../Reducer/CommandPaletteFeature.swift | 3 +- .../Views/CommandPaletteOverlayView.swift | 6 +- .../Views/AgentsToolbarButton.swift | 15 ++-- .../Views/WorktreeDetailView.swift | 1 + .../Reducer/AgentProfileEditorFeature.swift | 5 ++ .../Views/AgentProfileEditorView.swift | 69 +++++++++++++++++++ .../Views/AgentProfileIconImage.swift | 43 ++++++++++++ .../Views/AgentProfilesSettingsView.swift | 2 + .../Views/RepositorySettingsView.swift | 8 ++- .../AgentProfileEditorFeatureTests.swift | 17 +++++ supacodeTests/AgentProfileTests.swift | 13 ++++ .../AppFeatureAgentProfileTests.swift | 4 +- 17 files changed, 269 insertions(+), 15 deletions(-) create mode 100644 docs-ai/053-agent-profiles/003-profile-icons.md create mode 100644 supacode/Features/Settings/Views/AgentProfileIconImage.swift diff --git a/docs-ai/053-agent-profiles/000-plan.md b/docs-ai/053-agent-profiles/000-plan.md index 0aabd9b2..3893be0a 100644 --- a/docs-ai/053-agent-profiles/000-plan.md +++ b/docs-ai/053-agent-profiles/000-plan.md @@ -330,6 +330,9 @@ agent 列表不变。 ## Amendments +- Updated 2026-07-31: profile launcher 图标的持久化、fallback 与 surface 范围决策 — see + [003-profile-icons.md](003-profile-icons.md). + - 2026-07-30 — Review 四轮(P2):palette 的 Launch Agent 条目工厂改用与 launch action 相同的 action-target 解析(selectedTerminalWorktree ?? Canvas 聚焦卡), Canvas 模式下条目不再缺席;补 Canvas 聚焦卡回归测试。 diff --git a/docs-ai/053-agent-profiles/003-profile-icons.md b/docs-ai/053-agent-profiles/003-profile-icons.md new file mode 100644 index 00000000..a91516d9 --- /dev/null +++ b/docs-ai/053-agent-profiles/003-profile-icons.md @@ -0,0 +1,66 @@ +# 053.003 — Agent Profile 图标 + +| | | +| --- | --- | +| **状态** | Implemented | +| **日期** | 2026-07-31 | +| **相关** | [000-plan](000-plan.md) · [001-action](001-action.md) · `docs/components/agent-profiles.md` | + +## Context + +Agent Profile 是用户在 Settings、工具栏 Agents popover 与 Command Palette 中选择的 +命名 preset。现有行只显示通用启动图标,无法在多个同 runtime profile 之间建立视觉 +识别;但 profile 未设置图标时不应丢失 Claude Code / Codex 的品牌识别。 + +## Scope + +- 为 `AgentProfile` 增加可选 SF Symbol override;旧 JSON 解码为 `nil`。 +- Settings → Agents 的编辑器提供与 repository appearance 同样的预览 tile 与图标菜单, + 复用 `TabIconPickerView` 的 preset / 任意 SF Symbol 输入能力。 +- 在 Settings profile 列表、toolbar Agents popover 和 Command Palette 的 profile launch + 条目显示 resolved profile icon。 +- 未设置 override 时,以上 surface 使用 runtime 的 `CommandIconMap` 品牌 asset,找不到 + asset 时回退其 SF Symbol。 + +## Design decisions + +1. 持久化值为可选 SF Symbol 名称,而不是 `RepositoryIconSource`。Profile 是全局设置, + 没有 repository-root 作为用户图片生命周期与导入目录的自然所有者;支持图片会增加 + 资产存储与清理契约,却没有本功能需要。 +2. 图标属于 profile launcher 的身份,不改变已运行 terminal pane 或 Active Agents 的 + 进程识别 icon。后两者表示实际检测到的 CLI,不应因 profile 后续编辑或删除而改变。 +3. `nil` 不是通用 placeholder,而是 runtime 品牌 icon。清除 override 立即恢复 Claude + Code / Codex 的既有品牌识别。 + +## Implementation shape + +| Concern | Planned owner | +| --- | --- | +| Persistence / migration | `supacode/Domain/AgentProfile/AgentProfile.swift` | +| Icon resolution and rendering | shared profile-icon view beside Settings / launcher surfaces | +| Picker state and mutation | `AgentProfileEditorFeature` + `AgentProfileEditorView` | +| Settings list | `AgentProfilesSettingsView` | +| Toolbar launcher | `AgentsToolbarButton` + `WorktreeDetailView` | +| Command Palette launcher | `CommandPaletteItem` + `CommandPaletteOverlayView` | + +## Result + +- `AgentProfile.icon` persists an optional SF Symbol; legacy records decode it as `nil`. +- `AgentProfileIconResolver` maps an override to an SF Symbol and `nil` to the existing + `CommandIconMap` runtime brand asset with a `sparkles` safety fallback. +- Settings now has the repository-style icon preview menu and `TabIconPickerView`; the + profile list and repository Default Agent Profile picker render the resolved icon. +- Toolbar Agents launch rows and Command Palette launch rows carry the same resolved + profile source. Live panes and Active Agents retain detected process-brand icons. + +## Verification + +- Legacy decode keeps `icon == nil`; edited profile persists an override. +- Reducer test covers setting and clearing the override. +- Launcher and palette factories carry the resolved custom icon and runtime fallback. +- Build the app, run changed-file checks, then manually inspect Settings and the Agents popover. + +## Non-goals + +- Per-profile colors, imported bitmap/SVG assets, or agent-specific image storage. +- Replacing live terminal / Active Agents process-brand icons. diff --git a/docs/components/agent-profiles.md b/docs/components/agent-profiles.md index fac24026..31714c61 100644 --- a/docs/components/agent-profiles.md +++ b/docs/components/agent-profiles.md @@ -11,11 +11,12 @@ ## What a profile is -A profile is a named preset for one runtime: display name, optional model and -reasoning effort (both accept free text with per-runtime suggestions), execution -mode (Standard / Unrestricted), and a launch placement (New Tab or New Split -with a direction). By default a profile is **argv-only**: launching it is -exactly like typing `claude`/`codex` with those flags yourself — same login, +A profile is a named preset for one runtime: display name, optional custom SF +Symbol icon, optional model and reasoning effort (both accept free text with +per-runtime suggestions), execution mode (Standard / Unrestricted), and a +launch placement (New Tab or New Split with a direction). By default a profile +is **argv-only**: launching it is exactly like typing `claude`/`codex` with +those flags yourself — same login, same skills, same session history. The same runtime can have any number of profiles. @@ -46,6 +47,12 @@ sidebar remains available. Adding a profile opens the same editor immediately. Changing another Settings sidebar section leaves the editor and opens that section's root. +The editor's **Icon** preview opens an SF Symbol picker. A custom symbol appears +where Prowl presents the launch preset: the Settings list, repository Default +Agent Profile picker, toolbar Agents popover, and Command Palette. Clearing it +restores the runtime's Claude Code / Codex brand icon. Live panes and Active +Agents retain the icon of the process Prowl actually detects. + Changing a profile's **Agent** resets its Model, Reasoning Effort, and Extra Arguments to the new runtime defaults. Those values are runtime-specific; add new values after choosing the destination agent. diff --git a/supacode/Domain/AgentProfile/AgentProfile.swift b/supacode/Domain/AgentProfile/AgentProfile.swift index c33e3240..adf2a0bc 100644 --- a/supacode/Domain/AgentProfile/AgentProfile.swift +++ b/supacode/Domain/AgentProfile/AgentProfile.swift @@ -42,6 +42,8 @@ nonisolated struct AgentProfile: Codable, Equatable, Sendable, Identifiable { var name: String var isEnabled: Bool var runtime: AgentProfileRuntime + /// User-selected SF Symbol override; nil uses the runtime brand icon. + var icon: String? var model: String? var reasoningEffort: String? var executionMode: AgentExecutionMode @@ -55,6 +57,7 @@ nonisolated struct AgentProfile: Codable, Equatable, Sendable, Identifiable { name: String, isEnabled: Bool = true, runtime: AgentProfileRuntime, + icon: String? = nil, model: String? = nil, reasoningEffort: String? = nil, executionMode: AgentExecutionMode = .standard, @@ -67,6 +70,7 @@ nonisolated struct AgentProfile: Codable, Equatable, Sendable, Identifiable { self.name = name self.isEnabled = isEnabled self.runtime = runtime + self.icon = icon self.model = model self.reasoningEffort = reasoningEffort self.executionMode = executionMode @@ -77,7 +81,7 @@ nonisolated struct AgentProfile: Codable, Equatable, Sendable, Identifiable { } private enum CodingKeys: String, CodingKey { - case id, name, isEnabled, runtime, model, reasoningEffort, executionMode + case id, name, isEnabled, runtime, icon, model, reasoningEffort, executionMode case placement, splitDirection, extraArguments, bindsDedicatedHome } @@ -87,6 +91,7 @@ nonisolated struct AgentProfile: Codable, Equatable, Sendable, Identifiable { name = try container.decode(String.self, forKey: .name) isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true runtime = try container.decode(AgentProfileRuntime.self, forKey: .runtime) + icon = try container.decodeIfPresent(String.self, forKey: .icon) model = try container.decodeIfPresent(String.self, forKey: .model) reasoningEffort = try container.decodeIfPresent(String.self, forKey: .reasoningEffort) executionMode = diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index cbbf3775..0dcf5d70 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -21,6 +21,7 @@ struct CommandPaletteItem: Identifiable, Equatable { let category: Category let keywords: [String] let defaultSuggestion: Bool + let agentProfileIconSource: AgentProfileIconSource? init( id: String, @@ -30,7 +31,8 @@ struct CommandPaletteItem: Identifiable, Equatable { category: Category, defaultSuggestion: Bool, keywords: [String] = [], - priorityTier: Int = defaultPriorityTier + priorityTier: Int = defaultPriorityTier, + agentProfileIconSource: AgentProfileIconSource? = nil ) { self.id = id self.title = title @@ -40,6 +42,7 @@ struct CommandPaletteItem: Identifiable, Equatable { self.defaultSuggestion = defaultSuggestion self.keywords = keywords self.priorityTier = priorityTier + self.agentProfileIconSource = agentProfileIconSource } enum Kind: Equatable { diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 85c8de78..8f5bbce5 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -575,7 +575,8 @@ func agentProfileLaunchItems( kind: .launchAgentProfile(profile.id), category: .terminal, defaultSuggestion: false, - keywords: ["launch", "agent", "profile", "start", profile.name] + keywords: ["launch", "agent", "profile", "start", profile.name], + agentProfileIconSource: profile.iconSource ) } } diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 8d47580a..b500e13f 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -664,7 +664,11 @@ private struct CommandPaletteRowView: View { var body: some View { Button(action: activate) { HStack(spacing: 8) { - if let leadingIcon { + if let iconSource = row.agentProfileIconSource { + AgentProfileIconImage(source: iconSource, pointSize: 15) + .foregroundStyle(emphasis ? .primary : .secondary) + .frame(width: 16, height: 16, alignment: .center) + } else if let leadingIcon { Image(systemName: leadingIcon) .foregroundStyle(emphasis ? .primary : .secondary) .font(.subheadline.weight(.medium)) diff --git a/supacode/Features/Repositories/Views/AgentsToolbarButton.swift b/supacode/Features/Repositories/Views/AgentsToolbarButton.swift index aef41ea0..d74e2c47 100644 --- a/supacode/Features/Repositories/Views/AgentsToolbarButton.swift +++ b/supacode/Features/Repositories/Views/AgentsToolbarButton.swift @@ -19,6 +19,7 @@ struct AgentsLauncherItem: Equatable, Identifiable { let id: AgentProfile.ID let name: String let runtimeName: String + let iconSource: AgentProfileIconSource let isRecommended: Bool /// Why the row is disabled ("Claude Code is not installed"); nil = launchable. let unavailableReason: String? @@ -163,6 +164,7 @@ private struct AgentsPopoverContent: View { subtitle: item.unavailableReason ?? "New agent in this worktree · \(item.runtimeName)", systemImage: "play.circle", + iconSource: item.iconSource, isEnabled: item.unavailableReason == nil, action: { onLaunchProfile(item.id) } ) @@ -184,6 +186,7 @@ private struct AgentsPopoverRow: View { let title: String let subtitle: String let systemImage: String + var iconSource: AgentProfileIconSource? var isEnabled: Bool = true let action: () -> Void @State private var isHovered = false @@ -191,10 +194,14 @@ private struct AgentsPopoverRow: View { var body: some View { Button(action: action) { HStack(alignment: .top, spacing: 8) { - Image(systemName: systemImage) - .frame(width: 16) - .padding(.top, 2) - .accessibilityHidden(true) + if let iconSource { + AgentProfileIconImage(source: iconSource, pointSize: 16) + .frame(width: 16) + } else { + Image(systemName: systemImage) + .frame(width: 16) + .accessibilityHidden(true) + } VStack(alignment: .leading, spacing: 2) { Text(title) Text(subtitle) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 4a6dd9d2..d2a69f77 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -443,6 +443,7 @@ struct WorktreeDetailView: View { id: profile.id, name: profile.name, runtimeName: runtimeName, + iconSource: profile.iconSource, isRecommended: profile.id == recommendedID, unavailableReason: installed ? nil : "\(runtimeName) is not installed" ) diff --git a/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift b/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift index cb858235..afed38c8 100644 --- a/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift +++ b/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift @@ -26,6 +26,7 @@ struct AgentProfileEditorFeature { enum Action: BindableAction { case task case runtimeChanged(AgentProfileRuntime) + case setIcon(String?) case binding(BindingAction) case removeTapped case revealProfileFiles @@ -69,6 +70,10 @@ struct AgentProfileEditorFeature { refreshHomeStatus(&state) return .send(.delegate(.profileEdited(state.profile))) + case .setIcon(let icon): + guard state.profile.icon != icon else { return .none } + state.profile.icon = icon + return .send(.delegate(.profileEdited(state.profile))) case .binding: // `.unrestricted` is never applied silently: the change reverts until // the user explicitly confirms it (docs-ai 053). diff --git a/supacode/Features/Settings/Views/AgentProfileEditorView.swift b/supacode/Features/Settings/Views/AgentProfileEditorView.swift index 8f838152..74811489 100644 --- a/supacode/Features/Settings/Views/AgentProfileEditorView.swift +++ b/supacode/Features/Settings/Views/AgentProfileEditorView.swift @@ -5,6 +5,8 @@ import SwiftUI /// owns the alert presentation because its state lives with this destination. struct AgentProfileEditorView: View { @Bindable var store: StoreOf + @State private var isIconPickerPresented = false + @State private var isHoveringIconTile = false var body: some View { Form { @@ -17,6 +19,19 @@ struct AgentProfileEditorView: View { .navigationTitle(store.profile.name) .task { store.send(.task) } .alert($store.scope(state: \.alert, action: \.alert)) + .sheet(isPresented: $isIconPickerPresented) { + TabIconPickerView( + initialIcon: store.profile.icon, + defaultIcon: "sparkles", + title: "Agent Icon", + subtitle: "Pick a preset or enter any SF Symbol name. Clearing restores the runtime brand icon.", + onApply: { icon in + store.send(.setIcon(icon)) + isIconPickerPresented = false + }, + onCancel: { isIconPickerPresented = false } + ) + } } private var profileSection: some View { @@ -33,6 +48,7 @@ struct AgentProfileEditorView: View { Text(AgentRuntimeAdapterRegistry.displayName(for: runtime.agent)).tag(runtime) } } + iconRow suggestedTextRow( title: "Model", prompt: "Runtime default", @@ -135,6 +151,59 @@ struct AgentProfileEditorView: View { } } + private var iconRow: some View { + HStack(alignment: .center, spacing: 12) { + iconMenu + VStack(alignment: .leading, spacing: 4) { + Text("Icon") + .font(.headline) + Text( + store.profile.icon == nil + ? "\(AgentRuntimeAdapterRegistry.displayName(for: store.profile.runtime.agent)) brand icon" + : "Custom SF Symbol" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } + + private var iconMenu: some View { + Menu { + Button("Choose Symbol…") { + isIconPickerPresented = true + } + if store.profile.icon != nil { + Divider() + Button("Clear Icon", role: .destructive) { + store.send(.setIcon(nil)) + } + } + } label: { + iconPreviewTile + } + .buttonStyle(.plain) + .menuIndicator(.hidden) + .fixedSize() + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.accentColor.opacity(isHoveringIconTile ? 0.65 : 0), lineWidth: 1.5) + } + .onHover { isHoveringIconTile = $0 } + .pointerStyle(.link) + .animation(.easeOut(duration: 0.12), value: isHoveringIconTile) + .help("Click the icon preview to choose an SF Symbol") + } + + private var iconPreviewTile: some View { + AgentProfileIconImage(source: store.profile.iconSource, pointSize: 22) + .frame(width: 40, height: 40) + .background(Color.secondary.opacity(0.12), in: .rect(cornerRadius: 8)) + .contentShape(.rect(cornerRadius: 8)) + .accessibilityLabel("Agent icon picker") + } + private func optionalTextRow( title: String, prompt: String, diff --git a/supacode/Features/Settings/Views/AgentProfileIconImage.swift b/supacode/Features/Settings/Views/AgentProfileIconImage.swift new file mode 100644 index 00000000..70d03097 --- /dev/null +++ b/supacode/Features/Settings/Views/AgentProfileIconImage.swift @@ -0,0 +1,43 @@ +import SwiftUI + +/// The persisted override and its runtime fallback. Keeping this value separate +/// from `TabIconSource` leaves `AgentProfile` free of terminal-view concerns. +struct AgentProfileIconSource: Equatable, Hashable, Sendable { + let overrideSymbol: String? + let runtime: AgentProfileRuntime + + init(profile: AgentProfile) { + overrideSymbol = profile.icon + runtime = profile.runtime + } +} + +extension AgentProfile { + var iconSource: AgentProfileIconSource { + AgentProfileIconSource(profile: self) + } +} +enum AgentProfileIconResolver { + static func source(for iconSource: AgentProfileIconSource) -> TabIconSource { + if let overrideSymbol = iconSource.overrideSymbol?.trimmingCharacters(in: .whitespacesAndNewlines), + !overrideSymbol.isEmpty + { + return TabIconSource(systemSymbol: overrideSymbol) + } + + return CommandIconMap.iconForFirstToken(iconSource.runtime.agent.iconLookupToken) + ?? TabIconSource(systemSymbol: "sparkles") + } +} + +struct AgentProfileIconImage: View { + let source: AgentProfileIconSource + let pointSize: CGFloat + + var body: some View { + TabIconImage( + rawName: AgentProfileIconResolver.source(for: source).storageString, + pointSize: pointSize + ) + } +} diff --git a/supacode/Features/Settings/Views/AgentProfilesSettingsView.swift b/supacode/Features/Settings/Views/AgentProfilesSettingsView.swift index a77c9ca5..c5c3325c 100644 --- a/supacode/Features/Settings/Views/AgentProfilesSettingsView.swift +++ b/supacode/Features/Settings/Views/AgentProfilesSettingsView.swift @@ -80,6 +80,8 @@ struct AgentProfilesSettingsView: View { private func profileLabel(_ profile: AgentProfile) -> some View { HStack(spacing: 8) { + AgentProfileIconImage(source: profile.iconSource, pointSize: 16) + .frame(width: 16, height: 16) Text(profile.name) if profile.bindsDedicatedHome { Image(systemName: "person.crop.circle.badge.checkmark") diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index eec6b735..5c6fd1b0 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -266,7 +266,13 @@ struct RepositorySettingsView: View { ) { Text("None").tag(AgentProfile.ID?.none) ForEach(globalSettings.agentProfiles.filter(\.isEnabled)) { profile in - Text(profile.name).tag(AgentProfile.ID?.some(profile.id)) + Label { + Text(profile.name) + } icon: { + AgentProfileIconImage(source: profile.iconSource, pointSize: 14) + .frame(width: 14, height: 14) + } + .tag(AgentProfile.ID?.some(profile.id)) } } } header: { diff --git a/supacodeTests/AgentProfileEditorFeatureTests.swift b/supacodeTests/AgentProfileEditorFeatureTests.swift index d44bfd54..356de61c 100644 --- a/supacodeTests/AgentProfileEditorFeatureTests.swift +++ b/supacodeTests/AgentProfileEditorFeatureTests.swift @@ -40,6 +40,23 @@ struct AgentProfileEditorFeatureTests { await store.receive(\.delegate.profileEdited) } + @Test(.dependencies) func settingProfileIconDelegatesTheEditedProfile() async { + let profile = AgentProfile(name: "Codex", runtime: .codex) + let store = TestStore(initialState: AgentProfileEditorFeature.State(profile: profile)) { + AgentProfileEditorFeature() + } + + await store.send(.setIcon("wand.and.stars")) { + $0.profile.icon = "wand.and.stars" + } + await store.receive(\.delegate.profileEdited) + + await store.send(.setIcon(nil)) { + $0.profile.icon = nil + } + await store.receive(\.delegate.profileEdited) + } + @Test(.dependencies) func unrestrictedRequiresExplicitConfirmation() async { let profile = AgentProfile(name: "Codex", runtime: .codex) let storage = SettingsTestStorage() diff --git a/supacodeTests/AgentProfileTests.swift b/supacodeTests/AgentProfileTests.swift index 1ef37e77..9fd6be13 100644 --- a/supacodeTests/AgentProfileTests.swift +++ b/supacodeTests/AgentProfileTests.swift @@ -42,6 +42,7 @@ struct AgentProfileTests { #expect(decoded.isEnabled) #expect(decoded.model == nil) #expect(decoded.reasoningEffort == nil) + #expect(decoded.icon == nil) #expect(decoded.executionMode == .standard) #expect(decoded.placement == .tab) #expect(decoded.splitDirection == .right) @@ -49,6 +50,18 @@ struct AgentProfileTests { #expect(!decoded.bindsDedicatedHome) } + @Test func profileIconUsesCustomSymbolThenRuntimeBrandFallback() throws { + let custom = AgentProfile(name: "Codex · Work", runtime: .codex, icon: "wand.and.stars") + #expect( + AgentProfileIconResolver.source(for: custom.iconSource) + == TabIconSource(systemSymbol: "wand.and.stars") + ) + + let fallback = AgentProfile(name: "Claude Code", runtime: .claude) + let expected = try #require(CommandIconMap.iconForFirstToken(fallback.runtime.agent.iconLookupToken)) + #expect(AgentProfileIconResolver.source(for: fallback.iconSource) == expected) + } + // MARK: - Recommendation @Test func recommendationPrefersDesignationOverMemoryAndOrder() { diff --git a/supacodeTests/AppFeatureAgentProfileTests.swift b/supacodeTests/AppFeatureAgentProfileTests.swift index 1a154ea3..c18ae863 100644 --- a/supacodeTests/AppFeatureAgentProfileTests.swift +++ b/supacodeTests/AppFeatureAgentProfileTests.swift @@ -108,7 +108,7 @@ struct AppFeatureAgentProfileTests { $0.repositoryLocalSettingsStorage = localStorage.storage } operation: { let first = AgentProfile(name: "First", runtime: .codex) - let second = AgentProfile(name: "Second", runtime: .claude) + let second = AgentProfile(name: "Second", runtime: .claude, icon: "wand.and.stars") let disabled = AgentProfile(name: "Hidden", isEnabled: false, runtime: .claude) @Shared(.userGlobalSettings) var settings $settings.withLock { $0.agentProfiles = [first, second, disabled] } @@ -121,6 +121,8 @@ struct AppFeatureAgentProfileTests { #expect(items.first?.kind == .launchAgentProfile(second.id)) #expect(items.first?.subtitle?.hasPrefix("Recommended") == true) #expect(items.last?.subtitle?.hasPrefix("Recommended") == false) + #expect(items.first?.agentProfileIconSource == second.iconSource) + #expect(items.last?.agentProfileIconSource == first.iconSource) } } -- 2.51.2 From 78e1b7aa8e9d14e9d86f5b6c7beea1d54f441ddc Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 31 Jul 2026 16:19:57 +0900 Subject: [PATCH 7/8] Harden agent profile editor transitions --- docs/components/agent-profiles.md | 7 ++-- .../Views/AgentsToolbarButton.swift | 2 ++ .../AgentProfileSuggestionSelection.swift | 24 ++++++++++++++ .../Reducer/AgentProfileEditorFeature.swift | 6 ++-- .../Views/AgentProfileEditorView.swift | 32 ++++++++++--------- .../AgentProfileEditorFeatureTests.swift | 21 ++++++++++++ supacodeTests/AgentProfileTests.swift | 8 +++++ 7 files changed, 80 insertions(+), 20 deletions(-) create mode 100644 supacode/Features/Settings/Models/AgentProfileSuggestionSelection.swift diff --git a/docs/components/agent-profiles.md b/docs/components/agent-profiles.md index 31714c61..c94f0078 100644 --- a/docs/components/agent-profiles.md +++ b/docs/components/agent-profiles.md @@ -53,9 +53,10 @@ Agent Profile picker, toolbar Agents popover, and Command Palette. Clearing it restores the runtime's Claude Code / Codex brand icon. Live panes and Active Agents retain the icon of the process Prowl actually detects. -Changing a profile's **Agent** resets its Model, Reasoning Effort, and Extra -Arguments to the new runtime defaults. Those values are runtime-specific; add -new values after choosing the destination agent. +Changing a profile's **Agent** resets its Model, Reasoning Effort, Extra +Arguments, and confirmed Unrestricted mode to the new runtime defaults. Those +values are runtime-specific; add new values after choosing the destination +agent. **Recommended** resolves in three tiers: the repo's **Default Agent Profile** (Repo Settings) → the last profile explicitly launched in this repo → the diff --git a/supacode/Features/Repositories/Views/AgentsToolbarButton.swift b/supacode/Features/Repositories/Views/AgentsToolbarButton.swift index d74e2c47..e43ac9af 100644 --- a/supacode/Features/Repositories/Views/AgentsToolbarButton.swift +++ b/supacode/Features/Repositories/Views/AgentsToolbarButton.swift @@ -197,10 +197,12 @@ private struct AgentsPopoverRow: View { if let iconSource { AgentProfileIconImage(source: iconSource, pointSize: 16) .frame(width: 16) + .padding(.top, 2) } else { Image(systemName: systemImage) .frame(width: 16) .accessibilityHidden(true) + .padding(.top, 2) } VStack(alignment: .leading, spacing: 2) { Text(title) diff --git a/supacode/Features/Settings/Models/AgentProfileSuggestionSelection.swift b/supacode/Features/Settings/Models/AgentProfileSuggestionSelection.swift new file mode 100644 index 00000000..539105b7 --- /dev/null +++ b/supacode/Features/Settings/Models/AgentProfileSuggestionSelection.swift @@ -0,0 +1,24 @@ +/// Selection state for a profile field that offers suggestions without +/// discarding a user-provided literal value. +nonisolated enum AgentProfileSuggestionSelection: Hashable, Sendable { + case runtimeDefault + case suggestion(String) + case custom(String) + + init(value: String?, suggestions: [String]) { + guard let value else { + self = .runtimeDefault + return + } + self = suggestions.contains(value) ? .suggestion(value) : .custom(value) + } + + var value: String? { + switch self { + case .runtimeDefault: + return nil + case .suggestion(let value), .custom(let value): + return value + } + } +} diff --git a/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift b/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift index afed38c8..3ec99873 100644 --- a/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift +++ b/supacode/Features/Settings/Reducer/AgentProfileEditorFeature.swift @@ -61,12 +61,14 @@ struct AgentProfileEditorFeature { case .runtimeChanged(let runtime): guard state.profile.runtime != runtime else { return .none } - // Model, effort, and literal CLI arguments belong to the selected - // runtime. Keep only cross-runtime profile settings on a switch. + // Model, effort, literal CLI arguments, and unrestricted approval + // belong to the selected runtime. The target CLI requires its own + // explicit confirmation before it can bypass safeguards. state.profile.runtime = runtime state.profile.model = nil state.profile.reasoningEffort = nil state.profile.extraArguments = "" + state.profile.executionMode = .standard refreshHomeStatus(&state) return .send(.delegate(.profileEdited(state.profile))) diff --git a/supacode/Features/Settings/Views/AgentProfileEditorView.swift b/supacode/Features/Settings/Views/AgentProfileEditorView.swift index 74811489..fa93fd34 100644 --- a/supacode/Features/Settings/Views/AgentProfileEditorView.swift +++ b/supacode/Features/Settings/Views/AgentProfileEditorView.swift @@ -211,6 +211,7 @@ struct AgentProfileEditorView: View { ) -> some View { LabeledContent(title) { TextField("", text: optionalTextBinding(for: text), prompt: Text(prompt)) + .accessibilityLabel(title) } } @@ -220,13 +221,18 @@ struct AgentProfileEditorView: View { text: Binding, suggestions: [String] ) -> some View { - LabeledContent(title) { + let selection = suggestionSelection(for: text, suggestions: suggestions) + return LabeledContent(title) { HStack(spacing: 4) { TextField("", text: optionalTextBinding(for: text), prompt: Text(prompt)) - Picker("", selection: suggestionIndex(for: text, suggestions: suggestions)) { - Text("Runtime Default").tag(0) - ForEach(suggestions.indices, id: \.self) { index in - Text(suggestions[index]).tag(index + 1) + .accessibilityLabel(title) + Picker("", selection: selection) { + Text("Runtime Default").tag(AgentProfileSuggestionSelection.runtimeDefault) + ForEach(suggestions, id: \.self) { suggestion in + Text(suggestion).tag(AgentProfileSuggestionSelection.suggestion(suggestion)) + } + if case .custom = selection.wrappedValue { + Text("Custom Value").tag(selection.wrappedValue) } } .labelsHidden() @@ -248,17 +254,13 @@ struct AgentProfileEditorView: View { ) } - private func suggestionIndex(for text: Binding, suggestions: [String]) -> Binding { + private func suggestionSelection( + for text: Binding, + suggestions: [String] + ) -> Binding { Binding( - get: { - guard let value = text.wrappedValue, let index = suggestions.firstIndex(of: value) else { - return 0 - } - return index + 1 - }, - set: { index in - text.wrappedValue = index == 0 ? nil : suggestions[index - 1] - } + get: { AgentProfileSuggestionSelection(value: text.wrappedValue, suggestions: suggestions) }, + set: { text.wrappedValue = $0.value } ) } diff --git a/supacodeTests/AgentProfileEditorFeatureTests.swift b/supacodeTests/AgentProfileEditorFeatureTests.swift index 356de61c..50cff70b 100644 --- a/supacodeTests/AgentProfileEditorFeatureTests.swift +++ b/supacodeTests/AgentProfileEditorFeatureTests.swift @@ -27,6 +27,11 @@ struct AgentProfileEditorFeatureTests { profile.model = "gpt-5.6-sol" profile.reasoningEffort = "xhigh" profile.extraArguments = "--search" + profile.executionMode = .unrestricted + profile.icon = "wand.and.stars" + profile.placement = .split + profile.splitDirection = .left + profile.bindsDedicatedHome = true let store = TestStore(initialState: AgentProfileEditorFeature.State(profile: profile)) { AgentProfileEditorFeature() } @@ -36,10 +41,26 @@ struct AgentProfileEditorFeatureTests { $0.profile.model = nil $0.profile.reasoningEffort = nil $0.profile.extraArguments = "" + $0.profile.executionMode = .standard + $0.profile.icon = "wand.and.stars" + $0.profile.placement = .split + $0.profile.splitDirection = .left + $0.profile.bindsDedicatedHome = true } await store.receive(\.delegate.profileEdited) } + @Test func suggestionSelectionDistinguishesCustomValuesFromRuntimeDefault() { + let suggestions = ["low", "medium", "high"] + + #expect(AgentProfileSuggestionSelection(value: nil, suggestions: suggestions) == .runtimeDefault) + #expect(AgentProfileSuggestionSelection(value: "medium", suggestions: suggestions) == .suggestion("medium")) + #expect( + AgentProfileSuggestionSelection(value: "gateway-specific", suggestions: suggestions) + == .custom("gateway-specific")) + #expect(AgentProfileSuggestionSelection.custom("gateway-specific").value == "gateway-specific") + } + @Test(.dependencies) func settingProfileIconDelegatesTheEditedProfile() async { let profile = AgentProfile(name: "Codex", runtime: .codex) let store = TestStore(initialState: AgentProfileEditorFeature.State(profile: profile)) { diff --git a/supacodeTests/AgentProfileTests.swift b/supacodeTests/AgentProfileTests.swift index 9fd6be13..83fa3ac5 100644 --- a/supacodeTests/AgentProfileTests.swift +++ b/supacodeTests/AgentProfileTests.swift @@ -50,6 +50,14 @@ struct AgentProfileTests { #expect(!decoded.bindsDedicatedHome) } + @Test func encodingRoundTripPreservesProfileIcon() throws { + let profile = AgentProfile(name: "Codex · Work", runtime: .codex, icon: "wand.and.stars") + + let decoded = try JSONDecoder().decode(AgentProfile.self, from: JSONEncoder().encode(profile)) + + #expect(decoded.icon == "wand.and.stars") + } + @Test func profileIconUsesCustomSymbolThenRuntimeBrandFallback() throws { let custom = AgentProfile(name: "Codex · Work", runtime: .codex, icon: "wand.and.stars") #expect( -- 2.51.2 From c9b640e191135abb0e04901103726676f35e64fb Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 31 Jul 2026 17:55:19 +0900 Subject: [PATCH 8/8] Align agent icon picker fallback --- .../Features/Canvas/Views/CanvasView.swift | 2 +- .../RepositoryAppearancePickerView.swift | 2 +- .../Views/AgentProfileEditorView.swift | 3 ++- .../Shelf/Views/ShelfOpenBookView.swift | 2 +- .../TabBar/Views/TabIconPickerView.swift | 20 ++++++++++--------- .../Views/WorktreeTerminalTabsView.swift | 2 +- 6 files changed, 17 insertions(+), 14 deletions(-) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index 0952b005..a11c31cb 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -442,7 +442,7 @@ struct CanvasView: View { let currentIcon = state.tabManager.tabs.first(where: { $0.id == tabId })?.icon return TabIconPickerView( initialIcon: currentIcon, - defaultIcon: state.defaultIcon(for: tabId), + defaultIcon: TabIconSource(systemSymbol: state.defaultIcon(for: tabId)), onApply: { newIcon in state.applyIconChange(tabId, icon: newIcon) state.dismissIconPicker() diff --git a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift index bd4997b6..d32ac2b3 100644 --- a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift +++ b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift @@ -48,7 +48,7 @@ struct RepositoryAppearancePickerView: View { .sheet(isPresented: $store.isSymbolPickerPresented) { TabIconPickerView( initialIcon: currentSymbolName, - defaultIcon: "folder.fill", + defaultIcon: TabIconSource(systemSymbol: "folder.fill"), title: "Repository Icon", subtitle: "Pick a preset or enter any SF Symbol name. SVG and SF Symbol icons are tinted " diff --git a/supacode/Features/Settings/Views/AgentProfileEditorView.swift b/supacode/Features/Settings/Views/AgentProfileEditorView.swift index fa93fd34..28cfc0e5 100644 --- a/supacode/Features/Settings/Views/AgentProfileEditorView.swift +++ b/supacode/Features/Settings/Views/AgentProfileEditorView.swift @@ -22,9 +22,10 @@ struct AgentProfileEditorView: View { .sheet(isPresented: $isIconPickerPresented) { TabIconPickerView( initialIcon: store.profile.icon, - defaultIcon: "sparkles", + defaultIcon: AgentProfileIconResolver.source(for: store.profile.iconSource), title: "Agent Icon", subtitle: "Pick a preset or enter any SF Symbol name. Clearing restores the runtime brand icon.", + resetHelp: "Restore the runtime brand icon", onApply: { icon in store.send(.setIcon(icon)) isIconPickerPresented = false diff --git a/supacode/Features/Shelf/Views/ShelfOpenBookView.swift b/supacode/Features/Shelf/Views/ShelfOpenBookView.swift index 81eb7241..03dcac40 100644 --- a/supacode/Features/Shelf/Views/ShelfOpenBookView.swift +++ b/supacode/Features/Shelf/Views/ShelfOpenBookView.swift @@ -98,7 +98,7 @@ struct ShelfOpenBookView: View { let currentIcon = state.tabManager.tabs.first(where: { $0.id == tabId })?.icon return TabIconPickerView( initialIcon: currentIcon, - defaultIcon: state.defaultIcon(for: tabId), + defaultIcon: TabIconSource(systemSymbol: state.defaultIcon(for: tabId)), onApply: { newIcon in state.applyIconChange(tabId, icon: newIcon) state.dismissIconPicker() diff --git a/supacode/Features/Terminal/TabBar/Views/TabIconPickerView.swift b/supacode/Features/Terminal/TabBar/Views/TabIconPickerView.swift index 644df095..775efcae 100644 --- a/supacode/Features/Terminal/TabBar/Views/TabIconPickerView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TabIconPickerView.swift @@ -3,9 +3,10 @@ import SwiftUI struct TabIconPickerView: View { let initialIcon: String? - let defaultIcon: String + let defaultIcon: TabIconSource let title: String let subtitle: String + let resetHelp: String let presets: [String] /// Optional host-provided section rendered between the header and /// the symbol field — the repository picker injects its @@ -22,9 +23,10 @@ struct TabIconPickerView: View { init( initialIcon: String?, - defaultIcon: String, + defaultIcon: TabIconSource, title: String = "Tab Icon", subtitle: String = "Pick a preset or enter any SF Symbol name available in your system.", + resetHelp: String = "Restore the default icon for this tab", presets: [String] = TabIconPickerView.symbolPresets, suggestionsSection: ((Binding) -> AnyView)? = nil, onApply: @escaping (String?) -> Void, @@ -34,6 +36,7 @@ struct TabIconPickerView: View { self.defaultIcon = defaultIcon self.title = title self.subtitle = subtitle + self.resetHelp = resetHelp self.presets = presets self.suggestionsSection = suggestionsSection self.onApply = onApply @@ -56,8 +59,7 @@ struct TabIconPickerView: View { } HStack(spacing: 10) { - Image(systemName: previewSymbol) - .imageScale(.large) + TabIconImage(rawName: previewIcon.storageString, pointSize: 20) .foregroundStyle(isPreviewValid ? Color.primary : Color.secondary) .frame(width: 32, height: 32) .background( @@ -107,7 +109,7 @@ struct TabIconPickerView: View { Button("Reset to Default") { onApply(nil) } - .help("Restore the default icon for this tab") + .help(resetHelp) Spacer() Button("Cancel", role: .cancel) { onCancel() @@ -131,8 +133,8 @@ struct TabIconPickerView: View { symbolName.trimmingCharacters(in: .whitespacesAndNewlines) } - private var previewSymbol: String { - isPreviewValid ? trimmedName : defaultIcon + private var previewIcon: TabIconSource { + isPreviewValid ? TabIconSource(systemSymbol: trimmedName) : defaultIcon } private var isPreviewValid: Bool { @@ -216,7 +218,7 @@ struct TabIconPickerView: View { #Preview("Default icon") { TabIconPickerView( initialIcon: nil, - defaultIcon: "terminal", + defaultIcon: TabIconSource(systemSymbol: "terminal"), onApply: { _ in }, onCancel: {} ) @@ -225,7 +227,7 @@ struct TabIconPickerView: View { #Preview("With override") { TabIconPickerView( initialIcon: "sparkles", - defaultIcon: "terminal", + defaultIcon: TabIconSource(systemSymbol: "terminal"), onApply: { _ in }, onCancel: {} ) diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 1d0de786..cb0926a5 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -123,7 +123,7 @@ struct WorktreeTerminalTabsView: View { let currentIcon = state.tabManager.tabs.first(where: { $0.id == tabId })?.icon return TabIconPickerView( initialIcon: currentIcon, - defaultIcon: state.defaultIcon(for: tabId), + defaultIcon: TabIconSource(systemSymbol: state.defaultIcon(for: tabId)), onApply: { newIcon in state.applyIconChange(tabId, icon: newIcon) state.dismissIconPicker()