mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8672ed1e6c | |||
| 46468890d5 | |||
| cd503378e2 |
Binary file not shown.
@@ -407,7 +407,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = 1.5.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -444,7 +444,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -459,7 +459,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = 1.5.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ final class ChatViewModel {
|
|||||||
richChatViewModel.handleACPEvent(
|
richChatViewModel.handleACPEvent(
|
||||||
.promptComplete(sessionId: sessionId, response: result)
|
.promptComplete(sessionId: sessionId, response: result)
|
||||||
)
|
)
|
||||||
|
// Re-fetch session from DB to pick up cost/token data Hermes may have written
|
||||||
|
await richChatViewModel.refreshSessionFromDB()
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
acpStatus = "Cancelled"
|
acpStatus = "Cancelled"
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -32,9 +32,21 @@ final class RichChatViewModel {
|
|||||||
var messageGroups: [MessageGroup] = []
|
var messageGroups: [MessageGroup] = []
|
||||||
var isAgentWorking = false
|
var isAgentWorking = false
|
||||||
var pendingPermission: PendingPermission?
|
var pendingPermission: PendingPermission?
|
||||||
|
/// Mutated to trigger a scroll-to-bottom in the message list.
|
||||||
|
var scrollTrigger = UUID()
|
||||||
|
|
||||||
|
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
|
||||||
|
private(set) var acpInputTokens = 0
|
||||||
|
private(set) var acpOutputTokens = 0
|
||||||
|
private(set) var acpThoughtTokens = 0
|
||||||
|
private(set) var acpCachedReadTokens = 0
|
||||||
|
|
||||||
var hasMessages: Bool { !messages.isEmpty }
|
var hasMessages: Bool { !messages.isEmpty }
|
||||||
|
|
||||||
|
func requestScrollToBottom() {
|
||||||
|
scrollTrigger = UUID()
|
||||||
|
}
|
||||||
|
|
||||||
private(set) var sessionId: String?
|
private(set) var sessionId: String?
|
||||||
/// The original CLI session ID when resuming a CLI session via ACP.
|
/// The original CLI session ID when resuming a CLI session via ACP.
|
||||||
/// Used to combine old CLI messages with new ACP messages.
|
/// Used to combine old CLI messages with new ACP messages.
|
||||||
@@ -77,6 +89,10 @@ final class RichChatViewModel {
|
|||||||
streamingAssistantText = ""
|
streamingAssistantText = ""
|
||||||
streamingThinkingText = ""
|
streamingThinkingText = ""
|
||||||
streamingToolCalls = []
|
streamingToolCalls = []
|
||||||
|
acpInputTokens = 0
|
||||||
|
acpOutputTokens = 0
|
||||||
|
acpThoughtTokens = 0
|
||||||
|
acpCachedReadTokens = 0
|
||||||
pendingPermission = nil
|
pendingPermission = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +107,17 @@ final class RichChatViewModel {
|
|||||||
await dataService.close()
|
await dataService.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-fetch session metadata from DB to pick up cost/token updates.
|
||||||
|
func refreshSessionFromDB() async {
|
||||||
|
guard let sessionId else { return }
|
||||||
|
let opened = await dataService.open()
|
||||||
|
guard opened else { return }
|
||||||
|
if let session = await dataService.fetchSession(id: sessionId) {
|
||||||
|
currentSession = session
|
||||||
|
}
|
||||||
|
await dataService.close()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - ACP Event Handling
|
// MARK: - ACP Event Handling
|
||||||
|
|
||||||
/// Add a user message immediately (before DB write) for instant UI feedback.
|
/// Add a user message immediately (before DB write) for instant UI feedback.
|
||||||
@@ -136,8 +163,8 @@ final class RichChatViewModel {
|
|||||||
kind: request.toolCallKind,
|
kind: request.toolCallKind,
|
||||||
options: request.options
|
options: request.options
|
||||||
)
|
)
|
||||||
case .promptComplete:
|
case .promptComplete(_, let response):
|
||||||
handlePromptComplete()
|
handlePromptComplete(response: response)
|
||||||
case .connectionLost(let reason):
|
case .connectionLost(let reason):
|
||||||
handleConnectionLost(reason: reason)
|
handleConnectionLost(reason: reason)
|
||||||
case .availableCommands, .unknown:
|
case .availableCommands, .unknown:
|
||||||
@@ -188,9 +215,13 @@ final class RichChatViewModel {
|
|||||||
buildMessageGroups()
|
buildMessageGroups()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handlePromptComplete() {
|
private func handlePromptComplete(response: ACPPromptResult) {
|
||||||
// Finalize any remaining streaming content
|
|
||||||
finalizeStreamingMessage()
|
finalizeStreamingMessage()
|
||||||
|
// Accumulate token usage from this prompt
|
||||||
|
acpInputTokens += response.inputTokens
|
||||||
|
acpOutputTokens += response.outputTokens
|
||||||
|
acpThoughtTokens += response.thoughtTokens
|
||||||
|
acpCachedReadTokens += response.cachedReadTokens
|
||||||
isAgentWorking = false
|
isAgentWorking = false
|
||||||
buildMessageGroups()
|
buildMessageGroups()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,9 +91,8 @@ struct ChatView: View {
|
|||||||
Menu {
|
Menu {
|
||||||
if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId {
|
if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId {
|
||||||
Button("Return to Active Session (\(activeId.prefix(8))...)") {
|
Button("Return to Active Session (\(activeId.prefix(8))...)") {
|
||||||
// Already active — just ensure we're showing it
|
viewModel.richChatViewModel.requestScrollToBottom()
|
||||||
}
|
}
|
||||||
.disabled(true)
|
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
Button("New Session") {
|
Button("New Session") {
|
||||||
@@ -105,6 +104,8 @@ struct ChatView: View {
|
|||||||
if !viewModel.recentSessions.isEmpty {
|
if !viewModel.recentSessions.isEmpty {
|
||||||
Divider()
|
Divider()
|
||||||
Text("Resume Session")
|
Text("Resume Session")
|
||||||
|
let activeSessionId = viewModel.richChatViewModel.sessionId
|
||||||
|
let originSessionId = viewModel.richChatViewModel.originSessionId
|
||||||
ForEach(viewModel.recentSessions) { session in
|
ForEach(viewModel.recentSessions) { session in
|
||||||
Button {
|
Button {
|
||||||
viewModel.resumeSession(session.id)
|
viewModel.resumeSession(session.id)
|
||||||
@@ -120,6 +121,7 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(session.id == activeSessionId || session.id == originSessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import SwiftUI
|
|||||||
struct RichChatMessageList: View {
|
struct RichChatMessageList: View {
|
||||||
let groups: [MessageGroup]
|
let groups: [MessageGroup]
|
||||||
let isWorking: Bool
|
let isWorking: Bool
|
||||||
|
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
|
||||||
|
var scrollTrigger: UUID = UUID()
|
||||||
|
|
||||||
/// Track the last group's assistant content length to detect streaming updates.
|
/// Track the last group's assistant content length to detect streaming updates.
|
||||||
private var scrollAnchor: String {
|
private var scrollAnchor: String {
|
||||||
@@ -30,6 +32,14 @@ struct RichChatMessageList: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.defaultScrollAnchor(.bottom)
|
.defaultScrollAnchor(.bottom)
|
||||||
|
// Scroll to bottom when view first appears with content
|
||||||
|
.onAppear {
|
||||||
|
if !groups.isEmpty {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
scrollToBottom(proxy: proxy, animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Scroll on new groups
|
// Scroll on new groups
|
||||||
.onChange(of: groups.count) {
|
.onChange(of: groups.count) {
|
||||||
scrollToBottom(proxy: proxy)
|
scrollToBottom(proxy: proxy)
|
||||||
@@ -50,6 +60,10 @@ struct RichChatMessageList: View {
|
|||||||
.onChange(of: groups.last?.toolCallCount ?? 0) {
|
.onChange(of: groups.last?.toolCallCount ?? 0) {
|
||||||
scrollToBottom(proxy: proxy)
|
scrollToBottom(proxy: proxy)
|
||||||
}
|
}
|
||||||
|
// Scroll on external trigger (e.g., "Return to Active Session" button)
|
||||||
|
.onChange(of: scrollTrigger) {
|
||||||
|
scrollToBottom(proxy: proxy)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ struct RichChatView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
SessionInfoBar(
|
SessionInfoBar(
|
||||||
session: richChat.currentSession,
|
session: richChat.currentSession,
|
||||||
isWorking: richChat.isAgentWorking
|
isWorking: richChat.isAgentWorking,
|
||||||
|
acpInputTokens: richChat.acpInputTokens,
|
||||||
|
acpOutputTokens: richChat.acpOutputTokens,
|
||||||
|
acpThoughtTokens: richChat.acpThoughtTokens
|
||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
@@ -28,7 +31,8 @@ struct RichChatView: View {
|
|||||||
} else {
|
} else {
|
||||||
RichChatMessageList(
|
RichChatMessageList(
|
||||||
groups: richChat.messageGroups,
|
groups: richChat.messageGroups,
|
||||||
isWorking: richChat.isAgentWorking
|
isWorking: richChat.isAgentWorking,
|
||||||
|
scrollTrigger: richChat.scrollTrigger
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import SwiftUI
|
|||||||
struct SessionInfoBar: View {
|
struct SessionInfoBar: View {
|
||||||
let session: HermesSession?
|
let session: HermesSession?
|
||||||
let isWorking: Bool
|
let isWorking: Bool
|
||||||
|
/// Fallback token counts from ACP prompt results (DB may have zeros for ACP sessions).
|
||||||
|
var acpInputTokens: Int = 0
|
||||||
|
var acpOutputTokens: Int = 0
|
||||||
|
var acpThoughtTokens: Int = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
@@ -30,11 +34,14 @@ struct SessionInfoBar: View {
|
|||||||
Label(model, systemImage: "cpu")
|
Label(model, systemImage: "cpu")
|
||||||
}
|
}
|
||||||
|
|
||||||
Label("\(formatTokens(session.inputTokens)) in / \(formatTokens(session.outputTokens)) out", systemImage: "number")
|
let inputToks = session.inputTokens > 0 ? session.inputTokens : acpInputTokens
|
||||||
|
let outputToks = session.outputTokens > 0 ? session.outputTokens : acpOutputTokens
|
||||||
|
Label("\(formatTokens(inputToks)) in / \(formatTokens(outputToks)) out", systemImage: "number")
|
||||||
.contentTransition(.numericText())
|
.contentTransition(.numericText())
|
||||||
|
|
||||||
if session.reasoningTokens > 0 {
|
let reasonToks = session.reasoningTokens > 0 ? session.reasoningTokens : acpThoughtTokens
|
||||||
Label("\(formatTokens(session.reasoningTokens)) reasoning", systemImage: "brain")
|
if reasonToks > 0 {
|
||||||
|
Label("\(formatTokens(reasonToks)) reasoning", systemImage: "brain")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let cost = session.displayCostUSD {
|
if let cost = session.displayCostUSD {
|
||||||
|
|||||||
@@ -164,6 +164,9 @@ struct SessionRow: View {
|
|||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Label("\(session.messageCount)", systemImage: "bubble.left")
|
Label("\(session.messageCount)", systemImage: "bubble.left")
|
||||||
Label("\(session.toolCallCount)", systemImage: "wrench")
|
Label("\(session.toolCallCount)", systemImage: "wrench")
|
||||||
|
if let cost = session.displayCostUSD, cost > 0 {
|
||||||
|
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
|
|
||||||
toolsets[idx].enabled.toggle()
|
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 }) {
|
||||||
|
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) {
|
||||||
|
await Task.detached {
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||||
process.arguments = arguments
|
process.arguments = arguments
|
||||||
let pipe = Pipe()
|
let stdoutPipe = Pipe()
|
||||||
process.standardOutput = pipe
|
let stderrPipe = Pipe()
|
||||||
process.standardError = Pipe()
|
process.standardOutput = stdoutPipe
|
||||||
|
process.standardError = stderrPipe
|
||||||
do {
|
do {
|
||||||
try process.run()
|
try process.run()
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
let output = String(data: data, encoding: .utf8) ?? ""
|
let output = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
try? stdoutPipe.fileHandleForReading.close()
|
||||||
|
try? stdoutPipe.fileHandleForWriting.close()
|
||||||
|
try? stderrPipe.fileHandleForReading.close()
|
||||||
|
try? stderrPipe.fileHandleForWriting.close()
|
||||||
return (output, process.terminationStatus)
|
return (output, process.terminationStatus)
|
||||||
} catch {
|
} catch {
|
||||||
|
try? stdoutPipe.fileHandleForReading.close()
|
||||||
|
try? stdoutPipe.fileHandleForWriting.close()
|
||||||
|
try? stderrPipe.fileHandleForReading.close()
|
||||||
|
try? stderrPipe.fileHandleForWriting.close()
|
||||||
return ("", -1)
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user