mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
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:
@@ -21,24 +21,24 @@
|
||||
|
||||
- **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)
|
||||
- **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
|
||||
- **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.)
|
||||
- **Skills Browser** — Browse all installed skills by category with file content viewer and file switcher
|
||||
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Webhook, Matrix) with toggle switches, MCP server status
|
||||
- **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, 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, 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)
|
||||
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
|
||||
- **Log Viewer** — Real-time log tailing with level filtering and text search
|
||||
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators
|
||||
- **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
|
||||
- **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
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 26.2+
|
||||
- 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
|
||||
|
||||
@@ -47,7 +47,8 @@ Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`,
|
||||
| Hermes Version | Status |
|
||||
|----------------|--------|
|
||||
| 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.
|
||||
|
||||
|
||||
@@ -421,7 +421,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -457,7 +457,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -479,7 +479,7 @@
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -500,7 +500,7 @@
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -519,7 +519,7 @@
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -538,7 +538,7 @@
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.4.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
|
||||
@@ -20,6 +20,9 @@ struct HermesConfig: Sendable {
|
||||
var approvalMode: String
|
||||
var browserBackend: String
|
||||
var memoryProvider: String
|
||||
var dockerEnv: [String: String]
|
||||
var commandAllowlist: [String]
|
||||
var memoryProfile: String
|
||||
|
||||
static let empty = HermesConfig(
|
||||
model: "unknown",
|
||||
@@ -40,7 +43,10 @@ struct HermesConfig: Sendable {
|
||||
showCost: false,
|
||||
approvalMode: "manual",
|
||||
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 skillsDir: String = home + "/skills"
|
||||
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 hermesBinary: String = userHome + "/.local/bin/hermes"
|
||||
nonisolated static let scarfDir: String = home + "/scarf"
|
||||
|
||||
@@ -13,12 +13,23 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
let nextRunAt: String?
|
||||
let lastRunAt: 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 {
|
||||
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 lastRunAt = "last_run_at"
|
||||
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 {
|
||||
|
||||
@@ -22,6 +22,8 @@ struct HermesSession: Identifiable, Sendable {
|
||||
let costStatus: String?
|
||||
let billingProvider: String?
|
||||
|
||||
var isSubagent: Bool { parentSessionId != nil }
|
||||
|
||||
var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
|
||||
|
||||
var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
|
||||
|
||||
@@ -12,4 +12,5 @@ struct HermesSkill: Identifiable, Sendable {
|
||||
let category: String
|
||||
let path: String
|
||||
let files: [String]
|
||||
let requiredConfig: [String]
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ enum KnownPlatforms {
|
||||
HermesToolPlatform(name: "homeassistant", displayName: "Home Assistant", icon: "house"),
|
||||
HermesToolPlatform(name: "webhook", displayName: "Webhook", icon: "arrow.up.right.square"),
|
||||
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 {
|
||||
@@ -42,6 +44,8 @@ enum KnownPlatforms {
|
||||
case "homeassistant": return "house"
|
||||
case "webhook": return "arrow.up.right.square"
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ actor HermesDataService {
|
||||
|
||||
func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
|
||||
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?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
@@ -73,7 +73,7 @@ actor HermesDataService {
|
||||
|
||||
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
||||
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?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
@@ -86,6 +86,21 @@ actor HermesDataService {
|
||||
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
|
||||
|
||||
private var messageColumns: String {
|
||||
@@ -252,7 +267,7 @@ actor HermesDataService {
|
||||
let sql = """
|
||||
SELECT COUNT(*) FROM messages m
|
||||
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?
|
||||
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
|
||||
FROM messages m
|
||||
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
|
||||
ORDER BY cnt DESC
|
||||
"""
|
||||
@@ -289,7 +304,7 @@ actor HermesDataService {
|
||||
func fetchSessionStartHours(since: Date) -> [Int: Int] {
|
||||
guard let db else { return [:] }
|
||||
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?
|
||||
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] {
|
||||
guard let db else { return [:] }
|
||||
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?
|
||||
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 {
|
||||
var values: [String: String] = [:]
|
||||
var currentSection = ""
|
||||
var dockerEnv: [String: String] = [:]
|
||||
var commandAllowlist: [String] = []
|
||||
var inDockerEnv = false
|
||||
var inAllowlist = false
|
||||
|
||||
for line in yaml.components(separatedBy: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
|
||||
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(":") {
|
||||
currentSection = String(trimmed.dropLast())
|
||||
continue
|
||||
@@ -26,6 +51,16 @@ struct HermesFileService: Sendable {
|
||||
if 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)
|
||||
|
||||
if key == "docker_env" && val.isEmpty {
|
||||
inDockerEnv = true
|
||||
continue
|
||||
}
|
||||
if key == "permanent_allowlist" && val.isEmpty {
|
||||
inAllowlist = true
|
||||
continue
|
||||
}
|
||||
|
||||
values[currentSection + "." + key] = val
|
||||
}
|
||||
}
|
||||
@@ -49,7 +84,10 @@ struct HermesFileService: Sendable {
|
||||
showCost: values["display.show_cost"] == "true",
|
||||
approvalMode: values["approvals.mode"] ?? "manual",
|
||||
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
|
||||
|
||||
func loadMemory() -> String {
|
||||
readFile(HermesPaths.memoryMD) ?? ""
|
||||
func loadMemoryProfiles() -> [String] {
|
||||
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 {
|
||||
readFile(HermesPaths.userMD) ?? ""
|
||||
func loadMemory(profile: String = "") -> String {
|
||||
let path = memoryPath(profile: profile, file: "MEMORY.md")
|
||||
return readFile(path) ?? ""
|
||||
}
|
||||
|
||||
func saveMemory(_ content: String) {
|
||||
writeFile(HermesPaths.memoryMD, content: content)
|
||||
func loadUserProfile(profile: String = "") -> String {
|
||||
let path = memoryPath(profile: profile, file: "USER.md")
|
||||
return readFile(path) ?? ""
|
||||
}
|
||||
|
||||
func saveUserProfile(_ content: String) {
|
||||
writeFile(HermesPaths.userMD, content: content)
|
||||
func saveMemory(_ content: String, profile: String = "") {
|
||||
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
|
||||
@@ -123,12 +182,14 @@ struct HermesFileService: Sendable {
|
||||
var isSkillDir: ObjCBool = false
|
||||
guard fm.fileExists(atPath: skillPath, isDirectory: &isSkillDir), isSkillDir.boolValue else { return nil }
|
||||
let files = (try? fm.contentsOfDirectory(atPath: skillPath)) ?? []
|
||||
let requiredConfig = parseSkillRequiredConfig(skillPath + "/skill.yaml")
|
||||
return HermesSkill(
|
||||
id: categoryName + "/" + skillName,
|
||||
name: skillName,
|
||||
category: categoryName,
|
||||
path: skillPath,
|
||||
files: files.sorted()
|
||||
files: files.sorted(),
|
||||
requiredConfig: requiredConfig
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,6 +208,30 @@ struct HermesFileService: Sendable {
|
||||
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
|
||||
|
||||
func isHermesRunning() -> Bool {
|
||||
|
||||
@@ -16,6 +16,7 @@ final class HermesFileWatcher {
|
||||
HermesPaths.userMD,
|
||||
HermesPaths.cronJobsJSON,
|
||||
HermesPaths.gatewayStateJSON,
|
||||
HermesPaths.agentLog,
|
||||
HermesPaths.errorsLog,
|
||||
HermesPaths.gatewayLog,
|
||||
HermesPaths.projectsRegistry
|
||||
|
||||
@@ -38,6 +38,11 @@ struct CronView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if job.silent == true {
|
||||
Text("SILENT")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.purple)
|
||||
}
|
||||
if !job.enabled {
|
||||
Text("Disabled")
|
||||
.font(.caption2)
|
||||
@@ -86,6 +91,20 @@ struct CronView: View {
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.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 {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Skills")
|
||||
@@ -118,6 +137,21 @@ struct CronView: View {
|
||||
.font(.caption)
|
||||
.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 {
|
||||
Divider()
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
|
||||
@@ -19,17 +19,7 @@ struct PlatformInfo: Identifiable {
|
||||
|
||||
var isConnected: Bool { state == "connected" }
|
||||
|
||||
var icon: String {
|
||||
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"
|
||||
}
|
||||
}
|
||||
var icon: String { KnownPlatforms.icon(for: name) }
|
||||
}
|
||||
|
||||
struct PairedUser: Identifiable {
|
||||
|
||||
@@ -177,15 +177,7 @@ struct GatewayView: View {
|
||||
}
|
||||
|
||||
private func platformIcon(_ platform: String) -> String {
|
||||
switch 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"
|
||||
}
|
||||
KnownPlatforms.icon(for: platform)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ final class LogsViewModel {
|
||||
private let logService = HermesLogService()
|
||||
|
||||
var entries: [LogEntry] = []
|
||||
var selectedLogFile: LogFile = .errors
|
||||
var selectedLogFile: LogFile = .agent
|
||||
var filterLevel: LogEntry.LogLevel?
|
||||
var searchText = ""
|
||||
private var pollTimer: Timer?
|
||||
|
||||
enum LogFile: String, CaseIterable, Identifiable {
|
||||
case agent = "agent.log"
|
||||
case errors = "errors.log"
|
||||
case gateway = "gateway.log"
|
||||
|
||||
@@ -18,6 +19,7 @@ final class LogsViewModel {
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
case .agent: return HermesPaths.agentLog
|
||||
case .errors: return HermesPaths.errorsLog
|
||||
case .gateway: return HermesPaths.gatewayLog
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ final class MemoryViewModel {
|
||||
var isEditing = false
|
||||
var editingFile: EditTarget = .memory
|
||||
var editText = ""
|
||||
var profiles: [String] = []
|
||||
var activeProfile = ""
|
||||
|
||||
enum EditTarget {
|
||||
case memory, user
|
||||
@@ -22,10 +24,23 @@ final class MemoryViewModel {
|
||||
!memoryProvider.isEmpty && memoryProvider != "file"
|
||||
}
|
||||
|
||||
var hasMultipleProfiles: Bool { !profiles.isEmpty }
|
||||
|
||||
func load() {
|
||||
memoryContent = fileService.loadMemory()
|
||||
userContent = fileService.loadUserProfile()
|
||||
memoryProvider = fileService.loadConfig().memoryProvider
|
||||
let config = fileService.loadConfig()
|
||||
memoryProvider = config.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) {
|
||||
@@ -37,10 +52,10 @@ final class MemoryViewModel {
|
||||
func save() {
|
||||
switch editingFile {
|
||||
case .memory:
|
||||
fileService.saveMemory(editText)
|
||||
fileService.saveMemory(editText, profile: activeProfile)
|
||||
memoryContent = editText
|
||||
case .user:
|
||||
fileService.saveUserProfile(editText)
|
||||
fileService.saveUserProfile(editText, profile: activeProfile)
|
||||
userContent = editText
|
||||
}
|
||||
isEditing = false
|
||||
|
||||
@@ -7,6 +7,23 @@ struct MemoryView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
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 {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "info.circle")
|
||||
|
||||
@@ -21,6 +21,7 @@ final class SessionsViewModel {
|
||||
var searchResults: [HermesMessage] = []
|
||||
var isSearching = false
|
||||
var storeStats: SessionStoreStats?
|
||||
var subagentSessions: [HermesSession] = []
|
||||
|
||||
var renameSessionId: String?
|
||||
var renameText = ""
|
||||
@@ -45,6 +46,7 @@ final class SessionsViewModel {
|
||||
func selectSession(_ session: HermesSession) async {
|
||||
selectedSession = session
|
||||
messages = await dataService.fetchMessages(sessionId: session.id)
|
||||
subagentSessions = await dataService.fetchSubagentSessions(parentId: session.id)
|
||||
}
|
||||
|
||||
func selectSessionById(_ id: String) async {
|
||||
|
||||
@@ -3,14 +3,19 @@ import SwiftUI
|
||||
struct SessionDetailView: View {
|
||||
let session: HermesSession
|
||||
let messages: [HermesMessage]
|
||||
var subagentSessions: [HermesSession] = []
|
||||
var preview: String?
|
||||
var onRename: (() -> Void)?
|
||||
var onExport: (() -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
var onSelectSubagent: ((HermesSession) -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
sessionHeader
|
||||
if !subagentSessions.isEmpty {
|
||||
subagentSection
|
||||
}
|
||||
Divider()
|
||||
messagesList
|
||||
}
|
||||
@@ -41,6 +46,13 @@ struct SessionDetailView: View {
|
||||
}
|
||||
HStack(spacing: 16) {
|
||||
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.messageCount) msgs", systemImage: "bubble.left")
|
||||
Label("\(session.toolCallCount) tools", systemImage: "wrench")
|
||||
@@ -64,6 +76,38 @@ struct SessionDetailView: View {
|
||||
.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 {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 12) {
|
||||
|
||||
@@ -115,10 +115,14 @@ struct SessionsView: View {
|
||||
SessionDetailView(
|
||||
session: session,
|
||||
messages: viewModel.messages,
|
||||
subagentSessions: viewModel.subagentSessions,
|
||||
preview: viewModel.previewFor(session),
|
||||
onRename: { viewModel.beginRename(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)
|
||||
} else {
|
||||
@@ -149,13 +153,6 @@ struct SessionsView: View {
|
||||
}
|
||||
|
||||
private func platformIcon(_ platform: String) -> String {
|
||||
switch 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"
|
||||
}
|
||||
KnownPlatforms.icon(for: platform)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ final class SettingsViewModel {
|
||||
var hermesRunning = false
|
||||
var rawConfigYAML = ""
|
||||
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 browserBackends = ["browseruse", "firecrawl", "local"]
|
||||
var saveMessage: String?
|
||||
var showAuthRemoveConfirmation = false
|
||||
|
||||
func load() {
|
||||
config = fileService.loadConfig()
|
||||
@@ -55,6 +57,19 @@ final class SettingsViewModel {
|
||||
func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) }
|
||||
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
|
||||
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() {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
|
||||
@@ -11,6 +11,12 @@ struct SettingsView: View {
|
||||
modelSection
|
||||
displaySection
|
||||
terminalSection
|
||||
if !viewModel.config.dockerEnv.isEmpty {
|
||||
dockerEnvSection
|
||||
}
|
||||
if !viewModel.config.commandAllowlist.isEmpty {
|
||||
allowlistSection
|
||||
}
|
||||
voiceSection
|
||||
memorySection
|
||||
pathsSection
|
||||
@@ -21,6 +27,12 @@ struct SettingsView: View {
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.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 {
|
||||
@@ -44,6 +56,20 @@ struct SettingsView: View {
|
||||
SettingsSection(title: "Model", icon: "cpu") {
|
||||
EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($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) }
|
||||
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: "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 {
|
||||
SettingsSection(title: "Memory", icon: "brain") {
|
||||
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: "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) }
|
||||
@@ -104,7 +152,8 @@ struct SettingsView: View {
|
||||
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
|
||||
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
|
||||
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 {
|
||||
let label: String
|
||||
let path: String
|
||||
|
||||
@@ -9,6 +9,8 @@ final class SkillsViewModel {
|
||||
var skillContent = ""
|
||||
var selectedFileName: String?
|
||||
var searchText = ""
|
||||
var missingConfig: [String] = []
|
||||
private var currentConfig = HermesConfig.empty
|
||||
|
||||
var filteredCategories: [HermesSkillCategory] {
|
||||
guard !searchText.isEmpty else { return categories }
|
||||
@@ -28,6 +30,7 @@ final class SkillsViewModel {
|
||||
|
||||
func load() {
|
||||
categories = fileService.loadSkills()
|
||||
currentConfig = fileService.loadConfig()
|
||||
}
|
||||
|
||||
func selectSkill(_ skill: HermesSkill) {
|
||||
@@ -40,6 +43,17 @@ final class SkillsViewModel {
|
||||
selectedFileName = nil
|
||||
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) {
|
||||
|
||||
@@ -53,9 +53,28 @@ struct SkillsView: View {
|
||||
HStack {
|
||||
Label(skill.category, systemImage: "folder")
|
||||
Label("\(skill.files.count) files", systemImage: "doc")
|
||||
if !skill.requiredConfig.isEmpty {
|
||||
Label("\(skill.requiredConfig.count) required config", systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.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()
|
||||
if !skill.files.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
|
||||
@@ -18,23 +18,20 @@ struct ToolsView: View {
|
||||
}
|
||||
|
||||
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)
|
||||
HStack(spacing: 12) {
|
||||
Picker("Platform", selection: Binding(
|
||||
get: { viewModel.selectedPlatform.name },
|
||||
set: { name in
|
||||
if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) {
|
||||
viewModel.switchPlatform(platform)
|
||||
}
|
||||
.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()
|
||||
Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled")
|
||||
.font(.caption)
|
||||
@@ -56,6 +53,7 @@ struct ToolsView: View {
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.id(viewModel.selectedPlatform.name)
|
||||
}
|
||||
|
||||
private var mcpSection: some View {
|
||||
|
||||
Reference in New Issue
Block a user