fix: Move Tools subprocess calls off main thread to fix toggle rendering

Synchronous Process.run()/waitUntilExit() calls on the main thread blocked
SwiftUI's render loop, causing toggle controls to appear as solid blue
rectangles instead of proper switches. All hermes subprocess and file I/O
calls are now async via Task.detached, toggle uses optimistic state update
for immediate visual feedback, and pipe file handles are properly closed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-09 23:16:52 -04:00
parent 86762eab6d
commit cd503378e2
2 changed files with 68 additions and 41 deletions
@@ -1,40 +1,56 @@
import Foundation import Foundation
import os
@Observable @Observable
final class ToolsViewModel { final class ToolsViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "ToolsViewModel")
var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
var toolsets: [HermesToolset] = [] var toolsets: [HermesToolset] = []
var mcpStatus: String = "" var mcpStatus: String = ""
var isLoading = false var isLoading = false
var availablePlatforms: [HermesToolPlatform] = [] var availablePlatforms: [HermesToolPlatform] = []
func load() { @MainActor
loadPlatforms() func load() async {
loadTools(for: selectedPlatform) isLoading = true
loadMCPStatus() await loadPlatforms()
await loadTools(for: selectedPlatform)
await loadMCPStatus()
isLoading = false
} }
func switchPlatform(_ platform: HermesToolPlatform) { @MainActor
func switchPlatform(_ platform: HermesToolPlatform) async {
selectedPlatform = platform selectedPlatform = platform
loadTools(for: platform) await loadTools(for: platform)
} }
func toggleTool(_ tool: HermesToolset) { @MainActor
let action = tool.enabled ? "disable" : "enable" func toggleTool(_ tool: HermesToolset) async {
let result = runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name]) guard let idx = toolsets.firstIndex(where: { $0.name == tool.name }) else { return }
if result.exitCode == 0 { toolsets[idx].enabled.toggle()
let newEnabled = toolsets[idx].enabled
let action = newEnabled ? "enable" : "disable"
let result = await runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name])
if result.exitCode != 0 {
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) { if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
toolsets[idx].enabled.toggle() toolsets[idx].enabled = !newEnabled
} }
} }
} }
private func loadPlatforms() { @MainActor
private func loadPlatforms() async {
let config: String let config: String
do { do {
config = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) config = try await Task.detached {
try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
}.value
} catch { } catch {
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)") logger.error("Failed to read config.yaml: \(error.localizedDescription)")
config = "" config = ""
} }
var platforms: [HermesToolPlatform] = [] var platforms: [HermesToolPlatform] = []
@@ -67,15 +83,15 @@ final class ToolsViewModel {
} }
} }
private func loadTools(for platform: HermesToolPlatform) { @MainActor
isLoading = true private func loadTools(for platform: HermesToolPlatform) async {
let result = runHermes(["tools", "list", "--platform", platform.name]) let result = await runHermes(["tools", "list", "--platform", platform.name])
toolsets = parseToolsList(result.output) toolsets = parseToolsList(result.output)
isLoading = false
} }
private func loadMCPStatus() { @MainActor
let result = runHermes(["mcp", "list"]) private func loadMCPStatus() async {
let result = await runHermes(["mcp", "list"])
mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines) mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
} }
@@ -121,21 +137,32 @@ final class ToolsViewModel {
return "🔧" return "🔧"
} }
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { private nonisolated func runHermes(_ arguments: [String]) async -> (output: String, exitCode: Int32) {
let process = Process() await Task.detached {
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) let process = Process()
process.arguments = arguments process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
let pipe = Pipe() process.arguments = arguments
process.standardOutput = pipe let stdoutPipe = Pipe()
process.standardError = Pipe() let stderrPipe = Pipe()
do { process.standardOutput = stdoutPipe
try process.run() process.standardError = stderrPipe
process.waitUntilExit() do {
let data = pipe.fileHandleForReading.readDataToEndOfFile() try process.run()
let output = String(data: data, encoding: .utf8) ?? "" process.waitUntilExit()
return (output, process.terminationStatus) let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
} catch { let output = String(data: data, encoding: .utf8) ?? ""
return ("", -1) try? stdoutPipe.fileHandleForReading.close()
} try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForWriting.close()
return (output, process.terminationStatus)
} catch {
try? stdoutPipe.fileHandleForReading.close()
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForWriting.close()
return ("", -1)
}
}.value
} }
} }
@@ -14,7 +14,7 @@ struct ToolsView: View {
} }
} }
.navigationTitle("Tools") .navigationTitle("Tools")
.onAppear { viewModel.load() } .task { await viewModel.load() }
} }
private var platformPicker: some View { private var platformPicker: some View {
@@ -23,7 +23,7 @@ struct ToolsView: View {
get: { viewModel.selectedPlatform.name }, get: { viewModel.selectedPlatform.name },
set: { name in set: { name in
if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) { if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) {
viewModel.switchPlatform(platform) Task { await viewModel.switchPlatform(platform) }
} }
} }
)) { )) {
@@ -46,7 +46,7 @@ struct ToolsView: View {
LazyVStack(spacing: 1) { LazyVStack(spacing: 1) {
ForEach(viewModel.toolsets) { tool in ForEach(viewModel.toolsets) { tool in
ToolRow(tool: tool) { ToolRow(tool: tool) {
viewModel.toggleTool(tool) await viewModel.toggleTool(tool)
} }
} }
} }
@@ -78,7 +78,7 @@ struct ToolsView: View {
struct ToolRow: View { struct ToolRow: View {
let tool: HermesToolset let tool: HermesToolset
let onToggle: () -> Void let onToggle: () async -> Void
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
@@ -95,7 +95,7 @@ struct ToolRow: View {
Spacer() Spacer()
Toggle("", isOn: Binding( Toggle("", isOn: Binding(
get: { tool.enabled }, get: { tool.enabled },
set: { _ in onToggle() } set: { _ in Task { await onToggle() } }
)) ))
.toggleStyle(.switch) .toggleStyle(.switch)
.labelsHidden() .labelsHidden()