mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
Add Tool Management panel with per-platform toggle switches
New Tools section in the Manage group: - Platform tabs parsed from config.yaml (CLI, Telegram, Discord, etc.) - Lists all toolsets with emoji icon, name, description, and toggle - Toggle switches call hermes tools enable/disable under the hood - Shows enabled count vs total - MCP server status section at bottom - Optimistic UI update on toggle with CLI fallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,8 @@ struct ContentView: View {
|
||||
MemoryView()
|
||||
case .skills:
|
||||
SkillsView()
|
||||
case .tools:
|
||||
ToolsView()
|
||||
case .cron:
|
||||
CronView()
|
||||
case .logs:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
struct HermesToolset: Identifiable, Sendable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let description: String
|
||||
let icon: String
|
||||
var enabled: Bool
|
||||
}
|
||||
|
||||
struct HermesToolPlatform: Identifiable, Sendable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let displayName: String
|
||||
let icon: String
|
||||
}
|
||||
|
||||
enum KnownPlatforms {
|
||||
static let all: [HermesToolPlatform] = [
|
||||
HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal"),
|
||||
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
|
||||
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
|
||||
HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"),
|
||||
HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"),
|
||||
HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"),
|
||||
HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class ToolsViewModel {
|
||||
var selectedPlatform: HermesToolPlatform = KnownPlatforms.all[0]
|
||||
var toolsets: [HermesToolset] = []
|
||||
var mcpStatus: String = ""
|
||||
var isLoading = false
|
||||
var availablePlatforms: [HermesToolPlatform] = []
|
||||
|
||||
func load() {
|
||||
loadPlatforms()
|
||||
loadTools(for: selectedPlatform)
|
||||
loadMCPStatus()
|
||||
}
|
||||
|
||||
func switchPlatform(_ platform: HermesToolPlatform) {
|
||||
selectedPlatform = platform
|
||||
loadTools(for: platform)
|
||||
}
|
||||
|
||||
func toggleTool(_ tool: HermesToolset) {
|
||||
let action = tool.enabled ? "disable" : "enable"
|
||||
let result = 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.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPlatforms() {
|
||||
let config = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
||||
var platforms: [HermesToolPlatform] = []
|
||||
var inSection = false
|
||||
for line in config.components(separatedBy: "\n") {
|
||||
if line.hasPrefix("platform_toolsets:") {
|
||||
inSection = true
|
||||
continue
|
||||
}
|
||||
if inSection {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || (!line.hasPrefix(" ") && !line.hasPrefix("\t")) {
|
||||
if !trimmed.isEmpty { break }
|
||||
continue
|
||||
}
|
||||
if trimmed.hasSuffix(":") && !trimmed.hasPrefix("-") {
|
||||
let name = String(trimmed.dropLast()).trimmingCharacters(in: .whitespaces)
|
||||
if let known = KnownPlatforms.all.first(where: { $0.name == name }) {
|
||||
platforms.append(known)
|
||||
} else {
|
||||
platforms.append(HermesToolPlatform(name: name, displayName: name.capitalized, icon: "bubble.left"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
availablePlatforms = platforms.isEmpty ? [KnownPlatforms.all[0]] : platforms
|
||||
if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }) {
|
||||
selectedPlatform = availablePlatforms[0]
|
||||
}
|
||||
}
|
||||
|
||||
private func loadTools(for platform: HermesToolPlatform) {
|
||||
isLoading = true
|
||||
let result = runHermes(["tools", "list", "--platform", platform.name])
|
||||
toolsets = parseToolsList(result.output)
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func loadMCPStatus() {
|
||||
let result = runHermes(["mcp", "list"])
|
||||
mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func parseToolsList(_ output: String) -> [HermesToolset] {
|
||||
var tools: [HermesToolset] = []
|
||||
for line in output.components(separatedBy: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
let isEnabled: Bool
|
||||
if trimmed.hasPrefix("✓ enabled") {
|
||||
isEnabled = true
|
||||
} else if trimmed.hasPrefix("✗ disabled") {
|
||||
isEnabled = false
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
let rest = trimmed
|
||||
.replacingOccurrences(of: "✓ enabled", with: "")
|
||||
.replacingOccurrences(of: "✗ disabled", with: "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
let parts = rest.split(separator: " ", maxSplits: 1)
|
||||
guard let namePart = parts.first else { continue }
|
||||
let name = String(namePart)
|
||||
let rawDesc = parts.count > 1 ? String(parts[1]) : name
|
||||
|
||||
let icon = extractEmoji(from: rawDesc)
|
||||
let description = rawDesc
|
||||
.unicodeScalars.filter { !$0.properties.isEmoji || $0.isASCII }
|
||||
.map { String($0) }.joined()
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
tools.append(HermesToolset(name: name, description: description, icon: icon, enabled: isEnabled))
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
private func extractEmoji(from text: String) -> String {
|
||||
for scalar in text.unicodeScalars {
|
||||
if scalar.properties.isEmoji && !scalar.isASCII {
|
||||
return String(scalar)
|
||||
}
|
||||
}
|
||||
return "🔧"
|
||||
}
|
||||
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
return (output, process.terminationStatus)
|
||||
} catch {
|
||||
return ("", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToolsView: View {
|
||||
@State private var viewModel = ToolsViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
platformPicker
|
||||
Divider()
|
||||
toolsList
|
||||
if !viewModel.mcpStatus.isEmpty {
|
||||
Divider()
|
||||
mcpSection
|
||||
}
|
||||
}
|
||||
.navigationTitle("Tools")
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var platformPicker: some View {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(viewModel.availablePlatforms) { platform in
|
||||
Button {
|
||||
viewModel.switchPlatform(platform)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: platform.icon)
|
||||
Text(platform.displayName)
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(viewModel.selectedPlatform.name == platform.name ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private var toolsList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 1) {
|
||||
ForEach(viewModel.toolsets) { tool in
|
||||
ToolRow(tool: tool) {
|
||||
viewModel.toggleTool(tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var mcpSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("MCP Servers")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
if viewModel.mcpStatus.contains("No MCP servers") {
|
||||
Label("No MCP servers configured", systemImage: "server.rack")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(viewModel.mcpStatus)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolRow: View {
|
||||
let tool: HermesToolset
|
||||
let onToggle: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Text(tool.icon)
|
||||
.font(.title3)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(tool.name)
|
||||
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||
Text(tool.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: Binding(
|
||||
get: { tool.enabled },
|
||||
set: { _ in onToggle() }
|
||||
))
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
case chat = "Chat"
|
||||
case memory = "Memory"
|
||||
case skills = "Skills"
|
||||
case tools = "Tools"
|
||||
case cron = "Cron"
|
||||
case logs = "Logs"
|
||||
case settings = "Settings"
|
||||
@@ -23,6 +24,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
case .chat: return "text.bubble"
|
||||
case .memory: return "brain"
|
||||
case .skills: return "lightbulb"
|
||||
case .tools: return "wrench.and.screwdriver"
|
||||
case .cron: return "clock.arrow.2.circlepath"
|
||||
case .logs: return "doc.text"
|
||||
case .settings: return "gearshape"
|
||||
|
||||
@@ -19,7 +19,7 @@ struct SidebarView: View {
|
||||
}
|
||||
}
|
||||
Section("Manage") {
|
||||
ForEach([SidebarSection.cron, .logs, .settings]) { section in
|
||||
ForEach([SidebarSection.tools, .cron, .logs, .settings]) { section in
|
||||
Label(section.rawValue, systemImage: section.icon)
|
||||
.tag(section)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user