Compare commits

...

12 Commits

Author SHA1 Message Date
Alan Wizemann 8672ed1e6c chore: Bump version to 1.5.2 and add universal release binary
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:53:21 -04:00
Alan Wizemann 46468890d5 feat: Track ACP token usage, improve chat scroll behavior, and show session costs
Add cumulative token tracking from ACP prompt results with fallback
display when DB has no data yet. Improve scroll-to-bottom reliability
with an external trigger for "Return to Active Session" and onAppear
auto-scroll. Show per-session cost in the dashboard session list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:15:44 -04:00
Alan Wizemann cd503378e2 fix: Move Tools subprocess calls off main thread to fix toggle rendering
Synchronous Process.run()/waitUntilExit() calls on the main thread blocked
SwiftUI's render loop, causing toggle controls to appear as solid blue
rectangles instead of proper switches. All hermes subprocess and file I/O
calls are now async via Task.detached, toggle uses optimistic state update
for immediate visual feedback, and pipe file handles are properly closed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:16:52 -04:00
Alan Wizemann 86762eab6d fix: Harden ACP session stability and recover messages on reconnection
Sessions were silently dying and losing chat history because:
- Pipe write errors (EPIPE) were completely undetected — broken pipe
  writes via Task.detached { handle.write() } failed silently, leaving
  the app unaware the subprocess had crashed
- Reconnection fell back to newSession() when loadSession() failed,
  creating a blank session and permanently losing all conversation context
- No message reconciliation after reconnect — DB-persisted messages
  were never re-fetched, so the UI stayed stale/incomplete
- Keepalive sent bare "\n" which caused json.loads("") parse errors
  in the ACP library every 30 seconds, destabilizing the connection
- TERM=xterm-256color was set on a pipe-based subprocess, risking
  terminal escape sequence pollution in the JSON-RPC stream

Fixes:
- Replace FileHandle.write() with POSIX Darwin.write() + SIGPIPE
  suppression for immediate broken-pipe detection at all write sites
- Send valid JSON-RPC notification {"jsonrpc":"2.0","method":"$/ping"}
  as keepalive instead of bare newlines
- Never fall back to newSession() during reconnection — try
  resumeSession then loadSession, fail visibly if both fail
- Add reconcileWithDB() to merge DB-persisted messages with local
  state after successful reconnection
- Finalize streaming messages immediately on disconnect so partial
  content is preserved before reconnection begins
- Use SIGINT instead of SIGTERM for graceful Python subprocess shutdown
- Remove TERM env var from ACP subprocess environment
- Consolidate disconnect cleanup into single idempotent method
- Add isHandlingDisconnect guard against double-handling
- Increase reconnect attempts from 3 to 5 with capped backoff
- Add "Reconnect" button to toolbar error state

Also: bump version to 1.5.1, set deployment target to macOS 14.6
(Sonoma), and update README with rich chat/ACP features, process
controls, skill editing, and corrected system requirements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:13:34 -04:00
Alan Wizemann a7fd193770 feat: Add ACP real-time chat with stable connection management
Implement a rich chat interface powered by the Hermes ACP (Agent
Communication Protocol) over JSON-RPC stdio pipes, with comprehensive
connection stability:

- ACPClient actor: manages hermes acp subprocess lifecycle, JSON-RPC
  transport, event streaming via AsyncStream, and session management
- ACPMessages: full event parsing for message chunks, thought chunks,
  tool calls, permission requests, and prompt completion
- RichChatViewModel: streaming message display with live updates,
  tool result rendering, and message grouping
- ChatViewModel: ACP session orchestration, auto-start on first
  message, and terminal mode fallback

Connection stability fixes:
- Non-blocking pipe writes via Task.detached to prevent actor deadlock
- Read loop cleanup (handleReadLoopEnded) finishes event stream and
  fails pending requests on EOF instead of hanging silently
- 30s request timeouts on control messages via watchdog Task pattern
- Keepalive: writes \n to stdin every 30s to detect dead processes
  via EPIPE before the next user action
- Health monitor: polls process.isRunning every 5s as belt-and-suspenders
- Auto-reconnect: retries up to 3 times with exponential backoff
  (1s/2s/4s), restores session, only shows error after all retries fail
- connectionLost event displays system message in chat on failure
- Proper stderr pipe management: stored task reference, closed in stop()
- Idempotent cleanup across handleReadLoopEnded, handleTermination,
  and handleConnectionDied via actor serialization and nil guards

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 04:33:03 -04:00
Alan Wizemann 521c6d63fc refactor: Use MarkdownContentView in rich chat bubbles
Replace inline AttributedString(markdown:) in RichMessageBubble with
the shared MarkdownContentView for consistent styled rendering of
headers, lists, blockquotes, and inline formatting in chat messages.
Code blocks continue to use CodeBlockView with its copy button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:13:17 -04:00
Alan Wizemann 66d04d838d Merge branch 'chat-interface' into main
Add rich chat interface with iMessage-style message bubbles, terminal
toggle, session info bar, code block rendering with copy button, and
tool call cards. Supports both terminal and rich chat display modes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:09:31 -04:00
Alan Wizemann ad30c0a943 feat: Show tool output in Activity inspector (#12)
Add tool result display to the Activity detail pane. When selecting a
tool call, the inspector now shows Arguments → Output → Assistant
Message, giving full visibility into what was requested, what came back,
and how the assistant interpreted it.

- Add fetchToolResult(callId:) query to HermesDataService
- Fetch tool result on entry selection in ActivityViewModel
- Display output in styled monospaced box in detail pane
- Render assistant message with MarkdownContentView

Closes #12

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:52:42 -04:00
Alan Wizemann 44afa8f53b fix: Hide false external memory provider warning on fresh installs
The config.yaml uses YAML empty string literal (provider: '') which the
parser reads as the literal string '' rather than an empty string. Strip
surrounding quotes before checking so '' and "" are treated as empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:40:35 -04:00
Alan Wizemann 481b937c33 feat: Add rich markdown rendering and skill editing (#11)
Add a custom MarkdownContentView that renders markdown with visual
styling — large headers, styled code blocks with language labels,
bullet and numbered lists, blockquotes with colored borders, and
horizontal rules. YAML frontmatter in skill files is hidden.

Markdown rendering added to:
- Memory view (MEMORY.md, USER.md) with live preview in editor
- Skills view (.md files) with new edit/save capability
- Session messages (assistant responses)
- Dashboard text widgets

Other changes:
- Shared MarkdownRenderer utility for inline formatting
- Split-pane editors (raw markdown left, live preview right)
- saveSkillContent() in HermesFileService with path validation
- Line breaks preserved in non-markdown content (Key: Value format)

Closes #11

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:30:44 -04:00
Alan Wizemann 790efb585b feat: Add Hermes process start/stop/restart controls (#10)
- Add hermesPID() and stopHermes() to HermesFileService for process
  signal management via SIGTERM
- Add process control bar to Health view with running status, PID
  display, and Start/Stop/Restart buttons
- Add Start/Stop/Restart Hermes quick actions to menu bar
- Start launches gateway, stop sends SIGTERM, restart combines both

Closes #10

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:48:53 -04:00
Alan Wizemann 7d69c82c2b Add rich chat interface with iMessage-style bubbles and terminal toggle
Introduce a new structured chat view as an alternative to the SwiftTerm
terminal. Users can switch between raw terminal and rich chat modes via a
segmented picker in the toolbar. The rich view polls state.db for messages
and renders them as conversation bubbles with markdown, code blocks,
expandable tool call cards, reasoning sections, and a live session info bar
showing tokens, cost, and model. The terminal process stays alive in both
modes — in rich mode it runs hidden while user input from the text field is
piped to its stdin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:33:55 -04:00
34 changed files with 3365 additions and 121 deletions
+10 -8
View File
@@ -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.
Binary file not shown.
+6 -4
View File
@@ -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 = 6;
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.2;
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 = 6;
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.2;
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;
+246
View File
@@ -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 ""
}
}
+516
View File
@@ -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,32 +16,425 @@ 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
richChatViewModel.reset()
if displayMode == .richChat {
startACPSession(resume: nil)
} else {
launchTerminal(arguments: ["chat"]) launchTerminal(arguments: ["chat"])
} }
}
func resumeSession(_ sessionId: String) { func resumeSession(_ sessionId: String) {
voiceEnabled = false voiceEnabled = false
ttsEnabled = false ttsEnabled = false
isRecording = false isRecording = false
richChatViewModel.reset()
if displayMode == .richChat {
startACPSession(resume: sessionId)
} else {
richChatViewModel.setSessionId(sessionId)
launchTerminal(arguments: ["chat", "--resume", sessionId]) launchTerminal(arguments: ["chat", "--resume", sessionId])
} }
}
func continueLastSession() { func continueLastSession() {
voiceEnabled = false voiceEnabled = false
ttsEnabled = false ttsEnabled = false
isRecording = false isRecording = false
richChatViewModel.reset()
if displayMode == .richChat {
// Find most recent session and resume via ACP
Task { @MainActor in
let opened = await dataService.open()
guard opened else { return }
let sessionId = await dataService.fetchMostRecentlyActiveSessionId()
await dataService.close()
if let sessionId {
startACPSession(resume: sessionId)
} else {
startACPSession(resume: nil)
}
}
} else {
launchTerminal(arguments: ["chat", "--continue"]) launchTerminal(arguments: ["chat", "--continue"])
} }
}
// MARK: - Send Message
func sendText(_ text: String) {
if displayMode == .richChat {
if let client = acpClient {
sendViaACP(client: client, text: text)
} else {
// Auto-start ACP and send the queued message
autoStartACPAndSend(text: text)
}
} else if let tv = terminalView {
sendToTerminal(tv, text: text + "\r")
}
}
/// Start ACP for the current or most recent session, then send the queued prompt.
private func autoStartACPAndSend(text: String) {
// Show the user message immediately
richChatViewModel.addUserMessage(text: text)
Task { @MainActor in
// Find a session to resume: prefer current sessionId, then most recent
var sessionToResume = richChatViewModel.sessionId
if sessionToResume == nil {
let opened = await dataService.open()
if opened {
sessionToResume = await dataService.fetchMostRecentlyActiveSessionId()
await dataService.close()
}
}
let client = ACPClient()
self.acpClient = client
do {
try await client.start()
acpStatus = await client.statusMessage
startACPEventLoop(client: client)
startHealthMonitor(client: client)
let cwd = NSHomeDirectory()
hasActiveProcess = true
let resolvedSessionId: String
if let existing = sessionToResume {
acpStatus = "Loading session..."
do {
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: existing)
} catch {
logger.info("Session \(existing) not found in ACP, creating new session")
acpStatus = "Creating new session..."
resolvedSessionId = try await client.newSession(cwd: cwd)
}
} else {
acpStatus = "Creating session..."
resolvedSessionId = try await client.newSession(cwd: cwd)
}
richChatViewModel.setSessionId(resolvedSessionId)
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
// Now send the queued prompt
sendViaACP(client: client, text: text)
} catch {
let msg = error.localizedDescription
logger.error("Auto-start ACP failed: \(msg)")
acpStatus = "Failed"
acpError = msg
hasActiveProcess = false
acpClient = nil
}
}
}
private func sendViaACP(client: ACPClient, text: String) {
guard let sessionId = richChatViewModel.sessionId else {
acpError = "No session ID — cannot send"
return
}
// Don't duplicate user message if autoStartACPAndSend already added it
if richChatViewModel.messages.last?.isUser != true
|| richChatViewModel.messages.last?.content != text {
richChatViewModel.addUserMessage(text: text)
}
acpStatus = "Agent working..."
acpPromptTask = Task { @MainActor in
do {
let result = try await client.sendPrompt(sessionId: sessionId, text: text)
acpStatus = "Ready"
richChatViewModel.handleACPEvent(
.promptComplete(sessionId: sessionId, response: result)
)
// Re-fetch session from DB to pick up cost/token data Hermes may have written
await richChatViewModel.refreshSessionFromDB()
} catch is CancellationError {
acpStatus = "Cancelled"
} catch {
let msg = error.localizedDescription
logger.error("ACP prompt failed: \(msg)")
acpStatus = "Error"
acpError = msg
richChatViewModel.handleACPEvent(
.promptComplete(sessionId: sessionId, response: ACPPromptResult(
stopReason: "error",
inputTokens: 0, outputTokens: 0,
thoughtTokens: 0, cachedReadTokens: 0
))
)
}
}
}
// MARK: - ACP Session Management
private func startACPSession(resume sessionId: String?) {
stopACP()
acpError = nil
acpStatus = "Starting..."
let client = ACPClient()
self.acpClient = client
Task { @MainActor in
do {
// Start ACP process and event loop FIRST
try await client.start()
acpStatus = await client.statusMessage
startACPEventLoop(client: client)
startHealthMonitor(client: client)
let cwd = NSHomeDirectory()
// Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true
// and doesn't wipe messages with a DB refresh
hasActiveProcess = true
let resolvedSessionId: String
if let sessionId {
acpStatus = "Loading session..."
do {
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
} catch {
logger.info("Session \(sessionId) not found in ACP, creating new session with history")
acpStatus = "Creating new session..."
resolvedSessionId = try await client.newSession(cwd: cwd)
}
// Load messages from both origin CLI session and ACP session
await richChatViewModel.loadSessionHistory(
sessionId: sessionId,
acpSessionId: resolvedSessionId
)
} else {
acpStatus = "Creating session..."
resolvedSessionId = try await client.newSession(cwd: cwd)
}
richChatViewModel.setSessionId(resolvedSessionId)
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
// Refresh session list so the new ACP session appears in the Resume menu
await loadRecentSessions()
logger.info("ACP session ready: \(resolvedSessionId)")
} catch {
let msg = error.localizedDescription
logger.error("Failed to start ACP session: \(msg)")
acpStatus = "Failed"
acpError = msg
hasActiveProcess = false
acpClient = nil
}
}
}
private func startACPEventLoop(client: ACPClient) {
acpEventTask = Task { @MainActor [weak self] in
let eventStream = await client.events
for await event in eventStream {
guard !Task.isCancelled else { break }
self?.richChatViewModel.handleACPEvent(event)
self?.acpStatus = await client.statusMessage
}
// Stream ended if we weren't cancelled, the connection died
if !Task.isCancelled {
self?.handleConnectionDied()
}
}
}
private func startHealthMonitor(client: ACPClient) {
healthMonitorTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 5_000_000_000)
guard !Task.isCancelled else { break }
let healthy = await client.isHealthy
if !healthy {
self?.handleConnectionDied()
break
}
}
}
}
private func handleConnectionDied() {
guard acpClient != nil, !isHandlingDisconnect else { return }
isHandlingDisconnect = true
logger.warning("ACP connection died")
// Finalize any in-progress streaming message before reconnection
richChatViewModel.finalizeOnDisconnect()
// Save session ID for reconnection before cleaning up
let savedSessionId = richChatViewModel.sessionId
// Clean up the dead client
acpPromptTask?.cancel()
acpPromptTask = nil
acpEventTask?.cancel()
acpEventTask = nil
healthMonitorTask?.cancel()
healthMonitorTask = nil
if let client = acpClient {
Task { await client.stop() }
}
acpClient = nil
hasActiveProcess = false
// Attempt auto-reconnect if we have a session to restore
guard let savedSessionId else {
showConnectionFailure()
isHandlingDisconnect = false
return
}
attemptReconnect(sessionId: savedSessionId)
}
private func attemptReconnect(sessionId: String) {
reconnectTask?.cancel()
acpError = nil
reconnectTask = Task { @MainActor [weak self] in
guard let self else { return }
for attempt in 1...Self.maxReconnectAttempts {
guard !Task.isCancelled else { return }
acpStatus = "Reconnecting (\(attempt)/\(Self.maxReconnectAttempts))..."
logger.info("Reconnect attempt \(attempt)/\(Self.maxReconnectAttempts) for session \(sessionId)")
// Backoff delay (skip on first attempt for fast recovery)
if attempt > 1 {
let delay = min(
Self.reconnectBaseDelay * UInt64(1 << (attempt - 1)),
Self.maxReconnectDelay
)
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled else { return }
}
let client = ACPClient()
do {
try await client.start()
let cwd = NSHomeDirectory()
let resolvedSessionId: String
// Try resumeSession first (designed for reconnection), then loadSession.
// NEVER fall back to newSession that loses all conversation context.
do {
resolvedSessionId = try await client.resumeSession(cwd: cwd, sessionId: sessionId)
} catch {
logger.info("session/resume failed, trying session/load: \(error.localizedDescription)")
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
}
// Success wire up the new client
self.acpClient = client
self.hasActiveProcess = true
richChatViewModel.setSessionId(resolvedSessionId)
// Reconcile in-memory messages with what Hermes persisted to DB
await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId)
acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))"
acpError = nil
startACPEventLoop(client: client)
startHealthMonitor(client: client)
isHandlingDisconnect = false
logger.info("Reconnected successfully on attempt \(attempt)")
return
} catch {
logger.warning("Reconnect attempt \(attempt) failed: \(error.localizedDescription)")
await client.stop()
continue
}
}
// All attempts exhausted
guard !Task.isCancelled else { return }
showConnectionFailure()
isHandlingDisconnect = false
}
}
private func showConnectionFailure() {
richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly"))
acpStatus = "Connection lost"
acpError = "Connection lost. Use the Session menu to reconnect."
}
func stopACP() {
reconnectTask?.cancel()
reconnectTask = nil
acpPromptTask?.cancel()
acpPromptTask = nil
acpEventTask?.cancel()
acpEventTask = nil
healthMonitorTask?.cancel()
healthMonitorTask = nil
if let client = acpClient {
Task { await client.stop() }
}
acpClient = nil
hasActiveProcess = false
isHandlingDisconnect = false
}
/// Respond to a permission request from the ACP agent.
func respondToPermission(optionId: String) {
guard let client = acpClient,
let permission = richChatViewModel.pendingPermission else { return }
Task {
await client.respondToPermission(requestId: permission.requestId, optionId: optionId)
}
richChatViewModel.pendingPermission = nil
}
// MARK: - Recent Sessions
func loadRecentSessions() async { func loadRecentSessions() async {
let opened = await dataService.open() let opened = await dataService.open()
@@ -55,6 +450,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 +473,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 +502,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,540 @@
import Foundation
enum ChatDisplayMode: String, CaseIterable {
case terminal
case richChat
}
struct MessageGroup: Identifiable {
let id: Int
let userMessage: HermesMessage?
let assistantMessages: [HermesMessage]
let toolResults: [String: HermesMessage]
var allMessages: [HermesMessage] {
var result: [HermesMessage] = []
if let user = userMessage { result.append(user) }
result.append(contentsOf: assistantMessages)
return result
}
var toolCallCount: Int {
assistantMessages.reduce(0) { $0 + $1.toolCalls.count }
}
}
@Observable
final class RichChatViewModel {
private let dataService = HermesDataService()
var messages: [HermesMessage] = []
var currentSession: HermesSession?
var messageGroups: [MessageGroup] = []
var isAgentWorking = false
var pendingPermission: PendingPermission?
/// Mutated to trigger a scroll-to-bottom in the message list.
var scrollTrigger = UUID()
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
private(set) var acpInputTokens = 0
private(set) var acpOutputTokens = 0
private(set) var acpThoughtTokens = 0
private(set) var acpCachedReadTokens = 0
var hasMessages: Bool { !messages.isEmpty }
func requestScrollToBottom() {
scrollTrigger = UUID()
}
private(set) var sessionId: String?
/// The original CLI session ID when resuming a CLI session via ACP.
/// Used to combine old CLI messages with new ACP messages.
private(set) var originSessionId: String?
private var nextLocalId = -1
private var streamingAssistantText = ""
private var streamingThinkingText = ""
private var streamingToolCalls: [HermesToolCall] = []
// DB polling state (used in terminal mode fallback)
private var lastKnownFingerprint: HermesDataService.MessageFingerprint?
private var debounceTask: Task<Void, Never>?
private var resetTimestamp: Date?
private var userSendPending = false
private var activePollingTimer: Timer?
struct PendingPermission {
let requestId: Int
let title: String
let kind: String
let options: [(optionId: String, name: String)]
}
// MARK: - Reset
func reset() {
debounceTask?.cancel()
stopActivePolling()
Task { await dataService.close() }
messages = []
messageGroups = []
currentSession = nil
lastKnownFingerprint = nil
sessionId = nil
originSessionId = nil
isAgentWorking = false
userSendPending = false
resetTimestamp = Date()
nextLocalId = -1
streamingAssistantText = ""
streamingThinkingText = ""
streamingToolCalls = []
acpInputTokens = 0
acpOutputTokens = 0
acpThoughtTokens = 0
acpCachedReadTokens = 0
pendingPermission = nil
}
func setSessionId(_ id: String?) {
sessionId = id
lastKnownFingerprint = nil
}
func cleanup() async {
stopActivePolling()
debounceTask?.cancel()
await dataService.close()
}
/// Re-fetch session metadata from DB to pick up cost/token updates.
func refreshSessionFromDB() async {
guard let sessionId else { return }
let opened = await dataService.open()
guard opened else { return }
if let session = await dataService.fetchSession(id: sessionId) {
currentSession = session
}
await dataService.close()
}
// MARK: - ACP Event Handling
/// Add a user message immediately (before DB write) for instant UI feedback.
func addUserMessage(text: String) {
let id = nextLocalId
nextLocalId -= 1
let message = HermesMessage(
id: id,
sessionId: sessionId ?? "",
role: "user",
content: text,
toolCallId: nil,
toolCalls: [],
toolName: nil,
timestamp: Date(),
tokenCount: nil,
finishReason: nil,
reasoning: nil
)
messages.append(message)
isAgentWorking = true
streamingAssistantText = ""
streamingThinkingText = ""
streamingToolCalls = []
buildMessageGroups()
}
/// Process a streaming ACP event and update the message list.
func handleACPEvent(_ event: ACPEvent) {
switch event {
case .messageChunk(_, let text):
appendMessageChunk(text: text)
case .thoughtChunk(_, let text):
appendThoughtChunk(text: text)
case .toolCallStart(_, let call):
handleToolCallStart(call)
case .toolCallUpdate(_, let update):
handleToolCallComplete(update)
case .permissionRequest(_, let requestId, let request):
pendingPermission = PendingPermission(
requestId: requestId,
title: request.toolCallTitle,
kind: request.toolCallKind,
options: request.options
)
case .promptComplete(_, let response):
handlePromptComplete(response: response)
case .connectionLost(let reason):
handleConnectionLost(reason: reason)
case .availableCommands, .unknown:
break
}
}
private func appendMessageChunk(text: String) {
streamingAssistantText += text
upsertStreamingMessage()
}
private func appendThoughtChunk(text: String) {
streamingThinkingText += text
upsertStreamingMessage()
}
private func handleToolCallStart(_ call: ACPToolCallEvent) {
let toolCall = HermesToolCall(
callId: call.toolCallId,
functionName: call.functionName,
arguments: call.argumentsJSON
)
streamingToolCalls.append(toolCall)
upsertStreamingMessage()
}
private func handleToolCallComplete(_ update: ACPToolCallUpdateEvent) {
// Finalize the streaming assistant message (with its tool calls) as a permanent message
finalizeStreamingMessage()
// Add tool result message
let id = nextLocalId
nextLocalId -= 1
messages.append(HermesMessage(
id: id,
sessionId: sessionId ?? "",
role: "tool",
content: update.rawOutput ?? update.content,
toolCallId: update.toolCallId,
toolCalls: [],
toolName: nil,
timestamp: Date(),
tokenCount: nil,
finishReason: nil,
reasoning: nil
))
buildMessageGroups()
}
private func handlePromptComplete(response: ACPPromptResult) {
finalizeStreamingMessage()
// Accumulate token usage from this prompt
acpInputTokens += response.inputTokens
acpOutputTokens += response.outputTokens
acpThoughtTokens += response.thoughtTokens
acpCachedReadTokens += response.cachedReadTokens
isAgentWorking = false
buildMessageGroups()
}
private func handleConnectionLost(reason: String) {
finalizeStreamingMessage()
let id = nextLocalId
nextLocalId -= 1
messages.append(HermesMessage(
id: id,
sessionId: sessionId ?? "",
role: "system",
content: "Connection lost: \(reason). Use the Session menu to start or resume a session.",
toolCallId: nil,
toolCalls: [],
toolName: nil,
timestamp: Date(),
tokenCount: nil,
finishReason: nil,
reasoning: nil
))
isAgentWorking = false
pendingPermission = nil
buildMessageGroups()
}
// MARK: - Streaming Message Management
private static let streamingId = 0
/// Insert or update the in-progress streaming assistant message (id=0).
private func upsertStreamingMessage() {
let msg = HermesMessage(
id: Self.streamingId,
sessionId: sessionId ?? "",
role: "assistant",
content: streamingAssistantText,
toolCallId: nil,
toolCalls: streamingToolCalls,
toolName: nil,
timestamp: Date(),
tokenCount: nil,
finishReason: nil,
reasoning: streamingThinkingText.isEmpty ? nil : streamingThinkingText
)
if let idx = messages.firstIndex(where: { $0.id == Self.streamingId }) {
messages[idx] = msg
} else {
messages.append(msg)
}
buildMessageGroups()
}
/// Convert the streaming message (id=0) into a permanent message and reset streaming state.
private func finalizeStreamingMessage() {
guard let idx = messages.firstIndex(where: { $0.id == Self.streamingId }) else { return }
// Only finalize if there's actual content
let hasContent = !streamingAssistantText.isEmpty
|| !streamingThinkingText.isEmpty
|| !streamingToolCalls.isEmpty
if hasContent {
let id = nextLocalId
nextLocalId -= 1
messages[idx] = HermesMessage(
id: id,
sessionId: sessionId ?? "",
role: "assistant",
content: streamingAssistantText,
toolCallId: nil,
toolCalls: streamingToolCalls,
toolName: nil,
timestamp: Date(),
tokenCount: nil,
finishReason: streamingToolCalls.isEmpty ? "stop" : nil,
reasoning: streamingThinkingText.isEmpty ? nil : streamingThinkingText
)
} else {
// Remove empty streaming placeholder
messages.remove(at: idx)
}
// Reset streaming state for next chunk
streamingAssistantText = ""
streamingThinkingText = ""
streamingToolCalls = []
}
// MARK: - Disconnect Recovery
/// Finalize streaming state on disconnect, before reconnection attempts begin.
/// Saves partial content as a permanent message without adding a system message.
func finalizeOnDisconnect() {
finalizeStreamingMessage()
isAgentWorking = false
pendingPermission = nil
buildMessageGroups()
}
/// Reconcile in-memory messages with DB state after a successful reconnection.
/// Merges DB-persisted messages with any local-only messages (e.g., user messages
/// that the ACP process may not have persisted before crashing).
func reconcileWithDB(sessionId: String) async {
let opened = await dataService.open()
guard opened else { return }
var dbMessages = await dataService.fetchMessages(sessionId: sessionId)
// If we have an origin session (CLI session continued via ACP),
// include those messages too
if let origin = originSessionId, origin != sessionId {
let originMessages = await dataService.fetchMessages(sessionId: origin)
if !originMessages.isEmpty {
dbMessages = originMessages + dbMessages
dbMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
}
}
let session = await dataService.fetchSession(id: sessionId)
await dataService.close()
// Find local-only user messages not yet in DB.
// Local messages have negative IDs; DB messages have positive IDs.
let dbUserContents = Set(dbMessages.filter(\.isUser).map(\.content))
let localOnlyMessages = messages.filter { msg in
msg.id < 0 && msg.isUser && !dbUserContents.contains(msg.content)
}
// Build reconciled list: DB messages + unmatched local user messages
var reconciled = dbMessages
for localMsg in localOnlyMessages {
if let ts = localMsg.timestamp,
let insertIdx = reconciled.firstIndex(where: { ($0.timestamp ?? .distantPast) > ts }) {
reconciled.insert(localMsg, at: insertIdx)
} else {
reconciled.append(localMsg)
}
}
messages = reconciled
currentSession = session
let minId = reconciled.map(\.id).min() ?? 0
nextLocalId = min(minId - 1, -1)
buildMessageGroups()
}
// MARK: - Load History from DB (for resumed sessions)
/// Load message history from the DB, optionally combining an origin session
/// (e.g., CLI session) with the current ACP session.
func loadSessionHistory(sessionId: String, acpSessionId: String? = nil) async {
self.sessionId = sessionId
let opened = await dataService.open()
guard opened else { return }
var allMessages = await dataService.fetchMessages(sessionId: sessionId)
let session = await dataService.fetchSession(id: sessionId)
// If the ACP session is different from the origin, load its messages too
// and combine them chronologically
if let acpId = acpSessionId, acpId != sessionId {
originSessionId = sessionId
self.sessionId = acpId
let acpMessages = await dataService.fetchMessages(sessionId: acpId)
if !acpMessages.isEmpty {
allMessages.append(contentsOf: acpMessages)
allMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
}
}
messages = allMessages
currentSession = session
let minId = allMessages.map(\.id).min() ?? 0
nextLocalId = min(minId - 1, -1)
buildMessageGroups()
}
// MARK: - DB Polling (terminal mode fallback)
func markAgentWorking() {
isAgentWorking = true
userSendPending = true
startActivePolling()
}
func scheduleRefresh() {
debounceTask?.cancel()
debounceTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { return }
await self?.refreshMessages()
}
}
func refreshMessages() async {
let opened = await dataService.open()
guard opened else { return }
if sessionId == nil {
if let resetTime = resetTimestamp {
if let candidate = await dataService.fetchMostRecentlyStartedSessionId(after: resetTime) {
sessionId = candidate
}
}
if sessionId == nil {
sessionId = await dataService.fetchMostRecentlyActiveSessionId()
}
}
guard let sessionId else { return }
let fingerprint = await dataService.fetchMessageFingerprint(sessionId: sessionId)
if fingerprint != lastKnownFingerprint {
let fetched = await dataService.fetchMessages(sessionId: sessionId)
let session = await dataService.fetchSession(id: sessionId)
lastKnownFingerprint = fingerprint
messages = fetched
currentSession = session
buildMessageGroups()
let derivedWorking = deriveAgentWorking(from: fetched)
if userSendPending {
if fetched.last?.isUser == true {
userSendPending = false
}
isAgentWorking = true
} else {
let wasWorking = isAgentWorking
isAgentWorking = derivedWorking
if wasWorking && !derivedWorking {
stopActivePolling()
}
}
}
}
private func startActivePolling() {
stopActivePolling()
activePollingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
await self?.refreshMessages()
}
}
}
private func stopActivePolling() {
activePollingTimer?.invalidate()
activePollingTimer = nil
}
private func deriveAgentWorking(from fetched: [HermesMessage]) -> Bool {
guard let last = fetched.last else { return false }
if last.isUser { return true }
if last.isToolResult { return true }
if last.isAssistant {
if !last.toolCalls.isEmpty {
let allCallIds = Set(last.toolCalls.map(\.callId))
let resultCallIds = Set(fetched.compactMap { $0.isToolResult ? $0.toolCallId : nil })
return !allCallIds.subtracting(resultCallIds).isEmpty
}
return last.finishReason == nil
}
return false
}
// MARK: - Message Grouping
private func buildMessageGroups() {
var groups: [MessageGroup] = []
var currentUser: HermesMessage?
var currentAssistant: [HermesMessage] = []
var currentToolResults: [String: HermesMessage] = [:]
var groupIndex = 0
func flushGroup() {
if currentUser != nil || !currentAssistant.isEmpty {
// Use stable sequential IDs so SwiftUI doesn't re-create views
// when streaming messages finalize (id changes from 0 to -N)
groups.append(MessageGroup(
id: groupIndex,
userMessage: currentUser,
assistantMessages: currentAssistant,
toolResults: currentToolResults
))
groupIndex += 1
}
currentUser = nil
currentAssistant = []
currentToolResults = [:]
}
for message in messages {
if message.isUser {
flushGroup()
currentUser = message
} else if message.isToolResult {
if let callId = message.toolCallId {
currentToolResults[callId] = message
}
currentAssistant.append(message)
} else {
if currentUser == nil && !currentAssistant.isEmpty && message.isAssistant {
flushGroup()
}
currentAssistant.append(message)
}
}
flushGroup()
messageGroups = groups
}
}
+176 -4
View File
@@ -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,12 @@ struct ChatView: View {
} }
Menu { Menu {
if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId {
Button("Return to Active Session (\(activeId.prefix(8))...)") {
viewModel.richChatViewModel.requestScrollToBottom()
}
Divider()
}
Button("New Session") { Button("New Session") {
viewModel.startNewSession() viewModel.startNewSession()
} }
@@ -60,6 +104,8 @@ struct ChatView: View {
if !viewModel.recentSessions.isEmpty { if !viewModel.recentSessions.isEmpty {
Divider() Divider()
Text("Resume Session") Text("Resume Session")
let activeSessionId = viewModel.richChatViewModel.sessionId
let originSessionId = viewModel.richChatViewModel.originSessionId
ForEach(viewModel.recentSessions) { session in ForEach(viewModel.recentSessions) { session in
Button { Button {
viewModel.resumeSession(session.id) viewModel.resumeSession(session.id)
@@ -75,6 +121,7 @@ struct ChatView: View {
} }
} }
} }
.disabled(session.id == activeSessionId || session.id == originSessionId)
} }
} }
} label: { } label: {
@@ -137,6 +184,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 +214,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,154 @@
import SwiftUI
struct RichChatMessageList: View {
let groups: [MessageGroup]
let isWorking: Bool
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
var scrollTrigger: UUID = UUID()
/// Track the last group's assistant content length to detect streaming updates.
private var scrollAnchor: String {
if isWorking { return "typing-indicator" }
if let last = groups.last { return "group-\(last.id)" }
return "scroll-top"
}
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
Spacer(minLength: 0)
.id("scroll-top")
ForEach(groups) { group in
MessageGroupView(group: group)
.id("group-\(group.id)")
}
if isWorking {
typingIndicator
.id("typing-indicator")
}
}
.padding()
}
.defaultScrollAnchor(.bottom)
// Scroll to bottom when view first appears with content
.onAppear {
if !groups.isEmpty {
DispatchQueue.main.async {
scrollToBottom(proxy: proxy, animated: false)
}
}
}
// Scroll on new groups
.onChange(of: groups.count) {
scrollToBottom(proxy: proxy)
}
// Scroll when agent starts/stops working
.onChange(of: isWorking) {
scrollToBottom(proxy: proxy)
}
// Scroll on streaming content updates (group content changes)
.onChange(of: scrollAnchor) {
scrollToBottom(proxy: proxy)
}
// Scroll on last message content change (streaming text)
.onChange(of: groups.last?.assistantMessages.last?.content ?? "") {
scrollToBottom(proxy: proxy, animated: false)
}
// Scroll on tool call count change
.onChange(of: groups.last?.toolCallCount ?? 0) {
scrollToBottom(proxy: proxy)
}
// Scroll on external trigger (e.g., "Return to Active Session" button)
.onChange(of: scrollTrigger) {
scrollToBottom(proxy: proxy)
}
}
}
private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool = true) {
let target = scrollAnchor
if animated {
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo(target, anchor: .bottom)
}
} else {
proxy.scrollTo(target, anchor: .bottom)
}
}
private var typingIndicator: some View {
HStack {
HStack(spacing: 4) {
ForEach(0..<3, id: \.self) { _ in
Circle()
.fill(.secondary)
.frame(width: 6, height: 6)
.opacity(0.6)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
Spacer(minLength: 80)
}
.symbolEffect(.pulse)
}
}
struct MessageGroupView: View {
let group: MessageGroup
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let user = group.userMessage {
RichMessageBubble(message: user, toolResults: [:])
}
ForEach(group.assistantMessages.filter(\.isAssistant)) { message in
RichMessageBubble(message: message, toolResults: group.toolResults)
}
if group.toolCallCount > 1 {
toolSummary
}
}
}
@ViewBuilder
private var toolSummary: some View {
let kinds = toolKindCounts
if !kinds.isEmpty {
HStack(spacing: 4) {
Image(systemName: "wrench")
.font(.caption2)
Text(summaryText(kinds))
.font(.caption2)
}
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 2)
}
}
private var toolKindCounts: [ToolKind: Int] {
var counts: [ToolKind: Int] = [:]
for msg in group.assistantMessages where msg.isAssistant {
for call in msg.toolCalls {
counts[call.toolKind, default: 0] += 1
}
}
return counts
}
private func summaryText(_ kinds: [ToolKind: Int]) -> String {
let total = kinds.values.reduce(0, +)
let parts = kinds.sorted(by: { $0.value > $1.value })
.map { "\($0.value) \($0.key.rawValue)" }
.joined(separator: ", ")
return "Used \(total) tools (\(parts))"
}
}
@@ -0,0 +1,54 @@
import SwiftUI
struct RichChatView: View {
@Bindable var richChat: RichChatViewModel
var onSend: (String) -> Void
var isEnabled: Bool
@Environment(HermesFileWatcher.self) private var fileWatcher
@Environment(ChatViewModel.self) private var chatViewModel
/// In ACP mode, events drive updates directly no DB polling needed.
private var isACPMode: Bool { chatViewModel.isACPConnected }
var body: some View {
VStack(spacing: 0) {
SessionInfoBar(
session: richChat.currentSession,
isWorking: richChat.isAgentWorking,
acpInputTokens: richChat.acpInputTokens,
acpOutputTokens: richChat.acpOutputTokens,
acpThoughtTokens: richChat.acpThoughtTokens
)
Divider()
if richChat.messageGroups.isEmpty && !richChat.isAgentWorking {
ContentUnavailableView(
"Chat Messages",
systemImage: "bubble.left.and.text.bubble.right",
description: Text("Messages will appear here as the conversation progresses.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
RichChatMessageList(
groups: richChat.messageGroups,
isWorking: richChat.isAgentWorking,
scrollTrigger: richChat.scrollTrigger
)
}
Divider()
RichChatInputBar(
onSend: { text in
onSend(text)
},
isEnabled: isEnabled
)
}
// DB polling fallback for terminal mode only never overwrite ACP messages
.onChange(of: fileWatcher.lastChangeDate) {
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
richChat.scheduleRefresh()
}
}
}
}
@@ -0,0 +1,189 @@
import SwiftUI
struct RichMessageBubble: View {
let message: HermesMessage
let toolResults: [String: HermesMessage]
var body: some View {
if message.isUser {
userBubble
} else if message.isAssistant {
assistantBubble
}
// Tool result messages are rendered inline in ToolCallCard, not as standalone bubbles
}
// MARK: - User Bubble
private var userBubble: some View {
VStack(alignment: .trailing, spacing: 2) {
HStack {
Spacer(minLength: 80)
Text(message.content)
.textSelection(.enabled)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
if let time = message.timestamp {
Text(time, style: .time)
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.trailing, 4)
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
// MARK: - Assistant Bubble
private var assistantBubble: some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
VStack(alignment: .leading, spacing: 8) {
if message.hasReasoning {
reasoningSection
}
if !message.content.isEmpty {
contentView
}
if !message.toolCalls.isEmpty {
toolCallsSection
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
Spacer(minLength: 40)
}
metadataFooter
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Content Rendering
@ViewBuilder
private var contentView: some View {
let blocks = parseContentBlocks(message.content)
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
switch block {
case .text(let text):
MarkdownContentView(content: text)
case .code(let code, let language):
CodeBlockView(code: code, language: language)
}
}
}
}
// MARK: - Reasoning
private var reasoningSection: some View {
DisclosureGroup {
Text(message.reasoning ?? "")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
} label: {
HStack(spacing: 4) {
Text("Reasoning")
if let tokens = message.tokenCount, tokens > 0 {
Text("(\(tokens) tokens)")
.foregroundStyle(.tertiary)
}
}
}
.font(.caption.bold())
.foregroundStyle(.orange)
}
// MARK: - Tool Calls
private var toolCallsSection: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(message.toolCalls) { call in
ToolCallCard(
call: call,
result: toolResults[call.callId]
)
}
}
}
// MARK: - Metadata Footer
private var metadataFooter: some View {
HStack(spacing: 8) {
if let tokens = message.tokenCount, tokens > 0 {
Text("\(tokens) tokens")
}
if let reason = message.finishReason, !reason.isEmpty {
Text(reason)
}
if let time = message.timestamp {
Text(time, style: .time)
}
}
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.leading, 4)
}
}
// MARK: - Content Block Parsing
private enum ContentBlock {
case text(String)
case code(String, String?)
}
private func parseContentBlocks(_ content: String) -> [ContentBlock] {
var blocks: [ContentBlock] = []
let lines = content.components(separatedBy: "\n")
var currentText: [String] = []
var currentCode: [String] = []
var codeLanguage: String?
var inCode = false
for line in lines {
if !inCode && line.hasPrefix("```") {
if !currentText.isEmpty {
blocks.append(.text(currentText.joined(separator: "\n")))
currentText = []
}
inCode = true
let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
codeLanguage = lang.isEmpty ? nil : lang
} else if inCode && line.hasPrefix("```") {
blocks.append(.code(currentCode.joined(separator: "\n"), codeLanguage))
currentCode = []
codeLanguage = nil
inCode = false
} else if inCode {
currentCode.append(line)
} else {
currentText.append(line)
}
}
if inCode && !currentCode.isEmpty {
blocks.append(.code(currentCode.joined(separator: "\n"), codeLanguage))
}
if !currentText.isEmpty {
let text = currentText.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(.text(text))
}
}
return blocks
}
@@ -0,0 +1,85 @@
import SwiftUI
struct SessionInfoBar: View {
let session: HermesSession?
let isWorking: Bool
/// Fallback token counts from ACP prompt results (DB may have zeros for ACP sessions).
var acpInputTokens: Int = 0
var acpOutputTokens: Int = 0
var acpThoughtTokens: Int = 0
var body: some View {
HStack(spacing: 16) {
if let session {
HStack(spacing: 4) {
Circle()
.fill(isWorking ? .green : .secondary)
.frame(width: 6, height: 6)
.opacity(isWorking ? 1 : 0.6)
if isWorking {
Text("Working")
.font(.caption)
.foregroundStyle(.green)
}
}
if let title = session.title, !title.isEmpty {
Text(title)
.font(.caption.bold())
.lineLimit(1)
.truncationMode(.tail)
}
if let model = session.model {
Label(model, systemImage: "cpu")
}
let inputToks = session.inputTokens > 0 ? session.inputTokens : acpInputTokens
let outputToks = session.outputTokens > 0 ? session.outputTokens : acpOutputTokens
Label("\(formatTokens(inputToks)) in / \(formatTokens(outputToks)) out", systemImage: "number")
.contentTransition(.numericText())
let reasonToks = session.reasoningTokens > 0 ? session.reasoningTokens : acpThoughtTokens
if reasonToks > 0 {
Label("\(formatTokens(reasonToks)) reasoning", systemImage: "brain")
}
if let cost = session.displayCostUSD {
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
.contentTransition(.numericText())
}
if let start = session.startedAt {
Label {
Text(start, style: .relative)
.monospacedDigit()
} icon: {
Image(systemName: "clock")
}
}
Spacer()
Label(session.source, systemImage: session.sourceIcon)
} else {
Text("No active session")
.foregroundStyle(.tertiary)
Spacer()
}
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal)
.padding(.vertical, 6)
.background(.bar)
}
private func formatTokens(_ count: Int) -> String {
if count >= 1_000_000 {
return String(format: "%.1fM", Double(count) / 1_000_000)
} else if count >= 1_000 {
return String(format: "%.1fK", Double(count) / 1_000)
}
return "\(count)"
}
}
@@ -0,0 +1,134 @@
import SwiftUI
struct ToolCallCard: View {
let call: HermesToolCall
let result: HermesMessage?
@State private var expanded = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
} label: {
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 1)
.fill(toolColor)
.frame(width: 3, height: 16)
Image(systemName: call.toolKind.icon)
.font(.caption)
.foregroundStyle(toolColor)
Text(call.functionName)
.font(.caption.monospaced().bold())
.foregroundStyle(.primary)
Text(call.argumentsSummary)
.font(.caption.monospaced())
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
if result != nil {
Image(systemName: "checkmark.circle.fill")
.font(.caption2)
.foregroundStyle(.green)
} else {
ProgressView()
.controlSize(.mini)
}
Image(systemName: expanded ? "chevron.down" : "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
.padding(.vertical, 4)
.padding(.horizontal, 8)
if expanded {
VStack(alignment: .leading, spacing: 6) {
if !call.arguments.isEmpty && call.arguments != "{}" {
Text("Arguments")
.font(.caption2.bold())
.foregroundStyle(.tertiary)
Text(formatJSON(call.arguments))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.padding(6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
if let result, !result.content.isEmpty {
Text("Result")
.font(.caption2.bold())
.foregroundStyle(.tertiary)
ToolResultContent(content: result.content)
}
}
.padding(.horizontal, 8)
.padding(.bottom, 6)
}
}
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
private var toolColor: Color {
switch call.toolKind {
case .read: return .green
case .edit: return .blue
case .execute: return .orange
case .fetch: return .purple
case .browser: return .indigo
case .other: return .secondary
}
}
private func formatJSON(_ raw: String) -> String {
guard let data = raw.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data),
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted),
let str = String(data: pretty, encoding: .utf8) else {
return raw
}
return str
}
}
struct ToolResultContent: View {
let content: String
@State private var showAll = false
private var lines: [String] { content.components(separatedBy: "\n") }
private var isLong: Bool { lines.count > 8 }
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(showAll ? content : lines.prefix(8).joined(separator: "\n"))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.padding(6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 4))
if isLong {
Button(showAll ? "Show less" : "Show all \(lines.count) lines") {
withAnimation { showAll.toggle() }
}
.font(.caption2)
.foregroundStyle(Color.accentColor)
}
}
}
}
@@ -164,6 +164,9 @@ struct SessionRow: View {
HStack(spacing: 12) { HStack(spacing: 12) {
Label("\(session.messageCount)", systemImage: "bubble.left") Label("\(session.messageCount)", systemImage: "bubble.left")
Label("\(session.toolCallCount)", systemImage: "wrench") Label("\(session.toolCallCount)", systemImage: "wrench")
if let cost = session.displayCostUSD, cost > 0 {
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
}
} }
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -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,6 +29,7 @@ struct HealthView: View {
// MARK: - Header // MARK: - Header
private var headerBar: some View { private var headerBar: some View {
VStack(spacing: 0) {
HStack(spacing: 16) { HStack(spacing: 16) {
if !viewModel.version.isEmpty { if !viewModel.version.isEmpty {
Text(viewModel.version) Text(viewModel.version)
@@ -59,6 +60,44 @@ struct HealthView: View {
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 8) .padding(.vertical, 8)
Divider()
HStack(spacing: 16) {
HStack(spacing: 6) {
Circle()
.fill(viewModel.hermesRunning ? .green : .red)
.frame(width: 8, height: 8)
Text(viewModel.hermesRunning ? "Hermes Running" : "Hermes Stopped")
.font(.caption.bold())
if let pid = viewModel.hermesPID {
Text("PID \(pid)")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
}
if let msg = viewModel.actionMessage {
Label(msg, systemImage: "arrow.triangle.2.circlepath")
.font(.caption)
.foregroundStyle(.orange)
}
Spacer()
HStack(spacing: 8) {
Button("Start") { viewModel.startHermes() }
.disabled(viewModel.hermesRunning)
Button("Stop") { viewModel.stopHermes() }
.disabled(!viewModel.hermesRunning)
Button("Restart") { viewModel.restartHermes() }
.disabled(!viewModel.hermesRunning)
}
.controlSize(.small)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
} }
// MARK: - Grid // 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()
HSplitView {
TextEditor(text: $viewModel.editText) TextEditor(text: $viewModel.editText)
.font(.system(.body, design: .monospaced)) .font(.system(.body, design: .monospaced))
.padding(8) .padding(8)
ScrollView {
MarkdownContentView(content: viewModel.editText)
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
} }
.frame(minWidth: 600, minHeight: 400)
} }
}
private func markdownAttributed(_ text: String) -> AttributedString { .frame(minWidth: 800, minHeight: 500)
(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,9 +139,13 @@ struct MessageBubble: View {
.foregroundStyle(.orange) .foregroundStyle(.orange)
} }
if !message.content.isEmpty { if !message.content.isEmpty {
if message.isAssistant {
MarkdownContentView(content: message.content)
} else {
Text(message.content) Text(message.content)
.textSelection(.enabled) .textSelection(.enabled)
} }
}
if !message.toolCalls.isEmpty { if !message.toolCalls.isEmpty {
ForEach(message.toolCalls) { call in ForEach(message.toolCalls) { call in
ToolCallBadge(call: call) ToolCallBadge(call: call)
@@ -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()
HStack {
Spacer()
Button("Edit") { viewModel.startEditing() }
.controlSize(.small)
}
if viewModel.isMarkdownFile {
MarkdownContentView(content: viewModel.skillContent)
} else {
Text(viewModel.skillContent) Text(viewModel.skillContent)
.font(.system(.body, design: .monospaced)) .font(.system(.body, design: .monospaced))
.textSelection(.enabled) .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)
}
} }
@@ -1,40 +1,56 @@
import Foundation import Foundation
import os
@Observable @Observable
final class ToolsViewModel { final class ToolsViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "ToolsViewModel")
var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
var toolsets: [HermesToolset] = [] var toolsets: [HermesToolset] = []
var mcpStatus: String = "" var mcpStatus: String = ""
var isLoading = false var isLoading = false
var availablePlatforms: [HermesToolPlatform] = [] var availablePlatforms: [HermesToolPlatform] = []
func load() { @MainActor
loadPlatforms() func load() async {
loadTools(for: selectedPlatform) isLoading = true
loadMCPStatus() await loadPlatforms()
await loadTools(for: selectedPlatform)
await loadMCPStatus()
isLoading = false
} }
func switchPlatform(_ platform: HermesToolPlatform) { @MainActor
func switchPlatform(_ platform: HermesToolPlatform) async {
selectedPlatform = platform selectedPlatform = platform
loadTools(for: platform) await loadTools(for: platform)
} }
func toggleTool(_ tool: HermesToolset) { @MainActor
let action = tool.enabled ? "disable" : "enable" func toggleTool(_ tool: HermesToolset) async {
let result = runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name]) guard let idx = toolsets.firstIndex(where: { $0.name == tool.name }) else { return }
if result.exitCode == 0 {
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
toolsets[idx].enabled.toggle() toolsets[idx].enabled.toggle()
let newEnabled = toolsets[idx].enabled
let action = newEnabled ? "enable" : "disable"
let result = await runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name])
if result.exitCode != 0 {
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
toolsets[idx].enabled = !newEnabled
} }
} }
} }
private func loadPlatforms() { @MainActor
private func loadPlatforms() async {
let config: String let config: String
do { do {
config = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) config = try await Task.detached {
try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
}.value
} catch { } catch {
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)") logger.error("Failed to read config.yaml: \(error.localizedDescription)")
config = "" config = ""
} }
var platforms: [HermesToolPlatform] = [] var platforms: [HermesToolPlatform] = []
@@ -67,15 +83,15 @@ final class ToolsViewModel {
} }
} }
private func loadTools(for platform: HermesToolPlatform) { @MainActor
isLoading = true private func loadTools(for platform: HermesToolPlatform) async {
let result = runHermes(["tools", "list", "--platform", platform.name]) let result = await runHermes(["tools", "list", "--platform", platform.name])
toolsets = parseToolsList(result.output) toolsets = parseToolsList(result.output)
isLoading = false
} }
private func loadMCPStatus() { @MainActor
let result = runHermes(["mcp", "list"]) private func loadMCPStatus() async {
let result = await runHermes(["mcp", "list"])
mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines) mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
} }
@@ -121,21 +137,32 @@ final class ToolsViewModel {
return "🔧" return "🔧"
} }
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { private nonisolated func runHermes(_ arguments: [String]) async -> (output: String, exitCode: Int32) {
await Task.detached {
let process = Process() let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments process.arguments = arguments
let pipe = Pipe() let stdoutPipe = Pipe()
process.standardOutput = pipe let stderrPipe = Pipe()
process.standardError = Pipe() process.standardOutput = stdoutPipe
process.standardError = stderrPipe
do { do {
try process.run() try process.run()
process.waitUntilExit() process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile() let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? "" let output = String(data: data, encoding: .utf8) ?? ""
try? stdoutPipe.fileHandleForReading.close()
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForWriting.close()
return (output, process.terminationStatus) return (output, process.terminationStatus)
} catch { } catch {
try? stdoutPipe.fileHandleForReading.close()
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForWriting.close()
return ("", -1) return ("", -1)
} }
}.value
} }
} }
@@ -14,7 +14,7 @@ struct ToolsView: View {
} }
} }
.navigationTitle("Tools") .navigationTitle("Tools")
.onAppear { viewModel.load() } .task { await viewModel.load() }
} }
private var platformPicker: some View { private var platformPicker: some View {
@@ -23,7 +23,7 @@ struct ToolsView: View {
get: { viewModel.selectedPlatform.name }, get: { viewModel.selectedPlatform.name },
set: { name in set: { name in
if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) { if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) {
viewModel.switchPlatform(platform) Task { await viewModel.switchPlatform(platform) }
} }
} }
)) { )) {
@@ -46,7 +46,7 @@ struct ToolsView: View {
LazyVStack(spacing: 1) { LazyVStack(spacing: 1) {
ForEach(viewModel.toolsets) { tool in ForEach(viewModel.toolsets) { tool in
ToolRow(tool: tool) { ToolRow(tool: tool) {
viewModel.toggleTool(tool) await viewModel.toggleTool(tool)
} }
} }
} }
@@ -78,7 +78,7 @@ struct ToolsView: View {
struct ToolRow: View { struct ToolRow: View {
let tool: HermesToolset let tool: HermesToolset
let onToggle: () -> Void let onToggle: () async -> Void
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
@@ -95,7 +95,7 @@ struct ToolRow: View {
Spacer() Spacer()
Toggle("", isOn: Binding( Toggle("", isOn: Binding(
get: { tool.enabled }, get: { tool.enabled },
set: { _ in onToggle() } set: { _ in Task { await onToggle() } }
)) ))
.toggleStyle(.switch) .toggleStyle(.switch)
.labelsHidden() .labelsHidden()
+34
View File
@@ -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()