BroV: single Mac target, chat brain = local claude CLI (stream-json, resume)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -89,9 +89,23 @@ final class AppState: ObservableObject {
|
||||
}
|
||||
|
||||
// In-chat provider + model — picked via the model selector in the prompt view
|
||||
@Published var chatProvider: ChatProvider = .anthropic {
|
||||
@Published var chatProvider: ChatProvider = .claudeCode {
|
||||
didSet { UserDefaults.standard.set(chatProvider.rawValue, forKey: "chatProvider") }
|
||||
}
|
||||
// Claude Code CLI chat (BroV): model alias ("" = CLI default), folder it runs in,
|
||||
// optional path to the binary, and the permission mode passed to `claude`.
|
||||
@Published var claudeCodeModel: String = "" {
|
||||
didSet { UserDefaults.standard.set(claudeCodeModel, forKey: "claudeCodeModel") }
|
||||
}
|
||||
@Published var chatWorkingDirectory: String = "~" {
|
||||
didSet { UserDefaults.standard.set(chatWorkingDirectory, forKey: "chatWorkingDirectory") }
|
||||
}
|
||||
@Published var claudeBinaryPath: String = "" {
|
||||
didSet { UserDefaults.standard.set(claudeBinaryPath, forKey: "claudeBinaryPath") }
|
||||
}
|
||||
@Published var claudePermissionMode: String = "default" {
|
||||
didSet { UserDefaults.standard.set(claudePermissionMode, forKey: "claudePermissionMode") }
|
||||
}
|
||||
@Published var googleChatModel: String = ChatProvider.google.defaultModel {
|
||||
didSet { UserDefaults.standard.set(googleChatModel, forKey: "googleChatModel") }
|
||||
}
|
||||
@@ -126,6 +140,10 @@ final class AppState: ObservableObject {
|
||||
func fetchModelsIfNeeded(for provider: ChatProvider) {
|
||||
guard !loadingProviderModels.contains(provider),
|
||||
fetchedProviderModels[provider] == nil else { return }
|
||||
if provider == .claudeCode {
|
||||
fetchedProviderModels[provider] = ClaudeCodeCLI.models
|
||||
return
|
||||
}
|
||||
// Local providers: fetch from server URL (no API key needed)
|
||||
if provider.isLocal {
|
||||
let baseURL = provider == .ollama ? ollamaServerURL : lmstudioServerURL
|
||||
@@ -170,6 +188,7 @@ final class AppState: ObservableObject {
|
||||
Task {
|
||||
let models: [(id: String, label: String)]
|
||||
switch provider {
|
||||
case .claudeCode: models = ClaudeCodeCLI.models
|
||||
case .anthropic: models = await ClaudeService.fetchModels(apiKey: apiKey)
|
||||
case .google: models = await ClaudeService.fetchGoogleModels(apiKey: apiKey)
|
||||
case .openai: models = await ClaudeService.fetchOpenAIModels(apiKey: apiKey)
|
||||
@@ -193,7 +212,7 @@ final class AppState: ObservableObject {
|
||||
if !models.contains(where: { $0.id == openAIChatModel }) {
|
||||
openAIChatModel = models.first(where: { $0.id.contains("mini") })?.id ?? models.first!.id
|
||||
}
|
||||
case .ollama, .lmstudio: break
|
||||
case .ollama, .lmstudio, .claudeCode: break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +221,7 @@ final class AppState: ObservableObject {
|
||||
/// The model currently active for chat (provider-aware).
|
||||
var activeChatModel: String {
|
||||
switch chatProvider {
|
||||
case .claudeCode: return claudeCodeModel
|
||||
case .anthropic: return claudeModel
|
||||
case .google: return googleChatModel
|
||||
case .openai: return openAIChatModel
|
||||
@@ -409,6 +429,10 @@ final class AppState: ObservableObject {
|
||||
if let v = ud.string(forKey: "claudeModel"),
|
||||
!v.trimmingCharacters(in: .whitespaces).isEmpty { claudeModel = v }
|
||||
if let v = ud.string(forKey: "chatProvider"), let p = ChatProvider(rawValue: v) { chatProvider = p }
|
||||
if let v = ud.string(forKey: "claudeCodeModel") { claudeCodeModel = v }
|
||||
if let v = ud.string(forKey: "chatWorkingDirectory"), !v.isEmpty { chatWorkingDirectory = v }
|
||||
if let v = ud.string(forKey: "claudeBinaryPath") { claudeBinaryPath = v }
|
||||
if let v = ud.string(forKey: "claudePermissionMode"), !v.isEmpty { claudePermissionMode = v }
|
||||
if let v = ud.string(forKey: "googleChatModel"), !v.isEmpty { googleChatModel = v }
|
||||
if let v = ud.string(forKey: "openAIChatModel"), !v.isEmpty { openAIChatModel = v }
|
||||
if let v = ud.string(forKey: "ollamaChatModel"), !v.isEmpty { ollamaChatModel = v }
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Claude Code CLI as the chat brain
|
||||
//
|
||||
// Every message runs the user's own `claude` binary headless:
|
||||
// claude -p <text> --output-format stream-json --verbose --include-partial-messages
|
||||
// [--resume <session id>] [--model <alias>]
|
||||
// in the chat folder (Settings → Chat), on the user's Claude subscription — no API key.
|
||||
// The session id from the first reply is kept, so the next message continues the same
|
||||
// conversation; "New conversation" forgets it. MCP servers, skills, CLAUDE.md and memory
|
||||
// all come along because it is the real CLI. Tool permissions go through the usual
|
||||
// Claude Code hooks, so they show up as Allow / Deny cards in the notch.
|
||||
|
||||
enum ClaudeCodeCLIError: LocalizedError {
|
||||
case notFound
|
||||
case failed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notFound:
|
||||
"Claude Code CLI not found. Install it, or set its path in Settings → Chat."
|
||||
case .failed(let message):
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ClaudeCodeCLI {
|
||||
static let shared = ClaudeCodeCLI()
|
||||
|
||||
/// Models offered in the picker. Empty id = whatever the CLI is configured to use.
|
||||
static let models: [(id: String, label: String)] = [
|
||||
("", "Default"),
|
||||
("opus", "Opus"),
|
||||
("sonnet", "Sonnet"),
|
||||
("haiku", "Haiku"),
|
||||
]
|
||||
|
||||
private(set) var sessionID: String?
|
||||
private var process: Process?
|
||||
|
||||
var isRunning: Bool { process?.isRunning == true }
|
||||
|
||||
func newConversation() {
|
||||
cancel()
|
||||
sessionID = nil
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
if let p = process, p.isRunning { p.terminate() }
|
||||
process = nil
|
||||
}
|
||||
|
||||
// MARK: Locating the binary
|
||||
|
||||
/// The `claude` binary: the path from Settings when set, else the usual install spots.
|
||||
/// GUI apps don't get the shell's PATH, so we look ourselves.
|
||||
nonisolated static func resolveBinary(custom: String) -> String? {
|
||||
let fm = FileManager.default
|
||||
let trimmed = (custom as NSString).expandingTildeInPath
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return fm.isExecutableFile(atPath: trimmed) ? trimmed : nil }
|
||||
let home = fm.homeDirectoryForCurrentUser.path
|
||||
let candidates = [
|
||||
"\(home)/.local/bin/claude",
|
||||
"\(home)/.claude/local/claude",
|
||||
"/opt/homebrew/bin/claude",
|
||||
"/usr/local/bin/claude",
|
||||
"\(home)/.npm-global/bin/claude",
|
||||
]
|
||||
return candidates.first { fm.isExecutableFile(atPath: $0) }
|
||||
}
|
||||
|
||||
/// The child's environment: ours, minus the markers that make `claude` refuse to start
|
||||
/// inside another Claude Code session, plus the common bin folders on PATH so MCP
|
||||
/// servers (node, uvx, python…) resolve like they do in a terminal.
|
||||
nonisolated static func childEnvironment() -> [String: String] {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env.removeValue(forKey: "CLAUDECODE")
|
||||
env.removeValue(forKey: "CLAUDE_CODE_ENTRYPOINT")
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
let extra = ["\(home)/.local/bin", "/opt/homebrew/bin", "/opt/homebrew/sbin",
|
||||
"/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
|
||||
var parts = (env["PATH"] ?? "").split(separator: ":").map(String.init)
|
||||
for dir in extra where !parts.contains(dir) { parts.append(dir) }
|
||||
env["PATH"] = parts.joined(separator: ":")
|
||||
return env
|
||||
}
|
||||
|
||||
// MARK: Running a turn
|
||||
|
||||
/// Sends `prompt` and streams the visible reply through `onUpdate` (the full text so far).
|
||||
/// Returns the final text. Throws on a missing binary, a crash or a CLI-reported error.
|
||||
func send(prompt: String,
|
||||
model: String,
|
||||
workingDirectory: String,
|
||||
binaryPath: String,
|
||||
permissionMode: String,
|
||||
onUpdate: @escaping @MainActor (String) -> Void) async throws -> String {
|
||||
cancel()
|
||||
guard let binary = Self.resolveBinary(custom: binaryPath) else { throw ClaudeCodeCLIError.notFound }
|
||||
|
||||
var args = ["-p", prompt,
|
||||
"--output-format", "stream-json",
|
||||
"--verbose",
|
||||
"--include-partial-messages",
|
||||
"--append-system-prompt", Self.notchPrompt]
|
||||
if let sessionID { args += ["--resume", sessionID] }
|
||||
if !model.isEmpty { args += ["--model", model] }
|
||||
if !permissionMode.isEmpty, permissionMode != "default" { args += ["--permission-mode", permissionMode] }
|
||||
|
||||
let p = Process()
|
||||
p.executableURL = URL(fileURLWithPath: binary)
|
||||
p.arguments = args
|
||||
p.environment = Self.childEnvironment()
|
||||
let dir = (workingDirectory as NSString).expandingTildeInPath
|
||||
var isDir: ObjCBool = false
|
||||
p.currentDirectoryURL = FileManager.default.fileExists(atPath: dir, isDirectory: &isDir) && isDir.boolValue
|
||||
? URL(fileURLWithPath: dir)
|
||||
: FileManager.default.homeDirectoryForCurrentUser
|
||||
p.standardInput = FileHandle.nullDevice
|
||||
let out = Pipe(), err = Pipe()
|
||||
p.standardOutput = out
|
||||
p.standardError = err
|
||||
process = p
|
||||
|
||||
let stream = AsyncStream<String> { continuation in
|
||||
let handle = out.fileHandleForReading
|
||||
Task.detached {
|
||||
do {
|
||||
for try await line in handle.bytes.lines { continuation.yield(line) }
|
||||
} catch {}
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
try p.run()
|
||||
|
||||
var parser = StreamParser()
|
||||
for await line in stream {
|
||||
if let update = parser.consume(line) {
|
||||
if let id = update.sessionID { sessionID = id }
|
||||
if update.textChanged { onUpdate(parser.visibleText) }
|
||||
}
|
||||
}
|
||||
p.waitUntilExit()
|
||||
if process === p { process = nil }
|
||||
|
||||
if let error = parser.errorMessage {
|
||||
if error.localizedCaseInsensitiveContains("authenticate") || error.localizedCaseInsensitiveContains("login") {
|
||||
throw ClaudeCodeCLIError.failed("Claude Code isn't logged in. Run `claude /login` in a terminal.")
|
||||
}
|
||||
// A resume that points at a gone session: start fresh next time.
|
||||
if error.localizedCaseInsensitiveContains("No conversation found") { sessionID = nil }
|
||||
throw ClaudeCodeCLIError.failed(error)
|
||||
}
|
||||
if p.terminationReason == .uncaughtSignal { throw CancellationError() }
|
||||
if parser.finalText == nil, p.terminationStatus != 0 {
|
||||
let stderr = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
let last = stderr.split(separator: "\n").last.map(String.init) ?? "exit code \(p.terminationStatus)"
|
||||
throw ClaudeCodeCLIError.failed("claude: \(last)")
|
||||
}
|
||||
return parser.finalText ?? parser.visibleText
|
||||
}
|
||||
|
||||
private static let notchPrompt = """
|
||||
You are talking through BroV, a tiny assistant living in the notch of the user's Mac. \
|
||||
The chat window is small: keep answers compact, use light Markdown (short paragraphs, lists, \
|
||||
**bold**, `code`), avoid tables and big headings. Respond in the user's language.
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - stream-json parsing
|
||||
|
||||
/// Turns the CLI's stream-json lines into the text shown in the chat bubble:
|
||||
/// assistant text as it streams, plus a short `⚙ Tool` line for each tool call.
|
||||
struct StreamParser {
|
||||
struct Update {
|
||||
var sessionID: String?
|
||||
var textChanged = false
|
||||
}
|
||||
|
||||
private(set) var visibleText = ""
|
||||
private(set) var finalText: String?
|
||||
private(set) var errorMessage: String?
|
||||
/// Text of the message currently streaming (partial deltas), not yet committed.
|
||||
private var liveText = ""
|
||||
private var committed = ""
|
||||
|
||||
mutating func consume(_ line: String) -> Update? {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let type = json["type"] as? String else { return nil }
|
||||
var update = Update(sessionID: json["session_id"] as? String)
|
||||
|
||||
switch type {
|
||||
case "stream_event":
|
||||
guard let event = json["event"] as? [String: Any] else { break }
|
||||
if event["type"] as? String == "content_block_delta",
|
||||
let delta = event["delta"] as? [String: Any],
|
||||
delta["type"] as? String == "text_delta",
|
||||
let text = delta["text"] as? String {
|
||||
liveText += text
|
||||
update.textChanged = refresh()
|
||||
} else if event["type"] as? String == "message_start" {
|
||||
liveText = ""
|
||||
}
|
||||
|
||||
case "assistant":
|
||||
// The complete message: commit its text (replacing the partial deltas) and tools.
|
||||
guard let message = json["message"] as? [String: Any],
|
||||
let content = message["content"] as? [[String: Any]] else { break }
|
||||
if json["error"] != nil || message["model"] as? String == "<synthetic>" {
|
||||
if let text = content.first(where: { $0["type"] as? String == "text" })?["text"] as? String {
|
||||
errorMessage = text
|
||||
}
|
||||
break
|
||||
}
|
||||
var piece = ""
|
||||
for block in content {
|
||||
switch block["type"] as? String {
|
||||
case "text":
|
||||
if let t = block["text"] as? String { piece += t }
|
||||
case "tool_use":
|
||||
let name = block["name"] as? String ?? "tool"
|
||||
piece += (piece.isEmpty ? "" : "\n\n") + "`⚙ \(Self.toolLabel(name, block["input"]))`"
|
||||
default: break
|
||||
}
|
||||
}
|
||||
if !piece.isEmpty {
|
||||
committed += (committed.isEmpty ? "" : "\n\n") + piece.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
liveText = ""
|
||||
update.textChanged = refresh()
|
||||
|
||||
case "result":
|
||||
let isError = json["is_error"] as? Bool ?? false
|
||||
let result = json["result"] as? String
|
||||
if isError {
|
||||
if errorMessage == nil { errorMessage = result ?? "Claude Code returned an error." }
|
||||
} else {
|
||||
// Keep the tool trail when there was one; otherwise the clean final answer.
|
||||
finalText = committed.isEmpty ? (result ?? "") : committed
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
return update
|
||||
}
|
||||
|
||||
private mutating func refresh() -> Bool {
|
||||
let live = liveText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let next = live.isEmpty ? committed : committed + (committed.isEmpty ? "" : "\n\n") + live
|
||||
guard next != visibleText else { return false }
|
||||
visibleText = next
|
||||
return true
|
||||
}
|
||||
|
||||
/// "Bash: git status", "Read: Foo.swift", or just the tool name.
|
||||
static func toolLabel(_ name: String, _ input: Any?) -> String {
|
||||
guard let input = input as? [String: Any] else { return name }
|
||||
let detail: String?
|
||||
if let command = input["command"] as? String {
|
||||
detail = command
|
||||
} else if let path = (input["file_path"] ?? input["path"] ?? input["notebook_path"]) as? String {
|
||||
detail = (path as NSString).lastPathComponent
|
||||
} else if let pattern = input["pattern"] as? String {
|
||||
detail = pattern
|
||||
} else if let url = input["url"] as? String {
|
||||
detail = url
|
||||
} else if let query = input["query"] as? String {
|
||||
detail = query
|
||||
} else {
|
||||
detail = nil
|
||||
}
|
||||
guard var d = detail?.replacingOccurrences(of: "\n", with: " "), !d.isEmpty else { return name }
|
||||
if d.count > 60 { d = String(d.prefix(57)) + "…" }
|
||||
d = d.replacingOccurrences(of: "`", with: "'")
|
||||
return "\(name): \(d)"
|
||||
}
|
||||
}
|
||||
@@ -217,6 +217,10 @@ final class ClaudeService {
|
||||
// MARK: - Chat (multi-turn, natural text + web search)
|
||||
|
||||
func chat(query: String, context: PromptContext?, state: AppState) async {
|
||||
if state.chatProvider == .claudeCode {
|
||||
await chatClaudeCode(query: query, context: context, state: state)
|
||||
return
|
||||
}
|
||||
guard state.chatProvider == .anthropic else {
|
||||
await chatOpenAICompatible(query: query, context: context, state: state)
|
||||
return
|
||||
@@ -264,6 +268,66 @@ final class ClaudeService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Claude Code CLI chat (BroV: the user's own `claude`, no API key)
|
||||
|
||||
func chatClaudeCode(query: String, context: PromptContext?, state: AppState) async {
|
||||
let cli = ClaudeCodeCLI.shared
|
||||
var prompt = query
|
||||
// Context goes with the first message of a conversation, like the API chat.
|
||||
if cli.sessionID == nil, let context {
|
||||
switch context {
|
||||
case .window(let app, let title, let url):
|
||||
var prefix = "Context — App: \(app), Window: \(title)"
|
||||
if let url { prefix += ", URL: \(url)" }
|
||||
prompt = prefix + "\n\n" + query
|
||||
case .file(let name, let fileURL):
|
||||
// The CLI reads files itself: hand it the path.
|
||||
if let fileURL {
|
||||
prompt = "Attached file: \(fileURL.path)\n(read it before answering)\n\n" + query
|
||||
} else {
|
||||
prompt = "File: \(name)\n\n" + query
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let placeholder = ChatMessage(role: .assistant, content: "")
|
||||
let msgId = placeholder.id
|
||||
state.chatHistory.append(placeholder)
|
||||
state.stateOverride = .thinking
|
||||
|
||||
do {
|
||||
let final = try await cli.send(
|
||||
prompt: prompt,
|
||||
model: state.claudeCodeModel,
|
||||
workingDirectory: state.chatWorkingDirectory,
|
||||
binaryPath: state.claudeBinaryPath,
|
||||
permissionMode: state.claudePermissionMode
|
||||
) { [state, msgId] visible in
|
||||
if !visible.isEmpty, state.stateOverride == .thinking {
|
||||
state.stateOverride = nil // hide typing dots on first visible text
|
||||
}
|
||||
if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
|
||||
state.chatHistory[idx].content = visible
|
||||
}
|
||||
}
|
||||
let trimmed = final.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
|
||||
if trimmed.isEmpty { state.chatHistory.remove(at: idx) }
|
||||
else { state.chatHistory[idx].content = trimmed }
|
||||
}
|
||||
state.stateOverride = nil
|
||||
state.view = .prompt
|
||||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
|
||||
} catch is CancellationError {
|
||||
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
|
||||
state.stateOverride = nil
|
||||
} catch {
|
||||
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
|
||||
state.stateOverride = nil
|
||||
await showError(error.localizedDescription, state: state)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OpenAI-compatible chat (Google Gemini / OpenAI / Ollama / LM Studio)
|
||||
|
||||
func chatOpenAICompatible(query: String, context: PromptContext?, state: AppState) async {
|
||||
@@ -279,7 +343,7 @@ final class ClaudeService {
|
||||
switch provider {
|
||||
case .google: baseURL = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
case .openai: baseURL = "https://api.openai.com/v1"
|
||||
case .anthropic, .ollama, .lmstudio: baseURL = ""
|
||||
case .anthropic, .ollama, .lmstudio, .claudeCode: baseURL = ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1243,6 +1243,7 @@ struct PromptView: View {
|
||||
text = ""
|
||||
state.chatHistory = []
|
||||
ClaudeService.shared.clearConversation()
|
||||
ClaudeCodeCLI.shared.newConversation()
|
||||
focused = true
|
||||
}
|
||||
}
|
||||
@@ -1384,6 +1385,7 @@ struct ModelPickerView: View {
|
||||
ForEach(models, id: \.id) { model in
|
||||
Button {
|
||||
switch state.chatProvider {
|
||||
case .claudeCode: state.claudeCodeModel = model.id
|
||||
case .anthropic: state.claudeModel = model.id
|
||||
case .google: state.googleChatModel = model.id
|
||||
case .openai: state.openAIChatModel = model.id
|
||||
|
||||
@@ -598,6 +598,46 @@ struct SettingsView: View {
|
||||
// MARK: - Chat section
|
||||
|
||||
@ViewBuilder private var chatSection: some View {
|
||||
GroupBox("Claude Code (your subscription, no API key)") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
TextField("Folder the chat runs in", text: $state.chatWorkingDirectory)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Choose…") {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.allowsMultipleSelection = false
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
state.chatWorkingDirectory = url.path
|
||||
ClaudeCodeCLI.shared.newConversation()
|
||||
}
|
||||
}
|
||||
}
|
||||
Picker("Model", selection: $state.claudeCodeModel) {
|
||||
ForEach(ClaudeCodeCLI.models, id: \.id) { m in
|
||||
Text(m.label).tag(m.id)
|
||||
}
|
||||
}
|
||||
Picker("Permissions", selection: $state.claudePermissionMode) {
|
||||
Text("Ask in the notch").tag("default")
|
||||
Text("Accept edits").tag("acceptEdits")
|
||||
Text("Plan only").tag("plan")
|
||||
Text("Bypass (dangerous)").tag("bypassPermissions")
|
||||
}
|
||||
TextField("Path to claude (empty = auto)", text: $state.claudeBinaryPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
let found = ClaudeCodeCLI.resolveBinary(custom: state.claudeBinaryPath)
|
||||
Text(found.map { "✓ Using \($0)" } ?? "✗ claude not found — install Claude Code or set the path.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(found == nil ? .red : .secondary)
|
||||
Text("The chat runs `claude -p` in this folder and keeps one conversation until you start a new one. Your MCP servers, skills and CLAUDE.md come along.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(6)
|
||||
}
|
||||
|
||||
GroupBox("Anthropic API") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SecureField("API key (sk-ant-…)", text: $apiKey)
|
||||
@@ -705,7 +745,7 @@ struct SettingsView: View {
|
||||
ollamaURL = ""
|
||||
state.fetchedProviderModels[.ollama] = nil
|
||||
state.providerModelFetchError[.ollama] = nil
|
||||
if state.chatProvider == .ollama { state.chatProvider = .anthropic }
|
||||
if state.chatProvider == .ollama { state.chatProvider = .claudeCode }
|
||||
statusMessage = "Ollama disconnected."
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
@@ -740,7 +780,7 @@ struct SettingsView: View {
|
||||
lmstudioURL = ""
|
||||
state.fetchedProviderModels[.lmstudio] = nil
|
||||
state.providerModelFetchError[.lmstudio] = nil
|
||||
if state.chatProvider == .lmstudio { state.chatProvider = .anthropic }
|
||||
if state.chatProvider == .lmstudio { state.chatProvider = .claudeCode }
|
||||
statusMessage = "LM Studio disconnected."
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Reference in New Issue
Block a user