mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86762eab6d | |||
| a7fd193770 | |||
| 521c6d63fc | |||
| 66d04d838d | |||
| ad30c0a943 | |||
| 44afa8f53b | |||
| 481b937c33 | |||
| 790efb585b | |||
| 7d69c82c2b |
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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/Swift-6-orange" alt="Swift">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br><br>
|
<br><br>
|
||||||
@@ -22,22 +22,23 @@
|
|||||||
- **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
|
- **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
|
||||||
- **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
|
- **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
|
||||||
- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export. Subagent sessions are filtered from the main list and accessible via parent session drill-down
|
- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export. Subagent sessions are filtered from the main list and accessible via parent session drill-down
|
||||||
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments
|
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments and tool output display
|
||||||
- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm), session persistence across navigation, resume/continue previous sessions, and voice mode controls
|
- **Live Chat** — 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
|
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker
|
||||||
- **Skills Browser** — Browse all installed skills by category with file content viewer, file switcher, and required config warnings for skills that need specific settings
|
- **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
|
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Webhook, Matrix, Feishu, Mattermost) with toggle switches and segmented platform picker, MCP server status
|
||||||
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
||||||
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators
|
- **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
|
- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering and text search
|
||||||
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
|
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
|
||||||
- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Docker environment, command allowlist, credential management, and more
|
- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Docker environment, command allowlist, credential management, and more
|
||||||
|
- **Hermes Process Control** — Start, stop, and restart the Hermes agent directly from Scarf
|
||||||
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- macOS 26.2+
|
- macOS 14.6+ (Sonoma)
|
||||||
- Xcode 26.3+
|
- 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)
|
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.8.0 recommended for full feature support)
|
||||||
|
|
||||||
### Compatibility
|
### Compatibility
|
||||||
@@ -91,7 +92,7 @@ scarf/
|
|||||||
Sessions/ Conversation browser with rename, delete, export
|
Sessions/ Conversation browser with rename, delete, export
|
||||||
Activity/ Tool execution feed with inspector
|
Activity/ Tool execution feed with inspector
|
||||||
Projects/ Agent-generated project dashboards with widget rendering
|
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
|
Memory/ Memory viewer and editor
|
||||||
Skills/ Skill browser by category
|
Skills/ Skill browser by category
|
||||||
Tools/ Toolset management per platform
|
Tools/ Toolset management per platform
|
||||||
@@ -115,6 +116,7 @@ Scarf reads Hermes data directly from `~/.hermes/`:
|
|||||||
| `logs/*.log` | Text | Read-only |
|
| `logs/*.log` | Text | Read-only |
|
||||||
| `gateway_state.json` | JSON | Read-only |
|
| `gateway_state.json` | JSON | Read-only |
|
||||||
| `skills/` | Directory tree | Read-only |
|
| `skills/` | Directory tree | Read-only |
|
||||||
|
| `hermes acp` | ACP subprocess (JSON-RPC stdio) | Real-time chat |
|
||||||
| `hermes chat` | Terminal subprocess | Interactive |
|
| `hermes chat` | Terminal subprocess | Interactive |
|
||||||
| `hermes tools` | CLI commands | Enable/Disable |
|
| `hermes tools` | CLI commands | Enable/Disable |
|
||||||
| `hermes sessions` | CLI commands | Rename/Delete/Export |
|
| `hermes sessions` | CLI commands | Rename/Delete/Export |
|
||||||
@@ -137,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.
|
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.
|
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_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -421,7 +421,8 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
|
MARKETING_VERSION = 1.5.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -443,7 +444,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -457,7 +458,8 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
|
MARKETING_VERSION = 1.5.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ actor HermesDataService {
|
|||||||
private var hasV07Schema = false
|
private var hasV07Schema = false
|
||||||
|
|
||||||
func open() -> Bool {
|
func open() -> Bool {
|
||||||
|
if db != nil { return true }
|
||||||
let path = HermesPaths.stateDB
|
let path = HermesPaths.stateDB
|
||||||
guard FileManager.default.fileExists(atPath: path) else { return false }
|
guard FileManager.default.fileExists(atPath: path) else { return false }
|
||||||
let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
|
let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
|
||||||
@@ -157,6 +158,17 @@ actor HermesDataService {
|
|||||||
return messages
|
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] {
|
func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = """
|
let sql = """
|
||||||
@@ -206,6 +218,81 @@ actor HermesDataService {
|
|||||||
return previews
|
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
|
// MARK: - Stats
|
||||||
|
|
||||||
struct SessionStats: Sendable {
|
struct SessionStats: Sendable {
|
||||||
|
|||||||
@@ -199,15 +199,23 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSkillContent(path: String) -> String {
|
func loadSkillContent(path: String) -> String {
|
||||||
// Validate path stays within the skills directory to prevent traversal
|
guard isValidSkillPath(path) else { return "" }
|
||||||
guard !path.contains(".."),
|
|
||||||
path.hasPrefix(HermesPaths.skillsDir) else {
|
|
||||||
print("[Scarf] Rejected skill path outside skills directory: \(path)")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return readFile(path) ?? ""
|
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] {
|
private func parseSkillRequiredConfig(_ path: String) -> [String] {
|
||||||
guard let content = readFile(path) else { return [] }
|
guard let content = readFile(path) else { return [] }
|
||||||
var result: [String] = []
|
var result: [String] = []
|
||||||
@@ -235,6 +243,10 @@ struct HermesFileService: Sendable {
|
|||||||
// MARK: - Hermes Process
|
// MARK: - Hermes Process
|
||||||
|
|
||||||
func isHermesRunning() -> Bool {
|
func isHermesRunning() -> Bool {
|
||||||
|
hermesPID() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hermesPID() -> pid_t? {
|
||||||
let pipe = Pipe()
|
let pipe = Pipe()
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
|
||||||
@@ -245,12 +257,21 @@ struct HermesFileService: Sendable {
|
|||||||
try process.run()
|
try process.run()
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
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 {
|
} 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
|
// MARK: - File I/O
|
||||||
|
|
||||||
private func readFile(_ path: String) -> String? {
|
private func readFile(_ path: String) -> String? {
|
||||||
|
|||||||
@@ -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 filterKind: ToolKind?
|
||||||
var filterSessionId: String?
|
var filterSessionId: String?
|
||||||
var selectedEntry: ActivityEntry?
|
var selectedEntry: ActivityEntry?
|
||||||
|
var toolResult: String?
|
||||||
var sessionPreviews: [String: String] = [:]
|
var sessionPreviews: [String: String] = [:]
|
||||||
var isLoading = true
|
var isLoading = true
|
||||||
|
|
||||||
@@ -54,6 +55,15 @@ final class ActivityViewModel {
|
|||||||
isLoading = false
|
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 {
|
func cleanup() async {
|
||||||
await dataService.close()
|
await dataService.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,11 +57,8 @@ struct ActivityView: View {
|
|||||||
List(selection: Binding(
|
List(selection: Binding(
|
||||||
get: { viewModel.selectedEntry?.id },
|
get: { viewModel.selectedEntry?.id },
|
||||||
set: { id in
|
set: { id in
|
||||||
if let id {
|
let entry = id.flatMap { id in viewModel.filteredActivity.first(where: { $0.id == id }) }
|
||||||
viewModel.selectedEntry = viewModel.filteredActivity.first(where: { $0.id == id })
|
Task { await viewModel.selectEntry(entry) }
|
||||||
} else {
|
|
||||||
viewModel.selectedEntry = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)) {
|
)) {
|
||||||
ForEach(viewModel.filteredActivity) { entry in
|
ForEach(viewModel.filteredActivity) { entry in
|
||||||
@@ -146,14 +143,32 @@ struct ActivityView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
.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 {
|
if !entry.messageContent.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Assistant Message")
|
Text("Assistant Message")
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Text(entry.messageContent)
|
MarkdownContentView(content: entry.messageContent)
|
||||||
.font(.caption)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(.quaternary.opacity(0.5))
|
.background(.quaternary.opacity(0.5))
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AppKit
|
import AppKit
|
||||||
import SwiftTerm
|
import SwiftTerm
|
||||||
|
import os
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class ChatViewModel {
|
final class ChatViewModel {
|
||||||
|
private let logger = Logger(subsystem: "com.scarf", category: "ChatViewModel")
|
||||||
private let dataService = HermesDataService()
|
private let dataService = HermesDataService()
|
||||||
private let fileService = HermesFileService()
|
private let fileService = HermesFileService()
|
||||||
|
|
||||||
@@ -14,33 +16,424 @@ final class ChatViewModel {
|
|||||||
var voiceEnabled = false
|
var voiceEnabled = false
|
||||||
var ttsEnabled = false
|
var ttsEnabled = false
|
||||||
var isRecording = false
|
var isRecording = false
|
||||||
|
var displayMode: ChatDisplayMode = .richChat
|
||||||
|
let richChatViewModel = RichChatViewModel()
|
||||||
private var coordinator: Coordinator?
|
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 {
|
var hermesBinaryExists: Bool {
|
||||||
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary)
|
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Lifecycle
|
||||||
|
|
||||||
func startNewSession() {
|
func startNewSession() {
|
||||||
voiceEnabled = false
|
voiceEnabled = false
|
||||||
ttsEnabled = false
|
ttsEnabled = false
|
||||||
isRecording = false
|
isRecording = false
|
||||||
launchTerminal(arguments: ["chat"])
|
richChatViewModel.reset()
|
||||||
|
|
||||||
|
if displayMode == .richChat {
|
||||||
|
startACPSession(resume: nil)
|
||||||
|
} else {
|
||||||
|
launchTerminal(arguments: ["chat"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resumeSession(_ sessionId: String) {
|
func resumeSession(_ sessionId: String) {
|
||||||
voiceEnabled = false
|
voiceEnabled = false
|
||||||
ttsEnabled = false
|
ttsEnabled = false
|
||||||
isRecording = 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() {
|
func continueLastSession() {
|
||||||
voiceEnabled = false
|
voiceEnabled = false
|
||||||
ttsEnabled = false
|
ttsEnabled = false
|
||||||
isRecording = 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 {
|
func loadRecentSessions() async {
|
||||||
let opened = await dataService.open()
|
let opened = await dataService.open()
|
||||||
guard opened else { return }
|
guard opened else { return }
|
||||||
@@ -55,6 +448,8 @@ final class ChatViewModel {
|
|||||||
return session.id
|
return session.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Voice (terminal mode only)
|
||||||
|
|
||||||
func toggleVoice() {
|
func toggleVoice() {
|
||||||
guard let tv = terminalView else { return }
|
guard let tv = terminalView else { return }
|
||||||
if voiceEnabled {
|
if voiceEnabled {
|
||||||
@@ -76,18 +471,21 @@ final class ChatViewModel {
|
|||||||
|
|
||||||
func pushToTalk() {
|
func pushToTalk() {
|
||||||
guard let tv = terminalView, voiceEnabled else { return }
|
guard let tv = terminalView, voiceEnabled else { return }
|
||||||
// Ctrl+B = ASCII 0x02
|
|
||||||
let ctrlB: [UInt8] = [0x02]
|
let ctrlB: [UInt8] = [0x02]
|
||||||
tv.send(source: tv, data: ctrlB[0..<1])
|
tv.send(source: tv, data: ctrlB[0..<1])
|
||||||
isRecording.toggle()
|
isRecording.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Terminal Mode
|
||||||
|
|
||||||
private func sendToTerminal(_ tv: LocalProcessTerminalView, text: String) {
|
private func sendToTerminal(_ tv: LocalProcessTerminalView, text: String) {
|
||||||
let bytes = Array(text.utf8)
|
let bytes = Array(text.utf8)
|
||||||
tv.send(source: tv, data: bytes[0..<bytes.count])
|
tv.send(source: tv, data: bytes[0..<bytes.count])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func launchTerminal(arguments: [String]) {
|
private func launchTerminal(arguments: [String]) {
|
||||||
|
stopACP()
|
||||||
|
|
||||||
if let existing = terminalView {
|
if let existing = terminalView {
|
||||||
existing.terminate()
|
existing.terminate()
|
||||||
existing.removeFromSuperview()
|
existing.removeFromSuperview()
|
||||||
@@ -102,6 +500,7 @@ final class ChatViewModel {
|
|||||||
self?.hasActiveProcess = false
|
self?.hasActiveProcess = false
|
||||||
self?.voiceEnabled = false
|
self?.voiceEnabled = false
|
||||||
self?.isRecording = false
|
self?.isRecording = false
|
||||||
|
Task { await self?.richChatViewModel.refreshMessages() }
|
||||||
})
|
})
|
||||||
terminal.processDelegate = coord
|
terminal.processDelegate = coord
|
||||||
self.coordinator = 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
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
@Bindable var vm = viewModel
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
toolbar
|
toolbar
|
||||||
Divider()
|
Divider()
|
||||||
terminalArea
|
chatArea
|
||||||
}
|
}
|
||||||
.navigationTitle("Chat")
|
.navigationTitle("Chat")
|
||||||
.task { await viewModel.loadRecentSessions() }
|
.task { await viewModel.loadRecentSessions() }
|
||||||
@@ -19,16 +20,42 @@ struct ChatView: View {
|
|||||||
|
|
||||||
private var toolbar: some View {
|
private var toolbar: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "terminal")
|
Image(systemName: viewModel.displayMode == .terminal ? "terminal" : "bubble.left.and.text.bubble.right")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
if viewModel.hasActiveProcess {
|
if viewModel.hasActiveProcess {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(.green)
|
.fill(.green)
|
||||||
.frame(width: 6, height: 6)
|
.frame(width: 6, height: 6)
|
||||||
Text("Active")
|
Text(viewModel.acpStatus.isEmpty ? "Active" : viewModel.acpStatus)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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 {
|
} else {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(.secondary)
|
.fill(.secondary)
|
||||||
@@ -40,10 +67,21 @@ struct ChatView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if viewModel.hasActiveProcess {
|
if viewModel.hasActiveProcess && viewModel.displayMode == .terminal {
|
||||||
voiceControls
|
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 {
|
if !viewModel.hermesBinaryExists {
|
||||||
Label("Hermes binary not found", systemImage: "exclamationmark.triangle")
|
Label("Hermes binary not found", systemImage: "exclamationmark.triangle")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -51,6 +89,13 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Menu {
|
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") {
|
Button("New Session") {
|
||||||
viewModel.startNewSession()
|
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
|
@ViewBuilder
|
||||||
private var terminalArea: some View {
|
private var terminalArea: some View {
|
||||||
if let terminal = viewModel.terminalView {
|
if let terminal = viewModel.terminalView {
|
||||||
@@ -157,4 +212,119 @@ struct ChatView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ struct HealthSection: Identifiable {
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class HealthViewModel {
|
final class HealthViewModel {
|
||||||
|
private let fileService = HermesFileService()
|
||||||
|
|
||||||
var version = ""
|
var version = ""
|
||||||
var updateInfo = ""
|
var updateInfo = ""
|
||||||
var hasUpdate = false
|
var hasUpdate = false
|
||||||
@@ -31,9 +33,13 @@ final class HealthViewModel {
|
|||||||
var warningCount = 0
|
var warningCount = 0
|
||||||
var okCount = 0
|
var okCount = 0
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
|
var hermesRunning = false
|
||||||
|
var hermesPID: pid_t?
|
||||||
|
var actionMessage: String?
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
refreshProcessStatus()
|
||||||
loadVersion()
|
loadVersion()
|
||||||
let statusOutput = runHermes(["status"]).output
|
let statusOutput = runHermes(["status"]).output
|
||||||
statusSections = parseOutput(statusOutput)
|
statusSections = parseOutput(statusOutput)
|
||||||
@@ -43,6 +49,41 @@ final class HealthViewModel {
|
|||||||
isLoading = false
|
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() {
|
private func loadVersion() {
|
||||||
let output = runHermes(["version"]).output
|
let output = runHermes(["version"]).output
|
||||||
let lines = output.components(separatedBy: "\n")
|
let lines = output.components(separatedBy: "\n")
|
||||||
|
|||||||
@@ -29,36 +29,75 @@ struct HealthView: View {
|
|||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
|
|
||||||
private var headerBar: some View {
|
private var headerBar: some View {
|
||||||
HStack(spacing: 16) {
|
VStack(spacing: 0) {
|
||||||
if !viewModel.version.isEmpty {
|
HStack(spacing: 16) {
|
||||||
Text(viewModel.version)
|
if !viewModel.version.isEmpty {
|
||||||
.font(.system(.caption, design: .monospaced))
|
Text(viewModel.version)
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
|
||||||
}
|
}
|
||||||
.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) {
|
HStack(spacing: 16) {
|
||||||
MiniCount(count: viewModel.okCount, color: .green, icon: "checkmark.circle.fill")
|
HStack(spacing: 6) {
|
||||||
MiniCount(count: viewModel.warningCount, color: .orange, icon: "exclamationmark.triangle.fill")
|
Circle()
|
||||||
MiniCount(count: viewModel.issueCount, color: .red, icon: "xmark.circle.fill")
|
.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)
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Grid
|
// MARK: - Grid
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ struct ToolUsage: Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct NotableSession: Identifiable {
|
struct NotableSession: Identifiable {
|
||||||
var id: String { session.id }
|
var id: String { "\(session.id)-\(label)" }
|
||||||
let label: String
|
let label: String
|
||||||
let value: String
|
let value: String
|
||||||
let session: HermesSession
|
let session: HermesSession
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ final class MemoryViewModel {
|
|||||||
var userCharCount: Int { userContent.count }
|
var userCharCount: Int { userContent.count }
|
||||||
|
|
||||||
var hasExternalProvider: Bool {
|
var hasExternalProvider: Bool {
|
||||||
!memoryProvider.isEmpty && memoryProvider != "file"
|
let stripped = memoryProvider
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "'\""))
|
||||||
|
return !stripped.isEmpty && stripped != "file"
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasMultipleProfiles: Bool { !profiles.isEmpty }
|
var hasMultipleProfiles: Bool { !profiles.isEmpty }
|
||||||
|
|||||||
@@ -71,8 +71,7 @@ struct MemoryView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding()
|
.padding()
|
||||||
} else {
|
} else {
|
||||||
Text(markdownAttributed(content))
|
MarkdownContentView(content: content)
|
||||||
.textSelection(.enabled)
|
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(.quaternary.opacity(0.5))
|
.background(.quaternary.opacity(0.5))
|
||||||
@@ -93,14 +92,17 @@ struct MemoryView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
Divider()
|
Divider()
|
||||||
TextEditor(text: $viewModel.editText)
|
HSplitView {
|
||||||
.font(.system(.body, design: .monospaced))
|
TextEditor(text: $viewModel.editText)
|
||||||
.padding(8)
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.padding(8)
|
||||||
|
ScrollView {
|
||||||
|
MarkdownContentView(content: viewModel.editText)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 600, minHeight: 400)
|
.frame(minWidth: 800, minHeight: 500)
|
||||||
}
|
|
||||||
|
|
||||||
private func markdownAttributed(_ text: String) -> AttributedString {
|
|
||||||
(try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ struct TextWidgetView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if let content = widget.content {
|
if let content = widget.content {
|
||||||
if widget.format == "markdown",
|
if widget.format == "markdown" {
|
||||||
let attributed = try? AttributedString(markdown: content) {
|
MarkdownContentView(content: content)
|
||||||
Text(attributed)
|
|
||||||
.font(.callout)
|
|
||||||
} else {
|
} else {
|
||||||
Text(content)
|
Text(content)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
|
|||||||
@@ -139,8 +139,12 @@ struct MessageBubble: View {
|
|||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
}
|
}
|
||||||
if !message.content.isEmpty {
|
if !message.content.isEmpty {
|
||||||
Text(message.content)
|
if message.isAssistant {
|
||||||
.textSelection(.enabled)
|
MarkdownContentView(content: message.content)
|
||||||
|
} else {
|
||||||
|
Text(message.content)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !message.toolCalls.isEmpty {
|
if !message.toolCalls.isEmpty {
|
||||||
ForEach(message.toolCalls) { call in
|
ForEach(message.toolCalls) { call in
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ final class SkillsViewModel {
|
|||||||
var selectedFileName: String?
|
var selectedFileName: String?
|
||||||
var searchText = ""
|
var searchText = ""
|
||||||
var missingConfig: [String] = []
|
var missingConfig: [String] = []
|
||||||
|
var isEditing = false
|
||||||
|
var editText = ""
|
||||||
private var currentConfig = HermesConfig.empty
|
private var currentConfig = HermesConfig.empty
|
||||||
|
|
||||||
var filteredCategories: [HermesSkillCategory] {
|
var filteredCategories: [HermesSkillCategory] {
|
||||||
@@ -61,4 +63,29 @@ final class SkillsViewModel {
|
|||||||
selectedFileName = file
|
selectedFileName = file
|
||||||
skillContent = fileService.loadSkillContent(path: skill.path + "/" + 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,17 +99,57 @@ struct SkillsView: View {
|
|||||||
}
|
}
|
||||||
if !viewModel.skillContent.isEmpty {
|
if !viewModel.skillContent.isEmpty {
|
||||||
Divider()
|
Divider()
|
||||||
Text(viewModel.skillContent)
|
HStack {
|
||||||
.font(.system(.body, design: .monospaced))
|
Spacer()
|
||||||
.textSelection(.enabled)
|
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()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $viewModel.isEditing) {
|
||||||
|
skillEditorSheet
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ContentUnavailableView("Select a Skill", systemImage: "lightbulb", description: Text("Choose a skill from the list"))
|
ContentUnavailableView("Select a Skill", systemImage: "lightbulb", description: Text("Choose a skill from the list"))
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,33 @@ final class MenuBarStatus {
|
|||||||
timer = nil
|
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() {
|
private func refresh() {
|
||||||
hermesRunning = fileService.isHermesRunning()
|
hermesRunning = fileService.isHermesRunning()
|
||||||
gatewayRunning = fileService.loadGatewayState()?.isRunning ?? false
|
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.hermesRunning ? "Hermes Running" : "Hermes Stopped", systemImage: status.hermesRunning ? "circle.fill" : "circle")
|
||||||
Label(status.gatewayRunning ? "Gateway Running" : "Gateway Stopped", systemImage: status.gatewayRunning ? "circle.fill" : "circle")
|
Label(status.gatewayRunning ? "Gateway Running" : "Gateway Stopped", systemImage: status.gatewayRunning ? "circle.fill" : "circle")
|
||||||
Divider()
|
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") {
|
Button("Open Dashboard") {
|
||||||
coordinator.selectedSection = .dashboard
|
coordinator.selectedSection = .dashboard
|
||||||
NSApplication.shared.activate()
|
NSApplication.shared.activate()
|
||||||
|
|||||||
Reference in New Issue
Block a user