Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -191,21 +191,20 @@ struct ModelCard: View {
.padding(.top, 8)
}

if let pricing = model.defaultPricing {
HStack {
HStack {
if let pricing = model.defaultPricing {
Text("Pricing:")
.font(.headline)
.fontWeight(.medium)
Text("\(displayPrice(pricing.input)) / \(displayPrice(pricing.output))")
.fontWeight(.medium)

Spacer()

Toggle("", isOn: $isActive)
.toggleStyle(.switch)
}
.padding(.top, 8)
Spacer()

Toggle("", isOn: $isActive)
.toggleStyle(.switch)
}
.padding(.top, 8)

if provider.externalAgent != nil {
Text("\(model.name) is an external agent")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ struct AIProviderView: View {
viewModel: LLMSettingsViewModel,
provider: AIProvider,
providerSettings: AIProviderSettings?,
isConnected: Bool,
isConfigured: Bool,
enabledModels: [AIModelID],
onSettingsChanged: ((AIProviderSettings?) -> Void)?,
onSelectModels: (() -> Void)?,
Expand All @@ -23,7 +23,7 @@ struct AIProviderView: View {
self.viewModel = viewModel
self.provider = provider
self.providerSettings = providerSettings
self.isConnected = isConnected
self.isConfigured = isConfigured
self.enabledModels = enabledModels
self.onSettingsChanged = onSettingsChanged
self.onSelectModels = onSelectModels
Expand All @@ -41,9 +41,9 @@ struct AIProviderView: View {
.font(.title2)
.fontWeight(.medium)
Spacer()
Text(isConnected ? "Connected" : "Not connected")
Text(isConfigured ? "Configured" : "Not configured")
.font(.subheadline)
.foregroundColor(isConnected ? colorScheme.addedLineDiffText : .secondary)
.foregroundColor(isConfigured ? colorScheme.addedLineDiffText : .secondary)
}

if let websiteURL = provider.websiteURL {
Expand Down Expand Up @@ -102,14 +102,19 @@ struct AIProviderView: View {
}
}

// Local executable section (for providers that are local)
// External agent section
if let externalAgent = provider.externalAgent {
ExternalAgentView(externalAgent: externalAgent, executable: $executable)
}

// Local inference section
if let localInference = provider.localInference {
LocalInferenceView(localInference: localInference, baseURL: $baseURL, executable: $executable)
}
}

// Models button
if isConnected, let onSelectModels, provider.externalAgent == nil {
if isConfigured, let onSelectModels, !provider.isExternalAgent {
Button(action: {
onSelectModels()
}) {
Expand Down Expand Up @@ -140,6 +145,9 @@ struct AIProviderView: View {
.onChange(of: apiKey) { _, _ in
saveSettings()
}
.onChange(of: baseURL) { _, _ in
saveSettings()
}
.onChange(of: executable) { _, _ in
saveSettings()
}
Expand All @@ -159,7 +167,7 @@ struct AIProviderView: View {

private let provider: AIProvider
private let providerSettings: AIProviderSettings?
private let isConnected: Bool
private let isConfigured: Bool
private let onSettingsChanged: ((AIProviderSettings?) -> Void)?
private let onSelectModels: (() -> Void)?
private let frameless: Bool
Expand All @@ -186,20 +194,25 @@ struct AIProviderView: View {
let trimmedBaseURL = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedExecutable = executable.trimmingCharacters(in: .whitespacesAndNewlines)

if provider.externalAgent == nil {
if provider.needsAPIKey {
guard !trimmedAPIKey.isEmpty else {
onSettingsChanged(nil)
return
}
} else {
} else if provider.isExternalAgent {
guard !trimmedExecutable.isEmpty else {
onSettingsChanged(nil)
return
}
} else if provider.isLocalInference {
guard !trimmedExecutable.isEmpty || URL(string: trimmedBaseURL) != nil else {
onSettingsChanged(nil)
return
}
}

let providerSettings = AIProviderSettings(
apiKey: trimmedAPIKey,
apiKey: trimmedAPIKey.isEmpty ? nil : trimmedAPIKey,
baseUrl: trimmedBaseURL.isEmpty ? nil : trimmedBaseURL,
executable: trimmedExecutable.isEmpty ? nil : trimmedExecutable,
createdOrder: -1)
Expand Down Expand Up @@ -236,21 +249,32 @@ extension AIProvider {
"Mistral"
case .inception:
"Inception"
case .ollama:
"Chat & build with open models"
default:
"Unknown provider"
}
}

/// Whether the provider requires an API key to function (regardless of whether one has already been provided).
var needsAPIKey: Bool {
externalAgent == nil
!isExternalAgent && !isLocalInference
}

func isConnected(_ providerSettings: AIProviderSettings?) -> Bool {
if externalAgent != nil {
providerSettings?.executable?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
} else {
providerSettings?.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
func isConfigured(_ providerSettings: AIProviderSettings?) -> Bool {
if needsAPIKey {
return providerSettings?.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
} else if isExternalAgent {
return providerSettings?.executable?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
} else if isLocalInference {
if
let baseUrl = providerSettings?.baseUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
URL(string: baseUrl) != nil
{
return true
}
return providerSettings?.executable?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
return false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public struct AIProvidersView: View {
viewModel: viewModel,
provider: providerInfo.provider,
providerSettings: providerInfo.settings,
isConnected: providerInfo.isConnected,
isConfigured: providerInfo.isConfigured,
enabledModels: viewModel.enabledModels,
onSettingsChanged: { newSettings in
updateProviderSettings(for: providerInfo.provider, with: newSettings)
Expand Down Expand Up @@ -88,7 +88,7 @@ public struct AIProvidersView: View {
return ProviderInfo(
provider: provider,
settings: existingSettings,
isConnected: provider.isConnected(existingSettings))
isConfigured: provider.isConfigured(existingSettings))
}

return searchText.isEmpty
Expand All @@ -104,7 +104,7 @@ public struct AIProvidersView: View {

private func setInitialOrder() {
orderedProviders = AIProvider.allCases.map { provider in
(provider, provider.isConnected(viewModel.providerSettings[provider]))
(provider, provider.isConfigured(viewModel.providerSettings[provider]))
}.sorted { lhs, rhs in
// Sort: connected first, then alphabetically
if lhs.1 != rhs.1 {
Expand Down Expand Up @@ -137,7 +137,7 @@ public struct AIProvidersView: View {
private struct ProviderInfo {
let provider: AIProvider
let settings: AIProviderSettings?
let isConnected: Bool
let isConfigured: Bool
}

// MARK: - ProviderModelSelectionView
Expand All @@ -164,7 +164,7 @@ private struct ProviderModelSelectionView: View {
viewModel: viewModel,
provider: provider,
providerSettings: providerSettings,
isConnected: true,
isConfigured: true,
enabledModels: viewModel.enabledModels,
onSettingsChanged: nil,
onSelectModels: nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ struct ExternalAgentView: View {
provider = externalAgent.llmProvider
self.externalAgent = externalAgent
_executable = executable
_executableFinder = .init(initialValue: ExecutableFinder(defaultExecutable: externalAgent.defaultExecutableName))
_executableFinder = .init(initialValue: ExecutableFinder(executable: externalAgent.defaultExecutableName))
}

/// The external agent configuration.
Expand Down Expand Up @@ -119,32 +119,6 @@ struct ExternalAgentView: View {
@Environment(\.colorScheme) private var colorScheme
}

// MARK: - ExecutableFinder

/// A helper that finds where a given executable is located on disk by running `which`.
@MainActor @Observable
private final class ExecutableFinder {
/// Initializes the finder and attempts to locate the executable using `which`.
init(defaultExecutable: String) {
@Dependency(\.shellService) var shellService

Task { [weak self] in
do {
let executablePath = try await shellService.runAndThrow("which \(defaultExecutable)", useInteractiveShell: true)
await MainActor.run {
guard let self else { return }
self.executablePath = executablePath?.trimmingCharacters(in: .whitespacesAndNewlines)
}
} catch {
// Silently ignore errors - executable not found is expected
}
}
}

/// The path where the executable was found, or nil if not found.
private(set) var executablePath: String?
}

extension ExternalAgent {
/// Whether the external agent has been enabled at least once.
/// When the agent is disabled, this value will help understand whether the agent has never been enabled, and can be enabled by default, or if it has been disabled by the user.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright cmd app, Inc. Licensed under the Apache License, Version 2.0.
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0

import Dependencies
import DLS
import LLMFoundation
import SwiftUI

// MARK: - LocalInferenceView

struct LocalInferenceView: View {
/// Initializes the card with the local inference configuration and a binding to its executable path.
init(
localInference: LocalInference,
baseURL: Binding<String>,
executable: Binding<String>)
{
provider = localInference.llmProvider
self.localInference = localInference
_baseURL = baseURL
_executable = executable
_executableFinder = .init(initialValue: ExecutableFinder(executable: localInference.executableName))
}

/// The local inference configuration.
let localInference: LocalInference

/// The AI provider associated with this local inference.
let provider: AIProvider

var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let executablePath = executableFinder.executablePath {
HStack {
Text("\(provider.name)'s executable was found")
.fontWeight(.medium)

Spacer()

HoveredButton(
action: {
executable = executable.isEmpty ? executablePath : ""
},
onHoverColor: colorScheme.tertiarySystemBackground,
backgroundColor: colorScheme.secondarySystemBackground,
padding: 5,
content: {
Text(executable.isEmpty ? "Enable" : "Disable")
})
}
} else if baseURL.isEmpty {
Text("\(provider.name)'s executable could not be found. Either:")
.font(.subheadline)
.fontWeight(.medium)
PlainLink("install it first", destination: localInference.installationInstructions)
.font(.subheadline)
.fontWeight(.medium)
Text("or configure a custom base URL below:")
.font(.subheadline)
.fontWeight(.medium)
}

let showTitle = executableFinder.executablePath != nil || !baseURL.isEmpty
if showTitle {
Text("Base URL")
.font(.subheadline)
.fontWeight(.medium)
}
TextField(localInference.defaultBaseUrl.absoluteString, text: $baseURL)
.textFieldStyle(.plain)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.with(
cornerRadius: 6,
backgroundColor: Color(
NSColor.textBackgroundColor),
borderColor: Color.gray.opacity(0.3))

Text("Leave empty to use default")
.font(.footnote)
.foregroundColor(.secondary)
}
}

/// Binding to the executable path or launch command.
@Binding private var executable: String

/// Binding to the base URL.
@Binding private var baseURL: String

/// Helper to locate the executable on disk.
@State private var executableFinder: ExecutableFinder

@Environment(\.colorScheme) private var colorScheme
}
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ private struct CodeCompletionProviderSection: View {
viewModel: llmSettingsViewModel,
provider: aiProvider,
providerSettings: llmSettingsViewModel.providerSettings[aiProvider],
isConnected: aiProvider.isConnected(llmSettingsViewModel.providerSettings[aiProvider]),
isConfigured: aiProvider.isConfigured(llmSettingsViewModel.providerSettings[aiProvider]),
enabledModels: llmSettingsViewModel.enabledModels,
onSettingsChanged: { newSettings in
updateProviderSettings(for: aiProvider, with: newSettings)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright cmd app, Inc. Licensed under the Apache License, Version 2.0.
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0

import Dependencies
import ShellServiceInterface
import SwiftUI

// MARK: - ExecutableFinder

/// A helper that finds where a given executable is located on disk by running `which`.
@MainActor @Observable
final class ExecutableFinder {
/// Initializes the finder and attempts to locate the executable using `which`.
init(executable: String) {
@Dependency(\.shellService) var shellService

Task { [weak self] in
do {
let executablePath = try await shellService.runAndThrow("which \(executable)", useInteractiveShell: true)
await MainActor.run {
guard let self else { return }
self.executablePath = executablePath?.trimmingCharacters(in: .whitespacesAndNewlines)
}
} catch {
// Silently ignore errors - executable not found is expected
}
}
}

/// The path where the executable was found, or nil if not found.
private(set) var executablePath: String?
}
Loading
Loading