feat: Add Hermes v0.8.0 compatibility and fix Tools tab toggling (#9)

Hermes v0.8.0 support:
- Filter subagent sessions from main list with parent session drill-down
- Add agent.log support (new default log file)
- Add Feishu and Mattermost platforms
- Add Google AI Studio, xAI, Ollama Cloud providers
- Expand cron job model (pre-run scripts, delivery tracking, timeouts, SILENT)
- Add Docker env, command allowlist, and memory profile to config
- Add profile-scoped memory with profile picker
- Add browser backend picker and credential removal to Settings
- Add skills required config warnings
- Consolidate platform icon resolution to single source of truth
- Filter Insights queries to exclude subagent sessions

Bug fix:
- Fix Tools tab phantom toggling when switching platforms (#9)
  - Add .id() to tool list for proper SwiftUI view identity
  - Replace ambiguous plain buttons with segmented Picker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-08 22:12:13 -04:00
parent ae2872e08f
commit 3acf95a824
25 changed files with 420 additions and 84 deletions
+10 -9
View File
@@ -21,24 +21,24 @@
- **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh - **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
- **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time) - **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export - **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export. Subagent sessions are filtered from the main list and accessible via parent session drill-down
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments - **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments
- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm), session persistence across navigation, resume/continue previous sessions, and voice mode controls - **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm), session persistence across navigation, resume/continue previous sessions, and voice mode controls
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, etc.) - **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker
- **Skills Browser** — Browse all installed skills by category with file content viewer and file switcher - **Skills Browser** — Browse all installed skills by category with file content viewer, file switcher, and required config warnings for skills that need specific settings
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Webhook, Matrix) with toggle switches, MCP server status - **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Webhook, Matrix, Feishu, Mattermost) with toggle switches and segmented platform picker, MCP server status
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke) - **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
- **Cron Manager** — View scheduled jobs, their status, prompts, and output - **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators
- **Log Viewer** — Real-time log tailing with level filtering and text search - **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering and text search
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically - **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
- **Settings** — Structured config editor for all Hermes settings including reasoning effort, approval mode, cost display, and more - **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Docker environment, command allowlist, credential management, and more
- **Menu Bar** — Status icon showing Hermes running state with quick actions - **Menu Bar** — Status icon showing Hermes running state with quick actions
## Requirements ## Requirements
- macOS 26.2+ - macOS 26.2+
- Xcode 26.3+ - Xcode 26.3+
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` - [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.8.0 recommended for full feature support)
### Compatibility ### Compatibility
@@ -47,7 +47,8 @@ Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`,
| Hermes Version | Status | | Hermes Version | Status |
|----------------|--------| |----------------|--------|
| v0.6.0 (2026-03-30) | Verified | | v0.6.0 (2026-03-30) | Verified |
| v0.7.0 (2026-04-03, latest) | Verified | | v0.7.0 (2026-04-03) | Verified |
| v0.8.0 (2026-04-08, latest) | Verified |
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings. If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
+6 -6
View File
@@ -421,7 +421,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.4.0; MARKETING_VERSION = 1.5.0;
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;
@@ -457,7 +457,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.4.0; MARKETING_VERSION = 1.5.0;
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;
@@ -479,7 +479,7 @@
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.4.0; MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -500,7 +500,7 @@
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2; MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.4.0; MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -519,7 +519,7 @@
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.4.0; MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -538,7 +538,7 @@
CURRENT_PROJECT_VERSION = 4; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.4.0; MARKETING_VERSION = 1.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
+7 -1
View File
@@ -20,6 +20,9 @@ struct HermesConfig: Sendable {
var approvalMode: String var approvalMode: String
var browserBackend: String var browserBackend: String
var memoryProvider: String var memoryProvider: String
var dockerEnv: [String: String]
var commandAllowlist: [String]
var memoryProfile: String
static let empty = HermesConfig( static let empty = HermesConfig(
model: "unknown", model: "unknown",
@@ -40,7 +43,10 @@ struct HermesConfig: Sendable {
showCost: false, showCost: false,
approvalMode: "manual", approvalMode: "manual",
browserBackend: "", browserBackend: "",
memoryProvider: "" memoryProvider: "",
dockerEnv: [:],
commandAllowlist: [],
memoryProfile: ""
) )
} }
@@ -17,6 +17,7 @@ enum HermesPaths: Sendable {
nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json" nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json"
nonisolated static let skillsDir: String = home + "/skills" nonisolated static let skillsDir: String = home + "/skills"
nonisolated static let errorsLog: String = home + "/logs/errors.log" nonisolated static let errorsLog: String = home + "/logs/errors.log"
nonisolated static let agentLog: String = home + "/logs/agent.log"
nonisolated static let gatewayLog: String = home + "/logs/gateway.log" nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes" nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
nonisolated static let scarfDir: String = home + "/scarf" nonisolated static let scarfDir: String = home + "/scarf"
+12 -1
View File
@@ -13,12 +13,23 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
let nextRunAt: String? let nextRunAt: String?
let lastRunAt: String? let lastRunAt: String?
let lastError: String? let lastError: String?
let preRunScript: String?
let deliveryFailures: Int?
let lastDeliveryError: String?
let timeoutType: String?
let timeoutSeconds: Int?
let silent: Bool?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, name, prompt, skills, model, schedule, enabled, state, deliver case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
case nextRunAt = "next_run_at" case nextRunAt = "next_run_at"
case lastRunAt = "last_run_at" case lastRunAt = "last_run_at"
case lastError = "last_error" case lastError = "last_error"
case preRunScript = "pre_run_script"
case deliveryFailures = "delivery_failures"
case lastDeliveryError = "last_delivery_error"
case timeoutType = "timeout_type"
case timeoutSeconds = "timeout_seconds"
} }
var stateIcon: String { var stateIcon: String {
@@ -22,6 +22,8 @@ struct HermesSession: Identifiable, Sendable {
let costStatus: String? let costStatus: String?
let billingProvider: String? let billingProvider: String?
var isSubagent: Bool { parentSessionId != nil }
var totalTokens: Int { inputTokens + outputTokens + reasoningTokens } var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD } var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
@@ -12,4 +12,5 @@ struct HermesSkill: Identifiable, Sendable {
let category: String let category: String
let path: String let path: String
let files: [String] let files: [String]
let requiredConfig: [String]
} }
+4
View File
@@ -28,6 +28,8 @@ enum KnownPlatforms {
HermesToolPlatform(name: "homeassistant", displayName: "Home Assistant", icon: "house"), HermesToolPlatform(name: "homeassistant", displayName: "Home Assistant", icon: "house"),
HermesToolPlatform(name: "webhook", displayName: "Webhook", icon: "arrow.up.right.square"), HermesToolPlatform(name: "webhook", displayName: "Webhook", icon: "arrow.up.right.square"),
HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"), HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"),
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
] ]
static func icon(for platform: String) -> String { static func icon(for platform: String) -> String {
@@ -42,6 +44,8 @@ enum KnownPlatforms {
case "homeassistant": return "house" case "homeassistant": return "house"
case "webhook": return "arrow.up.right.square" case "webhook": return "arrow.up.right.square"
case "matrix": return "lock.rectangle.stack" case "matrix": return "lock.rectangle.stack"
case "feishu": return "message.badge.circle"
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
default: return "bubble.left" default: return "bubble.left"
} }
} }
@@ -58,7 +58,7 @@ actor HermesDataService {
func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] { func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
guard let db else { return [] } guard let db else { return [] }
let sql = "SELECT \(sessionColumns) FROM sessions ORDER BY started_at DESC LIMIT ?" let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT ?"
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) } defer { sqlite3_finalize(stmt) }
@@ -73,7 +73,7 @@ actor HermesDataService {
func fetchSessionsInPeriod(since: Date) -> [HermesSession] { func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
guard let db else { return [] } guard let db else { return [] }
let sql = "SELECT \(sessionColumns) FROM sessions WHERE started_at >= ? ORDER BY started_at DESC" let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? ORDER BY started_at DESC"
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) } defer { sqlite3_finalize(stmt) }
@@ -86,6 +86,21 @@ actor HermesDataService {
return sessions return sessions
} }
func fetchSubagentSessions(parentId: String) -> [HermesSession] {
guard let db else { return [] }
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id = ? ORDER BY started_at ASC"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, parentId, -1, sqliteTransient)
var sessions: [HermesSession] = []
while sqlite3_step(stmt) == SQLITE_ROW {
sessions.append(sessionFromRow(stmt!))
}
return sessions
}
// MARK: - Message Queries // MARK: - Message Queries
private var messageColumns: String { private var messageColumns: String {
@@ -252,7 +267,7 @@ actor HermesDataService {
let sql = """ let sql = """
SELECT COUNT(*) FROM messages m SELECT COUNT(*) FROM messages m
JOIN sessions s ON m.session_id = s.id JOIN sessions s ON m.session_id = s.id
WHERE m.role = 'user' AND s.started_at >= ? WHERE m.role = 'user' AND s.parent_session_id IS NULL AND s.started_at >= ?
""" """
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 } guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
@@ -268,7 +283,7 @@ actor HermesDataService {
SELECT m.tool_name, COUNT(*) as cnt SELECT m.tool_name, COUNT(*) as cnt
FROM messages m FROM messages m
JOIN sessions s ON m.session_id = s.id JOIN sessions s ON m.session_id = s.id
WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.started_at >= ? WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.parent_session_id IS NULL AND s.started_at >= ?
GROUP BY m.tool_name GROUP BY m.tool_name
ORDER BY cnt DESC ORDER BY cnt DESC
""" """
@@ -289,7 +304,7 @@ actor HermesDataService {
func fetchSessionStartHours(since: Date) -> [Int: Int] { func fetchSessionStartHours(since: Date) -> [Int: Int] {
guard let db else { return [:] } guard let db else { return [:] }
let sql = """ let sql = """
SELECT started_at FROM sessions WHERE started_at >= ? SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
""" """
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] } guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
@@ -310,7 +325,7 @@ actor HermesDataService {
func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] { func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] {
guard let db else { return [:] } guard let db else { return [:] }
let sql = """ let sql = """
SELECT started_at FROM sessions WHERE started_at >= ? SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
""" """
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] } guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
@@ -12,12 +12,37 @@ struct HermesFileService: Sendable {
private func parseConfig(_ yaml: String) -> HermesConfig { private func parseConfig(_ yaml: String) -> HermesConfig {
var values: [String: String] = [:] var values: [String: String] = [:]
var currentSection = "" var currentSection = ""
var dockerEnv: [String: String] = [:]
var commandAllowlist: [String] = []
var inDockerEnv = false
var inAllowlist = false
for line in yaml.components(separatedBy: "\n") { for line in yaml.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces) let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = line.prefix(while: { $0 == " " }).count let indent = line.prefix(while: { $0 == " " }).count
// Detect end of nested blocks when indent returns to section level
if indent <= 2 && (inDockerEnv || inAllowlist) {
inDockerEnv = false
inAllowlist = false
}
// Collect docker_env nested key-value pairs
if inDockerEnv, indent >= 4, let colonIdx = trimmed.firstIndex(of: ":") {
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
dockerEnv[key] = val
continue
}
// Collect allowlist items
if inAllowlist, indent >= 4, trimmed.hasPrefix("- ") {
commandAllowlist.append(String(trimmed.dropFirst(2)))
continue
}
if indent == 0 && trimmed.hasSuffix(":") { if indent == 0 && trimmed.hasSuffix(":") {
currentSection = String(trimmed.dropLast()) currentSection = String(trimmed.dropLast())
continue continue
@@ -26,6 +51,16 @@ struct HermesFileService: Sendable {
if let colonIdx = trimmed.firstIndex(of: ":") { if let colonIdx = trimmed.firstIndex(of: ":") {
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces) let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces) let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
if key == "docker_env" && val.isEmpty {
inDockerEnv = true
continue
}
if key == "permanent_allowlist" && val.isEmpty {
inAllowlist = true
continue
}
values[currentSection + "." + key] = val values[currentSection + "." + key] = val
} }
} }
@@ -49,7 +84,10 @@ struct HermesFileService: Sendable {
showCost: values["display.show_cost"] == "true", showCost: values["display.show_cost"] == "true",
approvalMode: values["approvals.mode"] ?? "manual", approvalMode: values["approvals.mode"] ?? "manual",
browserBackend: values["browser.backend"] ?? "", browserBackend: values["browser.backend"] ?? "",
memoryProvider: values["memory.provider"] ?? "" memoryProvider: values["memory.provider"] ?? "",
dockerEnv: dockerEnv,
commandAllowlist: commandAllowlist,
memoryProfile: values["memory.profile"] ?? ""
) )
} }
@@ -67,20 +105,41 @@ struct HermesFileService: Sendable {
// MARK: - Memory // MARK: - Memory
func loadMemory() -> String { func loadMemoryProfiles() -> [String] {
readFile(HermesPaths.memoryMD) ?? "" let fm = FileManager.default
guard let entries = try? fm.contentsOfDirectory(atPath: HermesPaths.memoriesDir) else { return [] }
return entries.filter { name in
var isDir: ObjCBool = false
let path = HermesPaths.memoriesDir + "/" + name
return fm.fileExists(atPath: path, isDirectory: &isDir) && isDir.boolValue
}.sorted()
} }
func loadUserProfile() -> String { func loadMemory(profile: String = "") -> String {
readFile(HermesPaths.userMD) ?? "" let path = memoryPath(profile: profile, file: "MEMORY.md")
return readFile(path) ?? ""
} }
func saveMemory(_ content: String) { func loadUserProfile(profile: String = "") -> String {
writeFile(HermesPaths.memoryMD, content: content) let path = memoryPath(profile: profile, file: "USER.md")
return readFile(path) ?? ""
} }
func saveUserProfile(_ content: String) { func saveMemory(_ content: String, profile: String = "") {
writeFile(HermesPaths.userMD, content: content) let path = memoryPath(profile: profile, file: "MEMORY.md")
writeFile(path, content: content)
}
func saveUserProfile(_ content: String, profile: String = "") {
let path = memoryPath(profile: profile, file: "USER.md")
writeFile(path, content: content)
}
private func memoryPath(profile: String, file: String) -> String {
if profile.isEmpty {
return HermesPaths.memoriesDir + "/" + file
}
return HermesPaths.memoriesDir + "/" + profile + "/" + file
} }
// MARK: - Cron // MARK: - Cron
@@ -123,12 +182,14 @@ struct HermesFileService: Sendable {
var isSkillDir: ObjCBool = false var isSkillDir: ObjCBool = false
guard fm.fileExists(atPath: skillPath, isDirectory: &isSkillDir), isSkillDir.boolValue else { return nil } guard fm.fileExists(atPath: skillPath, isDirectory: &isSkillDir), isSkillDir.boolValue else { return nil }
let files = (try? fm.contentsOfDirectory(atPath: skillPath)) ?? [] let files = (try? fm.contentsOfDirectory(atPath: skillPath)) ?? []
let requiredConfig = parseSkillRequiredConfig(skillPath + "/skill.yaml")
return HermesSkill( return HermesSkill(
id: categoryName + "/" + skillName, id: categoryName + "/" + skillName,
name: skillName, name: skillName,
category: categoryName, category: categoryName,
path: skillPath, path: skillPath,
files: files.sorted() files: files.sorted(),
requiredConfig: requiredConfig
) )
} }
@@ -147,6 +208,30 @@ struct HermesFileService: Sendable {
return readFile(path) ?? "" return readFile(path) ?? ""
} }
private func parseSkillRequiredConfig(_ path: String) -> [String] {
guard let content = readFile(path) else { return [] }
var result: [String] = []
var inRequiredConfig = false
for line in content.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = line.prefix(while: { $0 == " " }).count
if trimmed == "required_config:" || trimmed.hasPrefix("required_config:") {
inRequiredConfig = true
continue
}
if inRequiredConfig {
if indent < 2 && !trimmed.isEmpty {
break
}
if trimmed.hasPrefix("- ") {
result.append(String(trimmed.dropFirst(2)))
}
}
}
return result
}
// MARK: - Hermes Process // MARK: - Hermes Process
func isHermesRunning() -> Bool { func isHermesRunning() -> Bool {
@@ -16,6 +16,7 @@ final class HermesFileWatcher {
HermesPaths.userMD, HermesPaths.userMD,
HermesPaths.cronJobsJSON, HermesPaths.cronJobsJSON,
HermesPaths.gatewayStateJSON, HermesPaths.gatewayStateJSON,
HermesPaths.agentLog,
HermesPaths.errorsLog, HermesPaths.errorsLog,
HermesPaths.gatewayLog, HermesPaths.gatewayLog,
HermesPaths.projectsRegistry HermesPaths.projectsRegistry
@@ -38,6 +38,11 @@ struct CronView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
if job.silent == true {
Text("SILENT")
.font(.caption2.bold())
.foregroundStyle(.purple)
}
if !job.enabled { if !job.enabled {
Text("Disabled") Text("Disabled")
.font(.caption2) .font(.caption2)
@@ -86,6 +91,20 @@ struct CronView: View {
.background(.quaternary.opacity(0.5)) .background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6)) .clipShape(RoundedRectangle(cornerRadius: 6))
} }
if let script = job.preRunScript, !script.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Pre-Run Script")
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(script)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
if let skills = job.skills, !skills.isEmpty { if let skills = job.skills, !skills.isEmpty {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Skills") Text("Skills")
@@ -118,6 +137,21 @@ struct CronView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.red) .foregroundStyle(.red)
} }
if let timeout = job.timeoutSeconds {
Label("Timeout: \(timeout)s (\(job.timeoutType ?? "wall_clock"))", systemImage: "timer")
.font(.caption)
.foregroundStyle(.secondary)
}
if let failures = job.deliveryFailures, failures > 0 {
Label("\(failures) delivery failure\(failures == 1 ? "" : "s")", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
if let deliveryError = job.lastDeliveryError {
Label(deliveryError, systemImage: "paperplane.circle")
.font(.caption)
.foregroundStyle(.orange)
}
if let output = viewModel.jobOutput { if let output = viewModel.jobOutput {
Divider() Divider()
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -19,17 +19,7 @@ struct PlatformInfo: Identifiable {
var isConnected: Bool { state == "connected" } var isConnected: Bool { state == "connected" }
var icon: String { var icon: String { KnownPlatforms.icon(for: name) }
switch name {
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "whatsapp": return "phone.bubble"
case "signal": return "lock.shield"
case "email": return "envelope"
default: return "bubble.left"
}
}
} }
struct PairedUser: Identifiable { struct PairedUser: Identifiable {
@@ -177,15 +177,7 @@ struct GatewayView: View {
} }
private func platformIcon(_ platform: String) -> String { private func platformIcon(_ platform: String) -> String {
switch platform { KnownPlatforms.icon(for: platform)
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "whatsapp": return "phone.bubble"
case "signal": return "lock.shield"
case "email": return "envelope"
default: return "bubble.left"
}
} }
} }
@@ -5,12 +5,13 @@ final class LogsViewModel {
private let logService = HermesLogService() private let logService = HermesLogService()
var entries: [LogEntry] = [] var entries: [LogEntry] = []
var selectedLogFile: LogFile = .errors var selectedLogFile: LogFile = .agent
var filterLevel: LogEntry.LogLevel? var filterLevel: LogEntry.LogLevel?
var searchText = "" var searchText = ""
private var pollTimer: Timer? private var pollTimer: Timer?
enum LogFile: String, CaseIterable, Identifiable { enum LogFile: String, CaseIterable, Identifiable {
case agent = "agent.log"
case errors = "errors.log" case errors = "errors.log"
case gateway = "gateway.log" case gateway = "gateway.log"
@@ -18,6 +19,7 @@ final class LogsViewModel {
var path: String { var path: String {
switch self { switch self {
case .agent: return HermesPaths.agentLog
case .errors: return HermesPaths.errorsLog case .errors: return HermesPaths.errorsLog
case .gateway: return HermesPaths.gatewayLog case .gateway: return HermesPaths.gatewayLog
} }
@@ -10,6 +10,8 @@ final class MemoryViewModel {
var isEditing = false var isEditing = false
var editingFile: EditTarget = .memory var editingFile: EditTarget = .memory
var editText = "" var editText = ""
var profiles: [String] = []
var activeProfile = ""
enum EditTarget { enum EditTarget {
case memory, user case memory, user
@@ -22,10 +24,23 @@ final class MemoryViewModel {
!memoryProvider.isEmpty && memoryProvider != "file" !memoryProvider.isEmpty && memoryProvider != "file"
} }
var hasMultipleProfiles: Bool { !profiles.isEmpty }
func load() { func load() {
memoryContent = fileService.loadMemory() let config = fileService.loadConfig()
userContent = fileService.loadUserProfile() memoryProvider = config.memoryProvider
memoryProvider = fileService.loadConfig().memoryProvider profiles = fileService.loadMemoryProfiles()
if activeProfile.isEmpty {
activeProfile = config.memoryProfile
}
memoryContent = fileService.loadMemory(profile: activeProfile)
userContent = fileService.loadUserProfile(profile: activeProfile)
}
func switchProfile(_ profile: String) {
activeProfile = profile
memoryContent = fileService.loadMemory(profile: profile)
userContent = fileService.loadUserProfile(profile: profile)
} }
func startEditing(_ target: EditTarget) { func startEditing(_ target: EditTarget) {
@@ -37,10 +52,10 @@ final class MemoryViewModel {
func save() { func save() {
switch editingFile { switch editingFile {
case .memory: case .memory:
fileService.saveMemory(editText) fileService.saveMemory(editText, profile: activeProfile)
memoryContent = editText memoryContent = editText
case .user: case .user:
fileService.saveUserProfile(editText) fileService.saveUserProfile(editText, profile: activeProfile)
userContent = editText userContent = editText
} }
isEditing = false isEditing = false
@@ -7,6 +7,23 @@ struct MemoryView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
if viewModel.hasMultipleProfiles {
HStack(spacing: 8) {
Text("Profile")
.font(.caption.bold())
.foregroundStyle(.secondary)
Picker("", selection: Binding(
get: { viewModel.activeProfile },
set: { viewModel.switchProfile($0) }
)) {
Text("Default").tag("")
ForEach(viewModel.profiles, id: \.self) { profile in
Text(profile).tag(profile)
}
}
.frame(maxWidth: 200)
}
}
if viewModel.hasExternalProvider { if viewModel.hasExternalProvider {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "info.circle") Image(systemName: "info.circle")
@@ -21,6 +21,7 @@ final class SessionsViewModel {
var searchResults: [HermesMessage] = [] var searchResults: [HermesMessage] = []
var isSearching = false var isSearching = false
var storeStats: SessionStoreStats? var storeStats: SessionStoreStats?
var subagentSessions: [HermesSession] = []
var renameSessionId: String? var renameSessionId: String?
var renameText = "" var renameText = ""
@@ -45,6 +46,7 @@ final class SessionsViewModel {
func selectSession(_ session: HermesSession) async { func selectSession(_ session: HermesSession) async {
selectedSession = session selectedSession = session
messages = await dataService.fetchMessages(sessionId: session.id) messages = await dataService.fetchMessages(sessionId: session.id)
subagentSessions = await dataService.fetchSubagentSessions(parentId: session.id)
} }
func selectSessionById(_ id: String) async { func selectSessionById(_ id: String) async {
@@ -3,14 +3,19 @@ import SwiftUI
struct SessionDetailView: View { struct SessionDetailView: View {
let session: HermesSession let session: HermesSession
let messages: [HermesMessage] let messages: [HermesMessage]
var subagentSessions: [HermesSession] = []
var preview: String? var preview: String?
var onRename: (() -> Void)? var onRename: (() -> Void)?
var onExport: (() -> Void)? var onExport: (() -> Void)?
var onDelete: (() -> Void)? var onDelete: (() -> Void)?
var onSelectSubagent: ((HermesSession) -> Void)?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
sessionHeader sessionHeader
if !subagentSessions.isEmpty {
subagentSection
}
Divider() Divider()
messagesList messagesList
} }
@@ -41,6 +46,13 @@ struct SessionDetailView: View {
} }
HStack(spacing: 16) { HStack(spacing: 16) {
Label(session.source, systemImage: session.sourceIcon) Label(session.source, systemImage: session.sourceIcon)
if session.isSubagent {
Label("Subagent", systemImage: "arrow.triangle.branch")
.foregroundStyle(.orange)
}
if let userId = session.userId, !userId.isEmpty, session.source != "cli" {
Label(userId, systemImage: "person")
}
Label(session.model ?? "unknown", systemImage: "cpu") Label(session.model ?? "unknown", systemImage: "cpu")
Label("\(session.messageCount) msgs", systemImage: "bubble.left") Label("\(session.messageCount) msgs", systemImage: "bubble.left")
Label("\(session.toolCallCount) tools", systemImage: "wrench") Label("\(session.toolCallCount) tools", systemImage: "wrench")
@@ -64,6 +76,38 @@ struct SessionDetailView: View {
.padding() .padding()
} }
private var subagentSection: some View {
VStack(alignment: .leading, spacing: 6) {
Divider()
Text("Subagent Sessions (\(subagentSessions.count))")
.font(.caption.bold())
.foregroundStyle(.secondary)
ForEach(subagentSessions) { sub in
Button {
onSelectSubagent?(sub)
} label: {
HStack(spacing: 8) {
Image(systemName: "arrow.triangle.branch")
.foregroundStyle(.orange)
Text(sub.displayTitle)
.lineLimit(1)
Spacer()
Text(sub.model ?? "")
.font(.caption2)
.foregroundStyle(.tertiary)
Text("\(sub.messageCount) msgs")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.font(.caption)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.bottom, 8)
}
private var messagesList: some View { private var messagesList: some View {
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 12) { LazyVStack(alignment: .leading, spacing: 12) {
@@ -115,10 +115,14 @@ struct SessionsView: View {
SessionDetailView( SessionDetailView(
session: session, session: session,
messages: viewModel.messages, messages: viewModel.messages,
subagentSessions: viewModel.subagentSessions,
preview: viewModel.previewFor(session), preview: viewModel.previewFor(session),
onRename: { viewModel.beginRename(session) }, onRename: { viewModel.beginRename(session) },
onExport: { viewModel.exportSession(session) }, onExport: { viewModel.exportSession(session) },
onDelete: { viewModel.beginDelete(session) } onDelete: { viewModel.beginDelete(session) },
onSelectSubagent: { sub in
Task { await viewModel.selectSession(sub) }
}
) )
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} else { } else {
@@ -149,13 +153,6 @@ struct SessionsView: View {
} }
private func platformIcon(_ platform: String) -> String { private func platformIcon(_ platform: String) -> String {
switch platform { KnownPlatforms.icon(for: platform)
case "cli": return "terminal"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "email": return "envelope"
default: return "bubble.left"
}
} }
} }
@@ -10,9 +10,11 @@ final class SettingsViewModel {
var hermesRunning = false var hermesRunning = false
var rawConfigYAML = "" var rawConfigYAML = ""
var personalities: [String] = [] var personalities: [String] = []
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax"] var providers = ["anthropic", "openrouter", "nous", "openai-codex", "google-ai-studio", "xai", "ollama-cloud", "zai", "kimi-coding", "minimax"]
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"] var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
var browserBackends = ["browseruse", "firecrawl", "local"]
var saveMessage: String? var saveMessage: String?
var showAuthRemoveConfirmation = false
func load() { func load() {
config = fileService.loadConfig() config = fileService.loadConfig()
@@ -55,6 +57,19 @@ final class SettingsViewModel {
func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) } func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) }
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") } func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) } func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) }
func setBrowserBackend(_ value: String) { setSetting("browser.backend", value: value) }
func removeAuth() {
let result = runHermes(["auth", "remove"])
if result.exitCode == 0 {
saveMessage = "Credentials removed"
} else {
saveMessage = "Failed to remove credentials"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.saveMessage = nil
}
}
func openConfigInEditor() { func openConfigInEditor() {
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML)) NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
@@ -11,6 +11,12 @@ struct SettingsView: View {
modelSection modelSection
displaySection displaySection
terminalSection terminalSection
if !viewModel.config.dockerEnv.isEmpty {
dockerEnvSection
}
if !viewModel.config.commandAllowlist.isEmpty {
allowlistSection
}
voiceSection voiceSection
memorySection memorySection
pathsSection pathsSection
@@ -21,6 +27,12 @@ struct SettingsView: View {
} }
.navigationTitle("Settings") .navigationTitle("Settings")
.onAppear { viewModel.load() } .onAppear { viewModel.load() }
.confirmationDialog("Remove Credentials?", isPresented: $viewModel.showAuthRemoveConfirmation) {
Button("Remove", role: .destructive) { viewModel.removeAuth() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This will permanently clear all stored provider credentials.")
}
} }
private var headerBar: some View { private var headerBar: some View {
@@ -44,6 +56,20 @@ struct SettingsView: View {
SettingsSection(title: "Model", icon: "cpu") { SettingsSection(title: "Model", icon: "cpu") {
EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($0) } EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($0) }
PickerRow(label: "Provider", selection: viewModel.config.provider, options: viewModel.providers) { viewModel.setProvider($0) } PickerRow(label: "Provider", selection: viewModel.config.provider, options: viewModel.providers) { viewModel.setProvider($0) }
HStack {
Text("Credentials")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Button("Remove Credentials", role: .destructive) {
viewModel.showAuthRemoveConfirmation = true
}
.controlSize(.small)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
} }
} }
@@ -71,6 +97,25 @@ struct SettingsView: View {
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) } StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
PickerRow(label: "Reasoning Effort", selection: viewModel.config.reasoningEffort, options: ["low", "medium", "high"]) { viewModel.setReasoningEffort($0) } PickerRow(label: "Reasoning Effort", selection: viewModel.config.reasoningEffort, options: ["low", "medium", "high"]) { viewModel.setReasoningEffort($0) }
PickerRow(label: "Approval Mode", selection: viewModel.config.approvalMode, options: ["auto", "manual", "smart"]) { viewModel.setApprovalMode($0) } PickerRow(label: "Approval Mode", selection: viewModel.config.approvalMode, options: ["auto", "manual", "smart"]) { viewModel.setApprovalMode($0) }
PickerRow(label: "Browser Backend", selection: viewModel.config.browserBackend, options: viewModel.browserBackends) { viewModel.setBrowserBackend($0) }
}
}
// MARK: - Docker Environment
private var dockerEnvSection: some View {
SettingsSection(title: "Docker Environment", icon: "shippingbox") {
ForEach(viewModel.config.dockerEnv.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
ReadOnlyRow(label: key, value: value)
}
}
}
// MARK: - Command Allowlist
private var allowlistSection: some View {
SettingsSection(title: "Command Allowlist", icon: "checkmark.shield") {
ReadOnlyRow(label: "Commands", value: viewModel.config.commandAllowlist.joined(separator: ", "))
} }
} }
@@ -88,6 +133,9 @@ struct SettingsView: View {
private var memorySection: some View { private var memorySection: some View {
SettingsSection(title: "Memory", icon: "brain") { SettingsSection(title: "Memory", icon: "brain") {
ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) } ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) }
if !viewModel.config.memoryProfile.isEmpty {
ReadOnlyRow(label: "Profile", value: viewModel.config.memoryProfile)
}
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) } StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) }
StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) } StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) }
StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) } StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) }
@@ -104,7 +152,8 @@ struct SettingsView: View {
PathRow(label: "Memory", path: HermesPaths.memoriesDir) PathRow(label: "Memory", path: HermesPaths.memoriesDir)
PathRow(label: "Sessions", path: HermesPaths.sessionsDir) PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
PathRow(label: "Skills", path: HermesPaths.skillsDir) PathRow(label: "Skills", path: HermesPaths.skillsDir)
PathRow(label: "Logs", path: HermesPaths.errorsLog) PathRow(label: "Agent Log", path: HermesPaths.agentLog)
PathRow(label: "Error Log", path: HermesPaths.errorsLog)
} }
} }
@@ -273,6 +322,27 @@ struct StepperRow: View {
} }
} }
struct ReadOnlyRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Text(value)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct PathRow: View { struct PathRow: View {
let label: String let label: String
let path: String let path: String
@@ -9,6 +9,8 @@ final class SkillsViewModel {
var skillContent = "" var skillContent = ""
var selectedFileName: String? var selectedFileName: String?
var searchText = "" var searchText = ""
var missingConfig: [String] = []
private var currentConfig = HermesConfig.empty
var filteredCategories: [HermesSkillCategory] { var filteredCategories: [HermesSkillCategory] {
guard !searchText.isEmpty else { return categories } guard !searchText.isEmpty else { return categories }
@@ -28,6 +30,7 @@ final class SkillsViewModel {
func load() { func load() {
categories = fileService.loadSkills() categories = fileService.loadSkills()
currentConfig = fileService.loadConfig()
} }
func selectSkill(_ skill: HermesSkill) { func selectSkill(_ skill: HermesSkill) {
@@ -40,6 +43,17 @@ final class SkillsViewModel {
selectedFileName = nil selectedFileName = nil
skillContent = "" skillContent = ""
} }
missingConfig = computeMissingConfig(for: skill)
}
private func computeMissingConfig(for skill: HermesSkill) -> [String] {
guard !skill.requiredConfig.isEmpty else { return [] }
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else {
return skill.requiredConfig
}
return skill.requiredConfig.filter { key in
!yaml.contains(key)
}
} }
func selectFile(_ file: String) { func selectFile(_ file: String) {
@@ -53,9 +53,28 @@ struct SkillsView: View {
HStack { HStack {
Label(skill.category, systemImage: "folder") Label(skill.category, systemImage: "folder")
Label("\(skill.files.count) files", systemImage: "doc") Label("\(skill.files.count) files", systemImage: "doc")
if !skill.requiredConfig.isEmpty {
Label("\(skill.requiredConfig.count) required config", systemImage: "gearshape")
}
} }
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if !viewModel.missingConfig.isEmpty {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
VStack(alignment: .leading, spacing: 2) {
Text("Missing required config:")
.font(.caption.bold())
Text(viewModel.missingConfig.joined(separator: ", "))
.font(.caption.monospaced())
}
}
.foregroundStyle(.orange)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Divider() Divider()
if !skill.files.isEmpty { if !skill.files.isEmpty {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -18,23 +18,20 @@ struct ToolsView: View {
} }
private var platformPicker: some View { private var platformPicker: some View {
HStack(spacing: 16) { HStack(spacing: 12) {
ForEach(viewModel.availablePlatforms) { platform in Picker("Platform", selection: Binding(
Button { get: { viewModel.selectedPlatform.name },
set: { name in
if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) {
viewModel.switchPlatform(platform) 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) )) {
ForEach(viewModel.availablePlatforms) { platform in
Text(platform.displayName).tag(platform.name)
} }
}
.pickerStyle(.segmented)
Spacer() Spacer()
Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled") Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled")
.font(.caption) .font(.caption)
@@ -56,6 +53,7 @@ struct ToolsView: View {
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 8) .padding(.vertical, 8)
} }
.id(viewModel.selectedPlatform.name)
} }
private var mcpSection: some View { private var mcpSection: some View {