mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86762eab6d | |||
| a7fd193770 | |||
| 521c6d63fc | |||
| 66d04d838d | |||
| ad30c0a943 | |||
| 44afa8f53b | |||
| 481b937c33 | |||
| 790efb585b | |||
| 3acf95a824 | |||
| 7d69c82c2b | |||
| ae2872e08f | |||
| 303f4502dd |
@@ -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,37 @@
|
||||
|
||||
## 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, and permission request dialogs; **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, Email, Home Assistant, Webhook, Matrix, Feishu, Mattermost) with toggle switches and segmented platform picker, MCP server status
|
||||
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
||||
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
|
||||
- **Log Viewer** — Real-time log tailing with level filtering and text search
|
||||
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators
|
||||
- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering and text search
|
||||
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
|
||||
- **Settings** — Structured config editor for all Hermes settings
|
||||
- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Docker environment, command allowlist, credential management, and more
|
||||
- **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.8.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, 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.
|
||||
|
||||
@@ -90,7 +92,7 @@ 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
|
||||
@@ -114,6 +116,7 @@ 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 |
|
||||
@@ -136,7 +139,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.
|
||||
|
||||
|
||||
@@ -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 = 5;
|
||||
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.1;
|
||||
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 = 5;
|
||||
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.1;
|
||||
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;
|
||||
|
||||
@@ -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,14 @@ 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
|
||||
|
||||
static let empty = HermesConfig(
|
||||
model: "unknown",
|
||||
@@ -30,7 +38,15 @@ struct HermesConfig: Sendable {
|
||||
showReasoning: false,
|
||||
verbose: false,
|
||||
autoTTS: true,
|
||||
silenceThreshold: 200
|
||||
silenceThreshold: 200,
|
||||
reasoningEffort: "medium",
|
||||
showCost: false,
|
||||
approvalMode: "manual",
|
||||
browserBackend: "",
|
||||
memoryProvider: "",
|
||||
dockerEnv: [:],
|
||||
commandAllowlist: [],
|
||||
memoryProfile: ""
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ enum HermesPaths: Sendable {
|
||||
nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json"
|
||||
nonisolated static let skillsDir: String = home + "/skills"
|
||||
nonisolated static let errorsLog: String = home + "/logs/errors.log"
|
||||
nonisolated static let agentLog: String = home + "/logs/agent.log"
|
||||
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
|
||||
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
|
||||
nonisolated static let scarfDir: String = home + "/scarf"
|
||||
|
||||
@@ -13,12 +13,23 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
let nextRunAt: String?
|
||||
let lastRunAt: String?
|
||||
let lastError: String?
|
||||
let preRunScript: String?
|
||||
let deliveryFailures: Int?
|
||||
let lastDeliveryError: String?
|
||||
let timeoutType: String?
|
||||
let timeoutSeconds: Int?
|
||||
let silent: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, prompt, skills, model, schedule, enabled, state, deliver
|
||||
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
|
||||
case nextRunAt = "next_run_at"
|
||||
case lastRunAt = "last_run_at"
|
||||
case lastError = "last_error"
|
||||
case preRunScript = "pre_run_script"
|
||||
case deliveryFailures = "delivery_failures"
|
||||
case lastDeliveryError = "last_delivery_error"
|
||||
case timeoutType = "timeout_type"
|
||||
case timeoutSeconds = "timeout_seconds"
|
||||
}
|
||||
|
||||
var stateIcon: String {
|
||||
|
||||
@@ -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,28 @@ 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"),
|
||||
]
|
||||
|
||||
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"
|
||||
default: return "bubble.left"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 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
|
||||
"""
|
||||
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 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 ?
|
||||
"""
|
||||
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 = """
|
||||
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
|
||||
"""
|
||||
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,15 @@ 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"] ?? ""
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,20 +105,41 @@ struct HermesFileService: Sendable {
|
||||
|
||||
// MARK: - Memory
|
||||
|
||||
func loadMemory() -> String {
|
||||
readFile(HermesPaths.memoryMD) ?? ""
|
||||
func loadMemoryProfiles() -> [String] {
|
||||
let fm = FileManager.default
|
||||
guard let entries = try? fm.contentsOfDirectory(atPath: HermesPaths.memoriesDir) else { return [] }
|
||||
return entries.filter { name in
|
||||
var isDir: ObjCBool = false
|
||||
let path = HermesPaths.memoriesDir + "/" + name
|
||||
return fm.fileExists(atPath: path, isDirectory: &isDir) && isDir.boolValue
|
||||
}.sorted()
|
||||
}
|
||||
|
||||
func loadUserProfile() -> String {
|
||||
readFile(HermesPaths.userMD) ?? ""
|
||||
func loadMemory(profile: String = "") -> String {
|
||||
let path = memoryPath(profile: profile, file: "MEMORY.md")
|
||||
return readFile(path) ?? ""
|
||||
}
|
||||
|
||||
func saveMemory(_ content: String) {
|
||||
writeFile(HermesPaths.memoryMD, content: content)
|
||||
func loadUserProfile(profile: String = "") -> String {
|
||||
let path = memoryPath(profile: profile, file: "USER.md")
|
||||
return readFile(path) ?? ""
|
||||
}
|
||||
|
||||
func saveUserProfile(_ content: String) {
|
||||
writeFile(HermesPaths.userMD, content: content)
|
||||
func saveMemory(_ content: String, profile: String = "") {
|
||||
let path = memoryPath(profile: profile, file: "MEMORY.md")
|
||||
writeFile(path, content: content)
|
||||
}
|
||||
|
||||
func saveUserProfile(_ content: String, profile: String = "") {
|
||||
let path = memoryPath(profile: profile, file: "USER.md")
|
||||
writeFile(path, content: content)
|
||||
}
|
||||
|
||||
private func memoryPath(profile: String, file: String) -> String {
|
||||
if profile.isEmpty {
|
||||
return HermesPaths.memoriesDir + "/" + file
|
||||
}
|
||||
return HermesPaths.memoriesDir + "/" + profile + "/" + file
|
||||
}
|
||||
|
||||
// MARK: - Cron
|
||||
@@ -118,12 +182,14 @@ struct HermesFileService: Sendable {
|
||||
var isSkillDir: ObjCBool = false
|
||||
guard fm.fileExists(atPath: skillPath, isDirectory: &isSkillDir), isSkillDir.boolValue else { return nil }
|
||||
let files = (try? fm.contentsOfDirectory(atPath: skillPath)) ?? []
|
||||
let requiredConfig = parseSkillRequiredConfig(skillPath + "/skill.yaml")
|
||||
return HermesSkill(
|
||||
id: categoryName + "/" + skillName,
|
||||
name: skillName,
|
||||
category: categoryName,
|
||||
path: skillPath,
|
||||
files: files.sorted()
|
||||
files: files.sorted(),
|
||||
requiredConfig: requiredConfig
|
||||
)
|
||||
}
|
||||
|
||||
@@ -133,18 +199,54 @@ 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: - 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,12 +257,21 @@ 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 {
|
||||
guard let pid = hermesPID() else { return false }
|
||||
return kill(pid, SIGTERM) == 0
|
||||
}
|
||||
|
||||
// MARK: - File I/O
|
||||
|
||||
private func readFile(_ path: String) -> String? {
|
||||
|
||||
@@ -16,6 +16,7 @@ final class HermesFileWatcher {
|
||||
HermesPaths.userMD,
|
||||
HermesPaths.cronJobsJSON,
|
||||
HermesPaths.gatewayStateJSON,
|
||||
HermesPaths.agentLog,
|
||||
HermesPaths.errorsLog,
|
||||
HermesPaths.gatewayLog,
|
||||
HermesPaths.projectsRegistry
|
||||
|
||||
@@ -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,33 +16,424 @@ 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
|
||||
launchTerminal(arguments: ["chat"])
|
||||
richChatViewModel.reset()
|
||||
|
||||
if displayMode == .richChat {
|
||||
startACPSession(resume: nil)
|
||||
} else {
|
||||
launchTerminal(arguments: ["chat"])
|
||||
}
|
||||
}
|
||||
|
||||
func resumeSession(_ sessionId: String) {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
launchTerminal(arguments: ["chat", "--resume", sessionId])
|
||||
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
|
||||
launchTerminal(arguments: ["chat", "--continue"])
|
||||
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)
|
||||
)
|
||||
} 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()
|
||||
guard opened else { return }
|
||||
@@ -55,6 +448,8 @@ final class ChatViewModel {
|
||||
return session.id
|
||||
}
|
||||
|
||||
// MARK: - Voice (terminal mode only)
|
||||
|
||||
func toggleVoice() {
|
||||
guard let tv = terminalView else { return }
|
||||
if voiceEnabled {
|
||||
@@ -76,18 +471,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 +500,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,509 @@
|
||||
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?
|
||||
|
||||
var hasMessages: Bool { !messages.isEmpty }
|
||||
|
||||
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 = []
|
||||
pendingPermission = nil
|
||||
}
|
||||
|
||||
func setSessionId(_ id: String?) {
|
||||
sessionId = id
|
||||
lastKnownFingerprint = nil
|
||||
}
|
||||
|
||||
func cleanup() async {
|
||||
stopActivePolling()
|
||||
debounceTask?.cancel()
|
||||
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:
|
||||
handlePromptComplete()
|
||||
case .connectionLost(let reason):
|
||||
handleConnectionLost(reason: reason)
|
||||
case .availableCommands, .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() {
|
||||
// Finalize any remaining streaming content
|
||||
finalizeStreamingMessage()
|
||||
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,13 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
Menu {
|
||||
if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId {
|
||||
Button("Return to Active Session (\(activeId.prefix(8))...)") {
|
||||
// Already active — just ensure we're showing it
|
||||
}
|
||||
.disabled(true)
|
||||
Divider()
|
||||
}
|
||||
Button("New Session") {
|
||||
viewModel.startNewSession()
|
||||
}
|
||||
@@ -137,6 +182,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 +212,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,65 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RichChatInputBar: View {
|
||||
let onSend: (String) -> Void
|
||||
let isEnabled: Bool
|
||||
|
||||
@State private var text = ""
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
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)
|
||||
}
|
||||
|
||||
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,140 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RichChatMessageList: View {
|
||||
let groups: [MessageGroup]
|
||||
let isWorking: Bool
|
||||
|
||||
/// 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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,50 @@
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
RichChatInputBar(
|
||||
onSend: { text in
|
||||
onSend(text)
|
||||
},
|
||||
isEnabled: isEnabled
|
||||
)
|
||||
}
|
||||
// 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,78 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SessionInfoBar: View {
|
||||
let session: HermesSession?
|
||||
let isWorking: Bool
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
Label("\(formatTokens(session.inputTokens)) in / \(formatTokens(session.outputTokens)) out", systemImage: "number")
|
||||
.contentTransition(.numericText())
|
||||
|
||||
if session.reasoningTokens > 0 {
|
||||
Label("\(formatTokens(session.reasoningTokens)) 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)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,36 +29,75 @@ struct HealthView: View {
|
||||
// MARK: - Header
|
||||
|
||||
private var headerBar: some View {
|
||||
HStack(spacing: 16) {
|
||||
if !viewModel.version.isEmpty {
|
||||
Text(viewModel.version)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if viewModel.hasUpdate {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.font(.caption2)
|
||||
Text(viewModel.updateInfo)
|
||||
.font(.caption)
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 16) {
|
||||
if !viewModel.version.isEmpty {
|
||||
Text(viewModel.version)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
if viewModel.hasUpdate {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.font(.caption2)
|
||||
Text(viewModel.updateInfo)
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
MiniCount(count: viewModel.okCount, color: .green, icon: "checkmark.circle.fill")
|
||||
MiniCount(count: viewModel.warningCount, color: .orange, icon: "exclamationmark.triangle.fill")
|
||||
MiniCount(count: viewModel.issueCount, color: .red, icon: "xmark.circle.fill")
|
||||
}
|
||||
|
||||
Button("Refresh") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Spacer()
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
MiniCount(count: viewModel.okCount, color: .green, icon: "checkmark.circle.fill")
|
||||
MiniCount(count: viewModel.warningCount, color: .orange, icon: "exclamationmark.triangle.fill")
|
||||
MiniCount(count: viewModel.issueCount, color: .red, icon: "xmark.circle.fill")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Button("Refresh") { viewModel.load() }
|
||||
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)
|
||||
}
|
||||
.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,13 @@ final class LogsViewModel {
|
||||
private let logService = HermesLogService()
|
||||
|
||||
var entries: [LogEntry] = []
|
||||
var selectedLogFile: LogFile = .errors
|
||||
var selectedLogFile: LogFile = .agent
|
||||
var filterLevel: LogEntry.LogLevel?
|
||||
var searchText = ""
|
||||
private var pollTimer: Timer?
|
||||
|
||||
enum LogFile: String, CaseIterable, Identifiable {
|
||||
case agent = "agent.log"
|
||||
case errors = "errors.log"
|
||||
case gateway = "gateway.log"
|
||||
|
||||
@@ -18,6 +19,7 @@ final class LogsViewModel {
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
case .agent: return HermesPaths.agentLog
|
||||
case .errors: return HermesPaths.errorsLog
|
||||
case .gateway: return HermesPaths.gatewayLog
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
TextEditor(text: $viewModel.editText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.padding(8)
|
||||
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,9 +128,23 @@ 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 {
|
||||
Text(message.content)
|
||||
.textSelection(.enabled)
|
||||
if message.isAssistant {
|
||||
MarkdownContentView(content: message.content)
|
||||
} else {
|
||||
Text(message.content)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
if !message.toolCalls.isEmpty {
|
||||
ForEach(message.toolCalls) { call in
|
||||
|
||||
@@ -115,10 +115,14 @@ struct SessionsView: View {
|
||||
SessionDetailView(
|
||||
session: session,
|
||||
messages: viewModel.messages,
|
||||
subagentSessions: viewModel.subagentSessions,
|
||||
preview: viewModel.previewFor(session),
|
||||
onRename: { viewModel.beginRename(session) },
|
||||
onExport: { viewModel.exportSession(session) },
|
||||
onDelete: { viewModel.beginDelete(session) }
|
||||
onDelete: { viewModel.beginDelete(session) },
|
||||
onSelectSubagent: { sub in
|
||||
Task { await viewModel.selectSession(sub) }
|
||||
}
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
} else {
|
||||
@@ -149,13 +153,6 @@ struct SessionsView: View {
|
||||
}
|
||||
|
||||
private func platformIcon(_ platform: String) -> String {
|
||||
switch platform {
|
||||
case "cli": return "terminal"
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
case "slack": return "number"
|
||||
case "email": return "envelope"
|
||||
default: return "bubble.left"
|
||||
}
|
||||
KnownPlatforms.icon(for: platform)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ final class SettingsViewModel {
|
||||
var hermesRunning = false
|
||||
var rawConfigYAML = ""
|
||||
var personalities: [String] = []
|
||||
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax"]
|
||||
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "google-ai-studio", "xai", "ollama-cloud", "zai", "kimi-coding", "minimax"]
|
||||
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
|
||||
var browserBackends = ["browseruse", "firecrawl", "local"]
|
||||
var saveMessage: String?
|
||||
var showAuthRemoveConfirmation = false
|
||||
|
||||
func load() {
|
||||
config = fileService.loadConfig()
|
||||
@@ -52,6 +54,22 @@ 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 removeAuth() {
|
||||
let result = runHermes(["auth", "remove"])
|
||||
if result.exitCode == 0 {
|
||||
saveMessage = "Credentials removed"
|
||||
} else {
|
||||
saveMessage = "Failed to remove credentials"
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.saveMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
func openConfigInEditor() {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
|
||||
@@ -11,6 +11,12 @@ struct SettingsView: View {
|
||||
modelSection
|
||||
displaySection
|
||||
terminalSection
|
||||
if !viewModel.config.dockerEnv.isEmpty {
|
||||
dockerEnvSection
|
||||
}
|
||||
if !viewModel.config.commandAllowlist.isEmpty {
|
||||
allowlistSection
|
||||
}
|
||||
voiceSection
|
||||
memorySection
|
||||
pathsSection
|
||||
@@ -21,6 +27,12 @@ struct SettingsView: View {
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.onAppear { viewModel.load() }
|
||||
.confirmationDialog("Remove Credentials?", isPresented: $viewModel.showAuthRemoveConfirmation) {
|
||||
Button("Remove", role: .destructive) { viewModel.removeAuth() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This will permanently clear all stored provider credentials.")
|
||||
}
|
||||
}
|
||||
|
||||
private var headerBar: some View {
|
||||
@@ -44,6 +56,20 @@ struct SettingsView: View {
|
||||
SettingsSection(title: "Model", icon: "cpu") {
|
||||
EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($0) }
|
||||
PickerRow(label: "Provider", selection: viewModel.config.provider, options: viewModel.providers) { viewModel.setProvider($0) }
|
||||
HStack {
|
||||
Text("Credentials")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Button("Remove Credentials", role: .destructive) {
|
||||
viewModel.showAuthRemoveConfirmation = true
|
||||
}
|
||||
.controlSize(.small)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +84,7 @@ 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: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
||||
}
|
||||
}
|
||||
@@ -68,6 +95,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,6 +133,9 @@ struct SettingsView: View {
|
||||
private var memorySection: some View {
|
||||
SettingsSection(title: "Memory", icon: "brain") {
|
||||
ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) }
|
||||
if !viewModel.config.memoryProfile.isEmpty {
|
||||
ReadOnlyRow(label: "Profile", value: viewModel.config.memoryProfile)
|
||||
}
|
||||
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) }
|
||||
StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) }
|
||||
StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) }
|
||||
@@ -101,7 +152,8 @@ struct SettingsView: View {
|
||||
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
|
||||
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
|
||||
PathRow(label: "Skills", path: HermesPaths.skillsDir)
|
||||
PathRow(label: "Logs", path: HermesPaths.errorsLog)
|
||||
PathRow(label: "Agent Log", path: HermesPaths.agentLog)
|
||||
PathRow(label: "Error Log", path: HermesPaths.errorsLog)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +322,27 @@ struct StepperRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadOnlyRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Text(value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct PathRow: View {
|
||||
let label: String
|
||||
let path: String
|
||||
|
||||
@@ -9,6 +9,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()
|
||||
Text(viewModel.skillContent)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,23 +18,20 @@ struct ToolsView: View {
|
||||
}
|
||||
|
||||
private var platformPicker: some View {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(viewModel.availablePlatforms) { platform in
|
||||
Button {
|
||||
viewModel.switchPlatform(platform)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: platform.icon)
|
||||
Text(platform.displayName)
|
||||
.font(.caption)
|
||||
HStack(spacing: 12) {
|
||||
Picker("Platform", selection: Binding(
|
||||
get: { viewModel.selectedPlatform.name },
|
||||
set: { name in
|
||||
if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) {
|
||||
viewModel.switchPlatform(platform)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(viewModel.selectedPlatform.name == platform.name ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
)) {
|
||||
ForEach(viewModel.availablePlatforms) { platform in
|
||||
Text(platform.displayName).tag(platform.name)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
Spacer()
|
||||
Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled")
|
||||
.font(.caption)
|
||||
@@ -56,6 +53,7 @@ struct ToolsView: View {
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.id(viewModel.selectedPlatform.name)
|
||||
}
|
||||
|
||||
private var mcpSection: some View {
|
||||
|
||||
@@ -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