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:
Alan Wizemann
2026-03-31 11:47:25 -04:00
parent cfbf3ea142
commit 36757a8c9a
6 changed files with 277 additions and 1 deletions
+2
View File
@@ -28,6 +28,8 @@ struct ContentView: View {
MemoryView() MemoryView()
case .skills: case .skills:
SkillsView() SkillsView()
case .tools:
ToolsView()
case .cron: case .cron:
CronView() CronView()
case .logs: case .logs:
+28
View File
@@ -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 chat = "Chat"
case memory = "Memory" case memory = "Memory"
case skills = "Skills" case skills = "Skills"
case tools = "Tools"
case cron = "Cron" case cron = "Cron"
case logs = "Logs" case logs = "Logs"
case settings = "Settings" case settings = "Settings"
@@ -23,6 +24,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case .chat: return "text.bubble" case .chat: return "text.bubble"
case .memory: return "brain" case .memory: return "brain"
case .skills: return "lightbulb" case .skills: return "lightbulb"
case .tools: return "wrench.and.screwdriver"
case .cron: return "clock.arrow.2.circlepath" case .cron: return "clock.arrow.2.circlepath"
case .logs: return "doc.text" case .logs: return "doc.text"
case .settings: return "gearshape" case .settings: return "gearshape"
+1 -1
View File
@@ -19,7 +19,7 @@ struct SidebarView: View {
} }
} }
Section("Manage") { Section("Manage") {
ForEach([SidebarSection.cron, .logs, .settings]) { section in ForEach([SidebarSection.tools, .cron, .logs, .settings]) { section in
Label(section.rawValue, systemImage: section.icon) Label(section.rawValue, systemImage: section.icon)
.tag(section) .tag(section)
} }