mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 117a0ee9dd | |||
| 61d59ba0e4 | |||
| 0a584f6722 | |||
| 219bca264e | |||
| c7e6a809ed | |||
| c5d6116f99 | |||
| 8672ed1e6c | |||
| 46468890d5 | |||
| cd503378e2 | |||
| 86762eab6d | |||
| a7fd193770 | |||
| 521c6d63fc | |||
| 66d04d838d | |||
| ad30c0a943 | |||
| 44afa8f53b | |||
| 481b937c33 | |||
| 790efb585b | |||
| 3acf95a824 | |||
| 7d69c82c2b | |||
| ae2872e08f | |||
| 303f4502dd |
@@ -38,3 +38,7 @@ scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup
|
||||
```bash
|
||||
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
||||
```
|
||||
|
||||
## Hermes Version
|
||||
|
||||
Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/macOS-26.2+-blue" alt="macOS">
|
||||
<img src="https://img.shields.io/badge/macOS-14.6+%20Sonoma-blue" alt="macOS">
|
||||
<img src="https://img.shields.io/badge/Swift-6-orange" alt="Swift">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br><br>
|
||||
@@ -19,35 +19,39 @@
|
||||
|
||||
## Features
|
||||
|
||||
- **Dashboard** — System health, token usage, recent sessions with live refresh
|
||||
- **Insights** — Usage analytics with token breakdown, 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, tool call inspection, full-text search, rename, delete, and JSONL export
|
||||
- **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
|
||||
- **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, etc.) with toggle switches, MCP server status
|
||||
- **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. 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 and tool output display
|
||||
- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, permission request dialogs, and a one-click `/compress` focus sheet (when Hermes advertises the command); **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, 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, Supermemory, etc.), and profile-scoped memory support with profile picker
|
||||
- **Skills Browser** — Browse and edit 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, iMessage, Email, Home Assistant, Webhook, Matrix, Feishu, Mattermost) with toggle switches and segmented platform picker, MCP server status
|
||||
- **MCP Servers** — Manage Model Context Protocol servers Hermes connects to. Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, and more) or fully custom (stdio command + args, or HTTP URL with optional bearer auth). Per-server detail view with enable/disable toggle, environment variable + header editor, tool-include/exclude filters, resources/prompts toggles, request and connect timeouts, OAuth token detection + clearing, and one-click "Test Connection" that runs `hermes mcp test` and surfaces the discovered tool list. Gateway-restart banner appears after config changes that require a reload
|
||||
- **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, component filter (Gateway / Agent / Tools / CLI / Cron), clickable session-ID pills that filter to a single session, 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
|
||||
- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Fast Mode service tier, interim assistant messages, gateway notify interval, force IPv4, context engine, Honcho eager init, Docker environment, command allowlist, credential management, and one-click **Backup & Restore** via `hermes backup` / `hermes import`
|
||||
- **Hermes Process Control** — Start, stop, and restart the Hermes agent directly from Scarf
|
||||
- **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/`
|
||||
- macOS 14.6+ (Sonoma)
|
||||
- Xcode 16.0+
|
||||
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.9.0 recommended for full feature support)
|
||||
|
||||
### Compatibility
|
||||
|
||||
Scarf reads Hermes's SQLite database (schema v6) and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Tested and verified against:
|
||||
Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Automatic schema detection provides backward compatibility with older databases while supporting new features in newer Hermes versions.
|
||||
|
||||
| Hermes Version | Status |
|
||||
|----------------|--------|
|
||||
| v0.6.0 (2026-03-30) | Verified |
|
||||
| v0.6.0 (2026-03-31, latest) | Verified |
|
||||
| v0.7.0 (2026-04-03) | Verified |
|
||||
| v0.8.0 (2026-04-08) | Verified |
|
||||
| v0.9.0 (2026-04-13, 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.
|
||||
|
||||
@@ -55,11 +59,13 @@ If a Hermes update changes the database schema or CLI output format, Scarf may n
|
||||
|
||||
### Pre-built Binary (no Xcode required)
|
||||
|
||||
Download the latest universal binary (Apple Silicon + Intel) from [Releases](https://github.com/awizemann/scarf/releases):
|
||||
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
|
||||
|
||||
1. Download `Scarf-vX.X.X-Universal.zip`
|
||||
2. Unzip and drag **Scarf.app** to Applications
|
||||
3. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
|
||||
- `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended)
|
||||
- `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller)
|
||||
|
||||
1. Unzip and drag **Scarf.app** to Applications
|
||||
2. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
|
||||
|
||||
### Build from Source
|
||||
|
||||
@@ -90,10 +96,11 @@ scarf/
|
||||
Sessions/ Conversation browser with rename, delete, export
|
||||
Activity/ Tool execution feed with inspector
|
||||
Projects/ Agent-generated project dashboards with widget rendering
|
||||
Chat/ Embedded terminal via SwiftTerm with voice controls
|
||||
Chat/ Rich ACP chat and embedded terminal with voice controls
|
||||
Memory/ Memory viewer and editor
|
||||
Skills/ Skill browser by category
|
||||
Tools/ Toolset management per platform
|
||||
MCPServers/ MCP server registry, presets, OAuth, tool filters, test runner
|
||||
Gateway/ Messaging gateway control and pairing
|
||||
Cron/ Scheduled job viewer
|
||||
Logs/ Real-time log viewer
|
||||
@@ -114,11 +121,14 @@ Scarf reads Hermes data directly from `~/.hermes/`:
|
||||
| `logs/*.log` | Text | Read-only |
|
||||
| `gateway_state.json` | JSON | Read-only |
|
||||
| `skills/` | Directory tree | Read-only |
|
||||
| `hermes acp` | ACP subprocess (JSON-RPC stdio) | Real-time chat |
|
||||
| `hermes chat` | Terminal subprocess | Interactive |
|
||||
| `hermes tools` | CLI commands | Enable/Disable |
|
||||
| `hermes sessions` | CLI commands | Rename/Delete/Export |
|
||||
| `hermes gateway` | CLI commands | Start/Stop/Restart |
|
||||
| `hermes pairing` | CLI commands | Approve/Revoke |
|
||||
| `hermes mcp` | CLI commands | Add/Remove/Test MCP servers |
|
||||
| `mcp-tokens/*.json` | JSON (per-server OAuth) | Detect/Delete |
|
||||
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
|
||||
| `scarf/projects.json` | JSON (registry) | Read/Write |
|
||||
|
||||
@@ -136,7 +146,7 @@ Everything else uses system frameworks: SQLite3 C API, Foundation JSON, Attribut
|
||||
|
||||
Scarf watches `~/.hermes/` for file changes and queries the SQLite database for sessions, messages, and analytics. Views refresh automatically when Hermes writes new data.
|
||||
|
||||
The Chat tab spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation — switch tabs and come back without losing your conversation.
|
||||
The Chat tab has two modes. **Rich Chat** communicates with Hermes via the Agent Client Protocol (ACP) — a JSON-RPC connection over stdio — streaming responses in real-time with automatic reconnection and session recovery on connection loss. **Terminal** mode spawns `hermes chat` in a pseudo-terminal for the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation in both modes — switch tabs and come back without losing your conversation.
|
||||
|
||||
Management actions (renaming sessions, toggling tools, editing memory) call the Hermes CLI or write directly to the appropriate files, keeping Scarf and Hermes in sync.
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -407,7 +407,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -421,7 +421,8 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 1.5.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -443,7 +444,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
@@ -457,7 +458,8 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 1.5.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -475,11 +477,11 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -496,11 +498,11 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -516,10 +518,10 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -535,10 +537,10 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.5.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
|
||||
@@ -32,6 +32,8 @@ struct ContentView: View {
|
||||
SkillsView()
|
||||
case .tools:
|
||||
ToolsView()
|
||||
case .mcpServers:
|
||||
MCPServersView()
|
||||
case .gateway:
|
||||
GatewayView()
|
||||
case .cron:
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - JSON-RPC Transport
|
||||
|
||||
struct ACPRequest: Encodable {
|
||||
let jsonrpc = "2.0"
|
||||
let id: Int
|
||||
let method: String
|
||||
let params: [String: AnyCodable]
|
||||
}
|
||||
|
||||
struct ACPRawMessage: Decodable {
|
||||
let jsonrpc: String?
|
||||
let id: Int?
|
||||
let method: String?
|
||||
let result: AnyCodable?
|
||||
let error: ACPError?
|
||||
let params: AnyCodable?
|
||||
|
||||
var isResponse: Bool { id != nil && method == nil }
|
||||
var isNotification: Bool { method != nil && id == nil }
|
||||
var isRequest: Bool { method != nil && id != nil }
|
||||
}
|
||||
|
||||
struct ACPError: Decodable, Sendable {
|
||||
let code: Int
|
||||
let message: String
|
||||
}
|
||||
|
||||
// MARK: - AnyCodable (for dynamic JSON)
|
||||
|
||||
struct AnyCodable: Codable, Sendable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) { self.value = value }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
value = NSNull()
|
||||
} else if let bool = try? container.decode(Bool.self) {
|
||||
value = bool
|
||||
} else if let int = try? container.decode(Int.self) {
|
||||
value = int
|
||||
} else if let double = try? container.decode(Double.self) {
|
||||
value = double
|
||||
} else if let string = try? container.decode(String.self) {
|
||||
value = string
|
||||
} else if let array = try? container.decode([AnyCodable].self) {
|
||||
value = array.map(\.value)
|
||||
} else if let dict = try? container.decode([String: AnyCodable].self) {
|
||||
value = dict.mapValues(\.value)
|
||||
} else {
|
||||
value = NSNull()
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch value {
|
||||
case is NSNull:
|
||||
try container.encodeNil()
|
||||
case let bool as Bool:
|
||||
try container.encode(bool)
|
||||
case let int as Int:
|
||||
try container.encode(int)
|
||||
case let double as Double:
|
||||
try container.encode(double)
|
||||
case let string as String:
|
||||
try container.encode(string)
|
||||
case let array as [Any]:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
case let dict as [String: Any]:
|
||||
try container.encode(dict.mapValues { AnyCodable($0) })
|
||||
default:
|
||||
try container.encodeNil()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Accessors
|
||||
|
||||
var stringValue: String? { value as? String }
|
||||
var intValue: Int? { value as? Int }
|
||||
var dictValue: [String: Any]? { value as? [String: Any] }
|
||||
var arrayValue: [Any]? { value as? [Any] }
|
||||
}
|
||||
|
||||
// MARK: - ACP Events (parsed from session/update notifications)
|
||||
|
||||
enum ACPEvent: Sendable {
|
||||
case messageChunk(sessionId: String, text: String)
|
||||
case thoughtChunk(sessionId: String, text: String)
|
||||
case toolCallStart(sessionId: String, call: ACPToolCallEvent)
|
||||
case toolCallUpdate(sessionId: String, update: ACPToolCallUpdateEvent)
|
||||
case permissionRequest(sessionId: String, requestId: Int, request: ACPPermissionRequestEvent)
|
||||
case promptComplete(sessionId: String, response: ACPPromptResult)
|
||||
case availableCommands(sessionId: String, commands: [[String: Any]])
|
||||
case connectionLost(reason: String)
|
||||
case unknown(sessionId: String, type: String)
|
||||
}
|
||||
|
||||
struct ACPToolCallEvent: Sendable {
|
||||
let toolCallId: String
|
||||
let title: String
|
||||
let kind: String
|
||||
let status: String
|
||||
let content: String
|
||||
let rawInput: [String: Any]?
|
||||
|
||||
var functionName: String {
|
||||
// title format is "functionName: summary" or just "functionName"
|
||||
let parts = title.split(separator: ":", maxSplits: 1)
|
||||
return String(parts.first ?? Substring(title)).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
var argumentsSummary: String {
|
||||
let parts = title.split(separator: ":", maxSplits: 1)
|
||||
if parts.count > 1 {
|
||||
return String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var argumentsJSON: String {
|
||||
guard let input = rawInput,
|
||||
let data = try? JSONSerialization.data(withJSONObject: input),
|
||||
let str = String(data: data, encoding: .utf8) else { return "{}" }
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
struct ACPToolCallUpdateEvent: Sendable {
|
||||
let toolCallId: String
|
||||
let kind: String
|
||||
let status: String
|
||||
let content: String
|
||||
let rawOutput: String?
|
||||
}
|
||||
|
||||
struct ACPPermissionRequestEvent: Sendable {
|
||||
let toolCallTitle: String
|
||||
let toolCallKind: String
|
||||
let options: [(optionId: String, name: String)]
|
||||
}
|
||||
|
||||
struct ACPPromptResult: Sendable {
|
||||
let stopReason: String
|
||||
let inputTokens: Int
|
||||
let outputTokens: Int
|
||||
let thoughtTokens: Int
|
||||
let cachedReadTokens: Int
|
||||
}
|
||||
|
||||
// MARK: - Event Parsing
|
||||
|
||||
enum ACPEventParser {
|
||||
static func parse(notification: ACPRawMessage) -> ACPEvent? {
|
||||
guard notification.method == "session/update",
|
||||
let params = notification.params?.dictValue,
|
||||
let sessionId = params["sessionId"] as? String,
|
||||
let update = params["update"] as? [String: Any],
|
||||
let updateType = update["sessionUpdate"] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch updateType {
|
||||
case "agent_message_chunk":
|
||||
let text = extractContentText(from: update)
|
||||
return .messageChunk(sessionId: sessionId, text: text)
|
||||
|
||||
case "agent_thought_chunk":
|
||||
let text = extractContentText(from: update)
|
||||
return .thoughtChunk(sessionId: sessionId, text: text)
|
||||
|
||||
case "tool_call":
|
||||
let event = ACPToolCallEvent(
|
||||
toolCallId: update["toolCallId"] as? String ?? "",
|
||||
title: update["title"] as? String ?? "",
|
||||
kind: update["kind"] as? String ?? "other",
|
||||
status: update["status"] as? String ?? "pending",
|
||||
content: extractContentArrayText(from: update),
|
||||
rawInput: update["rawInput"] as? [String: Any]
|
||||
)
|
||||
return .toolCallStart(sessionId: sessionId, call: event)
|
||||
|
||||
case "tool_call_update":
|
||||
let event = ACPToolCallUpdateEvent(
|
||||
toolCallId: update["toolCallId"] as? String ?? "",
|
||||
kind: update["kind"] as? String ?? "other",
|
||||
status: update["status"] as? String ?? "completed",
|
||||
content: extractContentArrayText(from: update),
|
||||
rawOutput: update["rawOutput"] as? String
|
||||
)
|
||||
return .toolCallUpdate(sessionId: sessionId, update: event)
|
||||
|
||||
case "available_commands_update":
|
||||
let commands = update["availableCommands"] as? [[String: Any]] ?? []
|
||||
return .availableCommands(sessionId: sessionId, commands: commands)
|
||||
|
||||
default:
|
||||
return .unknown(sessionId: sessionId, type: updateType)
|
||||
}
|
||||
}
|
||||
|
||||
static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
|
||||
guard message.method == "session/request_permission",
|
||||
let params = message.params?.dictValue,
|
||||
let sessionId = params["sessionId"] as? String,
|
||||
let requestId = message.id else { return nil }
|
||||
|
||||
let toolCall = params["toolCall"] as? [String: Any] ?? [:]
|
||||
let optionsRaw = params["options"] as? [[String: Any]] ?? []
|
||||
let options = optionsRaw.compactMap { opt -> (optionId: String, name: String)? in
|
||||
guard let id = opt["optionId"] as? String,
|
||||
let name = opt["name"] as? String else { return nil }
|
||||
return (optionId: id, name: name)
|
||||
}
|
||||
|
||||
let event = ACPPermissionRequestEvent(
|
||||
toolCallTitle: toolCall["title"] as? String ?? "",
|
||||
toolCallKind: toolCall["kind"] as? String ?? "other",
|
||||
options: options
|
||||
)
|
||||
return .permissionRequest(sessionId: sessionId, requestId: requestId, request: event)
|
||||
}
|
||||
|
||||
// MARK: - Content Extraction
|
||||
|
||||
private static func extractContentText(from update: [String: Any]) -> String {
|
||||
if let content = update["content"] as? [String: Any],
|
||||
let text = content["text"] as? String {
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private static func extractContentArrayText(from update: [String: Any]) -> String {
|
||||
if let contentArray = update["content"] as? [[String: Any]] {
|
||||
return contentArray.compactMap { item -> String? in
|
||||
guard let inner = item["content"] as? [String: Any] else { return nil }
|
||||
return inner["text"] as? String
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,20 @@ struct HermesConfig: Sendable {
|
||||
var verbose: Bool
|
||||
var autoTTS: Bool
|
||||
var silenceThreshold: Int
|
||||
var reasoningEffort: String
|
||||
var showCost: Bool
|
||||
var approvalMode: String
|
||||
var browserBackend: String
|
||||
var memoryProvider: String
|
||||
var dockerEnv: [String: String]
|
||||
var commandAllowlist: [String]
|
||||
var memoryProfile: String
|
||||
var serviceTier: String
|
||||
var gatewayNotifyInterval: Int
|
||||
var forceIPv4: Bool
|
||||
var contextEngine: String
|
||||
var interimAssistantMessages: Bool
|
||||
var honchoInitOnSessionStart: Bool
|
||||
|
||||
static let empty = HermesConfig(
|
||||
model: "unknown",
|
||||
@@ -30,7 +44,21 @@ struct HermesConfig: Sendable {
|
||||
showReasoning: false,
|
||||
verbose: false,
|
||||
autoTTS: true,
|
||||
silenceThreshold: 200
|
||||
silenceThreshold: 200,
|
||||
reasoningEffort: "medium",
|
||||
showCost: false,
|
||||
approvalMode: "manual",
|
||||
browserBackend: "",
|
||||
memoryProvider: "",
|
||||
dockerEnv: [:],
|
||||
commandAllowlist: [],
|
||||
memoryProfile: "",
|
||||
serviceTier: "normal",
|
||||
gatewayNotifyInterval: 600,
|
||||
forceIPv4: false,
|
||||
contextEngine: "compressor",
|
||||
interimAssistantMessages: true,
|
||||
honchoInitOnSessionStart: false
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,12 @@ 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"
|
||||
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
|
||||
nonisolated static let mcpTokensDir: String = home + "/mcp-tokens"
|
||||
}
|
||||
|
||||
// MARK: - SQLite Constants
|
||||
|
||||
@@ -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 {
|
||||
@@ -30,6 +41,21 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
default: return "questionmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var deliveryDisplay: String? {
|
||||
guard let deliver, !deliver.isEmpty else { return nil }
|
||||
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
|
||||
if deliver.hasPrefix("discord:") {
|
||||
let parts = deliver.dropFirst("discord:".count).split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
if parts.count == 2 {
|
||||
return "Discord thread \(parts[1]) in \(parts[0])"
|
||||
}
|
||||
if parts.count == 1 {
|
||||
return "Discord \(parts[0])"
|
||||
}
|
||||
}
|
||||
return deliver
|
||||
}
|
||||
}
|
||||
|
||||
struct CronSchedule: Sendable, Codable {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
|
||||
case stdio
|
||||
case http
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .stdio: return "Local (stdio)"
|
||||
case .http: return "Remote (HTTP)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HermesMCPServer: Identifiable, Sendable, Equatable {
|
||||
let name: String
|
||||
let transport: MCPTransport
|
||||
let command: String?
|
||||
let args: [String]
|
||||
let url: String?
|
||||
let auth: String?
|
||||
let env: [String: String]
|
||||
let headers: [String: String]
|
||||
let timeout: Int?
|
||||
let connectTimeout: Int?
|
||||
let enabled: Bool
|
||||
let toolsInclude: [String]
|
||||
let toolsExclude: [String]
|
||||
let resourcesEnabled: Bool
|
||||
let promptsEnabled: Bool
|
||||
let hasOAuthToken: Bool
|
||||
|
||||
var id: String { name }
|
||||
|
||||
var summary: String {
|
||||
switch transport {
|
||||
case .stdio:
|
||||
let argString = args.isEmpty ? "" : " " + args.joined(separator: " ")
|
||||
return (command ?? "") + argString
|
||||
case .http:
|
||||
return url ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MCPTestResult: Sendable, Equatable {
|
||||
let serverName: String
|
||||
let succeeded: Bool
|
||||
let output: String
|
||||
let tools: [String]
|
||||
let elapsed: TimeInterval
|
||||
}
|
||||
@@ -11,10 +11,12 @@ struct HermesMessage: Identifiable, Sendable {
|
||||
let timestamp: Date?
|
||||
let tokenCount: Int?
|
||||
let finishReason: String?
|
||||
let reasoning: String?
|
||||
|
||||
var isUser: Bool { role == "user" }
|
||||
var isAssistant: Bool { role == "assistant" }
|
||||
var isToolResult: Bool { role == "tool" }
|
||||
var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) }
|
||||
}
|
||||
|
||||
struct HermesToolCall: Identifiable, Sendable, Codable {
|
||||
@@ -61,7 +63,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
|
||||
switch functionName {
|
||||
case "read_file", "search_files", "vision_analyze": return .read
|
||||
case "write_file", "patch": return .edit
|
||||
case "terminal": return .execute
|
||||
case "terminal", "execute_code": return .execute
|
||||
case "web_search", "web_extract": return .fetch
|
||||
case "browser_navigate", "browser_click", "browser_screenshot": return .browser
|
||||
default: return .other
|
||||
|
||||
@@ -17,8 +17,18 @@ struct HermesSession: Identifiable, Sendable {
|
||||
let cacheReadTokens: Int
|
||||
let cacheWriteTokens: Int
|
||||
let estimatedCostUSD: Double?
|
||||
let reasoningTokens: Int
|
||||
let actualCostUSD: Double?
|
||||
let costStatus: String?
|
||||
let billingProvider: String?
|
||||
|
||||
var totalTokens: Int { inputTokens + outputTokens }
|
||||
var isSubagent: Bool { parentSessionId != nil }
|
||||
|
||||
var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
|
||||
|
||||
var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
|
||||
|
||||
var costIsActual: Bool { actualCostUSD != nil }
|
||||
|
||||
var duration: TimeInterval? {
|
||||
guard let start = startedAt, let end = endedAt else { return nil }
|
||||
@@ -30,13 +40,20 @@ struct HermesSession: Identifiable, Sendable {
|
||||
}
|
||||
|
||||
var sourceIcon: String {
|
||||
switch source {
|
||||
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: source)
|
||||
}
|
||||
|
||||
func withTitle(_ newTitle: String) -> HermesSession {
|
||||
HermesSession(
|
||||
id: id, source: source, userId: userId, model: model,
|
||||
title: newTitle, parentSessionId: parentSessionId,
|
||||
startedAt: startedAt, endedAt: endedAt, endReason: endReason,
|
||||
messageCount: messageCount, toolCallCount: toolCallCount,
|
||||
inputTokens: inputTokens, outputTokens: outputTokens,
|
||||
cacheReadTokens: cacheReadTokens, cacheWriteTokens: cacheWriteTokens,
|
||||
estimatedCostUSD: estimatedCostUSD, reasoningTokens: reasoningTokens,
|
||||
actualCostUSD: actualCostUSD, costStatus: costStatus,
|
||||
billingProvider: billingProvider
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ struct HermesSkill: Identifiable, Sendable {
|
||||
let category: String
|
||||
let path: String
|
||||
let files: [String]
|
||||
let requiredConfig: [String]
|
||||
}
|
||||
|
||||
@@ -25,5 +25,30 @@ enum KnownPlatforms {
|
||||
HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"),
|
||||
HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"),
|
||||
HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"),
|
||||
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"),
|
||||
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
|
||||
]
|
||||
|
||||
static func icon(for 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 "whatsapp": return "phone.bubble"
|
||||
case "signal": return "lock.shield"
|
||||
case "email": return "envelope"
|
||||
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"
|
||||
case "imessage": return "message.fill"
|
||||
default: return "bubble.left"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
|
||||
struct MCPServerPreset: Identifiable, Sendable, Equatable {
|
||||
let id: String
|
||||
let displayName: String
|
||||
let description: String
|
||||
let category: String
|
||||
let iconSystemName: String
|
||||
let transport: MCPTransport
|
||||
let command: String?
|
||||
let args: [String]
|
||||
let url: String?
|
||||
let auth: String?
|
||||
let requiredEnvKeys: [String]
|
||||
let optionalEnvKeys: [String]
|
||||
let pathArgPrompt: String?
|
||||
let docsURL: String
|
||||
|
||||
static let gallery: [MCPServerPreset] = [
|
||||
MCPServerPreset(
|
||||
id: "filesystem",
|
||||
displayName: "Filesystem",
|
||||
description: "Read and write files under a root directory you choose.",
|
||||
category: "Built-in",
|
||||
iconSystemName: "folder",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: [],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: "Root directory (absolute path)",
|
||||
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "github",
|
||||
displayName: "GitHub",
|
||||
description: "Issues, pull requests, code search, and file operations via GitHub API.",
|
||||
category: "Dev",
|
||||
iconSystemName: "chevron.left.forwardslash.chevron.right",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-github"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: nil,
|
||||
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/github"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "postgres",
|
||||
displayName: "Postgres",
|
||||
description: "Read-only SQL access against a Postgres database.",
|
||||
category: "Data",
|
||||
iconSystemName: "cylinder.split.1x2",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-postgres"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: [],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: "Connection URL (postgres://user:pass@host/db)",
|
||||
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "slack",
|
||||
displayName: "Slack",
|
||||
description: "Read channels, post messages, and search your Slack workspace.",
|
||||
category: "Productivity",
|
||||
iconSystemName: "bubble.left.and.bubble.right",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-slack"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: ["SLACK_BOT_TOKEN", "SLACK_TEAM_ID"],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: nil,
|
||||
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/slack"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "linear",
|
||||
displayName: "Linear",
|
||||
description: "Query and update Linear issues. Uses OAuth — no token needed.",
|
||||
category: "Productivity",
|
||||
iconSystemName: "list.bullet.rectangle",
|
||||
transport: .http,
|
||||
command: nil,
|
||||
args: [],
|
||||
url: "https://mcp.linear.app/sse",
|
||||
auth: "oauth",
|
||||
requiredEnvKeys: [],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: nil,
|
||||
docsURL: "https://linear.app/docs/mcp"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "sentry",
|
||||
displayName: "Sentry",
|
||||
description: "Investigate errors and performance issues from Sentry.",
|
||||
category: "Dev",
|
||||
iconSystemName: "exclamationmark.triangle",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@sentry/mcp-server"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG"],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: nil,
|
||||
docsURL: "https://docs.sentry.io/product/mcp/"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "puppeteer",
|
||||
displayName: "Puppeteer",
|
||||
description: "Headless browser automation — navigate pages, click, screenshot.",
|
||||
category: "Automation",
|
||||
iconSystemName: "safari",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-puppeteer"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: [],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: nil,
|
||||
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "memory",
|
||||
displayName: "Memory (Knowledge Graph)",
|
||||
description: "Persistent knowledge graph of entities and relations across sessions.",
|
||||
category: "Built-in",
|
||||
iconSystemName: "brain",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-memory"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: [],
|
||||
optionalEnvKeys: ["MEMORY_FILE_PATH"],
|
||||
pathArgPrompt: nil,
|
||||
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory"
|
||||
),
|
||||
MCPServerPreset(
|
||||
id: "fetch",
|
||||
displayName: "Fetch",
|
||||
description: "Retrieve and convert web pages to markdown.",
|
||||
category: "Built-in",
|
||||
iconSystemName: "arrow.down.circle",
|
||||
transport: .stdio,
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-fetch"],
|
||||
url: nil,
|
||||
auth: nil,
|
||||
requiredEnvKeys: [],
|
||||
optionalEnvKeys: [],
|
||||
pathArgPrompt: nil,
|
||||
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch"
|
||||
)
|
||||
]
|
||||
|
||||
static var categories: [String] {
|
||||
var seen = Set<String>()
|
||||
return gallery.compactMap { p in seen.insert(p.category).inserted ? p.category : nil }
|
||||
}
|
||||
|
||||
static func byCategory(_ category: String) -> [MCPServerPreset] {
|
||||
gallery.filter { $0.category == category }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Manages a `hermes acp` subprocess and communicates via JSON-RPC over stdio.
|
||||
/// Provides an async event stream for real-time session updates.
|
||||
actor ACPClient {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ACPClient")
|
||||
|
||||
private var process: Process?
|
||||
private var stdinPipe: Pipe?
|
||||
private var stdoutPipe: Pipe?
|
||||
private var stderrPipe: Pipe?
|
||||
private var stdinFd: Int32 = -1
|
||||
|
||||
private var nextRequestId = 1
|
||||
private var pendingRequests: [Int: CheckedContinuation<AnyCodable?, Error>] = [:]
|
||||
private var readTask: Task<Void, Never>?
|
||||
private var stderrTask: Task<Void, Never>?
|
||||
private var keepaliveTask: Task<Void, Never>?
|
||||
private var eventContinuation: AsyncStream<ACPEvent>.Continuation?
|
||||
private var _eventStream: AsyncStream<ACPEvent>?
|
||||
|
||||
private(set) var isConnected = false
|
||||
private(set) var currentSessionId: String?
|
||||
private(set) var statusMessage = ""
|
||||
|
||||
/// Check if the underlying process is still alive and connected.
|
||||
var isHealthy: Bool {
|
||||
guard isConnected, let process else { return false }
|
||||
return process.isRunning
|
||||
}
|
||||
|
||||
// MARK: - Event Stream
|
||||
|
||||
/// Access the event stream. Must call `start()` first.
|
||||
var events: AsyncStream<ACPEvent> {
|
||||
guard let stream = _eventStream else {
|
||||
// Return an empty stream if not started
|
||||
return AsyncStream { $0.finish() }
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func start() async throws {
|
||||
guard process == nil else { return }
|
||||
|
||||
// Ignore SIGPIPE so broken-pipe writes return EPIPE instead of crashing
|
||||
signal(SIGPIPE, SIG_IGN)
|
||||
|
||||
// Create the event stream BEFORE anything else so no events are lost
|
||||
let (stream, continuation) = AsyncStream.makeStream(of: ACPEvent.self)
|
||||
self._eventStream = stream
|
||||
self.eventContinuation = continuation
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
proc.arguments = ["acp"]
|
||||
|
||||
let stdin = Pipe()
|
||||
let stdout = Pipe()
|
||||
let stderr = Pipe()
|
||||
|
||||
proc.standardInput = stdin
|
||||
proc.standardOutput = stdout
|
||||
proc.standardError = stderr
|
||||
|
||||
// ACP uses JSON-RPC over pipes — do NOT set TERM to avoid terminal escape pollution
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env.removeValue(forKey: "TERM")
|
||||
proc.environment = env
|
||||
|
||||
proc.terminationHandler = { [weak self] proc in
|
||||
Task { await self?.handleTermination(exitCode: proc.terminationStatus) }
|
||||
}
|
||||
|
||||
statusMessage = "Starting hermes acp..."
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
} catch {
|
||||
statusMessage = "Failed to start: \(error.localizedDescription)"
|
||||
logger.error("Failed to start hermes acp: \(error.localizedDescription)")
|
||||
continuation.finish()
|
||||
throw error
|
||||
}
|
||||
|
||||
self.process = proc
|
||||
self.stdinPipe = stdin
|
||||
self.stdoutPipe = stdout
|
||||
self.stderrPipe = stderr
|
||||
self.stdinFd = stdin.fileHandleForWriting.fileDescriptor
|
||||
self.isConnected = true
|
||||
|
||||
// Start reading stdout BEFORE sending initialize (so we catch the response)
|
||||
startReadLoop(stdout: stdout, stderr: stderr)
|
||||
logger.info("hermes acp process started (pid: \(proc.processIdentifier))")
|
||||
statusMessage = "Initializing..."
|
||||
|
||||
// Initialize the ACP connection
|
||||
let initParams: [String: AnyCodable] = [
|
||||
"protocolVersion": AnyCodable(1),
|
||||
"clientCapabilities": AnyCodable([String: Any]()),
|
||||
"clientInfo": AnyCodable([
|
||||
"name": "Scarf",
|
||||
"version": "1.0"
|
||||
] as [String: Any])
|
||||
]
|
||||
_ = try await sendRequest(method: "initialize", params: initParams)
|
||||
statusMessage = "Connected"
|
||||
logger.info("ACP connection initialized")
|
||||
startKeepalive()
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
readTask?.cancel()
|
||||
readTask = nil
|
||||
stderrTask?.cancel()
|
||||
stderrTask = nil
|
||||
keepaliveTask?.cancel()
|
||||
keepaliveTask = nil
|
||||
eventContinuation?.finish()
|
||||
eventContinuation = nil
|
||||
_eventStream = nil
|
||||
|
||||
for (_, continuation) in pendingRequests {
|
||||
continuation.resume(throwing: CancellationError())
|
||||
}
|
||||
pendingRequests.removeAll()
|
||||
|
||||
// Close stdin first so the subprocess sees EOF and can shut down gracefully
|
||||
stdinPipe?.fileHandleForWriting.closeFile()
|
||||
|
||||
if let process, process.isRunning {
|
||||
// SIGINT for graceful Python shutdown (raises KeyboardInterrupt cleanly)
|
||||
process.interrupt()
|
||||
// Watchdog: force-kill if still running after 2 seconds
|
||||
let watchdogProcess = process
|
||||
Task.detached {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
if watchdogProcess.isRunning {
|
||||
watchdogProcess.terminate()
|
||||
}
|
||||
}
|
||||
}
|
||||
stdinPipe?.fileHandleForReading.closeFile()
|
||||
stdoutPipe?.fileHandleForReading.closeFile()
|
||||
stderrPipe?.fileHandleForReading.closeFile()
|
||||
|
||||
process = nil
|
||||
stdinPipe = nil
|
||||
stdoutPipe = nil
|
||||
stderrPipe = nil
|
||||
stdinFd = -1
|
||||
isConnected = false
|
||||
currentSessionId = nil
|
||||
statusMessage = "Disconnected"
|
||||
logger.info("ACP client stopped")
|
||||
}
|
||||
|
||||
// MARK: - Keepalive
|
||||
|
||||
private func startKeepalive() {
|
||||
keepaliveTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds
|
||||
guard !Task.isCancelled else { break }
|
||||
await self?.sendKeepalive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Valid JSON-RPC notification used as a keepalive probe.
|
||||
/// Sending bare newlines causes `json.loads("")` errors in the ACP library.
|
||||
private static let keepalivePayload: Data = {
|
||||
let json = #"{"jsonrpc":"2.0","method":"$/ping"}"# + "\n"
|
||||
return Data(json.utf8)
|
||||
}()
|
||||
|
||||
private func sendKeepalive() {
|
||||
let fd = stdinFd
|
||||
guard fd >= 0 else { return }
|
||||
Task.detached { [weak self] in
|
||||
let ok = Self.safeWrite(fd: fd, data: Self.keepalivePayload)
|
||||
if !ok {
|
||||
await self?.handleWriteFailed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Management
|
||||
|
||||
func newSession(cwd: String) async throws -> String {
|
||||
statusMessage = "Creating session..."
|
||||
let params: [String: AnyCodable] = [
|
||||
"cwd": AnyCodable(cwd),
|
||||
"mcpServers": AnyCodable([Any]())
|
||||
]
|
||||
let result = try await sendRequest(method: "session/new", params: params)
|
||||
guard let dict = result?.dictValue,
|
||||
let sessionId = dict["sessionId"] as? String else {
|
||||
throw ACPClientError.invalidResponse("Missing sessionId in session/new response")
|
||||
}
|
||||
currentSessionId = sessionId
|
||||
statusMessage = "Session ready"
|
||||
logger.info("Created new ACP session: \(sessionId)")
|
||||
return sessionId
|
||||
}
|
||||
|
||||
func loadSession(cwd: String, sessionId: String) async throws -> String {
|
||||
statusMessage = "Loading session \(sessionId.prefix(12))..."
|
||||
let params: [String: AnyCodable] = [
|
||||
"cwd": AnyCodable(cwd),
|
||||
"sessionId": AnyCodable(sessionId),
|
||||
"mcpServers": AnyCodable([Any]())
|
||||
]
|
||||
let result = try await sendRequest(method: "session/load", params: params)
|
||||
// ACP returns {} on success (no sessionId echoed), or an error if not found.
|
||||
// If we got here without throwing, the session was loaded. Use the ID we sent.
|
||||
let loadedId = (result?.dictValue?["sessionId"] as? String) ?? sessionId
|
||||
currentSessionId = loadedId
|
||||
statusMessage = "Session loaded"
|
||||
logger.info("Loaded ACP session: \(loadedId)")
|
||||
return loadedId
|
||||
}
|
||||
|
||||
func resumeSession(cwd: String, sessionId: String) async throws -> String {
|
||||
statusMessage = "Resuming session..."
|
||||
let params: [String: AnyCodable] = [
|
||||
"cwd": AnyCodable(cwd),
|
||||
"sessionId": AnyCodable(sessionId),
|
||||
"mcpServers": AnyCodable([Any]())
|
||||
]
|
||||
let result = try await sendRequest(method: "session/resume", params: params)
|
||||
guard let dict = result?.dictValue,
|
||||
let resumedId = dict["sessionId"] as? String else {
|
||||
throw ACPClientError.invalidResponse("Missing sessionId in session/resume response")
|
||||
}
|
||||
currentSessionId = resumedId
|
||||
statusMessage = "Session resumed"
|
||||
logger.info("Resumed ACP session: \(resumedId)")
|
||||
return resumedId
|
||||
}
|
||||
|
||||
// MARK: - Messaging
|
||||
|
||||
func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult {
|
||||
statusMessage = "Sending prompt..."
|
||||
let messageId = UUID().uuidString
|
||||
let params: [String: AnyCodable] = [
|
||||
"sessionId": AnyCodable(sessionId),
|
||||
"messageId": AnyCodable(messageId),
|
||||
"prompt": AnyCodable([
|
||||
["type": "text", "text": text] as [String: Any]
|
||||
] as [Any])
|
||||
]
|
||||
let result = try await sendRequest(method: "session/prompt", params: params)
|
||||
let dict = result?.dictValue ?? [:]
|
||||
let usage = dict["usage"] as? [String: Any] ?? [:]
|
||||
|
||||
statusMessage = "Ready"
|
||||
return ACPPromptResult(
|
||||
stopReason: dict["stopReason"] as? String ?? "end_turn",
|
||||
inputTokens: usage["inputTokens"] as? Int ?? 0,
|
||||
outputTokens: usage["outputTokens"] as? Int ?? 0,
|
||||
thoughtTokens: usage["thoughtTokens"] as? Int ?? 0,
|
||||
cachedReadTokens: usage["cachedReadTokens"] as? Int ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
func cancel(sessionId: String) async throws {
|
||||
let params: [String: AnyCodable] = [
|
||||
"sessionId": AnyCodable(sessionId)
|
||||
]
|
||||
_ = try await sendRequest(method: "session/cancel", params: params)
|
||||
statusMessage = "Cancelled"
|
||||
}
|
||||
|
||||
func respondToPermission(requestId: Int, optionId: String) {
|
||||
let response: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"id": requestId,
|
||||
"result": [
|
||||
"outcome": [
|
||||
"kind": optionId == "deny" ? "rejected" : "allowed",
|
||||
"optionId": optionId
|
||||
] as [String: Any]
|
||||
] as [String: Any]
|
||||
]
|
||||
writeJSON(response)
|
||||
}
|
||||
|
||||
// MARK: - JSON-RPC Transport
|
||||
|
||||
private func sendRequest(method: String, params: [String: AnyCodable]) async throws -> AnyCodable? {
|
||||
let requestId = nextRequestId
|
||||
nextRequestId += 1
|
||||
|
||||
let request = ACPRequest(id: requestId, method: method, params: params)
|
||||
|
||||
guard let data = try? JSONEncoder().encode(request) else {
|
||||
throw ACPClientError.encodingFailed
|
||||
}
|
||||
|
||||
logger.debug("Sending: \(method) (id: \(requestId))")
|
||||
|
||||
// session/prompt streams events and can run for minutes — no hard timeout.
|
||||
// Control messages get a 30s watchdog.
|
||||
let timeoutTask: Task<Void, Error>? = if method != "session/prompt" {
|
||||
Task { [weak self] in
|
||||
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
|
||||
await self?.timeoutRequest(id: requestId, method: method)
|
||||
}
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
defer { timeoutTask?.cancel() }
|
||||
|
||||
let fd = stdinFd
|
||||
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<AnyCodable?, Error>) in
|
||||
pendingRequests[requestId] = continuation
|
||||
|
||||
guard fd >= 0 else {
|
||||
pendingRequests.removeValue(forKey: requestId)
|
||||
continuation.resume(throwing: ACPClientError.notConnected)
|
||||
return
|
||||
}
|
||||
|
||||
var payload = data
|
||||
payload.append(contentsOf: "\n".utf8)
|
||||
// Write in a detached task to avoid blocking the actor's executor.
|
||||
// The continuation is already stored; the response arrives via the read loop.
|
||||
Task.detached { [weak self] in
|
||||
let ok = Self.safeWrite(fd: fd, data: payload)
|
||||
if !ok {
|
||||
await self?.handleWriteFailedForRequest(id: requestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutRequest(id: Int, method: String) {
|
||||
guard let continuation = pendingRequests.removeValue(forKey: id) else { return }
|
||||
logger.error("Request timed out: \(method) (id: \(id))")
|
||||
statusMessage = "Request timed out"
|
||||
continuation.resume(throwing: ACPClientError.requestTimeout(method: method))
|
||||
}
|
||||
|
||||
private func writeJSON(_ dict: [String: Any]) {
|
||||
let fd = stdinFd
|
||||
guard fd >= 0,
|
||||
let data = try? JSONSerialization.data(withJSONObject: dict) else { return }
|
||||
var payload = data
|
||||
payload.append(contentsOf: "\n".utf8)
|
||||
Task.detached { [weak self] in
|
||||
let ok = Self.safeWrite(fd: fd, data: payload)
|
||||
if !ok {
|
||||
await self?.handleWriteFailed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Read Loop
|
||||
|
||||
private func startReadLoop(stdout: Pipe, stderr: Pipe) {
|
||||
// Read stdout for JSON-RPC messages
|
||||
readTask = Task.detached { [weak self] in
|
||||
let handle = stdout.fileHandleForReading
|
||||
var buffer = Data()
|
||||
|
||||
while !Task.isCancelled {
|
||||
let chunk = handle.availableData
|
||||
if chunk.isEmpty { break } // EOF
|
||||
buffer.append(chunk)
|
||||
|
||||
while let newlineIndex = buffer.firstIndex(of: UInt8(ascii: "\n")) {
|
||||
let lineData = Data(buffer[buffer.startIndex..<newlineIndex])
|
||||
buffer = Data(buffer[buffer.index(after: newlineIndex)...])
|
||||
|
||||
guard !lineData.isEmpty else { continue }
|
||||
|
||||
if let lineStr = String(data: lineData, encoding: .utf8) {
|
||||
await self?.logger.debug("ACP recv: \(lineStr.prefix(200))")
|
||||
}
|
||||
|
||||
do {
|
||||
let message = try JSONDecoder().decode(ACPRawMessage.self, from: lineData)
|
||||
await self?.handleMessage(message)
|
||||
} catch {
|
||||
await self?.logger.warning("Failed to decode ACP message: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
await self?.handleReadLoopEnded()
|
||||
}
|
||||
|
||||
// Read stderr in background for diagnostic logging
|
||||
stderrTask = Task.detached { [weak self] in
|
||||
let handle = stderr.fileHandleForReading
|
||||
while !Task.isCancelled {
|
||||
let data = handle.availableData
|
||||
if data.isEmpty { break }
|
||||
if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty {
|
||||
await self?.logger.info("ACP stderr: \(text.prefix(500))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMessage(_ message: ACPRawMessage) {
|
||||
if message.isResponse {
|
||||
if let requestId = message.id,
|
||||
let continuation = pendingRequests.removeValue(forKey: requestId) {
|
||||
if let error = message.error {
|
||||
logger.error("ACP RPC error (id: \(requestId)): \(error.message)")
|
||||
statusMessage = "Error: \(error.message)"
|
||||
continuation.resume(throwing: ACPClientError.rpcError(code: error.code, message: error.message))
|
||||
} else {
|
||||
logger.debug("ACP response (id: \(requestId))")
|
||||
continuation.resume(returning: message.result)
|
||||
}
|
||||
} else {
|
||||
logger.warning("ACP response for unknown request id: \(message.id ?? -1)")
|
||||
}
|
||||
} else if message.isNotification {
|
||||
if let event = ACPEventParser.parse(notification: message) {
|
||||
logger.debug("ACP event: \(String(describing: event).prefix(100))")
|
||||
eventContinuation?.yield(event)
|
||||
}
|
||||
} else if message.isRequest {
|
||||
if message.method == "session/request_permission",
|
||||
let event = ACPEventParser.parsePermissionRequest(message) {
|
||||
statusMessage = "Permission required"
|
||||
eventContinuation?.yield(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Disconnect Cleanup
|
||||
|
||||
/// Single idempotent cleanup path for all disconnect scenarios.
|
||||
private func performDisconnectCleanup(reason: String) {
|
||||
guard isConnected else { return }
|
||||
logger.warning("ACP disconnecting: \(reason)")
|
||||
isConnected = false
|
||||
statusMessage = "Connection lost"
|
||||
for (_, continuation) in pendingRequests {
|
||||
continuation.resume(throwing: ACPClientError.processTerminated)
|
||||
}
|
||||
pendingRequests.removeAll()
|
||||
eventContinuation?.finish()
|
||||
eventContinuation = nil
|
||||
}
|
||||
|
||||
private func handleReadLoopEnded() {
|
||||
performDisconnectCleanup(reason: "read loop ended (EOF)")
|
||||
}
|
||||
|
||||
private func handleTermination(exitCode: Int32) {
|
||||
performDisconnectCleanup(reason: "process exited (\(exitCode))")
|
||||
}
|
||||
|
||||
private func handleWriteFailed() {
|
||||
performDisconnectCleanup(reason: "write failed (broken pipe)")
|
||||
}
|
||||
|
||||
private func handleWriteFailedForRequest(id: Int) {
|
||||
if let continuation = pendingRequests.removeValue(forKey: id) {
|
||||
continuation.resume(throwing: ACPClientError.processTerminated)
|
||||
}
|
||||
performDisconnectCleanup(reason: "write failed (broken pipe)")
|
||||
}
|
||||
|
||||
// MARK: - Safe POSIX Write
|
||||
|
||||
/// Write data to a file descriptor using POSIX write(), returning false on error.
|
||||
/// Handles partial writes and returns false on EPIPE or other errors.
|
||||
private static func safeWrite(fd: Int32, data: Data) -> Bool {
|
||||
data.withUnsafeBytes { buf in
|
||||
guard let base = buf.baseAddress else { return false }
|
||||
var written = 0
|
||||
let total = buf.count
|
||||
while written < total {
|
||||
let result = Darwin.write(fd, base.advanced(by: written), total - written)
|
||||
if result <= 0 { return false }
|
||||
written += result
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum ACPClientError: Error, LocalizedError {
|
||||
case notConnected
|
||||
case encodingFailed
|
||||
case invalidResponse(String)
|
||||
case rpcError(code: Int, message: String)
|
||||
case processTerminated
|
||||
case requestTimeout(method: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConnected: return "ACP client is not connected"
|
||||
case .encodingFailed: return "Failed to encode JSON-RPC request"
|
||||
case .invalidResponse(let msg): return "Invalid ACP response: \(msg)"
|
||||
case .rpcError(let code, let msg): return "ACP error \(code): \(msg)"
|
||||
case .processTerminated: return "ACP process terminated unexpectedly"
|
||||
case .requestTimeout(let method): return "ACP request '\(method)' timed out"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import SQLite3
|
||||
|
||||
actor HermesDataService {
|
||||
private var db: OpaquePointer?
|
||||
private var hasV07Schema = false
|
||||
|
||||
func open() -> Bool {
|
||||
if db != nil { return true }
|
||||
let path = HermesPaths.stateDB
|
||||
guard FileManager.default.fileExists(atPath: path) else { return false }
|
||||
let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
|
||||
@@ -14,6 +16,7 @@ actor HermesDataService {
|
||||
return false
|
||||
}
|
||||
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
||||
detectSchema()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -24,17 +27,39 @@ actor HermesDataService {
|
||||
db = nil
|
||||
}
|
||||
|
||||
func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT id, source, user_id, model, title, parent_session_id,
|
||||
// MARK: - Schema Detection
|
||||
|
||||
private func detectSchema() {
|
||||
guard let db else { return }
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, "PRAGMA table_info(sessions)", -1, &stmt, nil) == SQLITE_OK else { return }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
if let name = sqlite3_column_text(stmt, 1), String(cString: name) == "reasoning_tokens" {
|
||||
hasV07Schema = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Queries
|
||||
|
||||
private var sessionColumns: String {
|
||||
var cols = """
|
||||
id, source, user_id, model, title, parent_session_id,
|
||||
started_at, ended_at, end_reason, message_count, tool_call_count,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||
estimated_cost_usd
|
||||
FROM sessions
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
if hasV07Schema {
|
||||
cols += ", reasoning_tokens, actual_cost_usd, cost_status, billing_provider"
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
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) }
|
||||
@@ -47,15 +72,52 @@ actor HermesDataService {
|
||||
return sessions
|
||||
}
|
||||
|
||||
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
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) }
|
||||
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||
|
||||
var sessions: [HermesSession] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
sessions.append(sessionFromRow(stmt!))
|
||||
}
|
||||
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 {
|
||||
var cols = """
|
||||
id, session_id, role, content, tool_call_id, tool_calls,
|
||||
tool_name, timestamp, token_count, finish_reason
|
||||
"""
|
||||
if hasV07Schema {
|
||||
cols += ", reasoning"
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
func fetchMessages(sessionId: String) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT id, session_id, role, content, tool_call_id, tool_calls,
|
||||
tool_name, timestamp, token_count, finish_reason
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
"""
|
||||
let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
@@ -70,9 +132,13 @@ actor HermesDataService {
|
||||
|
||||
func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sanitized = sanitizeFTSQuery(query)
|
||||
guard !sanitized.isEmpty else { return [] }
|
||||
let msgCols = hasV07Schema
|
||||
? "m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, m.tool_name, m.timestamp, m.token_count, m.finish_reason, m.reasoning"
|
||||
: "m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, m.tool_name, m.timestamp, m.token_count, m.finish_reason"
|
||||
let sql = """
|
||||
SELECT m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls,
|
||||
m.tool_name, m.timestamp, m.token_count, m.finish_reason
|
||||
SELECT \(msgCols)
|
||||
FROM messages_fts fts
|
||||
JOIN messages m ON m.id = fts.rowid
|
||||
WHERE messages_fts MATCH ?
|
||||
@@ -82,7 +148,7 @@ actor HermesDataService {
|
||||
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, query, -1, sqliteTransient)
|
||||
sqlite3_bind_text(stmt, 1, sanitized, -1, sqliteTransient)
|
||||
sqlite3_bind_int(stmt, 2, Int32(limit))
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
@@ -92,11 +158,21 @@ actor HermesDataService {
|
||||
return messages
|
||||
}
|
||||
|
||||
func fetchToolResult(callId: String) -> String? {
|
||||
guard let db else { return nil }
|
||||
let sql = "SELECT content FROM messages WHERE role = 'tool' AND tool_call_id = ? LIMIT 1"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, callId, -1, sqliteTransient)
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
|
||||
return columnText(stmt!, 0)
|
||||
}
|
||||
|
||||
func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT id, session_id, role, content, tool_call_id, tool_calls,
|
||||
tool_name, timestamp, token_count, finish_reason
|
||||
SELECT \(messageColumns)
|
||||
FROM messages
|
||||
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
|
||||
ORDER BY timestamp DESC
|
||||
@@ -142,6 +218,83 @@ actor HermesDataService {
|
||||
return previews
|
||||
}
|
||||
|
||||
// MARK: - Single-Row Queries
|
||||
|
||||
struct MessageFingerprint: Equatable, Sendable {
|
||||
let count: Int
|
||||
let maxId: Int
|
||||
let maxTimestamp: Double
|
||||
|
||||
static let empty = MessageFingerprint(count: 0, maxId: 0, maxTimestamp: 0)
|
||||
}
|
||||
|
||||
func fetchMessageFingerprint(sessionId: String) -> MessageFingerprint {
|
||||
guard let db else { return .empty }
|
||||
let sql = "SELECT COUNT(*), COALESCE(MAX(id), 0), COALESCE(MAX(timestamp), 0) FROM messages WHERE session_id = ?"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
|
||||
return MessageFingerprint(
|
||||
count: Int(sqlite3_column_int(stmt, 0)),
|
||||
maxId: Int(sqlite3_column_int(stmt, 1)),
|
||||
maxTimestamp: sqlite3_column_double(stmt, 2)
|
||||
)
|
||||
}
|
||||
|
||||
func fetchMessageCount(sessionId: String) -> Int {
|
||||
guard let db else { return 0 }
|
||||
let sql = "SELECT COUNT(*) FROM messages WHERE session_id = ?"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return 0 }
|
||||
return Int(sqlite3_column_int(stmt, 0))
|
||||
}
|
||||
|
||||
func fetchSession(id: String) -> HermesSession? {
|
||||
guard let db else { return nil }
|
||||
let sql = "SELECT \(sessionColumns) FROM sessions WHERE id = ? LIMIT 1"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, id, -1, sqliteTransient)
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
|
||||
return sessionFromRow(stmt!)
|
||||
}
|
||||
|
||||
func fetchMostRecentlyActiveSessionId() -> String? {
|
||||
guard let db else { return nil }
|
||||
let sql = "SELECT session_id FROM messages ORDER BY timestamp DESC LIMIT 1"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
|
||||
return columnText(stmt!, 0)
|
||||
}
|
||||
|
||||
func fetchMostRecentlyStartedSessionId(after: Date? = nil) -> String? {
|
||||
guard let db else { return nil }
|
||||
let sql: String
|
||||
if after != nil {
|
||||
sql = "SELECT id FROM sessions WHERE parent_session_id IS NULL AND started_at > ? ORDER BY started_at DESC LIMIT 1"
|
||||
} else {
|
||||
sql = "SELECT id FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT 1"
|
||||
}
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
if let after {
|
||||
sqlite3_bind_double(stmt, 1, after.timeIntervalSince1970)
|
||||
}
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
|
||||
return columnText(stmt!, 0)
|
||||
}
|
||||
|
||||
// MARK: - Stats
|
||||
|
||||
struct SessionStats: Sendable {
|
||||
let totalSessions: Int
|
||||
let totalMessages: Int
|
||||
@@ -149,21 +302,35 @@ actor HermesDataService {
|
||||
let totalInputTokens: Int
|
||||
let totalOutputTokens: Int
|
||||
let totalCostUSD: Double
|
||||
let totalReasoningTokens: Int
|
||||
let totalActualCostUSD: Double
|
||||
|
||||
static let empty = SessionStats(
|
||||
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0,
|
||||
totalReasoningTokens: 0, totalActualCostUSD: 0
|
||||
)
|
||||
}
|
||||
|
||||
func fetchStats() -> SessionStats {
|
||||
guard let db else { return .empty }
|
||||
let sql = """
|
||||
let sql: String
|
||||
if hasV07Schema {
|
||||
sql = """
|
||||
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||
COALESCE(SUM(estimated_cost_usd),0),
|
||||
COALESCE(SUM(reasoning_tokens),0), COALESCE(SUM(actual_cost_usd),0)
|
||||
FROM sessions
|
||||
"""
|
||||
} else {
|
||||
sql = """
|
||||
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||
COALESCE(SUM(estimated_cost_usd),0)
|
||||
FROM sessions
|
||||
"""
|
||||
}
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
@@ -174,41 +341,20 @@ actor HermesDataService {
|
||||
totalToolCalls: Int(sqlite3_column_int(stmt, 2)),
|
||||
totalInputTokens: Int(sqlite3_column_int(stmt, 3)),
|
||||
totalOutputTokens: Int(sqlite3_column_int(stmt, 4)),
|
||||
totalCostUSD: sqlite3_column_double(stmt, 5)
|
||||
totalCostUSD: sqlite3_column_double(stmt, 5),
|
||||
totalReasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 6)) : 0,
|
||||
totalActualCostUSD: hasV07Schema ? sqlite3_column_double(stmt, 7) : 0
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Insights Queries
|
||||
|
||||
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT id, source, user_id, model, title, parent_session_id,
|
||||
started_at, ended_at, end_reason, message_count, tool_call_count,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||
estimated_cost_usd
|
||||
FROM sessions
|
||||
WHERE 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) }
|
||||
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||
|
||||
var sessions: [HermesSession] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
sessions.append(sessionFromRow(stmt!))
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
func fetchUserMessageCount(since: Date) -> Int {
|
||||
guard let db else { return 0 }
|
||||
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 }
|
||||
@@ -224,7 +370,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
|
||||
"""
|
||||
@@ -245,7 +391,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 [:] }
|
||||
@@ -266,7 +412,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 [:] }
|
||||
@@ -315,7 +461,11 @@ actor HermesDataService {
|
||||
outputTokens: Int(sqlite3_column_int(stmt, 12)),
|
||||
cacheReadTokens: Int(sqlite3_column_int(stmt, 13)),
|
||||
cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)),
|
||||
estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil
|
||||
estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil,
|
||||
reasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 16)) : 0,
|
||||
actualCostUSD: hasV07Schema && sqlite3_column_type(stmt, 17) != SQLITE_NULL ? sqlite3_column_double(stmt, 17) : nil,
|
||||
costStatus: hasV07Schema ? columnOptionalText(stmt, 18) : nil,
|
||||
billingProvider: hasV07Schema ? columnOptionalText(stmt, 19) : nil
|
||||
)
|
||||
}
|
||||
|
||||
@@ -332,7 +482,8 @@ actor HermesDataService {
|
||||
toolName: columnOptionalText(stmt, 6),
|
||||
timestamp: columnDate(stmt, 7),
|
||||
tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil,
|
||||
finishReason: columnOptionalText(stmt, 9)
|
||||
finishReason: columnOptionalText(stmt, 9),
|
||||
reasoning: hasV07Schema ? columnOptionalText(stmt, 10) : nil
|
||||
)
|
||||
}
|
||||
|
||||
@@ -365,4 +516,17 @@ actor HermesDataService {
|
||||
let value = sqlite3_column_double(stmt, col)
|
||||
return Date(timeIntervalSince1970: value)
|
||||
}
|
||||
|
||||
/// Wraps each whitespace-delimited token in double quotes to prevent FTS5 parse errors
|
||||
/// on terms containing dots, hyphens, or FTS5 operators (e.g., "v0.7.0", "config.yaml").
|
||||
private func sanitizeFTSQuery(_ raw: String) -> String {
|
||||
raw.split(separator: " ")
|
||||
.map { token in
|
||||
let t = String(token)
|
||||
let stripped = t.replacingOccurrences(of: "\"", with: "")
|
||||
return stripped.isEmpty ? nil : "\"\(stripped)\""
|
||||
}
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -44,7 +79,21 @@ struct HermesFileService: Sendable {
|
||||
showReasoning: values["display.show_reasoning"] == "true",
|
||||
verbose: values["agent.verbose"] == "true",
|
||||
autoTTS: values["voice.auto_tts"] != "false",
|
||||
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold
|
||||
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold,
|
||||
reasoningEffort: values["agent.reasoning_effort"] ?? "medium",
|
||||
showCost: values["display.show_cost"] == "true",
|
||||
approvalMode: values["approvals.mode"] ?? "manual",
|
||||
browserBackend: values["browser.backend"] ?? "",
|
||||
memoryProvider: values["memory.provider"] ?? "",
|
||||
dockerEnv: dockerEnv,
|
||||
commandAllowlist: commandAllowlist,
|
||||
memoryProfile: values["memory.profile"] ?? "",
|
||||
serviceTier: values["agent.service_tier"] ?? "normal",
|
||||
gatewayNotifyInterval: Int(values["agent.gateway_notify_interval"] ?? "") ?? 600,
|
||||
forceIPv4: values["network.force_ipv4"] == "true",
|
||||
contextEngine: values["context.engine"] ?? "compressor",
|
||||
interimAssistantMessages: values["display.interim_assistant_messages"] != "false",
|
||||
honchoInitOnSessionStart: values["honcho.initOnSessionStart"] == "true"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,20 +111,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
|
||||
@@ -118,12 +188,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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -133,18 +205,692 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
|
||||
func loadSkillContent(path: String) -> String {
|
||||
// Validate path stays within the skills directory to prevent traversal
|
||||
guard !path.contains(".."),
|
||||
path.hasPrefix(HermesPaths.skillsDir) else {
|
||||
print("[Scarf] Rejected skill path outside skills directory: \(path)")
|
||||
return ""
|
||||
}
|
||||
guard isValidSkillPath(path) else { return "" }
|
||||
return readFile(path) ?? ""
|
||||
}
|
||||
|
||||
func saveSkillContent(path: String, content: String) {
|
||||
guard isValidSkillPath(path) else { return }
|
||||
writeFile(path, content: content)
|
||||
}
|
||||
|
||||
private func isValidSkillPath(_ path: String) -> Bool {
|
||||
guard !path.contains(".."), path.hasPrefix(HermesPaths.skillsDir) else {
|
||||
print("[Scarf] Rejected skill path outside skills directory: \(path)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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: - MCP Servers
|
||||
|
||||
func loadMCPServers() -> [HermesMCPServer] {
|
||||
guard let yaml = readFile(HermesPaths.configYAML) else { return [] }
|
||||
let parsed = parseMCPServersBlock(yaml: yaml)
|
||||
let fm = FileManager.default
|
||||
return parsed.map { server in
|
||||
let tokenPath = HermesPaths.mcpTokensDir + "/" + server.name + ".json"
|
||||
let hasToken = fm.fileExists(atPath: tokenPath)
|
||||
guard hasToken != server.hasOAuthToken else { return server }
|
||||
return HermesMCPServer(
|
||||
name: server.name,
|
||||
transport: server.transport,
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
url: server.url,
|
||||
auth: server.auth,
|
||||
env: server.env,
|
||||
headers: server.headers,
|
||||
timeout: server.timeout,
|
||||
connectTimeout: server.connectTimeout,
|
||||
enabled: server.enabled,
|
||||
toolsInclude: server.toolsInclude,
|
||||
toolsExclude: server.toolsExclude,
|
||||
resourcesEnabled: server.resourcesEnabled,
|
||||
promptsEnabled: server.promptsEnabled,
|
||||
hasOAuthToken: hasToken
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the server entry via `hermes mcp add` with only the command (no args).
|
||||
/// Args are written separately via `setMCPServerArgs` to avoid argparse issues with `-`-prefixed args like `-y`.
|
||||
/// Pipes `y\n` because the CLI prompts to save even when the initial connection check fails (which it will, since we intentionally add no args first).
|
||||
@discardableResult
|
||||
func addMCPServerStdio(name: String, command: String, args: [String]) -> (exitCode: Int32, output: String) {
|
||||
let addResult = runHermesCLI(
|
||||
args: ["mcp", "add", name, "--command", command],
|
||||
timeout: 45,
|
||||
stdinInput: "y\ny\ny\n"
|
||||
)
|
||||
guard addResult.exitCode == 0 else { return addResult }
|
||||
if !args.isEmpty {
|
||||
_ = setMCPServerArgs(name: name, args: args)
|
||||
}
|
||||
return addResult
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addMCPServerHTTP(name: String, url: String, auth: String?) -> (exitCode: Int32, output: String) {
|
||||
var cliArgs: [String] = ["mcp", "add", name, "--url", url]
|
||||
if let auth, !auth.isEmpty {
|
||||
cliArgs.append(contentsOf: ["--auth", auth])
|
||||
}
|
||||
return runHermesCLI(args: cliArgs, timeout: 45, stdinInput: "y\ny\ny\n")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setMCPServerArgs(name: String, args: [String]) -> Bool {
|
||||
patchMCPServerField(name: name) { entryLines in
|
||||
Self.replaceOrInsertList(header: "args", items: args, in: &entryLines)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func removeMCPServer(name: String) -> (exitCode: Int32, output: String) {
|
||||
runHermesCLI(args: ["mcp", "remove", name], timeout: 30)
|
||||
}
|
||||
|
||||
nonisolated func testMCPServer(name: String) async -> MCPTestResult {
|
||||
let started = Date()
|
||||
let service = self
|
||||
let result = await Task.detached { () -> (Int32, String) in
|
||||
service.runHermesCLI(args: ["mcp", "test", name], timeout: 30)
|
||||
}.value
|
||||
let elapsed = Date().timeIntervalSince(started)
|
||||
let tools = Self.parseToolListFromTestOutput(result.1)
|
||||
return MCPTestResult(
|
||||
serverName: name,
|
||||
succeeded: result.0 == 0,
|
||||
output: result.1,
|
||||
tools: tools,
|
||||
elapsed: elapsed
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseToolListFromTestOutput(_ output: String) -> [String] {
|
||||
var tools: [String] = []
|
||||
for rawLine in output.components(separatedBy: "\n") {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
guard line.hasPrefix("- ") || line.hasPrefix("* ") else { continue }
|
||||
let candidate = String(line.dropFirst(2)).trimmingCharacters(in: .whitespaces)
|
||||
// Take only the identifier before any separator (":" or whitespace).
|
||||
let token = candidate.split(whereSeparator: { ":(".contains($0) || $0.isWhitespace }).first.map(String.init) ?? candidate
|
||||
if !token.isEmpty, token.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" }) {
|
||||
tools.append(token)
|
||||
}
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleMCPServerEnabled(name: String, enabled: Bool) -> Bool {
|
||||
patchMCPServerField(name: name) { entryLines in
|
||||
Self.replaceOrInsertScalar(key: "enabled", value: enabled ? "true" : "false", in: &entryLines)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setMCPServerEnv(name: String, env: [String: String]) -> Bool {
|
||||
patchMCPServerField(name: name) { entryLines in
|
||||
Self.replaceOrInsertSubMap(header: "env", map: env, in: &entryLines)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setMCPServerHeaders(name: String, headers: [String: String]) -> Bool {
|
||||
patchMCPServerField(name: name) { entryLines in
|
||||
Self.replaceOrInsertSubMap(header: "headers", map: headers, in: &entryLines)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateMCPToolFilters(name: String, include: [String], exclude: [String], resources: Bool, prompts: Bool) -> Bool {
|
||||
patchMCPServerField(name: name) { entryLines in
|
||||
Self.replaceOrInsertToolsBlock(include: include, exclude: exclude, resources: resources, prompts: prompts, in: &entryLines)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setMCPServerTimeouts(name: String, timeout: Int?, connectTimeout: Int?) -> Bool {
|
||||
patchMCPServerField(name: name) { entryLines in
|
||||
if let timeout {
|
||||
Self.replaceOrInsertScalar(key: "timeout", value: String(timeout), in: &entryLines)
|
||||
} else {
|
||||
Self.removeScalar(key: "timeout", in: &entryLines)
|
||||
}
|
||||
if let connectTimeout {
|
||||
Self.replaceOrInsertScalar(key: "connect_timeout", value: String(connectTimeout), in: &entryLines)
|
||||
} else {
|
||||
Self.removeScalar(key: "connect_timeout", in: &entryLines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func deleteMCPOAuthToken(name: String) -> Bool {
|
||||
let path = HermesPaths.mcpTokensDir + "/" + name + ".json"
|
||||
do {
|
||||
try FileManager.default.removeItem(atPath: path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restartGateway() -> (exitCode: Int32, output: String) {
|
||||
runHermesCLI(args: ["gateway", "restart"], timeout: 30)
|
||||
}
|
||||
|
||||
// MARK: - MCP YAML: block extractor + parser
|
||||
|
||||
private struct MCPBlockLocation {
|
||||
let prefix: [String]
|
||||
let block: [String] // includes the "mcp_servers:" header line
|
||||
let suffix: [String]
|
||||
}
|
||||
|
||||
private func extractMCPBlock(yaml: String) -> MCPBlockLocation {
|
||||
let lines = yaml.components(separatedBy: "\n")
|
||||
var blockStart = -1
|
||||
var blockEnd = lines.count
|
||||
for (index, line) in lines.enumerated() {
|
||||
if blockStart < 0 {
|
||||
if line.hasPrefix("mcp_servers:") {
|
||||
blockStart = index
|
||||
}
|
||||
continue
|
||||
}
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
if indent == 0 && trimmed.contains(":") {
|
||||
blockEnd = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if blockStart < 0 {
|
||||
return MCPBlockLocation(prefix: lines, block: [], suffix: [])
|
||||
}
|
||||
// Trim trailing blank lines and comments from the block — they belong
|
||||
// to the file footer, not the mcp_servers section. Without this, when
|
||||
// mcp_servers is the last top-level key, the block would extend to EOF
|
||||
// and any inserted content (args, env, headers, tools) would land
|
||||
// after the trailing comments.
|
||||
while blockEnd > blockStart + 1 {
|
||||
let line = lines[blockEnd - 1]
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||
blockEnd -= 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return MCPBlockLocation(
|
||||
prefix: Array(lines[0..<blockStart]),
|
||||
block: Array(lines[blockStart..<blockEnd]),
|
||||
suffix: Array(lines[blockEnd..<lines.count])
|
||||
)
|
||||
}
|
||||
|
||||
fileprivate func parseMCPServersBlock(yaml: String) -> [HermesMCPServer] {
|
||||
let location = extractMCPBlock(yaml: yaml)
|
||||
guard location.block.count > 1 else { return [] }
|
||||
|
||||
var servers: [HermesMCPServer] = []
|
||||
|
||||
var currentName: String?
|
||||
var fields: [String: String] = [:]
|
||||
var argsList: [String] = []
|
||||
var envMap: [String: String] = [:]
|
||||
var headersMap: [String: String] = [:]
|
||||
var includeList: [String] = []
|
||||
var excludeList: [String] = []
|
||||
var resources = false
|
||||
var prompts = false
|
||||
var subSection: String?
|
||||
|
||||
func flush() {
|
||||
guard let name = currentName else { return }
|
||||
let transport: MCPTransport = fields["url"] != nil ? .http : .stdio
|
||||
let enabledStr = fields["enabled"]?.lowercased()
|
||||
let enabled = enabledStr != "false"
|
||||
let timeout = fields["timeout"].flatMap(Int.init)
|
||||
let connectTimeout = fields["connect_timeout"].flatMap(Int.init)
|
||||
let server = HermesMCPServer(
|
||||
name: name,
|
||||
transport: transport,
|
||||
command: fields["command"].map { Self.unquote($0) },
|
||||
args: argsList,
|
||||
url: fields["url"].map { Self.unquote($0) },
|
||||
auth: fields["auth"].map { Self.unquote($0) },
|
||||
env: envMap,
|
||||
headers: headersMap,
|
||||
timeout: timeout,
|
||||
connectTimeout: connectTimeout,
|
||||
enabled: enabled,
|
||||
toolsInclude: includeList,
|
||||
toolsExclude: excludeList,
|
||||
resourcesEnabled: resources,
|
||||
promptsEnabled: prompts,
|
||||
hasOAuthToken: false
|
||||
)
|
||||
servers.append(server)
|
||||
|
||||
currentName = nil
|
||||
fields = [:]
|
||||
argsList = []
|
||||
envMap = [:]
|
||||
headersMap = [:]
|
||||
includeList = []
|
||||
excludeList = []
|
||||
resources = false
|
||||
prompts = false
|
||||
subSection = nil
|
||||
}
|
||||
|
||||
for rawLine in location.block.dropFirst() {
|
||||
let trimmed = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
let indent = rawLine.prefix(while: { $0 == " " }).count
|
||||
|
||||
if indent == 2 && trimmed.hasSuffix(":") && !trimmed.contains(" ") {
|
||||
flush()
|
||||
currentName = String(trimmed.dropLast())
|
||||
subSection = nil
|
||||
continue
|
||||
}
|
||||
|
||||
guard currentName != nil else { continue }
|
||||
|
||||
if indent == 4 {
|
||||
if trimmed.hasPrefix("- ") && subSection == "args" {
|
||||
argsList.append(Self.unquote(String(trimmed.dropFirst(2))))
|
||||
continue
|
||||
}
|
||||
subSection = nil
|
||||
if trimmed.hasSuffix(":") {
|
||||
subSection = String(trimmed.dropLast())
|
||||
continue
|
||||
}
|
||||
if let colonIdx = trimmed.firstIndex(of: ":") {
|
||||
let key = String(trimmed[..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||
let value = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||
fields[key] = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if indent >= 6 {
|
||||
switch subSection {
|
||||
case "args":
|
||||
if trimmed.hasPrefix("- ") {
|
||||
argsList.append(Self.unquote(String(trimmed.dropFirst(2))))
|
||||
}
|
||||
case "env":
|
||||
if let colonIdx = trimmed.firstIndex(of: ":") {
|
||||
let key = String(trimmed[..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||
let value = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||
envMap[key] = Self.unquote(value)
|
||||
}
|
||||
case "headers":
|
||||
if let colonIdx = trimmed.firstIndex(of: ":") {
|
||||
let key = String(trimmed[..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||
let value = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||
headersMap[key] = Self.unquote(value)
|
||||
}
|
||||
case "tools":
|
||||
if trimmed == "include:" {
|
||||
subSection = "tools.include"
|
||||
} else if trimmed == "exclude:" {
|
||||
subSection = "tools.exclude"
|
||||
} else if trimmed.hasPrefix("resources:") {
|
||||
resources = trimmed.lowercased().hasSuffix("true")
|
||||
} else if trimmed.hasPrefix("prompts:") {
|
||||
prompts = trimmed.lowercased().hasSuffix("true")
|
||||
}
|
||||
case "tools.include":
|
||||
if trimmed.hasPrefix("- ") {
|
||||
includeList.append(Self.unquote(String(trimmed.dropFirst(2))))
|
||||
}
|
||||
case "tools.exclude":
|
||||
if trimmed.hasPrefix("- ") {
|
||||
excludeList.append(Self.unquote(String(trimmed.dropFirst(2))))
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flush()
|
||||
return servers
|
||||
}
|
||||
|
||||
// MARK: - MCP YAML: surgical patcher
|
||||
|
||||
private func patchMCPServerField(name: String, mutate: (inout [String]) -> Void) -> Bool {
|
||||
guard let yaml = readFile(HermesPaths.configYAML) else { return false }
|
||||
let location = extractMCPBlock(yaml: yaml)
|
||||
guard !location.block.isEmpty else { return false }
|
||||
|
||||
var block = location.block
|
||||
|
||||
var entryStart = -1
|
||||
var entryEnd = block.count
|
||||
for (index, line) in block.enumerated() {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
if entryStart < 0 {
|
||||
if indent == 2 && trimmed == "\(name):" {
|
||||
entryStart = index
|
||||
}
|
||||
continue
|
||||
}
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
if indent <= 2 {
|
||||
entryEnd = index
|
||||
break
|
||||
}
|
||||
}
|
||||
guard entryStart >= 0 else { return false }
|
||||
|
||||
// Trim trailing blank lines and comments off the entry so inserts land
|
||||
// immediately after the entry's last real key, not after intervening
|
||||
// comments that conceptually belong to the next entry (or the file
|
||||
// footer when this is the last entry in the block).
|
||||
while entryEnd > entryStart + 1 {
|
||||
let line = block[entryEnd - 1]
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||
entryEnd -= 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var entryLines = Array(block[entryStart..<entryEnd])
|
||||
mutate(&entryLines)
|
||||
|
||||
block.replaceSubrange(entryStart..<entryEnd, with: entryLines)
|
||||
|
||||
var combined: [String] = []
|
||||
combined.append(contentsOf: location.prefix)
|
||||
combined.append(contentsOf: block)
|
||||
combined.append(contentsOf: location.suffix)
|
||||
let newYAML = combined.joined(separator: "\n")
|
||||
writeFile(HermesPaths.configYAML, content: newYAML)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - MCP YAML: mutators
|
||||
|
||||
private static func replaceOrInsertScalar(key: String, value: String, in lines: inout [String]) {
|
||||
// entry header is at lines[0] at indent 2. Scalars live at indent 4.
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent == 4, trimmed.hasPrefix(key + ":") || trimmed == key + ":" {
|
||||
lines[index] = " \(key): \(value)"
|
||||
return
|
||||
}
|
||||
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Insert right after header.
|
||||
lines.insert(" \(key): \(value)", at: 1)
|
||||
}
|
||||
|
||||
private static func removeScalar(key: String, in lines: inout [String]) {
|
||||
var removeIndex: Int?
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent == 4, trimmed.hasPrefix(key + ":") || trimmed == key + ":" {
|
||||
removeIndex = index
|
||||
break
|
||||
}
|
||||
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||
break
|
||||
}
|
||||
}
|
||||
if let removeIndex {
|
||||
lines.remove(at: removeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private static func replaceOrInsertList(header: String, items: [String], in lines: inout [String]) {
|
||||
var headerIndex: Int?
|
||||
var removeEnd: Int?
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent == 4 && trimmed == "\(header):" {
|
||||
headerIndex = index
|
||||
continue
|
||||
}
|
||||
if headerIndex != nil {
|
||||
// List items can appear at indent 4 (as " - item") OR indent 6 depending on style.
|
||||
if trimmed.hasPrefix("- ") && indent >= 4 {
|
||||
continue
|
||||
} else if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||
continue
|
||||
} else if indent >= 6 {
|
||||
continue
|
||||
} else {
|
||||
removeEnd = index
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if items.isEmpty {
|
||||
if let headerIndex, let end = removeEnd {
|
||||
lines.removeSubrange(headerIndex..<end)
|
||||
} else if let headerIndex {
|
||||
lines.removeSubrange(headerIndex..<lines.count)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var newLines: [String] = [" \(header):"]
|
||||
for item in items {
|
||||
newLines.append(" - \(yamlScalar(item))")
|
||||
}
|
||||
|
||||
if let headerIndex {
|
||||
let end = removeEnd ?? lines.count
|
||||
lines.replaceSubrange(headerIndex..<end, with: newLines)
|
||||
} else {
|
||||
var insertAt = lines.count
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||
insertAt = index
|
||||
break
|
||||
}
|
||||
}
|
||||
lines.insert(contentsOf: newLines, at: insertAt)
|
||||
}
|
||||
}
|
||||
|
||||
private static func replaceOrInsertSubMap(header: String, map: [String: String], in lines: inout [String]) {
|
||||
var headerIndex: Int?
|
||||
var removeEnd: Int?
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent == 4 && trimmed == "\(header):" {
|
||||
headerIndex = index
|
||||
continue
|
||||
}
|
||||
if headerIndex != nil {
|
||||
if indent >= 6 {
|
||||
continue
|
||||
} else if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||
continue
|
||||
} else {
|
||||
removeEnd = index
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var newLines: [String] = []
|
||||
if map.isEmpty {
|
||||
if let headerIndex, let end = removeEnd {
|
||||
lines.removeSubrange(headerIndex..<end)
|
||||
} else if let headerIndex {
|
||||
lines.removeSubrange(headerIndex..<lines.count)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
newLines.append(" \(header):")
|
||||
for key in map.keys.sorted() {
|
||||
let value = map[key] ?? ""
|
||||
newLines.append(" \(key): \(yamlScalar(value))")
|
||||
}
|
||||
|
||||
if let headerIndex {
|
||||
let end = removeEnd ?? lines.count
|
||||
lines.replaceSubrange(headerIndex..<end, with: newLines)
|
||||
} else {
|
||||
// Insert just before the first indent<=2 line we find after the header, else at end.
|
||||
var insertAt = lines.count
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||
insertAt = index
|
||||
break
|
||||
}
|
||||
}
|
||||
lines.insert(contentsOf: newLines, at: insertAt)
|
||||
}
|
||||
}
|
||||
|
||||
private static func replaceOrInsertToolsBlock(include: [String], exclude: [String], resources: Bool, prompts: Bool, in lines: inout [String]) {
|
||||
var headerIndex: Int?
|
||||
var removeEnd: Int?
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent == 4 && trimmed == "tools:" {
|
||||
headerIndex = index
|
||||
continue
|
||||
}
|
||||
if headerIndex != nil {
|
||||
if indent >= 6 {
|
||||
continue
|
||||
} else if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||
continue
|
||||
} else {
|
||||
removeEnd = index
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var newLines: [String] = [" tools:"]
|
||||
newLines.append(" include:")
|
||||
for tool in include { newLines.append(" - \(yamlScalar(tool))") }
|
||||
newLines.append(" exclude:")
|
||||
for tool in exclude { newLines.append(" - \(yamlScalar(tool))") }
|
||||
newLines.append(" resources: \(resources ? "true" : "false")")
|
||||
newLines.append(" prompts: \(prompts ? "true" : "false")")
|
||||
|
||||
if let headerIndex {
|
||||
let end = removeEnd ?? lines.count
|
||||
lines.replaceSubrange(headerIndex..<end, with: newLines)
|
||||
} else {
|
||||
var insertAt = lines.count
|
||||
for index in 1..<lines.count {
|
||||
let line = lines[index]
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if indent <= 2 && !trimmed.isEmpty && !trimmed.hasPrefix("#") {
|
||||
insertAt = index
|
||||
break
|
||||
}
|
||||
}
|
||||
lines.insert(contentsOf: newLines, at: insertAt)
|
||||
}
|
||||
}
|
||||
|
||||
private static func yamlScalar(_ value: String) -> String {
|
||||
if value.isEmpty { return "\"\"" }
|
||||
// YAML 1.2 reserved indicators that change meaning at the start of a
|
||||
// scalar: @ * & ? | > ! % , [ ] { } < ` ' " — plus space (would be
|
||||
// trimmed) and dash (looks like a sequence). Anything starting with
|
||||
// one of these must be quoted or YAML treats the value as an alias,
|
||||
// tag, flow collection, etc., and parsing breaks.
|
||||
let reservedFirstChars: Set<Character> = [
|
||||
"@", "*", "&", "?", "|", ">", "!", "%", ",",
|
||||
"[", "]", "{", "}", "<", "`", "'", "\""
|
||||
]
|
||||
let firstCharNeedsQuoting = value.first.map { reservedFirstChars.contains($0) } ?? false
|
||||
let needsQuoting = value.contains(":") || value.contains("#") || value.contains("\"")
|
||||
|| value.hasPrefix(" ") || value.hasSuffix(" ") || value.hasPrefix("-")
|
||||
|| ["true", "false", "null", "yes", "no"].contains(value.lowercased())
|
||||
|| firstCharNeedsQuoting
|
||||
if needsQuoting {
|
||||
let escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return "\"\(escaped)\""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private static func unquote(_ value: String) -> String {
|
||||
var v = value
|
||||
if (v.hasPrefix("\"") && v.hasSuffix("\"") && v.count >= 2) || (v.hasPrefix("'") && v.hasSuffix("'") && v.count >= 2) {
|
||||
v = String(v.dropFirst().dropLast())
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// MARK: - Hermes Process
|
||||
|
||||
func isHermesRunning() -> Bool {
|
||||
hermesPID() != nil
|
||||
}
|
||||
|
||||
func hermesPID() -> pid_t? {
|
||||
let pipe = Pipe()
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
|
||||
@@ -155,9 +901,73 @@ struct HermesFileService: Sendable {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return !data.isEmpty
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
guard let firstLine = output.components(separatedBy: "\n").first(where: { !$0.isEmpty }),
|
||||
let pid = pid_t(firstLine.trimmingCharacters(in: .whitespaces)) else { return nil }
|
||||
return pid
|
||||
} catch {
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func stopHermes() -> Bool {
|
||||
// v0.9.0 fixed `hermes gateway stop` so it issues `launchctl bootout` and
|
||||
// waits for exit. Use the CLI to avoid racing launchd's KeepAlive respawn.
|
||||
if runHermesCLI(args: ["gateway", "stop"]).exitCode == 0 {
|
||||
return true
|
||||
}
|
||||
guard let pid = hermesPID() else { return false }
|
||||
return kill(pid, SIGTERM) == 0
|
||||
}
|
||||
|
||||
nonisolated func hermesBinaryPath() -> String? {
|
||||
let candidates = [
|
||||
("\(NSHomeDirectory())/.local/bin/hermes"),
|
||||
"/opt/homebrew/bin/hermes",
|
||||
"/usr/local/bin/hermes"
|
||||
]
|
||||
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, output: String) {
|
||||
guard let binary = hermesBinaryPath() else { return (-1, "") }
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
let stdinPipe: Pipe? = stdinInput != nil ? Pipe() : nil
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: binary)
|
||||
process.arguments = args
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
if let stdinPipe { process.standardInput = stdinPipe }
|
||||
defer {
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
try? stdoutPipe.fileHandleForWriting.close()
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
try? stderrPipe.fileHandleForWriting.close()
|
||||
try? stdinPipe?.fileHandleForReading.close()
|
||||
try? stdinPipe?.fileHandleForWriting.close()
|
||||
}
|
||||
do {
|
||||
try process.run()
|
||||
if let stdinInput, let stdinPipe, let data = stdinInput.data(using: .utf8) {
|
||||
stdinPipe.fileHandleForWriting.write(data)
|
||||
try? stdinPipe.fileHandleForWriting.close()
|
||||
}
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while process.isRunning && Date() < deadline {
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
if process.isRunning { process.terminate() }
|
||||
process.waitUntilExit()
|
||||
let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let combined = (String(data: outData, encoding: .utf8) ?? "") + (String(data: errData, encoding: .utf8) ?? "")
|
||||
return (process.terminationStatus, combined)
|
||||
} catch {
|
||||
return (-1, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,11 @@ final class HermesFileWatcher {
|
||||
HermesPaths.userMD,
|
||||
HermesPaths.cronJobsJSON,
|
||||
HermesPaths.gatewayStateJSON,
|
||||
HermesPaths.agentLog,
|
||||
HermesPaths.errorsLog,
|
||||
HermesPaths.gatewayLog,
|
||||
HermesPaths.projectsRegistry
|
||||
HermesPaths.projectsRegistry,
|
||||
HermesPaths.mcpTokensDir
|
||||
]
|
||||
|
||||
for path in paths {
|
||||
|
||||
@@ -4,6 +4,7 @@ struct LogEntry: Identifiable, Sendable {
|
||||
let id: Int
|
||||
let timestamp: String
|
||||
let level: LogLevel
|
||||
let sessionId: String?
|
||||
let logger: String
|
||||
let message: String
|
||||
let raw: String
|
||||
@@ -72,23 +73,30 @@ actor HermesLogService {
|
||||
|
||||
private func parseLine(_ line: String) -> LogEntry {
|
||||
entryCounter += 1
|
||||
// Format: YYYY-MM-DD HH:MM:SS,MMM LEVEL logger: message
|
||||
let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s+(.*)$"#
|
||||
// Format (v0.9.0+): YYYY-MM-DD HH:MM:SS,MMM LEVEL [session_id] logger: message
|
||||
// Session tag is optional — earlier Hermes releases and out-of-session lines omit it.
|
||||
let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(?:\[([^\]]+)\]\s+)?(\S+?):\s+(.*)$"#
|
||||
if let regex = try? NSRegularExpression(pattern: pattern),
|
||||
let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) {
|
||||
let timestamp = String(line[Range(match.range(at: 1), in: line)!])
|
||||
let levelStr = String(line[Range(match.range(at: 2), in: line)!])
|
||||
let logger = String(line[Range(match.range(at: 3), in: line)!])
|
||||
let message = String(line[Range(match.range(at: 4), in: line)!])
|
||||
let sessionId: String? = {
|
||||
let range = match.range(at: 3)
|
||||
guard range.location != NSNotFound, let r = Range(range, in: line) else { return nil }
|
||||
return String(line[r])
|
||||
}()
|
||||
let logger = String(line[Range(match.range(at: 4), in: line)!])
|
||||
let message = String(line[Range(match.range(at: 5), in: line)!])
|
||||
return LogEntry(
|
||||
id: entryCounter,
|
||||
timestamp: timestamp,
|
||||
level: LogEntry.LogLevel(rawValue: levelStr) ?? .info,
|
||||
sessionId: sessionId,
|
||||
logger: logger,
|
||||
message: message,
|
||||
raw: line
|
||||
)
|
||||
}
|
||||
return LogEntry(id: entryCounter, timestamp: "", level: .info, logger: "", message: line, raw: line)
|
||||
return LogEntry(id: entryCounter, timestamp: "", level: .info, sessionId: nil, logger: "", message: line, raw: line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MarkdownContentView: View {
|
||||
let content: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(parseBlocks().enumerated()), id: \.offset) { _, block in
|
||||
blockView(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func blockView(_ block: MarkdownBlock) -> some View {
|
||||
switch block {
|
||||
case .heading(let level, let text):
|
||||
headingView(level: level, text: text)
|
||||
case .paragraph(let text):
|
||||
Text(MarkdownRenderer.inlineAttributedString(text))
|
||||
.textSelection(.enabled)
|
||||
case .codeBlock(let code, let language):
|
||||
codeBlockView(code: code, language: language)
|
||||
case .bulletItem(let text, let indent):
|
||||
bulletView(text: text, indent: indent)
|
||||
case .numberedItem(let number, let text):
|
||||
numberedView(number: number, text: text)
|
||||
case .blockquote(let text):
|
||||
blockquoteView(text: text)
|
||||
case .horizontalRule:
|
||||
Divider().padding(.vertical, 4)
|
||||
case .blank:
|
||||
Spacer().frame(height: 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Block Views
|
||||
|
||||
private func headingView(level: Int, text: String) -> some View {
|
||||
let font: Font = switch level {
|
||||
case 1: .title.bold()
|
||||
case 2: .title2.bold()
|
||||
case 3: .title3.bold()
|
||||
case 4: .headline
|
||||
default: .subheadline.bold()
|
||||
}
|
||||
return Text(MarkdownRenderer.inlineAttributedString(text))
|
||||
.font(font)
|
||||
.textSelection(.enabled)
|
||||
.padding(.top, level <= 2 ? 8 : 4)
|
||||
}
|
||||
|
||||
private func codeBlockView(code: String, language: String?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let lang = language, !lang.isEmpty {
|
||||
Text(lang)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(code)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.textBackgroundColor).opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.strokeBorder(.quaternary, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func bulletView(text: String, indent: Int) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text("\u{2022}")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(MarkdownRenderer.inlineAttributedString(text))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding(.leading, CGFloat(indent) * 16)
|
||||
}
|
||||
|
||||
private func numberedView(number: Int, text: String) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text("\(number).")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20, alignment: .trailing)
|
||||
Text(MarkdownRenderer.inlineAttributedString(text))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func blockquoteView(text: String) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
RoundedRectangle(cornerRadius: 1)
|
||||
.fill(.blue.opacity(0.5))
|
||||
.frame(width: 3)
|
||||
Text(MarkdownRenderer.inlineAttributedString(text))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.padding(.leading, 10)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
// MARK: - Parser
|
||||
|
||||
private func parseBlocks() -> [MarkdownBlock] {
|
||||
var blocks: [MarkdownBlock] = []
|
||||
let lines = content.components(separatedBy: "\n")
|
||||
var i = 0
|
||||
|
||||
// Skip YAML frontmatter (--- delimited block at start of file)
|
||||
if i < lines.count && lines[i].trimmingCharacters(in: .whitespaces) == "---" {
|
||||
i += 1
|
||||
while i < lines.count {
|
||||
if lines[i].trimmingCharacters(in: .whitespaces) == "---" {
|
||||
i += 1
|
||||
break
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
while i < lines.count {
|
||||
let line = lines[i]
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Blank line
|
||||
if trimmed.isEmpty {
|
||||
if blocks.last != .blank {
|
||||
blocks.append(.blank)
|
||||
}
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Code block (fenced)
|
||||
if trimmed.hasPrefix("```") {
|
||||
let language = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces)
|
||||
var codeLines: [String] = []
|
||||
i += 1
|
||||
while i < lines.count {
|
||||
if lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
|
||||
i += 1
|
||||
break
|
||||
}
|
||||
codeLines.append(lines[i])
|
||||
i += 1
|
||||
}
|
||||
blocks.append(.codeBlock(codeLines.joined(separator: "\n"), language: language.isEmpty ? nil : language))
|
||||
continue
|
||||
}
|
||||
|
||||
// Heading
|
||||
if let heading = parseHeading(trimmed) {
|
||||
blocks.append(heading)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if isHorizontalRule(trimmed) {
|
||||
blocks.append(.horizontalRule)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if trimmed.hasPrefix("> ") {
|
||||
var quoteLines: [String] = []
|
||||
while i < lines.count {
|
||||
let l = lines[i].trimmingCharacters(in: .whitespaces)
|
||||
if l.hasPrefix("> ") {
|
||||
quoteLines.append(String(l.dropFirst(2)))
|
||||
} else if l.hasPrefix(">") {
|
||||
quoteLines.append(String(l.dropFirst(1)))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
blocks.append(.blockquote(quoteLines.joined(separator: " ")))
|
||||
continue
|
||||
}
|
||||
|
||||
// Bullet list
|
||||
if let bullet = parseBullet(line) {
|
||||
blocks.append(bullet)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Numbered list
|
||||
if let numbered = parseNumbered(trimmed) {
|
||||
blocks.append(numbered)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Paragraph — each line is its own paragraph to preserve line breaks
|
||||
blocks.append(.paragraph(trimmed))
|
||||
i += 1
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
private func parseHeading(_ line: String) -> MarkdownBlock? {
|
||||
let levels: [(prefix: String, level: Int)] = [
|
||||
("##### ", 5), ("#### ", 4), ("### ", 3), ("## ", 2), ("# ", 1)
|
||||
]
|
||||
for (prefix, level) in levels {
|
||||
if line.hasPrefix(prefix) {
|
||||
return .heading(level, String(line.dropFirst(prefix.count)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func parseBullet(_ line: String) -> MarkdownBlock? {
|
||||
let indent = line.prefix(while: { $0 == " " }).count / 2
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix("- ") {
|
||||
return .bulletItem(String(trimmed.dropFirst(2)), indent: indent)
|
||||
}
|
||||
if trimmed.hasPrefix("* ") {
|
||||
return .bulletItem(String(trimmed.dropFirst(2)), indent: indent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func parseNumbered(_ line: String) -> MarkdownBlock? {
|
||||
guard let dotIdx = line.firstIndex(of: ".") else { return nil }
|
||||
let numStr = String(line[line.startIndex..<dotIdx])
|
||||
guard let num = Int(numStr), line[line.index(after: dotIdx)...].hasPrefix(" ") else { return nil }
|
||||
let text = String(line[line.index(dotIdx, offsetBy: 2)...])
|
||||
return .numberedItem(num, text)
|
||||
}
|
||||
|
||||
private func isHorizontalRule(_ line: String) -> Bool {
|
||||
let stripped = line.replacingOccurrences(of: " ", with: "")
|
||||
return (stripped.allSatisfy({ $0 == "-" }) && stripped.count >= 3) ||
|
||||
(stripped.allSatisfy({ $0 == "*" }) && stripped.count >= 3) ||
|
||||
(stripped.allSatisfy({ $0 == "_" }) && stripped.count >= 3)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Block Model
|
||||
|
||||
private enum MarkdownBlock: Equatable {
|
||||
case heading(Int, String)
|
||||
case paragraph(String)
|
||||
case codeBlock(String, language: String?)
|
||||
case bulletItem(String, indent: Int)
|
||||
case numberedItem(Int, String)
|
||||
case blockquote(String)
|
||||
case horizontalRule
|
||||
case blank
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
enum MarkdownRenderer {
|
||||
/// Inline-only rendering — bold, italic, code spans, links. Preserves whitespace/newlines.
|
||||
static func inlineAttributedString(_ text: String) -> AttributedString {
|
||||
(try? AttributedString(markdown: text, options: .init(
|
||||
interpretedSyntax: .inlineOnlyPreservingWhitespace
|
||||
))) ?? AttributedString(text)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ final class ActivityViewModel {
|
||||
var filterKind: ToolKind?
|
||||
var filterSessionId: String?
|
||||
var selectedEntry: ActivityEntry?
|
||||
var toolResult: String?
|
||||
var sessionPreviews: [String: String] = [:]
|
||||
var isLoading = true
|
||||
|
||||
@@ -54,6 +55,15 @@ final class ActivityViewModel {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func selectEntry(_ entry: ActivityEntry?) async {
|
||||
selectedEntry = entry
|
||||
if let entry {
|
||||
toolResult = await dataService.fetchToolResult(callId: entry.id)
|
||||
} else {
|
||||
toolResult = nil
|
||||
}
|
||||
}
|
||||
|
||||
func cleanup() async {
|
||||
await dataService.close()
|
||||
}
|
||||
|
||||
@@ -57,11 +57,8 @@ struct ActivityView: View {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedEntry?.id },
|
||||
set: { id in
|
||||
if let id {
|
||||
viewModel.selectedEntry = viewModel.filteredActivity.first(where: { $0.id == id })
|
||||
} else {
|
||||
viewModel.selectedEntry = nil
|
||||
}
|
||||
let entry = id.flatMap { id in viewModel.filteredActivity.first(where: { $0.id == id }) }
|
||||
Task { await viewModel.selectEntry(entry) }
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.filteredActivity) { entry in
|
||||
@@ -146,14 +143,32 @@ struct ActivityView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
if let result = viewModel.toolResult, !result.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Output")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(result)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(50)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.textBackgroundColor).opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.strokeBorder(.quaternary, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if !entry.messageContent.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Assistant Message")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(entry.messageContent)
|
||||
.font(.caption)
|
||||
.textSelection(.enabled)
|
||||
MarkdownContentView(content: entry.messageContent)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import SwiftTerm
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class ChatViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ChatViewModel")
|
||||
private let dataService = HermesDataService()
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
@@ -14,32 +16,425 @@ final class ChatViewModel {
|
||||
var voiceEnabled = false
|
||||
var ttsEnabled = false
|
||||
var isRecording = false
|
||||
var displayMode: ChatDisplayMode = .richChat
|
||||
let richChatViewModel = RichChatViewModel()
|
||||
private var coordinator: Coordinator?
|
||||
|
||||
// ACP state
|
||||
private var acpClient: ACPClient?
|
||||
private var acpEventTask: Task<Void, Never>?
|
||||
private var acpPromptTask: Task<Void, Never>?
|
||||
private var healthMonitorTask: Task<Void, Never>?
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var isHandlingDisconnect = false
|
||||
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
|
||||
var acpStatus: String = ""
|
||||
var acpError: String?
|
||||
|
||||
private static let maxReconnectAttempts = 5
|
||||
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
|
||||
private static let maxReconnectDelay: UInt64 = 16_000_000_000 // 16 seconds
|
||||
|
||||
var hermesBinaryExists: Bool {
|
||||
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary)
|
||||
}
|
||||
|
||||
// MARK: - Session Lifecycle
|
||||
|
||||
func startNewSession() {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
richChatViewModel.reset()
|
||||
|
||||
if displayMode == .richChat {
|
||||
startACPSession(resume: nil)
|
||||
} else {
|
||||
launchTerminal(arguments: ["chat"])
|
||||
}
|
||||
}
|
||||
|
||||
func resumeSession(_ sessionId: String) {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
richChatViewModel.reset()
|
||||
|
||||
if displayMode == .richChat {
|
||||
startACPSession(resume: sessionId)
|
||||
} else {
|
||||
richChatViewModel.setSessionId(sessionId)
|
||||
launchTerminal(arguments: ["chat", "--resume", sessionId])
|
||||
}
|
||||
}
|
||||
|
||||
func continueLastSession() {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
richChatViewModel.reset()
|
||||
|
||||
if displayMode == .richChat {
|
||||
// Find most recent session and resume via ACP
|
||||
Task { @MainActor in
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
let sessionId = await dataService.fetchMostRecentlyActiveSessionId()
|
||||
await dataService.close()
|
||||
if let sessionId {
|
||||
startACPSession(resume: sessionId)
|
||||
} else {
|
||||
startACPSession(resume: nil)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchTerminal(arguments: ["chat", "--continue"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Send Message
|
||||
|
||||
func sendText(_ text: String) {
|
||||
if displayMode == .richChat {
|
||||
if let client = acpClient {
|
||||
sendViaACP(client: client, text: text)
|
||||
} else {
|
||||
// Auto-start ACP and send the queued message
|
||||
autoStartACPAndSend(text: text)
|
||||
}
|
||||
} else if let tv = terminalView {
|
||||
sendToTerminal(tv, text: text + "\r")
|
||||
}
|
||||
}
|
||||
|
||||
/// Start ACP for the current or most recent session, then send the queued prompt.
|
||||
private func autoStartACPAndSend(text: String) {
|
||||
// Show the user message immediately
|
||||
richChatViewModel.addUserMessage(text: text)
|
||||
|
||||
Task { @MainActor in
|
||||
// Find a session to resume: prefer current sessionId, then most recent
|
||||
var sessionToResume = richChatViewModel.sessionId
|
||||
if sessionToResume == nil {
|
||||
let opened = await dataService.open()
|
||||
if opened {
|
||||
sessionToResume = await dataService.fetchMostRecentlyActiveSessionId()
|
||||
await dataService.close()
|
||||
}
|
||||
}
|
||||
|
||||
let client = ACPClient()
|
||||
self.acpClient = client
|
||||
|
||||
do {
|
||||
try await client.start()
|
||||
acpStatus = await client.statusMessage
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
let cwd = NSHomeDirectory()
|
||||
|
||||
hasActiveProcess = true
|
||||
|
||||
let resolvedSessionId: String
|
||||
if let existing = sessionToResume {
|
||||
acpStatus = "Loading session..."
|
||||
do {
|
||||
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: existing)
|
||||
} catch {
|
||||
logger.info("Session \(existing) not found in ACP, creating new session")
|
||||
acpStatus = "Creating new session..."
|
||||
resolvedSessionId = try await client.newSession(cwd: cwd)
|
||||
}
|
||||
} else {
|
||||
acpStatus = "Creating session..."
|
||||
resolvedSessionId = try await client.newSession(cwd: cwd)
|
||||
}
|
||||
|
||||
richChatViewModel.setSessionId(resolvedSessionId)
|
||||
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
|
||||
|
||||
// Now send the queued prompt
|
||||
sendViaACP(client: client, text: text)
|
||||
} catch {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("Auto-start ACP failed: \(msg)")
|
||||
acpStatus = "Failed"
|
||||
acpError = msg
|
||||
hasActiveProcess = false
|
||||
acpClient = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendViaACP(client: ACPClient, text: String) {
|
||||
guard let sessionId = richChatViewModel.sessionId else {
|
||||
acpError = "No session ID — cannot send"
|
||||
return
|
||||
}
|
||||
|
||||
// Don't duplicate user message if autoStartACPAndSend already added it
|
||||
if richChatViewModel.messages.last?.isUser != true
|
||||
|| richChatViewModel.messages.last?.content != text {
|
||||
richChatViewModel.addUserMessage(text: text)
|
||||
}
|
||||
|
||||
acpStatus = "Agent working..."
|
||||
acpPromptTask = Task { @MainActor in
|
||||
do {
|
||||
let result = try await client.sendPrompt(sessionId: sessionId, text: text)
|
||||
acpStatus = "Ready"
|
||||
richChatViewModel.handleACPEvent(
|
||||
.promptComplete(sessionId: sessionId, response: result)
|
||||
)
|
||||
// Re-fetch session from DB to pick up cost/token data Hermes may have written
|
||||
await richChatViewModel.refreshSessionFromDB()
|
||||
} catch is CancellationError {
|
||||
acpStatus = "Cancelled"
|
||||
} catch {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("ACP prompt failed: \(msg)")
|
||||
acpStatus = "Error"
|
||||
acpError = msg
|
||||
richChatViewModel.handleACPEvent(
|
||||
.promptComplete(sessionId: sessionId, response: ACPPromptResult(
|
||||
stopReason: "error",
|
||||
inputTokens: 0, outputTokens: 0,
|
||||
thoughtTokens: 0, cachedReadTokens: 0
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ACP Session Management
|
||||
|
||||
private func startACPSession(resume sessionId: String?) {
|
||||
stopACP()
|
||||
acpError = nil
|
||||
acpStatus = "Starting..."
|
||||
|
||||
let client = ACPClient()
|
||||
self.acpClient = client
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
// Start ACP process and event loop FIRST
|
||||
try await client.start()
|
||||
acpStatus = await client.statusMessage
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
let cwd = NSHomeDirectory()
|
||||
|
||||
// Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true
|
||||
// and doesn't wipe messages with a DB refresh
|
||||
hasActiveProcess = true
|
||||
|
||||
let resolvedSessionId: String
|
||||
if let sessionId {
|
||||
acpStatus = "Loading session..."
|
||||
do {
|
||||
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
|
||||
} catch {
|
||||
logger.info("Session \(sessionId) not found in ACP, creating new session with history")
|
||||
acpStatus = "Creating new session..."
|
||||
resolvedSessionId = try await client.newSession(cwd: cwd)
|
||||
}
|
||||
// Load messages from both origin CLI session and ACP session
|
||||
await richChatViewModel.loadSessionHistory(
|
||||
sessionId: sessionId,
|
||||
acpSessionId: resolvedSessionId
|
||||
)
|
||||
} else {
|
||||
acpStatus = "Creating session..."
|
||||
resolvedSessionId = try await client.newSession(cwd: cwd)
|
||||
}
|
||||
|
||||
richChatViewModel.setSessionId(resolvedSessionId)
|
||||
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
|
||||
|
||||
// Refresh session list so the new ACP session appears in the Resume menu
|
||||
await loadRecentSessions()
|
||||
|
||||
logger.info("ACP session ready: \(resolvedSessionId)")
|
||||
} catch {
|
||||
let msg = error.localizedDescription
|
||||
logger.error("Failed to start ACP session: \(msg)")
|
||||
acpStatus = "Failed"
|
||||
acpError = msg
|
||||
hasActiveProcess = false
|
||||
acpClient = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startACPEventLoop(client: ACPClient) {
|
||||
acpEventTask = Task { @MainActor [weak self] in
|
||||
let eventStream = await client.events
|
||||
for await event in eventStream {
|
||||
guard !Task.isCancelled else { break }
|
||||
self?.richChatViewModel.handleACPEvent(event)
|
||||
self?.acpStatus = await client.statusMessage
|
||||
}
|
||||
// Stream ended — if we weren't cancelled, the connection died
|
||||
if !Task.isCancelled {
|
||||
self?.handleConnectionDied()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startHealthMonitor(client: ACPClient) {
|
||||
healthMonitorTask = Task { @MainActor [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||
guard !Task.isCancelled else { break }
|
||||
let healthy = await client.isHealthy
|
||||
if !healthy {
|
||||
self?.handleConnectionDied()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleConnectionDied() {
|
||||
guard acpClient != nil, !isHandlingDisconnect else { return }
|
||||
isHandlingDisconnect = true
|
||||
logger.warning("ACP connection died")
|
||||
|
||||
// Finalize any in-progress streaming message before reconnection
|
||||
richChatViewModel.finalizeOnDisconnect()
|
||||
|
||||
// Save session ID for reconnection before cleaning up
|
||||
let savedSessionId = richChatViewModel.sessionId
|
||||
|
||||
// Clean up the dead client
|
||||
acpPromptTask?.cancel()
|
||||
acpPromptTask = nil
|
||||
acpEventTask?.cancel()
|
||||
acpEventTask = nil
|
||||
healthMonitorTask?.cancel()
|
||||
healthMonitorTask = nil
|
||||
if let client = acpClient {
|
||||
Task { await client.stop() }
|
||||
}
|
||||
acpClient = nil
|
||||
hasActiveProcess = false
|
||||
|
||||
// Attempt auto-reconnect if we have a session to restore
|
||||
guard let savedSessionId else {
|
||||
showConnectionFailure()
|
||||
isHandlingDisconnect = false
|
||||
return
|
||||
}
|
||||
attemptReconnect(sessionId: savedSessionId)
|
||||
}
|
||||
|
||||
private func attemptReconnect(sessionId: String) {
|
||||
reconnectTask?.cancel()
|
||||
acpError = nil
|
||||
|
||||
reconnectTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
for attempt in 1...Self.maxReconnectAttempts {
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
acpStatus = "Reconnecting (\(attempt)/\(Self.maxReconnectAttempts))..."
|
||||
logger.info("Reconnect attempt \(attempt)/\(Self.maxReconnectAttempts) for session \(sessionId)")
|
||||
|
||||
// Backoff delay (skip on first attempt for fast recovery)
|
||||
if attempt > 1 {
|
||||
let delay = min(
|
||||
Self.reconnectBaseDelay * UInt64(1 << (attempt - 1)),
|
||||
Self.maxReconnectDelay
|
||||
)
|
||||
try? await Task.sleep(nanoseconds: delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
}
|
||||
|
||||
let client = ACPClient()
|
||||
do {
|
||||
try await client.start()
|
||||
|
||||
let cwd = NSHomeDirectory()
|
||||
let resolvedSessionId: String
|
||||
|
||||
// Try resumeSession first (designed for reconnection), then loadSession.
|
||||
// NEVER fall back to newSession — that loses all conversation context.
|
||||
do {
|
||||
resolvedSessionId = try await client.resumeSession(cwd: cwd, sessionId: sessionId)
|
||||
} catch {
|
||||
logger.info("session/resume failed, trying session/load: \(error.localizedDescription)")
|
||||
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
|
||||
}
|
||||
|
||||
// Success — wire up the new client
|
||||
self.acpClient = client
|
||||
self.hasActiveProcess = true
|
||||
richChatViewModel.setSessionId(resolvedSessionId)
|
||||
|
||||
// Reconcile in-memory messages with what Hermes persisted to DB
|
||||
await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId)
|
||||
|
||||
acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))"
|
||||
acpError = nil
|
||||
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
isHandlingDisconnect = false
|
||||
logger.info("Reconnected successfully on attempt \(attempt)")
|
||||
return
|
||||
} catch {
|
||||
logger.warning("Reconnect attempt \(attempt) failed: \(error.localizedDescription)")
|
||||
await client.stop()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts exhausted
|
||||
guard !Task.isCancelled else { return }
|
||||
showConnectionFailure()
|
||||
isHandlingDisconnect = false
|
||||
}
|
||||
}
|
||||
|
||||
private func showConnectionFailure() {
|
||||
richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly"))
|
||||
acpStatus = "Connection lost"
|
||||
acpError = "Connection lost. Use the Session menu to reconnect."
|
||||
}
|
||||
|
||||
func stopACP() {
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
acpPromptTask?.cancel()
|
||||
acpPromptTask = nil
|
||||
acpEventTask?.cancel()
|
||||
acpEventTask = nil
|
||||
healthMonitorTask?.cancel()
|
||||
healthMonitorTask = nil
|
||||
if let client = acpClient {
|
||||
Task { await client.stop() }
|
||||
}
|
||||
acpClient = nil
|
||||
hasActiveProcess = false
|
||||
isHandlingDisconnect = false
|
||||
}
|
||||
|
||||
/// Respond to a permission request from the ACP agent.
|
||||
func respondToPermission(optionId: String) {
|
||||
guard let client = acpClient,
|
||||
let permission = richChatViewModel.pendingPermission else { return }
|
||||
Task {
|
||||
await client.respondToPermission(requestId: permission.requestId, optionId: optionId)
|
||||
}
|
||||
richChatViewModel.pendingPermission = nil
|
||||
}
|
||||
|
||||
// MARK: - Recent Sessions
|
||||
|
||||
func loadRecentSessions() async {
|
||||
let opened = await dataService.open()
|
||||
@@ -55,6 +450,8 @@ final class ChatViewModel {
|
||||
return session.id
|
||||
}
|
||||
|
||||
// MARK: - Voice (terminal mode only)
|
||||
|
||||
func toggleVoice() {
|
||||
guard let tv = terminalView else { return }
|
||||
if voiceEnabled {
|
||||
@@ -76,18 +473,21 @@ final class ChatViewModel {
|
||||
|
||||
func pushToTalk() {
|
||||
guard let tv = terminalView, voiceEnabled else { return }
|
||||
// Ctrl+B = ASCII 0x02
|
||||
let ctrlB: [UInt8] = [0x02]
|
||||
tv.send(source: tv, data: ctrlB[0..<1])
|
||||
isRecording.toggle()
|
||||
}
|
||||
|
||||
// MARK: - Terminal Mode
|
||||
|
||||
private func sendToTerminal(_ tv: LocalProcessTerminalView, text: String) {
|
||||
let bytes = Array(text.utf8)
|
||||
tv.send(source: tv, data: bytes[0..<bytes.count])
|
||||
}
|
||||
|
||||
private func launchTerminal(arguments: [String]) {
|
||||
stopACP()
|
||||
|
||||
if let existing = terminalView {
|
||||
existing.terminate()
|
||||
existing.removeFromSuperview()
|
||||
@@ -102,6 +502,7 @@ final class ChatViewModel {
|
||||
self?.hasActiveProcess = false
|
||||
self?.voiceEnabled = false
|
||||
self?.isRecording = false
|
||||
Task { await self?.richChatViewModel.refreshMessages() }
|
||||
})
|
||||
terminal.processDelegate = coord
|
||||
self.coordinator = coord
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import Foundation
|
||||
|
||||
enum ChatDisplayMode: String, CaseIterable {
|
||||
case terminal
|
||||
case richChat
|
||||
}
|
||||
|
||||
struct MessageGroup: Identifiable {
|
||||
let id: Int
|
||||
let userMessage: HermesMessage?
|
||||
let assistantMessages: [HermesMessage]
|
||||
let toolResults: [String: HermesMessage]
|
||||
|
||||
var allMessages: [HermesMessage] {
|
||||
var result: [HermesMessage] = []
|
||||
if let user = userMessage { result.append(user) }
|
||||
result.append(contentsOf: assistantMessages)
|
||||
return result
|
||||
}
|
||||
|
||||
var toolCallCount: Int {
|
||||
assistantMessages.reduce(0) { $0 + $1.toolCalls.count }
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class RichChatViewModel {
|
||||
private let dataService = HermesDataService()
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
var currentSession: HermesSession?
|
||||
var messageGroups: [MessageGroup] = []
|
||||
var isAgentWorking = false
|
||||
var pendingPermission: PendingPermission?
|
||||
/// Mutated to trigger a scroll-to-bottom in the message list.
|
||||
var scrollTrigger = UUID()
|
||||
|
||||
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
|
||||
private(set) var acpInputTokens = 0
|
||||
private(set) var acpOutputTokens = 0
|
||||
private(set) var acpThoughtTokens = 0
|
||||
private(set) var acpCachedReadTokens = 0
|
||||
|
||||
/// Slash commands advertised by the ACP server via `available_commands_update`.
|
||||
private(set) var availableCommandNames: Set<String> = []
|
||||
|
||||
var supportsCompress: Bool { availableCommandNames.contains("compress") }
|
||||
|
||||
var hasMessages: Bool { !messages.isEmpty }
|
||||
|
||||
func requestScrollToBottom() {
|
||||
scrollTrigger = UUID()
|
||||
}
|
||||
|
||||
private(set) var sessionId: String?
|
||||
/// The original CLI session ID when resuming a CLI session via ACP.
|
||||
/// Used to combine old CLI messages with new ACP messages.
|
||||
private(set) var originSessionId: String?
|
||||
private var nextLocalId = -1
|
||||
private var streamingAssistantText = ""
|
||||
private var streamingThinkingText = ""
|
||||
private var streamingToolCalls: [HermesToolCall] = []
|
||||
|
||||
// DB polling state (used in terminal mode fallback)
|
||||
private var lastKnownFingerprint: HermesDataService.MessageFingerprint?
|
||||
private var debounceTask: Task<Void, Never>?
|
||||
private var resetTimestamp: Date?
|
||||
private var userSendPending = false
|
||||
private var activePollingTimer: Timer?
|
||||
|
||||
struct PendingPermission {
|
||||
let requestId: Int
|
||||
let title: String
|
||||
let kind: String
|
||||
let options: [(optionId: String, name: String)]
|
||||
}
|
||||
|
||||
// MARK: - Reset
|
||||
|
||||
func reset() {
|
||||
debounceTask?.cancel()
|
||||
stopActivePolling()
|
||||
Task { await dataService.close() }
|
||||
messages = []
|
||||
messageGroups = []
|
||||
currentSession = nil
|
||||
lastKnownFingerprint = nil
|
||||
sessionId = nil
|
||||
originSessionId = nil
|
||||
isAgentWorking = false
|
||||
userSendPending = false
|
||||
resetTimestamp = Date()
|
||||
nextLocalId = -1
|
||||
streamingAssistantText = ""
|
||||
streamingThinkingText = ""
|
||||
streamingToolCalls = []
|
||||
acpInputTokens = 0
|
||||
acpOutputTokens = 0
|
||||
acpThoughtTokens = 0
|
||||
acpCachedReadTokens = 0
|
||||
availableCommandNames = []
|
||||
pendingPermission = nil
|
||||
}
|
||||
|
||||
func setSessionId(_ id: String?) {
|
||||
sessionId = id
|
||||
lastKnownFingerprint = nil
|
||||
}
|
||||
|
||||
func cleanup() async {
|
||||
stopActivePolling()
|
||||
debounceTask?.cancel()
|
||||
await dataService.close()
|
||||
}
|
||||
|
||||
/// Re-fetch session metadata from DB to pick up cost/token updates.
|
||||
func refreshSessionFromDB() async {
|
||||
guard let sessionId else { return }
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
if let session = await dataService.fetchSession(id: sessionId) {
|
||||
currentSession = session
|
||||
}
|
||||
await dataService.close()
|
||||
}
|
||||
|
||||
// MARK: - ACP Event Handling
|
||||
|
||||
/// Add a user message immediately (before DB write) for instant UI feedback.
|
||||
func addUserMessage(text: String) {
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
let message = HermesMessage(
|
||||
id: id,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "user",
|
||||
content: text,
|
||||
toolCallId: nil,
|
||||
toolCalls: [],
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: nil,
|
||||
reasoning: nil
|
||||
)
|
||||
messages.append(message)
|
||||
isAgentWorking = true
|
||||
streamingAssistantText = ""
|
||||
streamingThinkingText = ""
|
||||
streamingToolCalls = []
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
/// Process a streaming ACP event and update the message list.
|
||||
func handleACPEvent(_ event: ACPEvent) {
|
||||
switch event {
|
||||
case .messageChunk(_, let text):
|
||||
appendMessageChunk(text: text)
|
||||
case .thoughtChunk(_, let text):
|
||||
appendThoughtChunk(text: text)
|
||||
case .toolCallStart(_, let call):
|
||||
handleToolCallStart(call)
|
||||
case .toolCallUpdate(_, let update):
|
||||
handleToolCallComplete(update)
|
||||
case .permissionRequest(_, let requestId, let request):
|
||||
pendingPermission = PendingPermission(
|
||||
requestId: requestId,
|
||||
title: request.toolCallTitle,
|
||||
kind: request.toolCallKind,
|
||||
options: request.options
|
||||
)
|
||||
case .promptComplete(_, let response):
|
||||
handlePromptComplete(response: response)
|
||||
case .connectionLost(let reason):
|
||||
handleConnectionLost(reason: reason)
|
||||
case .availableCommands(_, let commands):
|
||||
var names: Set<String> = []
|
||||
for entry in commands {
|
||||
if let name = entry["name"] as? String {
|
||||
// Hermes sends names either as "compress" or "/compress"
|
||||
names.insert(name.trimmingCharacters(in: CharacterSet(charactersIn: "/")))
|
||||
}
|
||||
}
|
||||
availableCommandNames = names
|
||||
case .unknown:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func appendMessageChunk(text: String) {
|
||||
streamingAssistantText += text
|
||||
upsertStreamingMessage()
|
||||
}
|
||||
|
||||
private func appendThoughtChunk(text: String) {
|
||||
streamingThinkingText += text
|
||||
upsertStreamingMessage()
|
||||
}
|
||||
|
||||
private func handleToolCallStart(_ call: ACPToolCallEvent) {
|
||||
let toolCall = HermesToolCall(
|
||||
callId: call.toolCallId,
|
||||
functionName: call.functionName,
|
||||
arguments: call.argumentsJSON
|
||||
)
|
||||
streamingToolCalls.append(toolCall)
|
||||
upsertStreamingMessage()
|
||||
}
|
||||
|
||||
private func handleToolCallComplete(_ update: ACPToolCallUpdateEvent) {
|
||||
// Finalize the streaming assistant message (with its tool calls) as a permanent message
|
||||
finalizeStreamingMessage()
|
||||
|
||||
// Add tool result message
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
messages.append(HermesMessage(
|
||||
id: id,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "tool",
|
||||
content: update.rawOutput ?? update.content,
|
||||
toolCallId: update.toolCallId,
|
||||
toolCalls: [],
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: nil,
|
||||
reasoning: nil
|
||||
))
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
private func handlePromptComplete(response: ACPPromptResult) {
|
||||
finalizeStreamingMessage()
|
||||
// Accumulate token usage from this prompt
|
||||
acpInputTokens += response.inputTokens
|
||||
acpOutputTokens += response.outputTokens
|
||||
acpThoughtTokens += response.thoughtTokens
|
||||
acpCachedReadTokens += response.cachedReadTokens
|
||||
isAgentWorking = false
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
private func handleConnectionLost(reason: String) {
|
||||
finalizeStreamingMessage()
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
messages.append(HermesMessage(
|
||||
id: id,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "system",
|
||||
content: "Connection lost: \(reason). Use the Session menu to start or resume a session.",
|
||||
toolCallId: nil,
|
||||
toolCalls: [],
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: nil,
|
||||
reasoning: nil
|
||||
))
|
||||
isAgentWorking = false
|
||||
pendingPermission = nil
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
// MARK: - Streaming Message Management
|
||||
|
||||
private static let streamingId = 0
|
||||
|
||||
/// Insert or update the in-progress streaming assistant message (id=0).
|
||||
private func upsertStreamingMessage() {
|
||||
let msg = HermesMessage(
|
||||
id: Self.streamingId,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "assistant",
|
||||
content: streamingAssistantText,
|
||||
toolCallId: nil,
|
||||
toolCalls: streamingToolCalls,
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: nil,
|
||||
reasoning: streamingThinkingText.isEmpty ? nil : streamingThinkingText
|
||||
)
|
||||
|
||||
if let idx = messages.firstIndex(where: { $0.id == Self.streamingId }) {
|
||||
messages[idx] = msg
|
||||
} else {
|
||||
messages.append(msg)
|
||||
}
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
/// Convert the streaming message (id=0) into a permanent message and reset streaming state.
|
||||
private func finalizeStreamingMessage() {
|
||||
guard let idx = messages.firstIndex(where: { $0.id == Self.streamingId }) else { return }
|
||||
|
||||
// Only finalize if there's actual content
|
||||
let hasContent = !streamingAssistantText.isEmpty
|
||||
|| !streamingThinkingText.isEmpty
|
||||
|| !streamingToolCalls.isEmpty
|
||||
|
||||
if hasContent {
|
||||
let id = nextLocalId
|
||||
nextLocalId -= 1
|
||||
messages[idx] = HermesMessage(
|
||||
id: id,
|
||||
sessionId: sessionId ?? "",
|
||||
role: "assistant",
|
||||
content: streamingAssistantText,
|
||||
toolCallId: nil,
|
||||
toolCalls: streamingToolCalls,
|
||||
toolName: nil,
|
||||
timestamp: Date(),
|
||||
tokenCount: nil,
|
||||
finishReason: streamingToolCalls.isEmpty ? "stop" : nil,
|
||||
reasoning: streamingThinkingText.isEmpty ? nil : streamingThinkingText
|
||||
)
|
||||
} else {
|
||||
// Remove empty streaming placeholder
|
||||
messages.remove(at: idx)
|
||||
}
|
||||
|
||||
// Reset streaming state for next chunk
|
||||
streamingAssistantText = ""
|
||||
streamingThinkingText = ""
|
||||
streamingToolCalls = []
|
||||
}
|
||||
|
||||
// MARK: - Disconnect Recovery
|
||||
|
||||
/// Finalize streaming state on disconnect, before reconnection attempts begin.
|
||||
/// Saves partial content as a permanent message without adding a system message.
|
||||
func finalizeOnDisconnect() {
|
||||
finalizeStreamingMessage()
|
||||
isAgentWorking = false
|
||||
pendingPermission = nil
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
/// Reconcile in-memory messages with DB state after a successful reconnection.
|
||||
/// Merges DB-persisted messages with any local-only messages (e.g., user messages
|
||||
/// that the ACP process may not have persisted before crashing).
|
||||
func reconcileWithDB(sessionId: String) async {
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
|
||||
var dbMessages = await dataService.fetchMessages(sessionId: sessionId)
|
||||
|
||||
// If we have an origin session (CLI session continued via ACP),
|
||||
// include those messages too
|
||||
if let origin = originSessionId, origin != sessionId {
|
||||
let originMessages = await dataService.fetchMessages(sessionId: origin)
|
||||
if !originMessages.isEmpty {
|
||||
dbMessages = originMessages + dbMessages
|
||||
dbMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
|
||||
}
|
||||
}
|
||||
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
await dataService.close()
|
||||
|
||||
// Find local-only user messages not yet in DB.
|
||||
// Local messages have negative IDs; DB messages have positive IDs.
|
||||
let dbUserContents = Set(dbMessages.filter(\.isUser).map(\.content))
|
||||
let localOnlyMessages = messages.filter { msg in
|
||||
msg.id < 0 && msg.isUser && !dbUserContents.contains(msg.content)
|
||||
}
|
||||
|
||||
// Build reconciled list: DB messages + unmatched local user messages
|
||||
var reconciled = dbMessages
|
||||
for localMsg in localOnlyMessages {
|
||||
if let ts = localMsg.timestamp,
|
||||
let insertIdx = reconciled.firstIndex(where: { ($0.timestamp ?? .distantPast) > ts }) {
|
||||
reconciled.insert(localMsg, at: insertIdx)
|
||||
} else {
|
||||
reconciled.append(localMsg)
|
||||
}
|
||||
}
|
||||
|
||||
messages = reconciled
|
||||
currentSession = session
|
||||
let minId = reconciled.map(\.id).min() ?? 0
|
||||
nextLocalId = min(minId - 1, -1)
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
// MARK: - Load History from DB (for resumed sessions)
|
||||
|
||||
/// Load message history from the DB, optionally combining an origin session
|
||||
/// (e.g., CLI session) with the current ACP session.
|
||||
func loadSessionHistory(sessionId: String, acpSessionId: String? = nil) async {
|
||||
self.sessionId = sessionId
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
|
||||
var allMessages = await dataService.fetchMessages(sessionId: sessionId)
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
|
||||
// If the ACP session is different from the origin, load its messages too
|
||||
// and combine them chronologically
|
||||
if let acpId = acpSessionId, acpId != sessionId {
|
||||
originSessionId = sessionId
|
||||
self.sessionId = acpId
|
||||
let acpMessages = await dataService.fetchMessages(sessionId: acpId)
|
||||
if !acpMessages.isEmpty {
|
||||
allMessages.append(contentsOf: acpMessages)
|
||||
allMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
|
||||
}
|
||||
}
|
||||
|
||||
messages = allMessages
|
||||
currentSession = session
|
||||
let minId = allMessages.map(\.id).min() ?? 0
|
||||
nextLocalId = min(minId - 1, -1)
|
||||
buildMessageGroups()
|
||||
}
|
||||
|
||||
// MARK: - DB Polling (terminal mode fallback)
|
||||
|
||||
func markAgentWorking() {
|
||||
isAgentWorking = true
|
||||
userSendPending = true
|
||||
startActivePolling()
|
||||
}
|
||||
|
||||
func scheduleRefresh() {
|
||||
debounceTask?.cancel()
|
||||
debounceTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.refreshMessages()
|
||||
}
|
||||
}
|
||||
|
||||
func refreshMessages() async {
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
|
||||
if sessionId == nil {
|
||||
if let resetTime = resetTimestamp {
|
||||
if let candidate = await dataService.fetchMostRecentlyStartedSessionId(after: resetTime) {
|
||||
sessionId = candidate
|
||||
}
|
||||
}
|
||||
if sessionId == nil {
|
||||
sessionId = await dataService.fetchMostRecentlyActiveSessionId()
|
||||
}
|
||||
}
|
||||
|
||||
guard let sessionId else { return }
|
||||
|
||||
let fingerprint = await dataService.fetchMessageFingerprint(sessionId: sessionId)
|
||||
|
||||
if fingerprint != lastKnownFingerprint {
|
||||
let fetched = await dataService.fetchMessages(sessionId: sessionId)
|
||||
let session = await dataService.fetchSession(id: sessionId)
|
||||
lastKnownFingerprint = fingerprint
|
||||
|
||||
messages = fetched
|
||||
currentSession = session
|
||||
buildMessageGroups()
|
||||
|
||||
let derivedWorking = deriveAgentWorking(from: fetched)
|
||||
if userSendPending {
|
||||
if fetched.last?.isUser == true {
|
||||
userSendPending = false
|
||||
}
|
||||
isAgentWorking = true
|
||||
} else {
|
||||
let wasWorking = isAgentWorking
|
||||
isAgentWorking = derivedWorking
|
||||
if wasWorking && !derivedWorking {
|
||||
stopActivePolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startActivePolling() {
|
||||
stopActivePolling()
|
||||
activePollingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.refreshMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopActivePolling() {
|
||||
activePollingTimer?.invalidate()
|
||||
activePollingTimer = nil
|
||||
}
|
||||
|
||||
private func deriveAgentWorking(from fetched: [HermesMessage]) -> Bool {
|
||||
guard let last = fetched.last else { return false }
|
||||
if last.isUser { return true }
|
||||
if last.isToolResult { return true }
|
||||
if last.isAssistant {
|
||||
if !last.toolCalls.isEmpty {
|
||||
let allCallIds = Set(last.toolCalls.map(\.callId))
|
||||
let resultCallIds = Set(fetched.compactMap { $0.isToolResult ? $0.toolCallId : nil })
|
||||
return !allCallIds.subtracting(resultCallIds).isEmpty
|
||||
}
|
||||
return last.finishReason == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Message Grouping
|
||||
|
||||
private func buildMessageGroups() {
|
||||
var groups: [MessageGroup] = []
|
||||
var currentUser: HermesMessage?
|
||||
var currentAssistant: [HermesMessage] = []
|
||||
var currentToolResults: [String: HermesMessage] = [:]
|
||||
var groupIndex = 0
|
||||
|
||||
func flushGroup() {
|
||||
if currentUser != nil || !currentAssistant.isEmpty {
|
||||
// Use stable sequential IDs so SwiftUI doesn't re-create views
|
||||
// when streaming messages finalize (id changes from 0 to -N)
|
||||
groups.append(MessageGroup(
|
||||
id: groupIndex,
|
||||
userMessage: currentUser,
|
||||
assistantMessages: currentAssistant,
|
||||
toolResults: currentToolResults
|
||||
))
|
||||
groupIndex += 1
|
||||
}
|
||||
currentUser = nil
|
||||
currentAssistant = []
|
||||
currentToolResults = [:]
|
||||
}
|
||||
|
||||
for message in messages {
|
||||
if message.isUser {
|
||||
flushGroup()
|
||||
currentUser = message
|
||||
} else if message.isToolResult {
|
||||
if let callId = message.toolCallId {
|
||||
currentToolResults[callId] = message
|
||||
}
|
||||
currentAssistant.append(message)
|
||||
} else {
|
||||
if currentUser == nil && !currentAssistant.isEmpty && message.isAssistant {
|
||||
flushGroup()
|
||||
}
|
||||
currentAssistant.append(message)
|
||||
}
|
||||
}
|
||||
flushGroup()
|
||||
|
||||
messageGroups = groups
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,11 @@ struct ChatView: View {
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
terminalArea
|
||||
chatArea
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
.task { await viewModel.loadRecentSessions() }
|
||||
@@ -19,16 +20,42 @@ struct ChatView: View {
|
||||
|
||||
private var toolbar: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "terminal")
|
||||
Image(systemName: viewModel.displayMode == .terminal ? "terminal" : "bubble.left.and.text.bubble.right")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if viewModel.hasActiveProcess {
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: 6, height: 6)
|
||||
Text("Active")
|
||||
Text(viewModel.acpStatus.isEmpty ? "Active" : viewModel.acpStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else if let error = viewModel.acpError {
|
||||
Circle()
|
||||
.fill(.red)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(1)
|
||||
.help(error)
|
||||
if let sid = viewModel.richChatViewModel.sessionId {
|
||||
Button("Reconnect") {
|
||||
viewModel.resumeSession(sid)
|
||||
}
|
||||
.font(.caption)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
} else if !viewModel.acpStatus.isEmpty {
|
||||
Circle()
|
||||
.fill(.yellow)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(viewModel.acpStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(.secondary)
|
||||
@@ -40,10 +67,21 @@ struct ChatView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if viewModel.hasActiveProcess {
|
||||
if viewModel.hasActiveProcess && viewModel.displayMode == .terminal {
|
||||
voiceControls
|
||||
}
|
||||
|
||||
Picker("View", selection: Bindable(viewModel).displayMode) {
|
||||
Image(systemName: "terminal")
|
||||
.help("Terminal")
|
||||
.tag(ChatDisplayMode.terminal)
|
||||
Image(systemName: "bubble.left.and.text.bubble.right")
|
||||
.help("Rich Chat")
|
||||
.tag(ChatDisplayMode.richChat)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.fixedSize()
|
||||
|
||||
if !viewModel.hermesBinaryExists {
|
||||
Label("Hermes binary not found", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
@@ -51,6 +89,12 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
Menu {
|
||||
if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId {
|
||||
Button("Return to Active Session (\(activeId.prefix(8))...)") {
|
||||
viewModel.richChatViewModel.requestScrollToBottom()
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
Button("New Session") {
|
||||
viewModel.startNewSession()
|
||||
}
|
||||
@@ -60,6 +104,8 @@ struct ChatView: View {
|
||||
if !viewModel.recentSessions.isEmpty {
|
||||
Divider()
|
||||
Text("Resume Session")
|
||||
let activeSessionId = viewModel.richChatViewModel.sessionId
|
||||
let originSessionId = viewModel.richChatViewModel.originSessionId
|
||||
ForEach(viewModel.recentSessions) { session in
|
||||
Button {
|
||||
viewModel.resumeSession(session.id)
|
||||
@@ -75,6 +121,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(session.id == activeSessionId || session.id == originSessionId)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
@@ -137,6 +184,16 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chatArea: some View {
|
||||
switch viewModel.displayMode {
|
||||
case .terminal:
|
||||
terminalArea
|
||||
case .richChat:
|
||||
richChatArea
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var terminalArea: some View {
|
||||
if let terminal = viewModel.terminalView {
|
||||
@@ -157,4 +214,119 @@ struct ChatView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var richChatArea: some View {
|
||||
ZStack {
|
||||
// Keep terminal alive in background if it exists (terminal mode session)
|
||||
if let terminal = viewModel.terminalView {
|
||||
PersistentTerminalView(terminalView: terminal)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
if viewModel.hermesBinaryExists {
|
||||
RichChatView(
|
||||
richChat: viewModel.richChatViewModel,
|
||||
onSend: { viewModel.sendText($0) },
|
||||
isEnabled: viewModel.hasActiveProcess || viewModel.hermesBinaryExists
|
||||
)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Hermes Not Found",
|
||||
systemImage: "terminal",
|
||||
description: Text("Expected at \(HermesPaths.hermesBinary)")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
// Permission approval sheet
|
||||
.sheet(item: permissionBinding) { permission in
|
||||
PermissionApprovalView(
|
||||
title: permission.title,
|
||||
kind: permission.kind,
|
||||
options: permission.options,
|
||||
onRespond: { optionId in
|
||||
viewModel.respondToPermission(optionId: optionId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var permissionBinding: Binding<RichChatViewModel.PendingPermission?> {
|
||||
Binding(
|
||||
get: { viewModel.richChatViewModel.pendingPermission },
|
||||
set: { viewModel.richChatViewModel.pendingPermission = $0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Permission Approval View
|
||||
|
||||
extension RichChatViewModel.PendingPermission: @retroactive Identifiable {
|
||||
var id: Int { requestId }
|
||||
}
|
||||
|
||||
struct PermissionApprovalView: View {
|
||||
let title: String
|
||||
let kind: String
|
||||
let options: [(optionId: String, name: String)]
|
||||
let onRespond: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: kindIcon)
|
||||
.font(.title)
|
||||
.foregroundStyle(kindColor)
|
||||
|
||||
Text("Tool Approval Required")
|
||||
.font(.headline)
|
||||
|
||||
Text(title)
|
||||
.font(.body.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(options, id: \.optionId) { option in
|
||||
if option.optionId == "deny" {
|
||||
Button(option.name) {
|
||||
onRespond(option.optionId)
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
Button(option.name) {
|
||||
onRespond(option.optionId)
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(minWidth: 350)
|
||||
}
|
||||
|
||||
private var kindIcon: String {
|
||||
switch kind {
|
||||
case "execute": return "terminal"
|
||||
case "edit": return "pencil"
|
||||
case "delete": return "trash"
|
||||
default: return "wrench"
|
||||
}
|
||||
}
|
||||
|
||||
private var kindColor: Color {
|
||||
switch kind {
|
||||
case "execute": return .orange
|
||||
case "edit": return .blue
|
||||
case "delete": return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct CodeBlockView: View {
|
||||
let code: String
|
||||
let language: String?
|
||||
|
||||
@State private var copied = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let language, !language.isEmpty {
|
||||
HStack {
|
||||
Text(language)
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
copyButton
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 2)
|
||||
} else {
|
||||
HStack {
|
||||
Spacer()
|
||||
copyButton
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
Text(code)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundStyle(Color(nsColor: NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)))
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.background(Color(nsColor: NSColor(red: 0.11, green: 0.12, blue: 0.14, alpha: 1.0)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var copyButton: some View {
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(code, forType: .string)
|
||||
copied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
copied = false
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: copied ? "checkmark" : "doc.on.doc")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(copied ? .green : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Copy code")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RichChatInputBar: View {
|
||||
let onSend: (String) -> Void
|
||||
let isEnabled: Bool
|
||||
var supportsCompress: Bool = false
|
||||
|
||||
@State private var text = ""
|
||||
@State private var showCompressSheet = false
|
||||
@State private var compressFocus = ""
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if supportsCompress {
|
||||
Button {
|
||||
compressFocus = ""
|
||||
showCompressSheet = true
|
||||
} label: {
|
||||
Image(systemName: "rectangle.compress.vertical")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!isEnabled)
|
||||
.help("Compress conversation (/compress)")
|
||||
}
|
||||
|
||||
TextEditor(text: $text)
|
||||
.font(.body)
|
||||
.scrollContentBackground(.hidden)
|
||||
.focused($isFocused)
|
||||
.frame(minHeight: 28, maxHeight: 120)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(alignment: .topLeading) {
|
||||
if text.isEmpty {
|
||||
Text("Message Hermes...")
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.onKeyPress(.return, phases: .down) { press in
|
||||
if press.modifiers.contains(.shift) {
|
||||
return .ignored
|
||||
}
|
||||
send()
|
||||
return .handled
|
||||
}
|
||||
|
||||
Button {
|
||||
send()
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(canSend ? Color.accentColor : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canSend)
|
||||
.help("Send message (Enter)")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.bar)
|
||||
.sheet(isPresented: $showCompressSheet) {
|
||||
compressSheet
|
||||
}
|
||||
}
|
||||
|
||||
private var compressSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Compress Conversation")
|
||||
.font(.headline)
|
||||
Text("Optionally focus the summary on a specific topic. Leave blank to compress evenly.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Focus topic (optional)", text: $compressFocus)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { showCompressSheet = false }
|
||||
Button("Compress") {
|
||||
let focus = compressFocus.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let command = focus.isEmpty ? "/compress" : "/compress \(focus)"
|
||||
onSend(command)
|
||||
showCompressSheet = false
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 360)
|
||||
}
|
||||
|
||||
private var canSend: Bool {
|
||||
isEnabled && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private func send() {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, isEnabled else { return }
|
||||
onSend(trimmed)
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RichChatMessageList: View {
|
||||
let groups: [MessageGroup]
|
||||
let isWorking: Bool
|
||||
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
|
||||
var scrollTrigger: UUID = UUID()
|
||||
|
||||
/// Track the last group's assistant content length to detect streaming updates.
|
||||
private var scrollAnchor: String {
|
||||
if isWorking { return "typing-indicator" }
|
||||
if let last = groups.last { return "group-\(last.id)" }
|
||||
return "scroll-top"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 16) {
|
||||
Spacer(minLength: 0)
|
||||
.id("scroll-top")
|
||||
ForEach(groups) { group in
|
||||
MessageGroupView(group: group)
|
||||
.id("group-\(group.id)")
|
||||
}
|
||||
|
||||
if isWorking {
|
||||
typingIndicator
|
||||
.id("typing-indicator")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.defaultScrollAnchor(.bottom)
|
||||
// Scroll to bottom when view first appears with content
|
||||
.onAppear {
|
||||
if !groups.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
scrollToBottom(proxy: proxy, animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scroll on new groups
|
||||
.onChange(of: groups.count) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
// Scroll when agent starts/stops working
|
||||
.onChange(of: isWorking) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
// Scroll on streaming content updates (group content changes)
|
||||
.onChange(of: scrollAnchor) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
// Scroll on last message content change (streaming text)
|
||||
.onChange(of: groups.last?.assistantMessages.last?.content ?? "") {
|
||||
scrollToBottom(proxy: proxy, animated: false)
|
||||
}
|
||||
// Scroll on tool call count change
|
||||
.onChange(of: groups.last?.toolCallCount ?? 0) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
// Scroll on external trigger (e.g., "Return to Active Session" button)
|
||||
.onChange(of: scrollTrigger) {
|
||||
scrollToBottom(proxy: proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool = true) {
|
||||
let target = scrollAnchor
|
||||
if animated {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
} else {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
private var typingIndicator: some View {
|
||||
HStack {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<3, id: \.self) { _ in
|
||||
Circle()
|
||||
.fill(.secondary)
|
||||
.frame(width: 6, height: 6)
|
||||
.opacity(0.6)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
.symbolEffect(.pulse)
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageGroupView: View {
|
||||
let group: MessageGroup
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let user = group.userMessage {
|
||||
RichMessageBubble(message: user, toolResults: [:])
|
||||
}
|
||||
|
||||
ForEach(group.assistantMessages.filter(\.isAssistant)) { message in
|
||||
RichMessageBubble(message: message, toolResults: group.toolResults)
|
||||
}
|
||||
|
||||
if group.toolCallCount > 1 {
|
||||
toolSummary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var toolSummary: some View {
|
||||
let kinds = toolKindCounts
|
||||
if !kinds.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "wrench")
|
||||
.font(.caption2)
|
||||
Text(summaryText(kinds))
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private var toolKindCounts: [ToolKind: Int] {
|
||||
var counts: [ToolKind: Int] = [:]
|
||||
for msg in group.assistantMessages where msg.isAssistant {
|
||||
for call in msg.toolCalls {
|
||||
counts[call.toolKind, default: 0] += 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
private func summaryText(_ kinds: [ToolKind: Int]) -> String {
|
||||
let total = kinds.values.reduce(0, +)
|
||||
let parts = kinds.sorted(by: { $0.value > $1.value })
|
||||
.map { "\($0.value) \($0.key.rawValue)" }
|
||||
.joined(separator: ", ")
|
||||
return "Used \(total) tools (\(parts))"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RichChatView: View {
|
||||
@Bindable var richChat: RichChatViewModel
|
||||
var onSend: (String) -> Void
|
||||
var isEnabled: Bool
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@Environment(ChatViewModel.self) private var chatViewModel
|
||||
|
||||
/// In ACP mode, events drive updates directly — no DB polling needed.
|
||||
private var isACPMode: Bool { chatViewModel.isACPConnected }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
SessionInfoBar(
|
||||
session: richChat.currentSession,
|
||||
isWorking: richChat.isAgentWorking,
|
||||
acpInputTokens: richChat.acpInputTokens,
|
||||
acpOutputTokens: richChat.acpOutputTokens,
|
||||
acpThoughtTokens: richChat.acpThoughtTokens
|
||||
)
|
||||
Divider()
|
||||
|
||||
if richChat.messageGroups.isEmpty && !richChat.isAgentWorking {
|
||||
ContentUnavailableView(
|
||||
"Chat Messages",
|
||||
systemImage: "bubble.left.and.text.bubble.right",
|
||||
description: Text("Messages will appear here as the conversation progresses.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
RichChatMessageList(
|
||||
groups: richChat.messageGroups,
|
||||
isWorking: richChat.isAgentWorking,
|
||||
scrollTrigger: richChat.scrollTrigger
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
RichChatInputBar(
|
||||
onSend: { text in
|
||||
onSend(text)
|
||||
},
|
||||
isEnabled: isEnabled,
|
||||
supportsCompress: richChat.supportsCompress
|
||||
)
|
||||
}
|
||||
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
|
||||
richChat.scheduleRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RichMessageBubble: View {
|
||||
let message: HermesMessage
|
||||
let toolResults: [String: HermesMessage]
|
||||
|
||||
var body: some View {
|
||||
if message.isUser {
|
||||
userBubble
|
||||
} else if message.isAssistant {
|
||||
assistantBubble
|
||||
}
|
||||
// Tool result messages are rendered inline in ToolCallCard, not as standalone bubbles
|
||||
}
|
||||
|
||||
// MARK: - User Bubble
|
||||
|
||||
private var userBubble: some View {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
HStack {
|
||||
Spacer(minLength: 80)
|
||||
Text(message.content)
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.accentColor.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
if let time = message.timestamp {
|
||||
Text(time, style: .time)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
|
||||
// MARK: - Assistant Bubble
|
||||
|
||||
private var assistantBubble: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if message.hasReasoning {
|
||||
reasoningSection
|
||||
}
|
||||
|
||||
if !message.content.isEmpty {
|
||||
contentView
|
||||
}
|
||||
|
||||
if !message.toolCalls.isEmpty {
|
||||
toolCallsSection
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
|
||||
metadataFooter
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - Content Rendering
|
||||
|
||||
@ViewBuilder
|
||||
private var contentView: some View {
|
||||
let blocks = parseContentBlocks(message.content)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
|
||||
switch block {
|
||||
case .text(let text):
|
||||
MarkdownContentView(content: text)
|
||||
case .code(let code, let language):
|
||||
CodeBlockView(code: code, language: language)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reasoning
|
||||
|
||||
private var reasoningSection: some View {
|
||||
DisclosureGroup {
|
||||
Text(message.reasoning ?? "")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("Reasoning")
|
||||
if let tokens = message.tokenCount, tokens > 0 {
|
||||
Text("(\(tokens) tokens)")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
// MARK: - Tool Calls
|
||||
|
||||
private var toolCallsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(message.toolCalls) { call in
|
||||
ToolCallCard(
|
||||
call: call,
|
||||
result: toolResults[call.callId]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metadata Footer
|
||||
|
||||
private var metadataFooter: some View {
|
||||
HStack(spacing: 8) {
|
||||
if let tokens = message.tokenCount, tokens > 0 {
|
||||
Text("\(tokens) tokens")
|
||||
}
|
||||
if let reason = message.finishReason, !reason.isEmpty {
|
||||
Text(reason)
|
||||
}
|
||||
if let time = message.timestamp {
|
||||
Text(time, style: .time)
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Block Parsing
|
||||
|
||||
private enum ContentBlock {
|
||||
case text(String)
|
||||
case code(String, String?)
|
||||
}
|
||||
|
||||
private func parseContentBlocks(_ content: String) -> [ContentBlock] {
|
||||
var blocks: [ContentBlock] = []
|
||||
let lines = content.components(separatedBy: "\n")
|
||||
var currentText: [String] = []
|
||||
var currentCode: [String] = []
|
||||
var codeLanguage: String?
|
||||
var inCode = false
|
||||
|
||||
for line in lines {
|
||||
if !inCode && line.hasPrefix("```") {
|
||||
if !currentText.isEmpty {
|
||||
blocks.append(.text(currentText.joined(separator: "\n")))
|
||||
currentText = []
|
||||
}
|
||||
inCode = true
|
||||
let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
|
||||
codeLanguage = lang.isEmpty ? nil : lang
|
||||
} else if inCode && line.hasPrefix("```") {
|
||||
blocks.append(.code(currentCode.joined(separator: "\n"), codeLanguage))
|
||||
currentCode = []
|
||||
codeLanguage = nil
|
||||
inCode = false
|
||||
} else if inCode {
|
||||
currentCode.append(line)
|
||||
} else {
|
||||
currentText.append(line)
|
||||
}
|
||||
}
|
||||
|
||||
if inCode && !currentCode.isEmpty {
|
||||
blocks.append(.code(currentCode.joined(separator: "\n"), codeLanguage))
|
||||
}
|
||||
if !currentText.isEmpty {
|
||||
let text = currentText.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !text.isEmpty {
|
||||
blocks.append(.text(text))
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SessionInfoBar: View {
|
||||
let session: HermesSession?
|
||||
let isWorking: Bool
|
||||
/// Fallback token counts from ACP prompt results (DB may have zeros for ACP sessions).
|
||||
var acpInputTokens: Int = 0
|
||||
var acpOutputTokens: Int = 0
|
||||
var acpThoughtTokens: Int = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
if let session {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(isWorking ? .green : .secondary)
|
||||
.frame(width: 6, height: 6)
|
||||
.opacity(isWorking ? 1 : 0.6)
|
||||
if isWorking {
|
||||
Text("Working")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
|
||||
if let title = session.title, !title.isEmpty {
|
||||
Text(title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
if let model = session.model {
|
||||
Label(model, systemImage: "cpu")
|
||||
}
|
||||
|
||||
let inputToks = session.inputTokens > 0 ? session.inputTokens : acpInputTokens
|
||||
let outputToks = session.outputTokens > 0 ? session.outputTokens : acpOutputTokens
|
||||
Label("\(formatTokens(inputToks)) in / \(formatTokens(outputToks)) out", systemImage: "number")
|
||||
.contentTransition(.numericText())
|
||||
|
||||
let reasonToks = session.reasoningTokens > 0 ? session.reasoningTokens : acpThoughtTokens
|
||||
if reasonToks > 0 {
|
||||
Label("\(formatTokens(reasonToks)) reasoning", systemImage: "brain")
|
||||
}
|
||||
|
||||
if let cost = session.displayCostUSD {
|
||||
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
if let start = session.startedAt {
|
||||
Label {
|
||||
Text(start, style: .relative)
|
||||
.monospacedDigit()
|
||||
} icon: {
|
||||
Image(systemName: "clock")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Label(session.source, systemImage: session.sourceIcon)
|
||||
} else {
|
||||
Text("No active session")
|
||||
.foregroundStyle(.tertiary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private func formatTokens(_ count: Int) -> String {
|
||||
if count >= 1_000_000 {
|
||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
||||
} else if count >= 1_000 {
|
||||
return String(format: "%.1fK", Double(count) / 1_000)
|
||||
}
|
||||
return "\(count)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToolCallCard: View {
|
||||
let call: HermesToolCall
|
||||
let result: HermesMessage?
|
||||
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 1)
|
||||
.fill(toolColor)
|
||||
.frame(width: 3, height: 16)
|
||||
|
||||
Image(systemName: call.toolKind.icon)
|
||||
.font(.caption)
|
||||
.foregroundStyle(toolColor)
|
||||
|
||||
Text(call.functionName)
|
||||
.font(.caption.monospaced().bold())
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(call.argumentsSummary)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Spacer()
|
||||
|
||||
if result != nil {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
}
|
||||
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
if expanded {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if !call.arguments.isEmpty && call.arguments != "{}" {
|
||||
Text("Arguments")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(formatJSON(call.arguments))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.padding(6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
|
||||
if let result, !result.content.isEmpty {
|
||||
Text("Result")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.tertiary)
|
||||
ToolResultContent(content: result.content)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
}
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
private var toolColor: Color {
|
||||
switch call.toolKind {
|
||||
case .read: return .green
|
||||
case .edit: return .blue
|
||||
case .execute: return .orange
|
||||
case .fetch: return .purple
|
||||
case .browser: return .indigo
|
||||
case .other: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func formatJSON(_ raw: String) -> String {
|
||||
guard let data = raw.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data),
|
||||
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted),
|
||||
let str = String(data: pretty, encoding: .utf8) else {
|
||||
return raw
|
||||
}
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolResultContent: View {
|
||||
let content: String
|
||||
|
||||
@State private var showAll = false
|
||||
|
||||
private var lines: [String] { content.components(separatedBy: "\n") }
|
||||
private var isLong: Bool { lines.count > 8 }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(showAll ? content : lines.prefix(8).joined(separator: "\n"))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.padding(6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
|
||||
if isLong {
|
||||
Button(showAll ? "Show less" : "Show all \(lines.count) lines") {
|
||||
withAnimation { showAll.toggle() }
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -67,7 +72,7 @@ struct CronView: View {
|
||||
Label(job.state, systemImage: job.stateIcon)
|
||||
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
|
||||
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
|
||||
if let deliver = job.deliver {
|
||||
if let deliver = job.deliveryDisplay {
|
||||
Label("Deliver: \(deliver)", systemImage: "paperplane")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -60,6 +60,10 @@ struct DashboardView: View {
|
||||
StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)")
|
||||
StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)")
|
||||
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
|
||||
let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
|
||||
if cost > 0 {
|
||||
StatCard(label: "Cost", value: String(format: "$%.2f", cost))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,14 +94,6 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTokens(_ count: Int) -> String {
|
||||
if count >= 1_000_000 {
|
||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
||||
} else if count >= 1_000 {
|
||||
return String(format: "%.1fK", Double(count) / 1_000)
|
||||
}
|
||||
return "\(count)"
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusCard: View {
|
||||
@@ -168,6 +164,9 @@ struct SessionRow: View {
|
||||
HStack(spacing: 12) {
|
||||
Label("\(session.messageCount)", systemImage: "bubble.left")
|
||||
Label("\(session.toolCallCount)", systemImage: "wrench")
|
||||
if let cost = session.displayCostUSD, cost > 0 {
|
||||
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ struct HealthSection: Identifiable {
|
||||
|
||||
@Observable
|
||||
final class HealthViewModel {
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var version = ""
|
||||
var updateInfo = ""
|
||||
var hasUpdate = false
|
||||
@@ -31,9 +33,13 @@ final class HealthViewModel {
|
||||
var warningCount = 0
|
||||
var okCount = 0
|
||||
var isLoading = false
|
||||
var hermesRunning = false
|
||||
var hermesPID: pid_t?
|
||||
var actionMessage: String?
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
refreshProcessStatus()
|
||||
loadVersion()
|
||||
let statusOutput = runHermes(["status"]).output
|
||||
statusSections = parseOutput(statusOutput)
|
||||
@@ -43,6 +49,41 @@ final class HealthViewModel {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func refreshProcessStatus() {
|
||||
hermesPID = fileService.hermesPID()
|
||||
hermesRunning = hermesPID != nil
|
||||
}
|
||||
|
||||
func stopHermes() {
|
||||
fileService.stopHermes()
|
||||
actionMessage = "Stop signal sent"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.refreshProcessStatus()
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
func startHermes() {
|
||||
runHermes(["gateway", "start"])
|
||||
actionMessage = "Start requested"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.refreshProcessStatus()
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
func restartHermes() {
|
||||
fileService.stopHermes()
|
||||
actionMessage = "Restarting..."
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.runHermes(["gateway", "start"])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.refreshProcessStatus()
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadVersion() {
|
||||
let output = runHermes(["version"]).output
|
||||
let lines = output.components(separatedBy: "\n")
|
||||
|
||||
@@ -29,6 +29,7 @@ struct HealthView: View {
|
||||
// MARK: - Header
|
||||
|
||||
private var headerBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 16) {
|
||||
if !viewModel.version.isEmpty {
|
||||
Text(viewModel.version)
|
||||
@@ -59,6 +60,44 @@ struct HealthView: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 16) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(viewModel.hermesRunning ? .green : .red)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(viewModel.hermesRunning ? "Hermes Running" : "Hermes Stopped")
|
||||
.font(.caption.bold())
|
||||
if let pid = viewModel.hermesPID {
|
||||
Text("PID \(pid)")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let msg = viewModel.actionMessage {
|
||||
Label(msg, systemImage: "arrow.triangle.2.circlepath")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Start") { viewModel.startHermes() }
|
||||
.disabled(viewModel.hermesRunning)
|
||||
Button("Stop") { viewModel.stopHermes() }
|
||||
.disabled(!viewModel.hermesRunning)
|
||||
Button("Restart") { viewModel.restartHermes() }
|
||||
.disabled(!viewModel.hermesRunning)
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grid
|
||||
|
||||
@@ -27,7 +27,8 @@ struct ModelUsage: Identifiable {
|
||||
let outputTokens: Int
|
||||
let cacheReadTokens: Int
|
||||
let cacheWriteTokens: Int
|
||||
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens }
|
||||
let reasoningTokens: Int
|
||||
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens }
|
||||
}
|
||||
|
||||
struct PlatformUsage: Identifiable {
|
||||
@@ -46,7 +47,7 @@ struct ToolUsage: Identifiable {
|
||||
}
|
||||
|
||||
struct NotableSession: Identifiable {
|
||||
var id: String { session.id }
|
||||
var id: String { "\(session.id)-\(label)" }
|
||||
let label: String
|
||||
let value: String
|
||||
let session: HermesSession
|
||||
@@ -69,7 +70,9 @@ final class InsightsViewModel {
|
||||
var totalOutputTokens = 0
|
||||
var totalCacheReadTokens = 0
|
||||
var totalCacheWriteTokens = 0
|
||||
var totalReasoningTokens = 0
|
||||
var totalTokens = 0
|
||||
var totalCost: Double = 0
|
||||
var activeTime: TimeInterval = 0
|
||||
var avgSessionDuration: TimeInterval = 0
|
||||
|
||||
@@ -119,7 +122,9 @@ final class InsightsViewModel {
|
||||
totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens }
|
||||
totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens }
|
||||
totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens }
|
||||
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens
|
||||
totalReasoningTokens = sessions.reduce(0) { $0 + $1.reasoningTokens }
|
||||
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens + totalReasoningTokens
|
||||
totalCost = sessions.reduce(0.0) { $0 + ($1.displayCostUSD ?? 0) }
|
||||
|
||||
var total: TimeInterval = 0
|
||||
var count = 0
|
||||
@@ -134,21 +139,22 @@ final class InsightsViewModel {
|
||||
}
|
||||
|
||||
private func computeModelBreakdown() {
|
||||
var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int)] = [:]
|
||||
var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int, reasoning: Int)] = [:]
|
||||
for s in sessions {
|
||||
let model = s.model ?? "unknown"
|
||||
var entry = grouped[model, default: (0, 0, 0, 0, 0)]
|
||||
var entry = grouped[model, default: (0, 0, 0, 0, 0, 0)]
|
||||
entry.sessions += 1
|
||||
entry.input += s.inputTokens
|
||||
entry.output += s.outputTokens
|
||||
entry.cacheRead += s.cacheReadTokens
|
||||
entry.cacheWrite += s.cacheWriteTokens
|
||||
entry.reasoning += s.reasoningTokens
|
||||
grouped[model] = entry
|
||||
}
|
||||
modelUsage = grouped.map { key, val in
|
||||
ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input,
|
||||
outputTokens: val.output, cacheReadTokens: val.cacheRead,
|
||||
cacheWriteTokens: val.cacheWrite)
|
||||
cacheWriteTokens: val.cacheWrite, reasoningTokens: val.reasoning)
|
||||
}.sorted { $0.totalTokens > $1.totalTokens }
|
||||
}
|
||||
|
||||
@@ -158,7 +164,7 @@ final class InsightsViewModel {
|
||||
var entry = grouped[s.source, default: (0, 0, 0)]
|
||||
entry.sessions += 1
|
||||
entry.messages += s.messageCount
|
||||
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens
|
||||
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens + s.reasoningTokens
|
||||
grouped[s.source] = entry
|
||||
}
|
||||
platformUsage = grouped.map { key, val in
|
||||
|
||||
@@ -50,7 +50,9 @@ struct InsightsView: View {
|
||||
InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens))
|
||||
InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens))
|
||||
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
|
||||
InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens))
|
||||
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
|
||||
InsightCard(label: "Total Cost", value: String(format: "$%.2f", viewModel.totalCost))
|
||||
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
|
||||
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration))
|
||||
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count)))
|
||||
@@ -273,19 +275,12 @@ struct InsightsView: View {
|
||||
// MARK: - Helpers
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private func barColor(for toolName: String) -> Color {
|
||||
switch toolName {
|
||||
case "terminal": return .orange
|
||||
case "terminal", "execute_code": return .orange
|
||||
case "read_file", "search_files": return .green
|
||||
case "write_file", "patch": return .blue
|
||||
case "web_search", "web_extract": return .purple
|
||||
|
||||
@@ -5,12 +5,14 @@ final class LogsViewModel {
|
||||
private let logService = HermesLogService()
|
||||
|
||||
var entries: [LogEntry] = []
|
||||
var selectedLogFile: LogFile = .errors
|
||||
var selectedLogFile: LogFile = .agent
|
||||
var filterLevel: LogEntry.LogLevel?
|
||||
var selectedComponent: LogComponent = .all
|
||||
var searchText = ""
|
||||
private var pollTimer: Timer?
|
||||
|
||||
enum LogFile: String, CaseIterable, Identifiable {
|
||||
case agent = "agent.log"
|
||||
case errors = "errors.log"
|
||||
case gateway = "gateway.log"
|
||||
|
||||
@@ -18,17 +20,44 @@ final class LogsViewModel {
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
case .agent: return HermesPaths.agentLog
|
||||
case .errors: return HermesPaths.errorsLog
|
||||
case .gateway: return HermesPaths.gatewayLog
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LogComponent: String, CaseIterable, Identifiable {
|
||||
case all = "All"
|
||||
case gateway = "Gateway"
|
||||
case agent = "Agent"
|
||||
case tools = "Tools"
|
||||
case cli = "CLI"
|
||||
case cron = "Cron"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var loggerPrefix: String? {
|
||||
switch self {
|
||||
case .all: return nil
|
||||
case .gateway: return "gateway"
|
||||
case .agent: return "agent"
|
||||
case .tools: return "tools"
|
||||
case .cli: return "cli"
|
||||
case .cron: return "cron"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var filteredEntries: [LogEntry] {
|
||||
entries.filter { entry in
|
||||
let levelOk = filterLevel == nil || entry.level == filterLevel
|
||||
let searchOk = searchText.isEmpty || entry.raw.localizedCaseInsensitiveContains(searchText)
|
||||
return levelOk && searchOk
|
||||
let componentOk: Bool = {
|
||||
guard let prefix = selectedComponent.loggerPrefix else { return true }
|
||||
return entry.logger.hasPrefix(prefix)
|
||||
}()
|
||||
return levelOk && searchOk && componentOk
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,13 @@ struct LogsView: View {
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 300)
|
||||
|
||||
Picker("Component", selection: $viewModel.selectedComponent) {
|
||||
ForEach(LogsViewModel.LogComponent.allCases) { component in
|
||||
Text(component.rawValue).tag(component)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 140)
|
||||
|
||||
Spacer()
|
||||
|
||||
Picker("Level", selection: $viewModel.filterLevel) {
|
||||
@@ -58,6 +65,27 @@ struct LogsView: View {
|
||||
.font(.caption.monospaced().bold())
|
||||
.foregroundStyle(colorForLevel(entry.level))
|
||||
.frame(width: 60, alignment: .leading)
|
||||
if let sessionId = entry.sessionId {
|
||||
Button {
|
||||
viewModel.searchText = sessionId
|
||||
} label: {
|
||||
Text(sessionId)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 1)
|
||||
.background(Color.accentColor.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Filter to session \(sessionId)")
|
||||
}
|
||||
Text(entry.logger)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: 140, alignment: .leading)
|
||||
Text(entry.message)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class MCPServerEditorViewModel {
|
||||
struct KeyValueRow: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
var key: String
|
||||
var value: String
|
||||
}
|
||||
|
||||
private let fileService = HermesFileService()
|
||||
let server: HermesMCPServer
|
||||
|
||||
var envDraft: [KeyValueRow]
|
||||
var headersDraft: [KeyValueRow]
|
||||
var includeDraft: String
|
||||
var excludeDraft: String
|
||||
var resourcesEnabled: Bool
|
||||
var promptsEnabled: Bool
|
||||
var timeoutDraft: String
|
||||
var connectTimeoutDraft: String
|
||||
var showSecrets: Bool = false
|
||||
var isSaving: Bool = false
|
||||
var saveError: String?
|
||||
|
||||
init(server: HermesMCPServer) {
|
||||
self.server = server
|
||||
self.envDraft = server.env.keys.sorted().map { KeyValueRow(key: $0, value: server.env[$0] ?? "") }
|
||||
self.headersDraft = server.headers.keys.sorted().map { KeyValueRow(key: $0, value: server.headers[$0] ?? "") }
|
||||
self.includeDraft = server.toolsInclude.joined(separator: ", ")
|
||||
self.excludeDraft = server.toolsExclude.joined(separator: ", ")
|
||||
self.resourcesEnabled = server.resourcesEnabled
|
||||
self.promptsEnabled = server.promptsEnabled
|
||||
self.timeoutDraft = server.timeout.map { String($0) } ?? ""
|
||||
self.connectTimeoutDraft = server.connectTimeout.map { String($0) } ?? ""
|
||||
}
|
||||
|
||||
func appendEnvRow() {
|
||||
envDraft.append(KeyValueRow(key: "", value: ""))
|
||||
}
|
||||
|
||||
func removeEnvRow(id: UUID) {
|
||||
envDraft.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
func appendHeaderRow() {
|
||||
headersDraft.append(KeyValueRow(key: "", value: ""))
|
||||
}
|
||||
|
||||
func removeHeaderRow(id: UUID) {
|
||||
headersDraft.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
func save(completion: @escaping (Bool) -> Void) {
|
||||
isSaving = true
|
||||
saveError = nil
|
||||
|
||||
let envMap = Dictionary(uniqueKeysWithValues: envDraft
|
||||
.filter { !$0.key.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
.map { ($0.key.trimmingCharacters(in: .whitespaces), $0.value) })
|
||||
let headerMap = Dictionary(uniqueKeysWithValues: headersDraft
|
||||
.filter { !$0.key.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
.map { ($0.key.trimmingCharacters(in: .whitespaces), $0.value) })
|
||||
let include = includeDraft.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||
let exclude = excludeDraft.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||
let timeoutValue = Int(timeoutDraft.trimmingCharacters(in: .whitespaces))
|
||||
let connectValue = Int(connectTimeoutDraft.trimmingCharacters(in: .whitespaces))
|
||||
|
||||
let service = fileService
|
||||
let transport = server.transport
|
||||
let name = server.name
|
||||
let resources = resourcesEnabled
|
||||
let prompts = promptsEnabled
|
||||
|
||||
Task.detached {
|
||||
var success = true
|
||||
switch transport {
|
||||
case .stdio:
|
||||
if !service.setMCPServerEnv(name: name, env: envMap) { success = false }
|
||||
case .http:
|
||||
if !service.setMCPServerHeaders(name: name, headers: headerMap) { success = false }
|
||||
}
|
||||
if !service.updateMCPToolFilters(
|
||||
name: name,
|
||||
include: include,
|
||||
exclude: exclude,
|
||||
resources: resources,
|
||||
prompts: prompts
|
||||
) { success = false }
|
||||
if !service.setMCPServerTimeouts(name: name, timeout: timeoutValue, connectTimeout: connectValue) {
|
||||
success = false
|
||||
}
|
||||
await MainActor.run {
|
||||
self.isSaving = false
|
||||
if !success {
|
||||
self.saveError = "One or more fields could not be written. Check \(HermesPaths.configYAML)."
|
||||
}
|
||||
completion(success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearOAuthToken(completion: @escaping (Bool) -> Void) {
|
||||
let service = fileService
|
||||
let name = server.name
|
||||
Task.detached {
|
||||
let ok = service.deleteMCPOAuthToken(name: name)
|
||||
await MainActor.run { completion(ok) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class MCPServersViewModel {
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var servers: [HermesMCPServer] = []
|
||||
var selectedServerName: String?
|
||||
var searchText = ""
|
||||
var isLoading = false
|
||||
var statusMessage: String?
|
||||
var showPresetPicker = false
|
||||
var showAddCustom = false
|
||||
var showRestartBanner = false
|
||||
var testResults: [String: MCPTestResult] = [:]
|
||||
var testingNames: Set<String> = []
|
||||
var activeError: String?
|
||||
var editingServer: HermesMCPServer?
|
||||
|
||||
var filteredServers: [HermesMCPServer] {
|
||||
guard !searchText.isEmpty else { return servers }
|
||||
let query = searchText.lowercased()
|
||||
return servers.filter { server in
|
||||
server.name.lowercased().contains(query) ||
|
||||
server.summary.lowercased().contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
var stdioServers: [HermesMCPServer] {
|
||||
filteredServers.filter { $0.transport == .stdio }
|
||||
}
|
||||
|
||||
var httpServers: [HermesMCPServer] {
|
||||
filteredServers.filter { $0.transport == .http }
|
||||
}
|
||||
|
||||
var selectedServer: HermesMCPServer? {
|
||||
guard let name = selectedServerName else { return nil }
|
||||
return servers.first(where: { $0.name == name })
|
||||
}
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
servers = fileService.loadMCPServers()
|
||||
isLoading = false
|
||||
if let name = selectedServerName, !servers.contains(where: { $0.name == name }) {
|
||||
selectedServerName = nil
|
||||
}
|
||||
}
|
||||
|
||||
func selectServer(name: String?) {
|
||||
selectedServerName = name
|
||||
}
|
||||
|
||||
func beginEdit() {
|
||||
editingServer = selectedServer
|
||||
}
|
||||
|
||||
func finishEdit(reload: Bool) {
|
||||
editingServer = nil
|
||||
if reload {
|
||||
load()
|
||||
showRestartBanner = true
|
||||
}
|
||||
}
|
||||
|
||||
func deleteServer(name: String) {
|
||||
let fileService = self.fileService
|
||||
Task.detached {
|
||||
let result = fileService.removeMCPServer(name: name)
|
||||
await MainActor.run {
|
||||
if result.exitCode == 0 {
|
||||
self.flashStatus("Removed \(name)")
|
||||
if self.selectedServerName == name {
|
||||
self.selectedServerName = nil
|
||||
}
|
||||
self.testResults.removeValue(forKey: name)
|
||||
self.load()
|
||||
self.showRestartBanner = true
|
||||
} else {
|
||||
self.activeError = "Remove failed: \(result.output)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleEnabled(name: String) {
|
||||
guard let server = servers.first(where: { $0.name == name }) else { return }
|
||||
let newValue = !server.enabled
|
||||
let fileService = self.fileService
|
||||
Task.detached {
|
||||
let ok = fileService.toggleMCPServerEnabled(name: name, enabled: newValue)
|
||||
await MainActor.run {
|
||||
if ok {
|
||||
self.flashStatus(newValue ? "Enabled \(name)" : "Disabled \(name)")
|
||||
self.load()
|
||||
self.showRestartBanner = true
|
||||
} else {
|
||||
self.activeError = "Could not update \(name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testServer(name: String) {
|
||||
guard !testingNames.contains(name) else { return }
|
||||
testingNames.insert(name)
|
||||
let fileService = self.fileService
|
||||
Task.detached {
|
||||
let result = await fileService.testMCPServer(name: name)
|
||||
await MainActor.run {
|
||||
self.testingNames.remove(name)
|
||||
self.testResults[name] = result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testAll() {
|
||||
let targets = servers.map(\.name)
|
||||
let fileService = self.fileService
|
||||
Task.detached {
|
||||
for name in targets {
|
||||
let result = await fileService.testMCPServer(name: name)
|
||||
await MainActor.run {
|
||||
self.testResults[name] = result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addFromPreset(preset: MCPServerPreset, name: String, pathArg: String?, envValues: [String: String]) {
|
||||
let fileService = self.fileService
|
||||
let allArgs: [String] = {
|
||||
var base = preset.args
|
||||
if let pathArg, !pathArg.isEmpty { base.append(pathArg) }
|
||||
return base
|
||||
}()
|
||||
Task.detached {
|
||||
let addResult: (exitCode: Int32, output: String)
|
||||
switch preset.transport {
|
||||
case .stdio:
|
||||
addResult = fileService.addMCPServerStdio(
|
||||
name: name,
|
||||
command: preset.command ?? "",
|
||||
args: allArgs
|
||||
)
|
||||
case .http:
|
||||
addResult = fileService.addMCPServerHTTP(
|
||||
name: name,
|
||||
url: preset.url ?? "",
|
||||
auth: preset.auth
|
||||
)
|
||||
}
|
||||
guard addResult.exitCode == 0 else {
|
||||
await MainActor.run {
|
||||
self.activeError = "Add failed: \(addResult.output)"
|
||||
}
|
||||
return
|
||||
}
|
||||
if !envValues.isEmpty {
|
||||
_ = fileService.setMCPServerEnv(name: name, env: envValues)
|
||||
}
|
||||
await MainActor.run {
|
||||
self.flashStatus("Added \(name)")
|
||||
self.load()
|
||||
self.selectedServerName = name
|
||||
self.showRestartBanner = true
|
||||
self.showPresetPicker = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addCustom(name: String, transport: MCPTransport, command: String, args: [String], url: String, auth: String?) {
|
||||
let fileService = self.fileService
|
||||
Task.detached {
|
||||
let result: (exitCode: Int32, output: String)
|
||||
switch transport {
|
||||
case .stdio:
|
||||
result = fileService.addMCPServerStdio(name: name, command: command, args: args)
|
||||
case .http:
|
||||
result = fileService.addMCPServerHTTP(name: name, url: url, auth: auth)
|
||||
}
|
||||
await MainActor.run {
|
||||
if result.exitCode == 0 {
|
||||
self.flashStatus("Added \(name)")
|
||||
self.load()
|
||||
self.selectedServerName = name
|
||||
self.showRestartBanner = true
|
||||
self.showAddCustom = false
|
||||
} else {
|
||||
self.activeError = "Add failed: \(result.output)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restartGateway() {
|
||||
let fileService = self.fileService
|
||||
Task.detached {
|
||||
let result = fileService.restartGateway()
|
||||
await MainActor.run {
|
||||
if result.exitCode == 0 {
|
||||
self.flashStatus("Gateway restarted")
|
||||
self.showRestartBanner = false
|
||||
} else {
|
||||
self.activeError = "Restart failed: \(result.output)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func flashStatus(_ message: String) {
|
||||
statusMessage = message
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
await MainActor.run {
|
||||
if self.statusMessage == message {
|
||||
self.statusMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MCPServerAddCustomView: View {
|
||||
let viewModel: MCPServersViewModel
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name: String = ""
|
||||
@State private var transport: MCPTransport = .stdio
|
||||
@State private var command: String = "npx"
|
||||
@State private var argsText: String = ""
|
||||
@State private var url: String = ""
|
||||
@State private var auth: String = "none"
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Add Custom MCP Server")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button("Cancel") { dismiss() }
|
||||
Button("Add") {
|
||||
submit()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canSubmit)
|
||||
}
|
||||
.padding()
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
sectionBox(title: "Identity") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Name").font(.caption.bold())
|
||||
TextField("my_server", text: $name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Text("Becomes the key under mcp_servers: in config.yaml.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
sectionBox(title: "Transport") {
|
||||
Picker("", selection: $transport) {
|
||||
ForEach(MCPTransport.allCases) { t in
|
||||
Text(t.displayName).tag(t)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
}
|
||||
if transport == .stdio {
|
||||
stdioSection
|
||||
} else {
|
||||
httpSection
|
||||
}
|
||||
Text("Env vars, headers, and tool filters can be edited after the server is added.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 560, minHeight: 500)
|
||||
}
|
||||
|
||||
private var stdioSection: some View {
|
||||
sectionBox(title: "Command") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Command").font(.caption.bold())
|
||||
TextField("npx", text: $command)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Args (one per line)").font(.caption.bold())
|
||||
TextEditor(text: $argsText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 80)
|
||||
.padding(4)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.25))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var httpSection: some View {
|
||||
sectionBox(title: "Endpoint") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("URL").font(.caption.bold())
|
||||
TextField("https://...", text: $url)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Auth").font(.caption.bold())
|
||||
Picker("", selection: $auth) {
|
||||
Text("None").tag("none")
|
||||
Text("OAuth 2.1").tag("oauth")
|
||||
Text("Header").tag("header")
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedName.isEmpty else { return false }
|
||||
switch transport {
|
||||
case .stdio:
|
||||
return !command.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
case .http:
|
||||
return !url.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
||||
let args = argsText
|
||||
.split(separator: "\n")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
let resolvedAuth: String? = (auth == "none") ? nil : auth
|
||||
viewModel.addCustom(
|
||||
name: trimmedName,
|
||||
transport: transport,
|
||||
command: command.trimmingCharacters(in: .whitespaces),
|
||||
args: args,
|
||||
url: url.trimmingCharacters(in: .whitespaces),
|
||||
auth: resolvedAuth
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionBox<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.bold())
|
||||
content()
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MCPServerDetailView: View {
|
||||
let server: HermesMCPServer
|
||||
let testResult: MCPTestResult?
|
||||
let isTesting: Bool
|
||||
let onTest: () -> Void
|
||||
let onToggleEnabled: () -> Void
|
||||
let onEdit: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
@State private var showDeleteConfirm = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
header
|
||||
overview
|
||||
if server.transport == .stdio {
|
||||
envSection
|
||||
} else {
|
||||
headersSection
|
||||
}
|
||||
toolsSection
|
||||
timeoutsSection
|
||||
if let result = testResult {
|
||||
MCPServerTestResultView(result: result)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Remove \(server.name)?",
|
||||
isPresented: $showDeleteConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Remove", role: .destructive) { onDelete() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This removes the server from config.yaml and deletes any OAuth token.")
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: server.transport == .http ? "network" : "terminal")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(server.name)
|
||||
.font(.title2.bold())
|
||||
if !server.enabled {
|
||||
Text("Disabled")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.secondary.opacity(0.2))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
if server.hasOAuthToken {
|
||||
Label("OAuth", systemImage: "key.fill")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.green.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
Text(server.transport.displayName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
onTest()
|
||||
} label: {
|
||||
if isTesting {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Label("Test", systemImage: "bolt.horizontal")
|
||||
}
|
||||
}
|
||||
.disabled(isTesting)
|
||||
Button {
|
||||
onToggleEnabled()
|
||||
} label: {
|
||||
Label(server.enabled ? "Disable" : "Enable", systemImage: server.enabled ? "pause.circle" : "play.circle")
|
||||
}
|
||||
Button {
|
||||
onEdit()
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirm = true
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var overview: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Connection")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
switch server.transport {
|
||||
case .stdio:
|
||||
summaryRow(label: "Command", value: server.command ?? "—")
|
||||
if !server.args.isEmpty {
|
||||
summaryRow(label: "Args", value: server.args.joined(separator: " "))
|
||||
}
|
||||
case .http:
|
||||
summaryRow(label: "URL", value: server.url ?? "—")
|
||||
if let auth = server.auth, !auth.isEmpty {
|
||||
summaryRow(label: "Auth", value: auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func summaryRow(label: String, value: String) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 80, alignment: .leading)
|
||||
Text(value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private var envSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Environment Variables")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
if server.env.isEmpty {
|
||||
Text("No env vars configured.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(server.env.keys.sorted(), id: \.self) { key in
|
||||
HStack {
|
||||
Text(key)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Spacer()
|
||||
Text(String(repeating: "•", count: 10))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var headersSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Headers")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
if server.headers.isEmpty {
|
||||
Text("No headers configured.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(server.headers.keys.sorted(), id: \.self) { key in
|
||||
HStack {
|
||||
Text(key)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Spacer()
|
||||
Text(String(repeating: "•", count: 10))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var toolsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Tool Filters")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
summaryRow(label: "Include", value: server.toolsInclude.isEmpty ? "(all)" : server.toolsInclude.joined(separator: ", "))
|
||||
summaryRow(label: "Exclude", value: server.toolsExclude.isEmpty ? "—" : server.toolsExclude.joined(separator: ", "))
|
||||
summaryRow(label: "Resources", value: server.resourcesEnabled ? "enabled" : "disabled")
|
||||
summaryRow(label: "Prompts", value: server.promptsEnabled ? "enabled" : "disabled")
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var timeoutsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Timeouts")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
summaryRow(label: "Connect", value: server.connectTimeout.map { "\($0)s" } ?? "default")
|
||||
summaryRow(label: "Call", value: server.timeout.map { "\($0)s" } ?? "default")
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MCPServerEditorView: View {
|
||||
@State var viewModel: MCPServerEditorViewModel
|
||||
let onSave: (Bool) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Edit \(viewModel.server.name)")
|
||||
.font(.headline)
|
||||
Text(viewModel.server.transport.displayName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Cancel") { onCancel() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button {
|
||||
viewModel.save { changed in
|
||||
if changed { onSave(true) }
|
||||
}
|
||||
} label: {
|
||||
if viewModel.isSaving {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(viewModel.isSaving)
|
||||
}
|
||||
.padding()
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
if let error = viewModel.saveError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.red.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
if viewModel.server.transport == .stdio {
|
||||
envSection
|
||||
} else {
|
||||
headersSection
|
||||
}
|
||||
toolsSection
|
||||
timeoutsSection
|
||||
if viewModel.server.hasOAuthToken {
|
||||
oauthSection
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 560)
|
||||
}
|
||||
|
||||
private var envSection: some View {
|
||||
sectionBox(title: "Environment Variables") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if viewModel.envDraft.isEmpty {
|
||||
Text("No env vars. Add one with the button below.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach($viewModel.envDraft) { $row in
|
||||
HStack(spacing: 8) {
|
||||
TextField("KEY", text: $row.key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(maxWidth: 240)
|
||||
if viewModel.showSecrets {
|
||||
TextField("value", text: $row.value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
} else {
|
||||
SecureField("value", text: $row.value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
viewModel.removeEnvRow(id: row.id)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Button {
|
||||
viewModel.appendEnvRow()
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus.circle")
|
||||
}
|
||||
Spacer()
|
||||
Toggle("Show values", isOn: $viewModel.showSecrets)
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headersSection: some View {
|
||||
sectionBox(title: "Headers") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if viewModel.headersDraft.isEmpty {
|
||||
Text("No headers. Add one with the button below.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach($viewModel.headersDraft) { $row in
|
||||
HStack(spacing: 8) {
|
||||
TextField("Header", text: $row.key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 240)
|
||||
TextField("value", text: $row.value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button(role: .destructive) {
|
||||
viewModel.removeHeaderRow(id: row.id)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
Button {
|
||||
viewModel.appendHeaderRow()
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var toolsSection: some View {
|
||||
sectionBox(title: "Tool Filters") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Include (comma-separated — if set, only these are exposed)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("tool_a, tool_b", text: $viewModel.includeDraft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Exclude")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("tool_c", text: $viewModel.excludeDraft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
Toggle("Expose resources", isOn: $viewModel.resourcesEnabled)
|
||||
Toggle("Expose prompts", isOn: $viewModel.promptsEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var timeoutsSection: some View {
|
||||
sectionBox(title: "Timeouts (seconds)") {
|
||||
HStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Connect timeout")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("default", text: $viewModel.connectTimeoutDraft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 140)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Call timeout")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("default", text: $viewModel.timeoutDraft)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 140)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var oauthSection: some View {
|
||||
sectionBox(title: "OAuth Token") {
|
||||
HStack {
|
||||
Text("Token on disk. Clear to re-authenticate next time the gateway connects.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Clear Token", role: .destructive) {
|
||||
viewModel.clearOAuthToken { _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionBox<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.bold())
|
||||
content()
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.06))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MCPServerPresetPickerView: View {
|
||||
let viewModel: MCPServersViewModel
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var selectedPreset: MCPServerPreset?
|
||||
@State private var nameOverride: String = ""
|
||||
@State private var pathArg: String = ""
|
||||
@State private var envValues: [String: String] = [:]
|
||||
@State private var showSecrets: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
if let preset = selectedPreset {
|
||||
configureStep(preset: preset)
|
||||
} else {
|
||||
galleryStep
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 720, minHeight: 560)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
if selectedPreset != nil {
|
||||
Button {
|
||||
selectedPreset = nil
|
||||
} label: {
|
||||
Label("Back", systemImage: "chevron.left")
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(selectedPreset?.displayName ?? "Add from Preset")
|
||||
.font(.headline)
|
||||
Text(selectedPreset?.description ?? "Pick an MCP server to add.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var galleryStep: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
ForEach(MCPServerPreset.categories, id: \.self) { category in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(category)
|
||||
.font(.subheadline.bold())
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.adaptive(minimum: 200), spacing: 12)],
|
||||
spacing: 12
|
||||
) {
|
||||
ForEach(MCPServerPreset.byCategory(category)) { preset in
|
||||
presetCard(preset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func presetCard(_ preset: MCPServerPreset) -> some View {
|
||||
Button {
|
||||
selectedPreset = preset
|
||||
nameOverride = preset.id
|
||||
pathArg = ""
|
||||
envValues = Dictionary(uniqueKeysWithValues: preset.requiredEnvKeys.map { ($0, "") })
|
||||
for key in preset.optionalEnvKeys {
|
||||
envValues[key] = ""
|
||||
}
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Image(systemName: preset.iconSystemName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Text(preset.displayName)
|
||||
.font(.body.bold())
|
||||
Spacer()
|
||||
Image(systemName: preset.transport == .http ? "network" : "terminal")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(preset.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if !preset.requiredEnvKeys.isEmpty {
|
||||
Text("Requires: \(preset.requiredEnvKeys.joined(separator: ", "))")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func configureStep(preset: MCPServerPreset) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
nameField
|
||||
if let prompt = preset.pathArgPrompt {
|
||||
pathArgField(prompt: prompt)
|
||||
}
|
||||
if !preset.requiredEnvKeys.isEmpty || !preset.optionalEnvKeys.isEmpty {
|
||||
envFields(preset: preset)
|
||||
}
|
||||
if !preset.docsURL.isEmpty {
|
||||
Link(destination: URL(string: preset.docsURL) ?? URL(string: "https://modelcontextprotocol.io")!) {
|
||||
Label("Docs", systemImage: "book")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Add Server") {
|
||||
submit(preset: preset)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canSubmit(preset: preset))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private var nameField: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Server name")
|
||||
.font(.caption.bold())
|
||||
TextField("e.g. github", text: $nameOverride)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Text("Used as the YAML key. Lowercase, no spaces.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func pathArgField(prompt: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(prompt)
|
||||
.font(.caption.bold())
|
||||
TextField(prompt, text: $pathArg)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
}
|
||||
|
||||
private func envFields(preset: MCPServerPreset) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Environment Variables")
|
||||
.font(.caption.bold())
|
||||
Spacer()
|
||||
Toggle("Show values", isOn: $showSecrets)
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.small)
|
||||
}
|
||||
ForEach(preset.requiredEnvKeys, id: \.self) { key in
|
||||
envRow(key: key, required: true)
|
||||
}
|
||||
ForEach(preset.optionalEnvKeys, id: \.self) { key in
|
||||
envRow(key: key, required: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func envRow(key: String, required: Bool) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(key)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
if required {
|
||||
Text("required")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.frame(width: 240, alignment: .leading)
|
||||
if showSecrets {
|
||||
TextField("value", text: bindingForEnv(key))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
} else {
|
||||
SecureField("value", text: bindingForEnv(key))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bindingForEnv(_ key: String) -> Binding<String> {
|
||||
Binding(
|
||||
get: { envValues[key] ?? "" },
|
||||
set: { envValues[key] = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private func canSubmit(preset: MCPServerPreset) -> Bool {
|
||||
let trimmedName = nameOverride.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmedName.isEmpty else { return false }
|
||||
if preset.pathArgPrompt != nil && pathArg.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
return false
|
||||
}
|
||||
for key in preset.requiredEnvKeys {
|
||||
if (envValues[key] ?? "").trimmingCharacters(in: .whitespaces).isEmpty { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func submit(preset: MCPServerPreset) {
|
||||
let finalName = nameOverride.trimmingCharacters(in: .whitespaces)
|
||||
let finalPath = pathArg.trimmingCharacters(in: .whitespaces)
|
||||
let trimmedEnv = envValues.reduce(into: [String: String]()) { acc, pair in
|
||||
let trimmedValue = pair.value.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmedValue.isEmpty { acc[pair.key] = pair.value }
|
||||
}
|
||||
viewModel.addFromPreset(
|
||||
preset: preset,
|
||||
name: finalName,
|
||||
pathArg: preset.pathArgPrompt != nil ? finalPath : nil,
|
||||
envValues: trimmedEnv
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MCPServerTestResultView: View {
|
||||
let result: MCPTestResult
|
||||
@State private var showOutput = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: result.succeeded ? "checkmark.seal.fill" : "xmark.seal.fill")
|
||||
.foregroundStyle(result.succeeded ? .green : .red)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(result.succeeded ? "Test passed" : "Test failed")
|
||||
.font(.subheadline.bold())
|
||||
Text(String(format: "%.1fs · %d tools", result.elapsed, result.tools.count))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
showOutput.toggle()
|
||||
} label: {
|
||||
Label(showOutput ? "Hide Output" : "Show Output", systemImage: showOutput ? "chevron.up" : "chevron.down")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
if !result.tools.isEmpty {
|
||||
WrapChips(items: result.tools)
|
||||
}
|
||||
if showOutput {
|
||||
ScrollView {
|
||||
Text(result.output.isEmpty ? "(no output)" : result.output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 220)
|
||||
.background(Color.black.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background((result.succeeded ? Color.green : Color.red).opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
private struct WrapChips: View {
|
||||
let items: [String]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 6)], spacing: 6) {
|
||||
ForEach(items, id: \.self) { item in
|
||||
Text(item)
|
||||
.font(.caption.monospaced())
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.secondary.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MCPServersView: View {
|
||||
@State private var viewModel = MCPServersViewModel()
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
serversList
|
||||
.frame(minWidth: 260, idealWidth: 300)
|
||||
serverDetail
|
||||
.frame(minWidth: 500)
|
||||
}
|
||||
.navigationTitle("MCP Servers (\(viewModel.servers.count))")
|
||||
.searchable(text: $viewModel.searchText, prompt: "Filter servers...")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button {
|
||||
viewModel.showPresetPicker = true
|
||||
} label: {
|
||||
Label("Add from Preset", systemImage: "square.grid.2x2")
|
||||
}
|
||||
Button {
|
||||
viewModel.showAddCustom = true
|
||||
} label: {
|
||||
Label("Add Custom", systemImage: "plus")
|
||||
}
|
||||
Button {
|
||||
viewModel.testAll()
|
||||
} label: {
|
||||
Label("Test All", systemImage: "bolt.horizontal")
|
||||
}
|
||||
.disabled(viewModel.servers.isEmpty)
|
||||
Button {
|
||||
viewModel.load()
|
||||
} label: {
|
||||
Label("Reload", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { viewModel.load() }
|
||||
.sheet(isPresented: $viewModel.showPresetPicker) {
|
||||
MCPServerPresetPickerView(viewModel: viewModel)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showAddCustom) {
|
||||
MCPServerAddCustomView(viewModel: viewModel)
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { viewModel.editingServer != nil },
|
||||
set: { if !$0 { viewModel.editingServer = nil } }
|
||||
)) {
|
||||
if let server = viewModel.editingServer {
|
||||
MCPServerEditorView(
|
||||
viewModel: MCPServerEditorViewModel(server: server),
|
||||
onSave: { changed in viewModel.finishEdit(reload: changed) },
|
||||
onCancel: { viewModel.finishEdit(reload: false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: Binding(
|
||||
get: { viewModel.activeError != nil },
|
||||
set: { if !$0 { viewModel.activeError = nil } }
|
||||
)) {
|
||||
Button("OK") { viewModel.activeError = nil }
|
||||
} message: {
|
||||
Text(viewModel.activeError ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private var serversList: some View {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedServerName },
|
||||
set: { viewModel.selectServer(name: $0) }
|
||||
)) {
|
||||
if !viewModel.stdioServers.isEmpty {
|
||||
Section("Local (stdio)") {
|
||||
ForEach(viewModel.stdioServers) { server in
|
||||
serverRow(server)
|
||||
.tag(server.name as String?)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !viewModel.httpServers.isEmpty {
|
||||
Section("Remote (HTTP)") {
|
||||
ForEach(viewModel.httpServers) { server in
|
||||
serverRow(server)
|
||||
.tag(server.name as String?)
|
||||
}
|
||||
}
|
||||
}
|
||||
if viewModel.servers.isEmpty && !viewModel.isLoading {
|
||||
Section {
|
||||
Text("No servers configured yet")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func serverRow(_ server: HermesMCPServer) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: server.transport == .http ? "network" : "terminal")
|
||||
.foregroundStyle(server.enabled ? Color.accentColor : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(server.name)
|
||||
.font(.body)
|
||||
if !server.enabled {
|
||||
Text("Disabled")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if viewModel.testingNames.contains(server.name) {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let result = viewModel.testResults[server.name] {
|
||||
Image(systemName: result.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.foregroundStyle(result.succeeded ? .green : .red)
|
||||
.help(result.succeeded ? "\(result.tools.count) tools" : "Test failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var serverDetail: some View {
|
||||
VStack(spacing: 0) {
|
||||
if viewModel.showRestartBanner {
|
||||
RestartGatewayBanner(
|
||||
onRestart: { viewModel.restartGateway() },
|
||||
onDismiss: { viewModel.showRestartBanner = false }
|
||||
)
|
||||
}
|
||||
if let status = viewModel.statusMessage {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.accentColor.opacity(0.12))
|
||||
}
|
||||
if let server = viewModel.selectedServer {
|
||||
MCPServerDetailView(
|
||||
server: server,
|
||||
testResult: viewModel.testResults[server.name],
|
||||
isTesting: viewModel.testingNames.contains(server.name),
|
||||
onTest: { viewModel.testServer(name: server.name) },
|
||||
onToggleEnabled: { viewModel.toggleEnabled(name: server.name) },
|
||||
onEdit: { viewModel.beginEdit() },
|
||||
onDelete: { viewModel.deleteServer(name: server.name) }
|
||||
)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Select an MCP Server",
|
||||
systemImage: "puzzlepiece.extension",
|
||||
description: Text("Pick one from the list, or add a new server from the toolbar.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RestartGatewayBanner: View {
|
||||
let onRestart: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Gateway restart required")
|
||||
.font(.caption.bold())
|
||||
Text("Changes won't take effect until Hermes reloads the config.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Restart Now") { onRestart() }
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button {
|
||||
onDismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.orange.opacity(0.14))
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,12 @@ final class MemoryViewModel {
|
||||
|
||||
var memoryContent = ""
|
||||
var userContent = ""
|
||||
var memoryProvider = ""
|
||||
var isEditing = false
|
||||
var editingFile: EditTarget = .memory
|
||||
var editText = ""
|
||||
var profiles: [String] = []
|
||||
var activeProfile = ""
|
||||
|
||||
enum EditTarget {
|
||||
case memory, user
|
||||
@@ -17,9 +20,30 @@ final class MemoryViewModel {
|
||||
var memoryCharCount: Int { memoryContent.count }
|
||||
var userCharCount: Int { userContent.count }
|
||||
|
||||
var hasExternalProvider: Bool {
|
||||
let stripped = memoryProvider
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "'\""))
|
||||
return !stripped.isEmpty && stripped != "file"
|
||||
}
|
||||
|
||||
var hasMultipleProfiles: Bool { !profiles.isEmpty }
|
||||
|
||||
func load() {
|
||||
memoryContent = fileService.loadMemory()
|
||||
userContent = fileService.loadUserProfile()
|
||||
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) {
|
||||
@@ -31,10 +55,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,35 @@ 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")
|
||||
Text("Memory is managed by \(viewModel.memoryProvider). File contents shown here may be stale.")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.orange.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
memorySection("Agent Memory", content: viewModel.memoryContent, charCount: viewModel.memoryCharCount, target: .memory)
|
||||
memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user)
|
||||
}
|
||||
@@ -42,8 +71,7 @@ struct MemoryView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
} else {
|
||||
Text(markdownAttributed(content))
|
||||
.textSelection(.enabled)
|
||||
MarkdownContentView(content: content)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
@@ -64,14 +92,17 @@ struct MemoryView: View {
|
||||
}
|
||||
.padding()
|
||||
Divider()
|
||||
HSplitView {
|
||||
TextEditor(text: $viewModel.editText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.padding(8)
|
||||
ScrollView {
|
||||
MarkdownContentView(content: viewModel.editText)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.frame(minWidth: 600, minHeight: 400)
|
||||
}
|
||||
|
||||
private func markdownAttributed(_ text: String) -> AttributedString {
|
||||
(try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
|
||||
}
|
||||
.frame(minWidth: 800, minHeight: 500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,8 @@ struct TextWidgetView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let content = widget.content {
|
||||
if widget.format == "markdown",
|
||||
let attributed = try? AttributedString(markdown: content) {
|
||||
Text(attributed)
|
||||
.font(.callout)
|
||||
if widget.format == "markdown" {
|
||||
MarkdownContentView(content: content)
|
||||
} else {
|
||||
Text(content)
|
||||
.font(.callout)
|
||||
|
||||
@@ -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 {
|
||||
@@ -83,17 +85,7 @@ final class SessionsViewModel {
|
||||
let result = runHermes(["sessions", "rename", sessionId, title])
|
||||
if result.exitCode == 0 {
|
||||
if let idx = sessions.firstIndex(where: { $0.id == sessionId }) {
|
||||
let updated = HermesSession(
|
||||
id: sessions[idx].id, source: sessions[idx].source,
|
||||
userId: sessions[idx].userId, model: sessions[idx].model,
|
||||
title: title, parentSessionId: sessions[idx].parentSessionId,
|
||||
startedAt: sessions[idx].startedAt, endedAt: sessions[idx].endedAt,
|
||||
endReason: sessions[idx].endReason, messageCount: sessions[idx].messageCount,
|
||||
toolCallCount: sessions[idx].toolCallCount, inputTokens: sessions[idx].inputTokens,
|
||||
outputTokens: sessions[idx].outputTokens, cacheReadTokens: sessions[idx].cacheReadTokens,
|
||||
cacheWriteTokens: sessions[idx].cacheWriteTokens,
|
||||
estimatedCostUSD: sessions[idx].estimatedCostUSD
|
||||
)
|
||||
let updated = sessions[idx].withTitle(title)
|
||||
sessions[idx] = updated
|
||||
if selectedSession?.id == sessionId {
|
||||
selectedSession = updated
|
||||
|
||||
@@ -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,9 +46,22 @@ 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")
|
||||
if session.reasoningTokens > 0 {
|
||||
Label("\(session.reasoningTokens) reasoning", systemImage: "brain")
|
||||
}
|
||||
if let cost = session.displayCostUSD {
|
||||
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
|
||||
}
|
||||
if let date = session.startedAt {
|
||||
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
|
||||
}
|
||||
@@ -58,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) {
|
||||
@@ -78,10 +128,24 @@ struct MessageBubble: View {
|
||||
HStack {
|
||||
if message.isUser { Spacer(minLength: 60) }
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if message.hasReasoning {
|
||||
DisclosureGroup("Reasoning") {
|
||||
Text(message.reasoning ?? "")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if !message.content.isEmpty {
|
||||
if message.isAssistant {
|
||||
MarkdownContentView(content: message.content)
|
||||
} else {
|
||||
Text(message.content)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
if !message.toolCalls.isEmpty {
|
||||
ForEach(message.toolCalls) { call in
|
||||
ToolCallBadge(call: call)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@Observable
|
||||
final class SettingsViewModel {
|
||||
@@ -10,9 +11,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()
|
||||
@@ -52,6 +55,95 @@ final class SettingsViewModel {
|
||||
func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") }
|
||||
func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
|
||||
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
|
||||
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 setServiceTier(_ value: String) { setSetting("agent.service_tier", value: value) }
|
||||
func setGatewayNotifyInterval(_ value: Int) { setSetting("agent.gateway_notify_interval", value: String(value)) }
|
||||
func setForceIPv4(_ value: Bool) { setSetting("network.force_ipv4", value: value ? "true" : "false") }
|
||||
func setInterimAssistantMessages(_ value: Bool) { setSetting("display.interim_assistant_messages", value: value ? "true" : "false") }
|
||||
// Hermes v0.9.0 PR #6995: the key is camelCase in config.yaml (not snake_case like the rest of Hermes).
|
||||
func setHonchoInitOnSessionStart(_ value: Bool) { setSetting("honcho.initOnSessionStart", value: value ? "true" : "false") }
|
||||
|
||||
// MARK: - Backup & Restore (v0.9.0)
|
||||
|
||||
var backupInProgress = false
|
||||
|
||||
func runBackup() {
|
||||
backupInProgress = true
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["backup"], timeout: 300)
|
||||
let zipPath = Self.extractZipPath(from: result.output)
|
||||
await MainActor.run {
|
||||
self.backupInProgress = false
|
||||
if result.exitCode == 0 {
|
||||
if let zipPath {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: zipPath)])
|
||||
self.saveMessage = "Backup saved"
|
||||
} else {
|
||||
self.saveMessage = "Backup complete"
|
||||
}
|
||||
} else {
|
||||
self.saveMessage = "Backup failed"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.saveMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runRestore(from url: URL) {
|
||||
backupInProgress = true
|
||||
Task.detached { [fileService] in
|
||||
let result = fileService.runHermesCLI(args: ["import", url.path], timeout: 300)
|
||||
await MainActor.run {
|
||||
self.backupInProgress = false
|
||||
self.saveMessage = result.exitCode == 0 ? "Restore complete — restart Scarf" : "Restore failed"
|
||||
if result.exitCode == 0 {
|
||||
self.load()
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.saveMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the first absolute `.zip` path out of `hermes backup` stdout.
|
||||
/// Hermes prints a line like "Backup saved to /Users/foo/.hermes-backups/hermes-2026-04-14.zip (5.4 MB)".
|
||||
nonisolated static func extractZipPath(from output: String) -> String? {
|
||||
let pattern = #"(/[^\s]+\.zip)"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(output.startIndex..., in: output)
|
||||
guard let match = regex.firstMatch(in: output, range: range),
|
||||
let r = Range(match.range(at: 1), in: output) else { return nil }
|
||||
return String(output[r])
|
||||
}
|
||||
|
||||
func presentRestorePicker() -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.message = "Choose a Hermes backup archive to restore"
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return nil }
|
||||
return url
|
||||
}
|
||||
|
||||
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,8 +11,18 @@ struct SettingsView: View {
|
||||
modelSection
|
||||
displaySection
|
||||
terminalSection
|
||||
if !viewModel.config.dockerEnv.isEmpty {
|
||||
dockerEnvSection
|
||||
}
|
||||
if !viewModel.config.commandAllowlist.isEmpty {
|
||||
allowlistSection
|
||||
}
|
||||
voiceSection
|
||||
memorySection
|
||||
performanceSection
|
||||
networkSection
|
||||
advancedSection
|
||||
backupSection
|
||||
pathsSection
|
||||
rawConfigSection
|
||||
}
|
||||
@@ -21,6 +31,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 +60,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +88,8 @@ struct SettingsView: View {
|
||||
}
|
||||
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
||||
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
||||
ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($0) }
|
||||
ToggleRow(label: "Interim Messages", isOn: viewModel.config.interimAssistantMessages) { viewModel.setInterimAssistantMessages($0) }
|
||||
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
||||
}
|
||||
}
|
||||
@@ -68,6 +100,27 @@ struct SettingsView: View {
|
||||
SettingsSection(title: "Terminal", icon: "terminal") {
|
||||
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($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: "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: ", "))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,9 +138,93 @@ 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) }
|
||||
if viewModel.config.memoryProvider == "honcho" {
|
||||
ToggleRow(label: "Honcho Eager Init", isOn: viewModel.config.honchoInitOnSessionStart) { viewModel.setHonchoInitOnSessionStart($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Performance (v0.9.0)
|
||||
|
||||
private var performanceSection: some View {
|
||||
SettingsSection(title: "Performance", icon: "bolt") {
|
||||
ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in
|
||||
viewModel.setServiceTier(on ? "fast" : "normal")
|
||||
}
|
||||
StepperRow(label: "Notify Interval (s)", value: viewModel.config.gatewayNotifyInterval, range: 0...3600) { viewModel.setGatewayNotifyInterval($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Network (v0.9.0)
|
||||
|
||||
private var networkSection: some View {
|
||||
SettingsSection(title: "Network", icon: "network") {
|
||||
ToggleRow(label: "Force IPv4", isOn: viewModel.config.forceIPv4) { viewModel.setForceIPv4($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Advanced (v0.9.0)
|
||||
|
||||
private var advancedSection: some View {
|
||||
SettingsSection(title: "Advanced", icon: "slider.horizontal.3") {
|
||||
ReadOnlyRow(label: "Context Engine", value: viewModel.config.contextEngine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Backup & Restore (v0.9.0)
|
||||
|
||||
@State private var showRestoreConfirm = false
|
||||
@State private var pendingRestoreURL: URL?
|
||||
|
||||
private var backupSection: some View {
|
||||
SettingsSection(title: "Backup & Restore", icon: "externaldrive") {
|
||||
HStack {
|
||||
Text("Archive")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Button {
|
||||
viewModel.runBackup()
|
||||
} label: {
|
||||
Label("Backup Now", systemImage: "arrow.down.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.backupInProgress)
|
||||
Button {
|
||||
if let url = viewModel.presentRestorePicker() {
|
||||
pendingRestoreURL = url
|
||||
showRestoreConfirm = true
|
||||
}
|
||||
} label: {
|
||||
Label("Restore…", systemImage: "arrow.up.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(viewModel.backupInProgress)
|
||||
if viewModel.backupInProgress {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
.confirmationDialog("Restore from backup?", isPresented: $showRestoreConfirm) {
|
||||
Button("Restore", role: .destructive) {
|
||||
if let url = pendingRestoreURL {
|
||||
viewModel.runRestore(from: url)
|
||||
}
|
||||
pendingRestoreURL = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingRestoreURL = nil }
|
||||
} message: {
|
||||
Text("This will overwrite files under ~/.hermes/ with the archive contents.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +238,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +408,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,10 @@ final class SkillsViewModel {
|
||||
var skillContent = ""
|
||||
var selectedFileName: String?
|
||||
var searchText = ""
|
||||
var missingConfig: [String] = []
|
||||
var isEditing = false
|
||||
var editText = ""
|
||||
private var currentConfig = HermesConfig.empty
|
||||
|
||||
var filteredCategories: [HermesSkillCategory] {
|
||||
guard !searchText.isEmpty else { return categories }
|
||||
@@ -28,6 +32,7 @@ final class SkillsViewModel {
|
||||
|
||||
func load() {
|
||||
categories = fileService.loadSkills()
|
||||
currentConfig = fileService.loadConfig()
|
||||
}
|
||||
|
||||
func selectSkill(_ skill: HermesSkill) {
|
||||
@@ -40,6 +45,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) {
|
||||
@@ -47,4 +63,29 @@ final class SkillsViewModel {
|
||||
selectedFileName = file
|
||||
skillContent = fileService.loadSkillContent(path: skill.path + "/" + file)
|
||||
}
|
||||
|
||||
var isMarkdownFile: Bool {
|
||||
selectedFileName?.hasSuffix(".md") == true
|
||||
}
|
||||
|
||||
private var currentFilePath: String? {
|
||||
guard let skill = selectedSkill, let file = selectedFileName else { return nil }
|
||||
return skill.path + "/" + file
|
||||
}
|
||||
|
||||
func startEditing() {
|
||||
editText = skillContent
|
||||
isEditing = true
|
||||
}
|
||||
|
||||
func saveEdit() {
|
||||
guard let path = currentFilePath else { return }
|
||||
fileService.saveSkillContent(path: path, content: editText)
|
||||
skillContent = editText
|
||||
isEditing = false
|
||||
}
|
||||
|
||||
func cancelEditing() {
|
||||
isEditing = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -80,17 +99,57 @@ struct SkillsView: View {
|
||||
}
|
||||
if !viewModel.skillContent.isEmpty {
|
||||
Divider()
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Edit") { viewModel.startEditing() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
if viewModel.isMarkdownFile {
|
||||
MarkdownContentView(content: viewModel.skillContent)
|
||||
} else {
|
||||
Text(viewModel.skillContent)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.isEditing) {
|
||||
skillEditorSheet
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView("Select a Skill", systemImage: "lightbulb", description: Text("Choose a skill from the list"))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var skillEditorSheet: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Edit \(viewModel.selectedFileName ?? "File")")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button("Cancel") { viewModel.cancelEditing() }
|
||||
Button("Save") { viewModel.saveEdit() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
Divider()
|
||||
HSplitView {
|
||||
TextEditor(text: $viewModel.editText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.padding(8)
|
||||
if viewModel.isMarkdownFile {
|
||||
ScrollView {
|
||||
MarkdownContentView(content: viewModel.editText)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 800, minHeight: 500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,56 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class ToolsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ToolsViewModel")
|
||||
|
||||
var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
|
||||
var toolsets: [HermesToolset] = []
|
||||
var mcpStatus: String = ""
|
||||
var isLoading = false
|
||||
var availablePlatforms: [HermesToolPlatform] = []
|
||||
|
||||
func load() {
|
||||
loadPlatforms()
|
||||
loadTools(for: selectedPlatform)
|
||||
loadMCPStatus()
|
||||
@MainActor
|
||||
func load() async {
|
||||
isLoading = true
|
||||
await loadPlatforms()
|
||||
await loadTools(for: selectedPlatform)
|
||||
await loadMCPStatus()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func switchPlatform(_ platform: HermesToolPlatform) {
|
||||
@MainActor
|
||||
func switchPlatform(_ platform: HermesToolPlatform) async {
|
||||
selectedPlatform = platform
|
||||
loadTools(for: platform)
|
||||
await 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 }) {
|
||||
@MainActor
|
||||
func toggleTool(_ tool: HermesToolset) async {
|
||||
guard let idx = toolsets.firstIndex(where: { $0.name == tool.name }) else { return }
|
||||
toolsets[idx].enabled.toggle()
|
||||
let newEnabled = toolsets[idx].enabled
|
||||
|
||||
let action = newEnabled ? "enable" : "disable"
|
||||
let result = await runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name])
|
||||
|
||||
if result.exitCode != 0 {
|
||||
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
|
||||
toolsets[idx].enabled = !newEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPlatforms() {
|
||||
@MainActor
|
||||
private func loadPlatforms() async {
|
||||
let config: String
|
||||
do {
|
||||
config = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
||||
config = try await Task.detached {
|
||||
try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
||||
}.value
|
||||
} catch {
|
||||
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
|
||||
logger.error("Failed to read config.yaml: \(error.localizedDescription)")
|
||||
config = ""
|
||||
}
|
||||
var platforms: [HermesToolPlatform] = []
|
||||
@@ -67,15 +83,15 @@ final class ToolsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadTools(for platform: HermesToolPlatform) {
|
||||
isLoading = true
|
||||
let result = runHermes(["tools", "list", "--platform", platform.name])
|
||||
@MainActor
|
||||
private func loadTools(for platform: HermesToolPlatform) async {
|
||||
let result = await runHermes(["tools", "list", "--platform", platform.name])
|
||||
toolsets = parseToolsList(result.output)
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func loadMCPStatus() {
|
||||
let result = runHermes(["mcp", "list"])
|
||||
@MainActor
|
||||
private func loadMCPStatus() async {
|
||||
let result = await runHermes(["mcp", "list"])
|
||||
mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
@@ -121,21 +137,32 @@ final class ToolsViewModel {
|
||||
return "🔧"
|
||||
}
|
||||
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
private nonisolated func runHermes(_ arguments: [String]) async -> (output: String, exitCode: Int32) {
|
||||
await Task.detached {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
try? stdoutPipe.fileHandleForWriting.close()
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
try? stderrPipe.fileHandleForWriting.close()
|
||||
return (output, process.terminationStatus)
|
||||
} catch {
|
||||
try? stdoutPipe.fileHandleForReading.close()
|
||||
try? stdoutPipe.fileHandleForWriting.close()
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
try? stderrPipe.fileHandleForWriting.close()
|
||||
return ("", -1)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,27 +14,24 @@ struct ToolsView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Tools")
|
||||
.onAppear { viewModel.load() }
|
||||
.task { await viewModel.load() }
|
||||
}
|
||||
|
||||
private var platformPicker: some View {
|
||||
HStack(spacing: 16) {
|
||||
HStack(spacing: 12) {
|
||||
Picker("Platform", selection: Binding(
|
||||
get: { viewModel.selectedPlatform.name },
|
||||
set: { name in
|
||||
if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) {
|
||||
Task { await viewModel.switchPlatform(platform) }
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(viewModel.availablePlatforms) { platform in
|
||||
Button {
|
||||
viewModel.switchPlatform(platform)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: platform.icon)
|
||||
Text(platform.displayName)
|
||||
.font(.caption)
|
||||
Text(platform.displayName).tag(platform.name)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
Spacer()
|
||||
Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled")
|
||||
.font(.caption)
|
||||
@@ -49,13 +46,14 @@ struct ToolsView: View {
|
||||
LazyVStack(spacing: 1) {
|
||||
ForEach(viewModel.toolsets) { tool in
|
||||
ToolRow(tool: tool) {
|
||||
viewModel.toggleTool(tool)
|
||||
await viewModel.toggleTool(tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.id(viewModel.selectedPlatform.name)
|
||||
}
|
||||
|
||||
private var mcpSection: some View {
|
||||
@@ -80,7 +78,7 @@ struct ToolsView: View {
|
||||
|
||||
struct ToolRow: View {
|
||||
let tool: HermesToolset
|
||||
let onToggle: () -> Void
|
||||
let onToggle: () async -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
@@ -97,7 +95,7 @@ struct ToolRow: View {
|
||||
Spacer()
|
||||
Toggle("", isOn: Binding(
|
||||
get: { tool.enabled },
|
||||
set: { _ in onToggle() }
|
||||
set: { _ in Task { await onToggle() } }
|
||||
))
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
|
||||
@@ -10,6 +10,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
case memory = "Memory"
|
||||
case skills = "Skills"
|
||||
case tools = "Tools"
|
||||
case mcpServers = "MCP Servers"
|
||||
case gateway = "Gateway"
|
||||
case cron = "Cron"
|
||||
case health = "Health"
|
||||
@@ -29,6 +30,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
case .memory: return "brain"
|
||||
case .skills: return "lightbulb"
|
||||
case .tools: return "wrench.and.screwdriver"
|
||||
case .mcpServers: return "puzzlepiece.extension"
|
||||
case .gateway: return "antenna.radiowaves.left.and.right"
|
||||
case .cron: return "clock.arrow.2.circlepath"
|
||||
case .health: return "stethoscope"
|
||||
|
||||
@@ -25,7 +25,7 @@ struct SidebarView: View {
|
||||
}
|
||||
}
|
||||
Section("Manage") {
|
||||
ForEach([SidebarSection.tools, .gateway, .cron, .health, .logs, .settings]) { section in
|
||||
ForEach([SidebarSection.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]) { section in
|
||||
Label(section.rawValue, systemImage: section.icon)
|
||||
.tag(section)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,33 @@ final class MenuBarStatus {
|
||||
timer = nil
|
||||
}
|
||||
|
||||
func startHermes() {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = ["gateway", "start"]
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
try? process.run()
|
||||
process.waitUntilExit()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func stopHermes() {
|
||||
fileService.stopHermes()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func restartHermes() {
|
||||
fileService.stopHermes()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.startHermes()
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
hermesRunning = fileService.isHermesRunning()
|
||||
gatewayRunning = fileService.loadGatewayState()?.isRunning ?? false
|
||||
@@ -69,6 +96,13 @@ struct MenuBarMenu: View {
|
||||
Label(status.hermesRunning ? "Hermes Running" : "Hermes Stopped", systemImage: status.hermesRunning ? "circle.fill" : "circle")
|
||||
Label(status.gatewayRunning ? "Gateway Running" : "Gateway Stopped", systemImage: status.gatewayRunning ? "circle.fill" : "circle")
|
||||
Divider()
|
||||
Button("Start Hermes") { status.startHermes() }
|
||||
.disabled(status.hermesRunning)
|
||||
Button("Stop Hermes") { status.stopHermes() }
|
||||
.disabled(!status.hermesRunning)
|
||||
Button("Restart Hermes") { status.restartHermes() }
|
||||
.disabled(!status.hermesRunning)
|
||||
Divider()
|
||||
Button("Open Dashboard") {
|
||||
coordinator.selectedSection = .dashboard
|
||||
NSApplication.shared.activate()
|
||||
|
||||
Reference in New Issue
Block a user