mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8672ed1e6c | |||
| 46468890d5 | |||
| cd503378e2 | |||
| 86762eab6d | |||
| a7fd193770 | |||
| 521c6d63fc | |||
| 66d04d838d | |||
| ad30c0a943 | |||
| 44afa8f53b | |||
| 481b937c33 | |||
| 790efb585b | |||
| 3acf95a824 | |||
| 7d69c82c2b | |||
| ae2872e08f | |||
| 303f4502dd | |||
| 815c9dcbcd | |||
| ef53ac1c93 | |||
| 2a3e8b1422 | |||
| 563f5a702c | |||
| c7f3ca9be3 | |||
| dbaadb8037 | |||
| ce001fe202 | |||
| a329eca419 | |||
| 528de938c5 | |||
| 4f791d491e | |||
| dd79891874 | |||
| a13288e759 | |||
| a16c8ec2d9 | |||
| 0e3712116f | |||
| ab45f95790 | |||
| d31bc63b6a | |||
| bc8f4b0c25 | |||
| 55ee99c839 | |||
| 3477fa733f | |||
| c6f45ac22e | |||
| b4c93ac79c | |||
| c09f167760 | |||
| b79200e950 | |||
| a800a630a8 | |||
| e4d5bb0364 | |||
| 36757a8c9a | |||
| cfbf3ea142 | |||
| f3cb1eb86b | |||
| 2b57025f3c | |||
| 2a14e28589 | |||
| 39bac7d2be | |||
| af8e120c9f | |||
| 0d38856b3e |
@@ -43,3 +43,6 @@ Package.resolved
|
|||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
scarf/standards/backups/
|
scarf/standards/backups/
|
||||||
|
|
||||||
|
# Scarf project dashboards (user-specific)
|
||||||
|
.scarf/
|
||||||
|
|||||||
@@ -10,31 +10,60 @@
|
|||||||
</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>
|
||||||
|
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Dashboard** — System health, token usage, cost tracking, recent sessions at a glance
|
- **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
|
||||||
- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, and full-text search (FTS5)
|
- **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)
|
||||||
- **Activity Feed** — Recent tool execution log with filtering by kind (read/edit/execute/fetch/browser) and detail inspector
|
- **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
|
||||||
- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm)
|
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments and tool output display
|
||||||
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live refresh
|
- **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
|
||||||
- **Skills Browser** — Browse all installed skills by category with file content viewer
|
- **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
|
||||||
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
|
- **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
|
||||||
- **Log Viewer** — Real-time tailing of error and gateway logs with level filtering
|
- **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
|
||||||
- **Settings** — Read-only config display with raw YAML viewer and Finder path links
|
- **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
|
||||||
|
- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering and text search
|
||||||
|
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
|
||||||
|
- **Settings** — Structured config editor for all Hermes settings including 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) installed at `~/.hermes/`
|
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.8.0 recommended for full feature support)
|
||||||
|
|
||||||
## Building
|
### Compatibility
|
||||||
|
|
||||||
|
Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Automatic schema detection provides backward compatibility with older databases while supporting new features in newer Hermes versions.
|
||||||
|
|
||||||
|
| Hermes Version | Status |
|
||||||
|
|----------------|--------|
|
||||||
|
| v0.6.0 (2026-03-30) | Verified |
|
||||||
|
| v0.7.0 (2026-04-03) | Verified |
|
||||||
|
| v0.8.0 (2026-04-08, latest) | Verified |
|
||||||
|
|
||||||
|
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
### Pre-built Binary (no Xcode required)
|
||||||
|
|
||||||
|
Download the latest universal binary (Apple Silicon + Intel) from [Releases](https://github.com/awizemann/scarf/releases):
|
||||||
|
|
||||||
|
1. Download `Scarf-vX.X.X-Universal.zip`
|
||||||
|
2. Unzip and drag **Scarf.app** to Applications
|
||||||
|
3. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
|
||||||
|
|
||||||
|
### Build from Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/awizemann/scarf.git
|
git clone https://github.com/awizemann/scarf.git
|
||||||
@@ -45,7 +74,7 @@ open scarf.xcodeproj
|
|||||||
Or from the command line:
|
Or from the command line:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Release -arch arm64 -arch x86_64 ONLY_ACTIVE_ARCH=NO build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -59,14 +88,18 @@ scarf/
|
|||||||
Services/ Data access (SQLite reader, file I/O, log tailing, file watcher)
|
Services/ Data access (SQLite reader, file I/O, log tailing, file watcher)
|
||||||
Features/ Self-contained feature modules
|
Features/ Self-contained feature modules
|
||||||
Dashboard/ System overview and stats
|
Dashboard/ System overview and stats
|
||||||
Sessions/ Conversation browser with detail view
|
Insights/ Usage analytics and activity patterns
|
||||||
|
Sessions/ Conversation browser with rename, delete, export
|
||||||
Activity/ Tool execution feed with inspector
|
Activity/ Tool execution feed with inspector
|
||||||
Chat/ Embedded terminal via SwiftTerm
|
Projects/ Agent-generated project dashboards with widget rendering
|
||||||
|
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
|
||||||
|
Gateway/ Messaging gateway control and pairing
|
||||||
Cron/ Scheduled job viewer
|
Cron/ Scheduled job viewer
|
||||||
Logs/ Real-time log viewer
|
Logs/ Real-time log viewer
|
||||||
Settings/ Configuration display
|
Settings/ Structured config editor
|
||||||
Navigation/ AppCoordinator + SidebarView
|
Navigation/ AppCoordinator + SidebarView
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -83,9 +116,16 @@ 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 sessions` | CLI commands | Rename/Delete/Export |
|
||||||
|
| `hermes gateway` | CLI commands | Start/Stop/Restart |
|
||||||
|
| `hermes pairing` | CLI commands | Approve/Revoke |
|
||||||
|
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
|
||||||
|
| `scarf/projects.json` | JSON (registry) | Read/Write |
|
||||||
|
|
||||||
The app **never writes** to `state.db` — it opens in read-only mode to avoid WAL contention with Hermes.
|
The app opens `state.db` in read-only mode to avoid WAL contention with Hermes. Management actions (tool toggles, session rename/delete/export) go through the Hermes CLI.
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
@@ -93,14 +133,154 @@ The app **never writes** to `state.db` — it opens in read-only mode to avoid W
|
|||||||
|---------|---------|
|
|---------|---------|
|
||||||
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
|
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
|
||||||
|
|
||||||
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, GCD file watching.
|
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
Scarf is a passive observer. It watches `~/.hermes/` for file changes and polls the SQLite database for new sessions and messages. The Chat tab spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive Hermes CLI experience with proper ANSI rendering.
|
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 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.
|
||||||
|
|
||||||
The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary.
|
The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary.
|
||||||
|
|
||||||
|
## Project Dashboards
|
||||||
|
|
||||||
|
Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically.
|
||||||
|
|
||||||
|
### What You Can Build
|
||||||
|
|
||||||
|
- **Development dashboards** — test coverage, build status, open issues, sprint progress
|
||||||
|
- **Data project trackers** — pipeline metrics, data quality scores, processing throughput
|
||||||
|
- **Deployment monitors** — deploy history tables, uptime stats, error rate charts
|
||||||
|
- **Research dashboards** — experiment results, key findings, paper status checklists
|
||||||
|
- **Agent activity views** — cron job results, content generation stats, task completion rates
|
||||||
|
- **Embedded web apps** — local dev servers, HTML reports, Grafana dashboards, any web-based tool your agent generates
|
||||||
|
- **Any project status** — if your agent can measure it, Scarf can display it
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
**1. Create the dashboard file**
|
||||||
|
|
||||||
|
Create `.scarf/dashboard.json` in any project folder:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"title": "My Project",
|
||||||
|
"description": "Project status at a glance",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "Overview",
|
||||||
|
"columns": 3,
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Test Coverage",
|
||||||
|
"value": "87%",
|
||||||
|
"icon": "checkmark.shield",
|
||||||
|
"color": "green",
|
||||||
|
"subtitle": "+2.1% this week"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "progress",
|
||||||
|
"title": "Sprint Progress",
|
||||||
|
"value": 0.73,
|
||||||
|
"label": "73% complete",
|
||||||
|
"color": "blue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "list",
|
||||||
|
"title": "Tasks",
|
||||||
|
"items": [
|
||||||
|
{ "text": "Write unit tests", "status": "done" },
|
||||||
|
{ "text": "Update API docs", "status": "active" },
|
||||||
|
{ "text": "Deploy to prod", "status": "pending" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Register your project**
|
||||||
|
|
||||||
|
In Scarf, go to **Projects** in the sidebar and click the **+** button to add your project folder. Or have your agent add it directly to the registry at `~/.hermes/scarf/projects.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projects": [
|
||||||
|
{ "name": "my-project", "path": "/Users/you/Developer/my-project" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. View in Scarf**
|
||||||
|
|
||||||
|
Select your project in the Projects sidebar — the dashboard renders immediately. Scarf watches the file for changes and refreshes automatically whenever the JSON is updated.
|
||||||
|
|
||||||
|
### Widget Types
|
||||||
|
|
||||||
|
| Type | Description | Key Fields |
|
||||||
|
|------|-------------|------------|
|
||||||
|
| `stat` | Key metric with large value display | `value`, `icon`, `color`, `subtitle` |
|
||||||
|
| `progress` | Progress bar with label | `value` (0.0–1.0), `label`, `color` |
|
||||||
|
| `text` | Rich text block | `content`, `format` ("markdown" or "plain") |
|
||||||
|
| `table` | Data table with headers | `columns`, `rows` |
|
||||||
|
| `chart` | Line, bar, or pie chart | `chartType`, `series` (each with `name`, `color`, `data`) |
|
||||||
|
| `list` | Checklist with status indicators | `items` (each with `text`, `status`: done/active/pending) |
|
||||||
|
| `webview` | Embedded web browser | `url`, `height` (default 400) |
|
||||||
|
|
||||||
|
The `webview` widget embeds a live web browser directly in your dashboard — perfect for displaying local dev servers, HTML reports, or any web-based tool your agent generates.
|
||||||
|
|
||||||
|
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows your normal widgets, **Site** shows the web content full-canvas with clean margins — using the entire available space in the app. This gives you the best of both worlds: compact metrics at a glance, and a full embedded browser when you need it.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "webview",
|
||||||
|
"title": "Project Report",
|
||||||
|
"url": "http://localhost:8000/dashboard",
|
||||||
|
"height": 500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `url`: Any URL — typically a local server (`http://localhost:...`) or file path
|
||||||
|
- `height`: Height in points when displayed as an inline widget card (default: 400). The Site tab always uses full available space regardless of this setting.
|
||||||
|
|
||||||
|
**Colors**: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray
|
||||||
|
|
||||||
|
**Icons**: Any [SF Symbol](https://developer.apple.com/sf-symbols/) name (e.g., `checkmark.shield`, `cpu`, `doc.text`, `chart.bar`)
|
||||||
|
|
||||||
|
### Agent-Generated Dashboards
|
||||||
|
|
||||||
|
The real power is letting your Hermes agent build and update dashboards automatically. Add instructions like this to your agent's context:
|
||||||
|
|
||||||
|
> Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, lists for task tracking, and a webview widget if the project has a local web server or HTML reports. Register the project in `~/.hermes/scarf/projects.json` if not already registered.
|
||||||
|
|
||||||
|
Your agent can update the dashboard as part of cron jobs, after builds, or whenever project state changes. Since Scarf watches the file, updates appear in real-time.
|
||||||
|
|
||||||
|
### Dashboard Schema Reference
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"title": "Required — dashboard title",
|
||||||
|
"description": "Optional — subtitle text",
|
||||||
|
"updatedAt": "Optional — ISO 8601 timestamp",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "Section Name",
|
||||||
|
"columns": 3,
|
||||||
|
"widgets": [{ "type": "...", "title": "..." }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each section defines a grid with 1–4 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
||||||
@@ -111,6 +291,12 @@ Contributions are welcome. Please open an issue to discuss what you'd like to ch
|
|||||||
4. Push to the branch (`git push origin feature/my-feature`)
|
4. Push to the branch (`git push origin feature/my-feature`)
|
||||||
5. Open a Pull Request
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
If you find Scarf useful, consider buying me a coffee.
|
||||||
|
|
||||||
|
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="40"></a>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](LICENSE)
|
[MIT](LICENSE)
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,169 @@
|
|||||||
|
# Scarf Project Dashboard Schema
|
||||||
|
|
||||||
|
Scarf can render project dashboards from a JSON file. Place a `dashboard.json` file at `.scarf/dashboard.json` in your project root, and register the project in Scarf.
|
||||||
|
|
||||||
|
## Registration
|
||||||
|
|
||||||
|
Projects are registered in `~/.hermes/scarf/projects.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projects": [
|
||||||
|
{ "name": "my-project", "path": "/path/to/my-project" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also add projects from the Scarf UI via the Projects section.
|
||||||
|
|
||||||
|
## Dashboard File
|
||||||
|
|
||||||
|
Create `.scarf/dashboard.json` in your project root:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"title": "My Project",
|
||||||
|
"description": "Optional description",
|
||||||
|
"updatedAt": "2026-03-31T14:00:00Z",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "Section Name",
|
||||||
|
"columns": 3,
|
||||||
|
"widgets": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Widget Types
|
||||||
|
|
||||||
|
### stat — Key metric display
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Test Coverage",
|
||||||
|
"value": "87.3%",
|
||||||
|
"icon": "checkmark.shield",
|
||||||
|
"color": "green",
|
||||||
|
"subtitle": "+2.1% from last week"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `value`: String or number
|
||||||
|
- `icon`: SF Symbol name (optional)
|
||||||
|
- `color`: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray (optional)
|
||||||
|
- `subtitle`: Secondary text (optional)
|
||||||
|
|
||||||
|
### progress — Progress bar
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "progress",
|
||||||
|
"title": "Sprint Progress",
|
||||||
|
"value": 0.73,
|
||||||
|
"label": "73% complete",
|
||||||
|
"color": "blue"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `value`: Number between 0.0 and 1.0
|
||||||
|
- `label`: Text below the bar (optional)
|
||||||
|
- `color`: Named color (optional)
|
||||||
|
|
||||||
|
### text — Rich text block
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"title": "Release Notes",
|
||||||
|
"content": "**v2.4.1** — Fixed auth timeout\n\n- Bug fix for session handling",
|
||||||
|
"format": "markdown"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `content`: Text content
|
||||||
|
- `format`: "markdown" or "plain" (default: plain)
|
||||||
|
|
||||||
|
### table — Data table
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "table",
|
||||||
|
"title": "Recent Deploys",
|
||||||
|
"columns": ["Date", "Env", "Status"],
|
||||||
|
"rows": [
|
||||||
|
["Mar 30", "prod", "success"],
|
||||||
|
["Mar 29", "staging", "success"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### chart — Line, bar, or pie chart
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "chart",
|
||||||
|
"title": "Tests Over Time",
|
||||||
|
"chartType": "line",
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "Passing",
|
||||||
|
"color": "green",
|
||||||
|
"data": [
|
||||||
|
{ "x": "Mon", "y": 142 },
|
||||||
|
{ "x": "Tue", "y": 145 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `chartType`: "line", "bar", or "pie"
|
||||||
|
- `series[].color`: Named color (optional)
|
||||||
|
- For pie charts, each series becomes a slice
|
||||||
|
|
||||||
|
### list — Checklist
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "list",
|
||||||
|
"title": "TODO Items",
|
||||||
|
"icon": "checklist",
|
||||||
|
"items": [
|
||||||
|
{ "text": "Write tests", "status": "done" },
|
||||||
|
{ "text": "Update docs", "status": "active" },
|
||||||
|
{ "text": "Deploy", "status": "pending" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `status`: "done" (checkmark), "active" (filled circle), "pending" (empty circle)
|
||||||
|
|
||||||
|
### webview — Embedded web browser
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "webview",
|
||||||
|
"title": "Project Dashboard",
|
||||||
|
"url": "http://localhost:8000",
|
||||||
|
"height": 500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `url`: Any URL — local servers, file paths, or remote pages
|
||||||
|
- `height`: Height in points (optional, default: 400)
|
||||||
|
|
||||||
|
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows all normal widgets, **Site** displays the web content full-canvas. The webview widget is automatically filtered out of the Dashboard tab's grid layout.
|
||||||
|
|
||||||
|
## Agent Instructions
|
||||||
|
|
||||||
|
To have your Hermes agent generate a dashboard, include these instructions:
|
||||||
|
|
||||||
|
> Analyze the project and create a `.scarf/dashboard.json` file with relevant metrics,
|
||||||
|
> status indicators, and visualizations. Use the Scarf dashboard schema with sections
|
||||||
|
> containing stat, progress, text, table, chart, list, and webview widgets. Register the project
|
||||||
|
> in `~/.hermes/scarf/projects.json` if not already registered.
|
||||||
|
|
||||||
|
The agent can update the dashboard file at any time — Scarf watches for changes and re-renders automatically.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Scarf — Feature Roadmap
|
||||||
|
|
||||||
|
## Tier 1 — High Value, Data Already Available
|
||||||
|
|
||||||
|
### 1. Insights Dashboard
|
||||||
|
Rich usage analytics pulled from the sessions and messages SQLite tables:
|
||||||
|
- Overview stats: sessions, messages, tool calls, tokens, active time, avg session duration
|
||||||
|
- Model breakdown: sessions and tokens per model
|
||||||
|
- Platform breakdown: CLI vs Telegram vs Discord usage
|
||||||
|
- Top tools chart: ranked tool usage with call counts and percentages
|
||||||
|
- Activity patterns: sessions by day-of-week, peak hours heatmap
|
||||||
|
- Notable sessions: longest, most messages, most tokens, most tool calls
|
||||||
|
- Time period selector: last 7/30/90 days
|
||||||
|
|
||||||
|
### 2. Tool Management Panel
|
||||||
|
- List all toolsets with enabled/disabled status and descriptions
|
||||||
|
- Toggle switches to enable/disable tools (via `hermes tools enable/disable`)
|
||||||
|
- Per-platform tool configuration
|
||||||
|
- MCP tool status
|
||||||
|
|
||||||
|
### 3. Session Management Enhancements
|
||||||
|
- Rename sessions from the Sessions browser (via `hermes sessions rename`)
|
||||||
|
- Delete sessions (via `hermes sessions delete`)
|
||||||
|
- Export sessions to JSONL (via `hermes sessions export`)
|
||||||
|
- Session stats card (total count, DB size, per-platform breakdown)
|
||||||
|
|
||||||
|
## Tier 2 — Medium Value, New Service Code Required
|
||||||
|
|
||||||
|
### 4. Skills Hub
|
||||||
|
- Search remote registries for new skills (6 sources)
|
||||||
|
- Install/uninstall skills from GUI
|
||||||
|
- Skill update indicator
|
||||||
|
- Trust level badges (builtin, local, hub)
|
||||||
|
|
||||||
|
### 5. Gateway Control Center
|
||||||
|
- Start/stop/restart gateway from GUI
|
||||||
|
- Real-time status: PID, uptime, connected platforms
|
||||||
|
- Pairing management: view approved users, approve/revoke
|
||||||
|
- Platform status per messaging service
|
||||||
|
|
||||||
|
### 6. System Health View
|
||||||
|
- Mirror `hermes status` and `hermes doctor` output
|
||||||
|
- API key validation, auth provider status, external tools
|
||||||
|
- Update available indicator
|
||||||
|
|
||||||
|
## Tier 3 — Nice to Have
|
||||||
|
|
||||||
|
### 7. Profile Management
|
||||||
|
- List/create/switch profiles (isolated Hermes instances)
|
||||||
|
|
||||||
|
### 8. Plugin Management
|
||||||
|
- Install from Git, enable/disable, update
|
||||||
|
|
||||||
|
### 9. MCP Server Management
|
||||||
|
- Add/remove/test MCP servers, toggle tools per server
|
||||||
|
|
||||||
|
### 10. Config Editor
|
||||||
|
- Structured form editor for config.yaml with validation
|
||||||
@@ -404,9 +404,10 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
|
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 = 1;
|
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;
|
||||||
@@ -415,11 +416,13 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.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;
|
||||||
@@ -438,9 +441,10 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
|
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 = 1;
|
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;
|
||||||
@@ -449,11 +453,13 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.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;
|
||||||
@@ -471,11 +477,11 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.5.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -492,11 +498,11 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.5.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -512,10 +518,10 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.5.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -531,10 +537,10 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.5.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
|||||||
@@ -16,18 +16,28 @@ struct ContentView: View {
|
|||||||
switch coordinator.selectedSection {
|
switch coordinator.selectedSection {
|
||||||
case .dashboard:
|
case .dashboard:
|
||||||
DashboardView()
|
DashboardView()
|
||||||
|
case .insights:
|
||||||
|
InsightsView()
|
||||||
case .sessions:
|
case .sessions:
|
||||||
SessionsView()
|
SessionsView()
|
||||||
case .activity:
|
case .activity:
|
||||||
ActivityView()
|
ActivityView()
|
||||||
|
case .projects:
|
||||||
|
ProjectsView()
|
||||||
case .chat:
|
case .chat:
|
||||||
ChatView()
|
ChatView()
|
||||||
case .memory:
|
case .memory:
|
||||||
MemoryView()
|
MemoryView()
|
||||||
case .skills:
|
case .skills:
|
||||||
SkillsView()
|
SkillsView()
|
||||||
|
case .tools:
|
||||||
|
ToolsView()
|
||||||
|
case .gateway:
|
||||||
|
GatewayView()
|
||||||
case .cron:
|
case .cron:
|
||||||
CronView()
|
CronView()
|
||||||
|
case .health:
|
||||||
|
HealthView()
|
||||||
case .logs:
|
case .logs:
|
||||||
LogsView()
|
LogsView()
|
||||||
case .settings:
|
case .settings:
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,16 @@ struct HermesConfig: Sendable {
|
|||||||
var streaming: Bool
|
var streaming: Bool
|
||||||
var showReasoning: Bool
|
var showReasoning: Bool
|
||||||
var verbose: Bool
|
var verbose: Bool
|
||||||
|
var autoTTS: Bool
|
||||||
|
var silenceThreshold: Int
|
||||||
|
var reasoningEffort: String
|
||||||
|
var showCost: Bool
|
||||||
|
var approvalMode: String
|
||||||
|
var browserBackend: String
|
||||||
|
var memoryProvider: String
|
||||||
|
var dockerEnv: [String: String]
|
||||||
|
var commandAllowlist: [String]
|
||||||
|
var memoryProfile: String
|
||||||
|
|
||||||
static let empty = HermesConfig(
|
static let empty = HermesConfig(
|
||||||
model: "unknown",
|
model: "unknown",
|
||||||
@@ -26,7 +36,17 @@ struct HermesConfig: Sendable {
|
|||||||
nudgeInterval: 0,
|
nudgeInterval: 0,
|
||||||
streaming: true,
|
streaming: true,
|
||||||
showReasoning: false,
|
showReasoning: false,
|
||||||
verbose: false
|
verbose: false,
|
||||||
|
autoTTS: true,
|
||||||
|
silenceThreshold: 200,
|
||||||
|
reasoningEffort: "medium",
|
||||||
|
showCost: false,
|
||||||
|
approvalMode: "manual",
|
||||||
|
browserBackend: "",
|
||||||
|
memoryProvider: "",
|
||||||
|
dockerEnv: [:],
|
||||||
|
commandAllowlist: [],
|
||||||
|
memoryProfile: ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SQLite3
|
||||||
|
|
||||||
enum HermesPaths: Sendable {
|
enum HermesPaths: Sendable {
|
||||||
// Using ProcessInfo to avoid main-actor isolation issues with FileManager/NSHomeDirectory
|
private nonisolated static let userHome: String = ProcessInfo.processInfo.environment["HOME"]
|
||||||
nonisolated static let home: String = ProcessInfo.processInfo.environment["HOME"]! + "/.hermes"
|
?? NSHomeDirectory()
|
||||||
|
|
||||||
|
nonisolated static let home: String = userHome + "/.hermes"
|
||||||
nonisolated static let stateDB: String = home + "/state.db"
|
nonisolated static let stateDB: String = home + "/state.db"
|
||||||
nonisolated static let configYAML: String = home + "/config.yaml"
|
nonisolated static let configYAML: String = home + "/config.yaml"
|
||||||
nonisolated static let memoriesDir: String = home + "/memories"
|
nonisolated static let memoriesDir: String = home + "/memories"
|
||||||
@@ -14,6 +17,34 @@ enum HermesPaths: Sendable {
|
|||||||
nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json"
|
nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json"
|
||||||
nonisolated static let skillsDir: String = home + "/skills"
|
nonisolated static let skillsDir: String = home + "/skills"
|
||||||
nonisolated static let errorsLog: String = home + "/logs/errors.log"
|
nonisolated static let errorsLog: String = home + "/logs/errors.log"
|
||||||
|
nonisolated static let agentLog: String = home + "/logs/agent.log"
|
||||||
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
|
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
|
||||||
nonisolated static let hermesBinary: String = ProcessInfo.processInfo.environment["HOME"]! + "/.local/bin/hermes"
|
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
|
||||||
|
nonisolated static let scarfDir: String = home + "/scarf"
|
||||||
|
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SQLite Constants
|
||||||
|
|
||||||
|
/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data.
|
||||||
|
/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift.
|
||||||
|
nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||||
|
|
||||||
|
// MARK: - Query Defaults
|
||||||
|
|
||||||
|
enum QueryDefaults: Sendable {
|
||||||
|
nonisolated static let sessionLimit = 100
|
||||||
|
nonisolated static let messageSearchLimit = 50
|
||||||
|
nonisolated static let toolCallLimit = 50
|
||||||
|
nonisolated static let sessionPreviewLimit = 10
|
||||||
|
nonisolated static let previewContentLength = 100
|
||||||
|
nonisolated static let logLineLimit = 200
|
||||||
|
nonisolated static let defaultSilenceThreshold = 200
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - File Size Formatting
|
||||||
|
|
||||||
|
enum FileSizeUnit: Sendable {
|
||||||
|
nonisolated static let kilobyte = 1_024.0
|
||||||
|
nonisolated static let megabyte = 1_048_576.0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,23 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
let nextRunAt: String?
|
let nextRunAt: String?
|
||||||
let lastRunAt: String?
|
let lastRunAt: String?
|
||||||
let lastError: String?
|
let lastError: String?
|
||||||
|
let preRunScript: String?
|
||||||
|
let deliveryFailures: Int?
|
||||||
|
let lastDeliveryError: String?
|
||||||
|
let timeoutType: String?
|
||||||
|
let timeoutSeconds: Int?
|
||||||
|
let silent: Bool?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, name, prompt, skills, model, schedule, enabled, state, deliver
|
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
|
||||||
case nextRunAt = "next_run_at"
|
case nextRunAt = "next_run_at"
|
||||||
case lastRunAt = "last_run_at"
|
case lastRunAt = "last_run_at"
|
||||||
case lastError = "last_error"
|
case lastError = "last_error"
|
||||||
|
case preRunScript = "pre_run_script"
|
||||||
|
case deliveryFailures = "delivery_failures"
|
||||||
|
case lastDeliveryError = "last_delivery_error"
|
||||||
|
case timeoutType = "timeout_type"
|
||||||
|
case timeoutSeconds = "timeout_seconds"
|
||||||
}
|
}
|
||||||
|
|
||||||
var stateIcon: String {
|
var stateIcon: String {
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ struct HermesMessage: Identifiable, Sendable {
|
|||||||
let timestamp: Date?
|
let timestamp: Date?
|
||||||
let tokenCount: Int?
|
let tokenCount: Int?
|
||||||
let finishReason: String?
|
let finishReason: String?
|
||||||
|
let reasoning: String?
|
||||||
|
|
||||||
var isUser: Bool { role == "user" }
|
var isUser: Bool { role == "user" }
|
||||||
var isAssistant: Bool { role == "assistant" }
|
var isAssistant: Bool { role == "assistant" }
|
||||||
var isToolResult: Bool { role == "tool" }
|
var isToolResult: Bool { role == "tool" }
|
||||||
|
var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HermesToolCall: Identifiable, Sendable, Codable {
|
struct HermesToolCall: Identifiable, Sendable, Codable {
|
||||||
@@ -61,7 +63,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
|
|||||||
switch functionName {
|
switch functionName {
|
||||||
case "read_file", "search_files", "vision_analyze": return .read
|
case "read_file", "search_files", "vision_analyze": return .read
|
||||||
case "write_file", "patch": return .edit
|
case "write_file", "patch": return .edit
|
||||||
case "terminal": return .execute
|
case "terminal", "execute_code": return .execute
|
||||||
case "web_search", "web_extract": return .fetch
|
case "web_search", "web_extract": return .fetch
|
||||||
case "browser_navigate", "browser_click", "browser_screenshot": return .browser
|
case "browser_navigate", "browser_click", "browser_screenshot": return .browser
|
||||||
default: return .other
|
default: return .other
|
||||||
|
|||||||
@@ -17,8 +17,18 @@ struct HermesSession: Identifiable, Sendable {
|
|||||||
let cacheReadTokens: Int
|
let cacheReadTokens: Int
|
||||||
let cacheWriteTokens: Int
|
let cacheWriteTokens: Int
|
||||||
let estimatedCostUSD: Double?
|
let estimatedCostUSD: Double?
|
||||||
|
let reasoningTokens: Int
|
||||||
|
let actualCostUSD: Double?
|
||||||
|
let costStatus: String?
|
||||||
|
let billingProvider: String?
|
||||||
|
|
||||||
var totalTokens: Int { inputTokens + outputTokens }
|
var isSubagent: Bool { parentSessionId != nil }
|
||||||
|
|
||||||
|
var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
|
||||||
|
|
||||||
|
var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
|
||||||
|
|
||||||
|
var costIsActual: Bool { actualCostUSD != nil }
|
||||||
|
|
||||||
var duration: TimeInterval? {
|
var duration: TimeInterval? {
|
||||||
guard let start = startedAt, let end = endedAt else { return nil }
|
guard let start = startedAt, let end = endedAt else { return nil }
|
||||||
@@ -30,13 +40,20 @@ struct HermesSession: Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sourceIcon: String {
|
var sourceIcon: String {
|
||||||
switch source {
|
KnownPlatforms.icon(for: source)
|
||||||
case "cli": return "terminal"
|
}
|
||||||
case "telegram": return "paperplane"
|
|
||||||
case "discord": return "bubble.left.and.bubble.right"
|
func withTitle(_ newTitle: String) -> HermesSession {
|
||||||
case "slack": return "number"
|
HermesSession(
|
||||||
case "email": return "envelope"
|
id: id, source: source, userId: userId, model: model,
|
||||||
default: return "bubble.left"
|
title: newTitle, parentSessionId: parentSessionId,
|
||||||
}
|
startedAt: startedAt, endedAt: endedAt, endReason: endReason,
|
||||||
|
messageCount: messageCount, toolCallCount: toolCallCount,
|
||||||
|
inputTokens: inputTokens, outputTokens: outputTokens,
|
||||||
|
cacheReadTokens: cacheReadTokens, cacheWriteTokens: cacheWriteTokens,
|
||||||
|
estimatedCostUSD: estimatedCostUSD, reasoningTokens: reasoningTokens,
|
||||||
|
actualCostUSD: actualCostUSD, costStatus: costStatus,
|
||||||
|
billingProvider: billingProvider
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ struct HermesSkill: Identifiable, Sendable {
|
|||||||
let category: String
|
let category: String
|
||||||
let path: String
|
let path: String
|
||||||
let files: [String]
|
let files: [String]
|
||||||
|
let requiredConfig: [String]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct HermesToolset: Identifiable, Sendable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let icon: String
|
||||||
|
var enabled: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HermesToolPlatform: Identifiable, Sendable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let displayName: String
|
||||||
|
let icon: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum KnownPlatforms {
|
||||||
|
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
|
||||||
|
static let all: [HermesToolPlatform] = [
|
||||||
|
cli,
|
||||||
|
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
|
||||||
|
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
|
||||||
|
HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"),
|
||||||
|
HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"),
|
||||||
|
HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"),
|
||||||
|
HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"),
|
||||||
|
HermesToolPlatform(name: "homeassistant", displayName: "Home Assistant", icon: "house"),
|
||||||
|
HermesToolPlatform(name: "webhook", displayName: "Webhook", icon: "arrow.up.right.square"),
|
||||||
|
HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"),
|
||||||
|
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
|
||||||
|
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
|
||||||
|
]
|
||||||
|
|
||||||
|
static func icon(for platform: String) -> String {
|
||||||
|
switch platform {
|
||||||
|
case "cli": return "terminal"
|
||||||
|
case "telegram": return "paperplane"
|
||||||
|
case "discord": return "bubble.left.and.bubble.right"
|
||||||
|
case "slack": return "number"
|
||||||
|
case "whatsapp": return "phone.bubble"
|
||||||
|
case "signal": return "lock.shield"
|
||||||
|
case "email": return "envelope"
|
||||||
|
case "homeassistant": return "house"
|
||||||
|
case "webhook": return "arrow.up.right.square"
|
||||||
|
case "matrix": return "lock.rectangle.stack"
|
||||||
|
case "feishu": return "message.badge.circle"
|
||||||
|
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
|
||||||
|
default: return "bubble.left"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Registry
|
||||||
|
|
||||||
|
struct ProjectRegistry: Codable, Sendable {
|
||||||
|
var projects: [ProjectEntry]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let path: String
|
||||||
|
|
||||||
|
var dashboardPath: String { path + "/.scarf/dashboard.json" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dashboard
|
||||||
|
|
||||||
|
struct ProjectDashboard: Codable, Sendable {
|
||||||
|
let version: Int
|
||||||
|
let title: String
|
||||||
|
let description: String?
|
||||||
|
let updatedAt: String?
|
||||||
|
let theme: DashboardTheme?
|
||||||
|
let sections: [DashboardSection]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DashboardTheme: Codable, Sendable {
|
||||||
|
let accent: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DashboardSection: Codable, Sendable, Identifiable {
|
||||||
|
var id: String { title }
|
||||||
|
let title: String
|
||||||
|
let columns: Int?
|
||||||
|
let widgets: [DashboardWidget]
|
||||||
|
|
||||||
|
var columnCount: Int { columns ?? 3 }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DashboardWidget: Codable, Sendable, Identifiable {
|
||||||
|
var id: String { type + ":" + title }
|
||||||
|
|
||||||
|
let type: String
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
// Stat
|
||||||
|
let value: WidgetValue?
|
||||||
|
let icon: String?
|
||||||
|
let color: String?
|
||||||
|
let subtitle: String?
|
||||||
|
|
||||||
|
// Progress
|
||||||
|
let label: String?
|
||||||
|
|
||||||
|
// Text
|
||||||
|
let content: String?
|
||||||
|
let format: String?
|
||||||
|
|
||||||
|
// Table
|
||||||
|
let columns: [String]?
|
||||||
|
let rows: [[String]]?
|
||||||
|
|
||||||
|
// Chart
|
||||||
|
let chartType: String?
|
||||||
|
let xLabel: String?
|
||||||
|
let yLabel: String?
|
||||||
|
let series: [ChartSeries]?
|
||||||
|
|
||||||
|
// List
|
||||||
|
let items: [ListItem]?
|
||||||
|
|
||||||
|
// Webview
|
||||||
|
let url: String?
|
||||||
|
let height: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget Value (String or Number)
|
||||||
|
|
||||||
|
enum WidgetValue: Codable, Sendable, Hashable {
|
||||||
|
case string(String)
|
||||||
|
case number(Double)
|
||||||
|
|
||||||
|
var displayString: String {
|
||||||
|
switch self {
|
||||||
|
case .string(let s): return s
|
||||||
|
case .number(let n):
|
||||||
|
return n.truncatingRemainder(dividingBy: 1) == 0
|
||||||
|
? String(Int(n))
|
||||||
|
: String(format: "%.1f", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if let d = try? container.decode(Double.self) {
|
||||||
|
self = .number(d)
|
||||||
|
} else if let s = try? container.decode(String.self) {
|
||||||
|
self = .string(s)
|
||||||
|
} else {
|
||||||
|
throw DecodingError.typeMismatch(
|
||||||
|
WidgetValue.self,
|
||||||
|
.init(codingPath: decoder.codingPath, debugDescription: "Expected String or Number")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch self {
|
||||||
|
case .string(let s): try container.encode(s)
|
||||||
|
case .number(let n): try container.encode(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chart Data
|
||||||
|
|
||||||
|
struct ChartSeries: Codable, Sendable, Identifiable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let color: String?
|
||||||
|
let data: [ChartDataPoint]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChartDataPoint: Codable, Sendable, Identifiable {
|
||||||
|
var id: String { x }
|
||||||
|
let x: String
|
||||||
|
let y: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - List Data
|
||||||
|
|
||||||
|
struct ListItem: Codable, Sendable, Identifiable {
|
||||||
|
var id: String { text }
|
||||||
|
let text: String
|
||||||
|
let status: String?
|
||||||
|
}
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Manages a `hermes acp` subprocess and communicates via JSON-RPC over stdio.
|
||||||
|
/// Provides an async event stream for real-time session updates.
|
||||||
|
actor ACPClient {
|
||||||
|
private let logger = Logger(subsystem: "com.scarf", category: "ACPClient")
|
||||||
|
|
||||||
|
private var process: Process?
|
||||||
|
private var stdinPipe: Pipe?
|
||||||
|
private var stdoutPipe: Pipe?
|
||||||
|
private var stderrPipe: Pipe?
|
||||||
|
private var stdinFd: Int32 = -1
|
||||||
|
|
||||||
|
private var nextRequestId = 1
|
||||||
|
private var pendingRequests: [Int: CheckedContinuation<AnyCodable?, Error>] = [:]
|
||||||
|
private var readTask: Task<Void, Never>?
|
||||||
|
private var stderrTask: Task<Void, Never>?
|
||||||
|
private var keepaliveTask: Task<Void, Never>?
|
||||||
|
private var eventContinuation: AsyncStream<ACPEvent>.Continuation?
|
||||||
|
private var _eventStream: AsyncStream<ACPEvent>?
|
||||||
|
|
||||||
|
private(set) var isConnected = false
|
||||||
|
private(set) var currentSessionId: String?
|
||||||
|
private(set) var statusMessage = ""
|
||||||
|
|
||||||
|
/// Check if the underlying process is still alive and connected.
|
||||||
|
var isHealthy: Bool {
|
||||||
|
guard isConnected, let process else { return false }
|
||||||
|
return process.isRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event Stream
|
||||||
|
|
||||||
|
/// Access the event stream. Must call `start()` first.
|
||||||
|
var events: AsyncStream<ACPEvent> {
|
||||||
|
guard let stream = _eventStream else {
|
||||||
|
// Return an empty stream if not started
|
||||||
|
return AsyncStream { $0.finish() }
|
||||||
|
}
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
func start() async throws {
|
||||||
|
guard process == nil else { return }
|
||||||
|
|
||||||
|
// Ignore SIGPIPE so broken-pipe writes return EPIPE instead of crashing
|
||||||
|
signal(SIGPIPE, SIG_IGN)
|
||||||
|
|
||||||
|
// Create the event stream BEFORE anything else so no events are lost
|
||||||
|
let (stream, continuation) = AsyncStream.makeStream(of: ACPEvent.self)
|
||||||
|
self._eventStream = stream
|
||||||
|
self.eventContinuation = continuation
|
||||||
|
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||||
|
proc.arguments = ["acp"]
|
||||||
|
|
||||||
|
let stdin = Pipe()
|
||||||
|
let stdout = Pipe()
|
||||||
|
let stderr = Pipe()
|
||||||
|
|
||||||
|
proc.standardInput = stdin
|
||||||
|
proc.standardOutput = stdout
|
||||||
|
proc.standardError = stderr
|
||||||
|
|
||||||
|
// ACP uses JSON-RPC over pipes — do NOT set TERM to avoid terminal escape pollution
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
env.removeValue(forKey: "TERM")
|
||||||
|
proc.environment = env
|
||||||
|
|
||||||
|
proc.terminationHandler = { [weak self] proc in
|
||||||
|
Task { await self?.handleTermination(exitCode: proc.terminationStatus) }
|
||||||
|
}
|
||||||
|
|
||||||
|
statusMessage = "Starting hermes acp..."
|
||||||
|
|
||||||
|
do {
|
||||||
|
try proc.run()
|
||||||
|
} catch {
|
||||||
|
statusMessage = "Failed to start: \(error.localizedDescription)"
|
||||||
|
logger.error("Failed to start hermes acp: \(error.localizedDescription)")
|
||||||
|
continuation.finish()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
self.process = proc
|
||||||
|
self.stdinPipe = stdin
|
||||||
|
self.stdoutPipe = stdout
|
||||||
|
self.stderrPipe = stderr
|
||||||
|
self.stdinFd = stdin.fileHandleForWriting.fileDescriptor
|
||||||
|
self.isConnected = true
|
||||||
|
|
||||||
|
// Start reading stdout BEFORE sending initialize (so we catch the response)
|
||||||
|
startReadLoop(stdout: stdout, stderr: stderr)
|
||||||
|
logger.info("hermes acp process started (pid: \(proc.processIdentifier))")
|
||||||
|
statusMessage = "Initializing..."
|
||||||
|
|
||||||
|
// Initialize the ACP connection
|
||||||
|
let initParams: [String: AnyCodable] = [
|
||||||
|
"protocolVersion": AnyCodable(1),
|
||||||
|
"clientCapabilities": AnyCodable([String: Any]()),
|
||||||
|
"clientInfo": AnyCodable([
|
||||||
|
"name": "Scarf",
|
||||||
|
"version": "1.0"
|
||||||
|
] as [String: Any])
|
||||||
|
]
|
||||||
|
_ = try await sendRequest(method: "initialize", params: initParams)
|
||||||
|
statusMessage = "Connected"
|
||||||
|
logger.info("ACP connection initialized")
|
||||||
|
startKeepalive()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() async {
|
||||||
|
readTask?.cancel()
|
||||||
|
readTask = nil
|
||||||
|
stderrTask?.cancel()
|
||||||
|
stderrTask = nil
|
||||||
|
keepaliveTask?.cancel()
|
||||||
|
keepaliveTask = nil
|
||||||
|
eventContinuation?.finish()
|
||||||
|
eventContinuation = nil
|
||||||
|
_eventStream = nil
|
||||||
|
|
||||||
|
for (_, continuation) in pendingRequests {
|
||||||
|
continuation.resume(throwing: CancellationError())
|
||||||
|
}
|
||||||
|
pendingRequests.removeAll()
|
||||||
|
|
||||||
|
// Close stdin first so the subprocess sees EOF and can shut down gracefully
|
||||||
|
stdinPipe?.fileHandleForWriting.closeFile()
|
||||||
|
|
||||||
|
if let process, process.isRunning {
|
||||||
|
// SIGINT for graceful Python shutdown (raises KeyboardInterrupt cleanly)
|
||||||
|
process.interrupt()
|
||||||
|
// Watchdog: force-kill if still running after 2 seconds
|
||||||
|
let watchdogProcess = process
|
||||||
|
Task.detached {
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
if watchdogProcess.isRunning {
|
||||||
|
watchdogProcess.terminate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stdinPipe?.fileHandleForReading.closeFile()
|
||||||
|
stdoutPipe?.fileHandleForReading.closeFile()
|
||||||
|
stderrPipe?.fileHandleForReading.closeFile()
|
||||||
|
|
||||||
|
process = nil
|
||||||
|
stdinPipe = nil
|
||||||
|
stdoutPipe = nil
|
||||||
|
stderrPipe = nil
|
||||||
|
stdinFd = -1
|
||||||
|
isConnected = false
|
||||||
|
currentSessionId = nil
|
||||||
|
statusMessage = "Disconnected"
|
||||||
|
logger.info("ACP client stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keepalive
|
||||||
|
|
||||||
|
private func startKeepalive() {
|
||||||
|
keepaliveTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds
|
||||||
|
guard !Task.isCancelled else { break }
|
||||||
|
await self?.sendKeepalive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valid JSON-RPC notification used as a keepalive probe.
|
||||||
|
/// Sending bare newlines causes `json.loads("")` errors in the ACP library.
|
||||||
|
private static let keepalivePayload: Data = {
|
||||||
|
let json = #"{"jsonrpc":"2.0","method":"$/ping"}"# + "\n"
|
||||||
|
return Data(json.utf8)
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func sendKeepalive() {
|
||||||
|
let fd = stdinFd
|
||||||
|
guard fd >= 0 else { return }
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
let ok = Self.safeWrite(fd: fd, data: Self.keepalivePayload)
|
||||||
|
if !ok {
|
||||||
|
await self?.handleWriteFailed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Management
|
||||||
|
|
||||||
|
func newSession(cwd: String) async throws -> String {
|
||||||
|
statusMessage = "Creating session..."
|
||||||
|
let params: [String: AnyCodable] = [
|
||||||
|
"cwd": AnyCodable(cwd),
|
||||||
|
"mcpServers": AnyCodable([Any]())
|
||||||
|
]
|
||||||
|
let result = try await sendRequest(method: "session/new", params: params)
|
||||||
|
guard let dict = result?.dictValue,
|
||||||
|
let sessionId = dict["sessionId"] as? String else {
|
||||||
|
throw ACPClientError.invalidResponse("Missing sessionId in session/new response")
|
||||||
|
}
|
||||||
|
currentSessionId = sessionId
|
||||||
|
statusMessage = "Session ready"
|
||||||
|
logger.info("Created new ACP session: \(sessionId)")
|
||||||
|
return sessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSession(cwd: String, sessionId: String) async throws -> String {
|
||||||
|
statusMessage = "Loading session \(sessionId.prefix(12))..."
|
||||||
|
let params: [String: AnyCodable] = [
|
||||||
|
"cwd": AnyCodable(cwd),
|
||||||
|
"sessionId": AnyCodable(sessionId),
|
||||||
|
"mcpServers": AnyCodable([Any]())
|
||||||
|
]
|
||||||
|
let result = try await sendRequest(method: "session/load", params: params)
|
||||||
|
// ACP returns {} on success (no sessionId echoed), or an error if not found.
|
||||||
|
// If we got here without throwing, the session was loaded. Use the ID we sent.
|
||||||
|
let loadedId = (result?.dictValue?["sessionId"] as? String) ?? sessionId
|
||||||
|
currentSessionId = loadedId
|
||||||
|
statusMessage = "Session loaded"
|
||||||
|
logger.info("Loaded ACP session: \(loadedId)")
|
||||||
|
return loadedId
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeSession(cwd: String, sessionId: String) async throws -> String {
|
||||||
|
statusMessage = "Resuming session..."
|
||||||
|
let params: [String: AnyCodable] = [
|
||||||
|
"cwd": AnyCodable(cwd),
|
||||||
|
"sessionId": AnyCodable(sessionId),
|
||||||
|
"mcpServers": AnyCodable([Any]())
|
||||||
|
]
|
||||||
|
let result = try await sendRequest(method: "session/resume", params: params)
|
||||||
|
guard let dict = result?.dictValue,
|
||||||
|
let resumedId = dict["sessionId"] as? String else {
|
||||||
|
throw ACPClientError.invalidResponse("Missing sessionId in session/resume response")
|
||||||
|
}
|
||||||
|
currentSessionId = resumedId
|
||||||
|
statusMessage = "Session resumed"
|
||||||
|
logger.info("Resumed ACP session: \(resumedId)")
|
||||||
|
return resumedId
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Messaging
|
||||||
|
|
||||||
|
func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult {
|
||||||
|
statusMessage = "Sending prompt..."
|
||||||
|
let messageId = UUID().uuidString
|
||||||
|
let params: [String: AnyCodable] = [
|
||||||
|
"sessionId": AnyCodable(sessionId),
|
||||||
|
"messageId": AnyCodable(messageId),
|
||||||
|
"prompt": AnyCodable([
|
||||||
|
["type": "text", "text": text] as [String: Any]
|
||||||
|
] as [Any])
|
||||||
|
]
|
||||||
|
let result = try await sendRequest(method: "session/prompt", params: params)
|
||||||
|
let dict = result?.dictValue ?? [:]
|
||||||
|
let usage = dict["usage"] as? [String: Any] ?? [:]
|
||||||
|
|
||||||
|
statusMessage = "Ready"
|
||||||
|
return ACPPromptResult(
|
||||||
|
stopReason: dict["stopReason"] as? String ?? "end_turn",
|
||||||
|
inputTokens: usage["inputTokens"] as? Int ?? 0,
|
||||||
|
outputTokens: usage["outputTokens"] as? Int ?? 0,
|
||||||
|
thoughtTokens: usage["thoughtTokens"] as? Int ?? 0,
|
||||||
|
cachedReadTokens: usage["cachedReadTokens"] as? Int ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel(sessionId: String) async throws {
|
||||||
|
let params: [String: AnyCodable] = [
|
||||||
|
"sessionId": AnyCodable(sessionId)
|
||||||
|
]
|
||||||
|
_ = try await sendRequest(method: "session/cancel", params: params)
|
||||||
|
statusMessage = "Cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondToPermission(requestId: Int, optionId: String) {
|
||||||
|
let response: [String: Any] = [
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": requestId,
|
||||||
|
"result": [
|
||||||
|
"outcome": [
|
||||||
|
"kind": optionId == "deny" ? "rejected" : "allowed",
|
||||||
|
"optionId": optionId
|
||||||
|
] as [String: Any]
|
||||||
|
] as [String: Any]
|
||||||
|
]
|
||||||
|
writeJSON(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON-RPC Transport
|
||||||
|
|
||||||
|
private func sendRequest(method: String, params: [String: AnyCodable]) async throws -> AnyCodable? {
|
||||||
|
let requestId = nextRequestId
|
||||||
|
nextRequestId += 1
|
||||||
|
|
||||||
|
let request = ACPRequest(id: requestId, method: method, params: params)
|
||||||
|
|
||||||
|
guard let data = try? JSONEncoder().encode(request) else {
|
||||||
|
throw ACPClientError.encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Sending: \(method) (id: \(requestId))")
|
||||||
|
|
||||||
|
// session/prompt streams events and can run for minutes — no hard timeout.
|
||||||
|
// Control messages get a 30s watchdog.
|
||||||
|
let timeoutTask: Task<Void, Error>? = if method != "session/prompt" {
|
||||||
|
Task { [weak self] in
|
||||||
|
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
|
||||||
|
await self?.timeoutRequest(id: requestId, method: method)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defer { timeoutTask?.cancel() }
|
||||||
|
|
||||||
|
let fd = stdinFd
|
||||||
|
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<AnyCodable?, Error>) in
|
||||||
|
pendingRequests[requestId] = continuation
|
||||||
|
|
||||||
|
guard fd >= 0 else {
|
||||||
|
pendingRequests.removeValue(forKey: requestId)
|
||||||
|
continuation.resume(throwing: ACPClientError.notConnected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = data
|
||||||
|
payload.append(contentsOf: "\n".utf8)
|
||||||
|
// Write in a detached task to avoid blocking the actor's executor.
|
||||||
|
// The continuation is already stored; the response arrives via the read loop.
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
let ok = Self.safeWrite(fd: fd, data: payload)
|
||||||
|
if !ok {
|
||||||
|
await self?.handleWriteFailedForRequest(id: requestId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timeoutRequest(id: Int, method: String) {
|
||||||
|
guard let continuation = pendingRequests.removeValue(forKey: id) else { return }
|
||||||
|
logger.error("Request timed out: \(method) (id: \(id))")
|
||||||
|
statusMessage = "Request timed out"
|
||||||
|
continuation.resume(throwing: ACPClientError.requestTimeout(method: method))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeJSON(_ dict: [String: Any]) {
|
||||||
|
let fd = stdinFd
|
||||||
|
guard fd >= 0,
|
||||||
|
let data = try? JSONSerialization.data(withJSONObject: dict) else { return }
|
||||||
|
var payload = data
|
||||||
|
payload.append(contentsOf: "\n".utf8)
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
let ok = Self.safeWrite(fd: fd, data: payload)
|
||||||
|
if !ok {
|
||||||
|
await self?.handleWriteFailed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Read Loop
|
||||||
|
|
||||||
|
private func startReadLoop(stdout: Pipe, stderr: Pipe) {
|
||||||
|
// Read stdout for JSON-RPC messages
|
||||||
|
readTask = Task.detached { [weak self] in
|
||||||
|
let handle = stdout.fileHandleForReading
|
||||||
|
var buffer = Data()
|
||||||
|
|
||||||
|
while !Task.isCancelled {
|
||||||
|
let chunk = handle.availableData
|
||||||
|
if chunk.isEmpty { break } // EOF
|
||||||
|
buffer.append(chunk)
|
||||||
|
|
||||||
|
while let newlineIndex = buffer.firstIndex(of: UInt8(ascii: "\n")) {
|
||||||
|
let lineData = Data(buffer[buffer.startIndex..<newlineIndex])
|
||||||
|
buffer = Data(buffer[buffer.index(after: newlineIndex)...])
|
||||||
|
|
||||||
|
guard !lineData.isEmpty else { continue }
|
||||||
|
|
||||||
|
if let lineStr = String(data: lineData, encoding: .utf8) {
|
||||||
|
await self?.logger.debug("ACP recv: \(lineStr.prefix(200))")
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let message = try JSONDecoder().decode(ACPRawMessage.self, from: lineData)
|
||||||
|
await self?.handleMessage(message)
|
||||||
|
} catch {
|
||||||
|
await self?.logger.warning("Failed to decode ACP message: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self?.handleReadLoopEnded()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read stderr in background for diagnostic logging
|
||||||
|
stderrTask = Task.detached { [weak self] in
|
||||||
|
let handle = stderr.fileHandleForReading
|
||||||
|
while !Task.isCancelled {
|
||||||
|
let data = handle.availableData
|
||||||
|
if data.isEmpty { break }
|
||||||
|
if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!text.isEmpty {
|
||||||
|
await self?.logger.info("ACP stderr: \(text.prefix(500))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleMessage(_ message: ACPRawMessage) {
|
||||||
|
if message.isResponse {
|
||||||
|
if let requestId = message.id,
|
||||||
|
let continuation = pendingRequests.removeValue(forKey: requestId) {
|
||||||
|
if let error = message.error {
|
||||||
|
logger.error("ACP RPC error (id: \(requestId)): \(error.message)")
|
||||||
|
statusMessage = "Error: \(error.message)"
|
||||||
|
continuation.resume(throwing: ACPClientError.rpcError(code: error.code, message: error.message))
|
||||||
|
} else {
|
||||||
|
logger.debug("ACP response (id: \(requestId))")
|
||||||
|
continuation.resume(returning: message.result)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warning("ACP response for unknown request id: \(message.id ?? -1)")
|
||||||
|
}
|
||||||
|
} else if message.isNotification {
|
||||||
|
if let event = ACPEventParser.parse(notification: message) {
|
||||||
|
logger.debug("ACP event: \(String(describing: event).prefix(100))")
|
||||||
|
eventContinuation?.yield(event)
|
||||||
|
}
|
||||||
|
} else if message.isRequest {
|
||||||
|
if message.method == "session/request_permission",
|
||||||
|
let event = ACPEventParser.parsePermissionRequest(message) {
|
||||||
|
statusMessage = "Permission required"
|
||||||
|
eventContinuation?.yield(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Disconnect Cleanup
|
||||||
|
|
||||||
|
/// Single idempotent cleanup path for all disconnect scenarios.
|
||||||
|
private func performDisconnectCleanup(reason: String) {
|
||||||
|
guard isConnected else { return }
|
||||||
|
logger.warning("ACP disconnecting: \(reason)")
|
||||||
|
isConnected = false
|
||||||
|
statusMessage = "Connection lost"
|
||||||
|
for (_, continuation) in pendingRequests {
|
||||||
|
continuation.resume(throwing: ACPClientError.processTerminated)
|
||||||
|
}
|
||||||
|
pendingRequests.removeAll()
|
||||||
|
eventContinuation?.finish()
|
||||||
|
eventContinuation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleReadLoopEnded() {
|
||||||
|
performDisconnectCleanup(reason: "read loop ended (EOF)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleTermination(exitCode: Int32) {
|
||||||
|
performDisconnectCleanup(reason: "process exited (\(exitCode))")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleWriteFailed() {
|
||||||
|
performDisconnectCleanup(reason: "write failed (broken pipe)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleWriteFailedForRequest(id: Int) {
|
||||||
|
if let continuation = pendingRequests.removeValue(forKey: id) {
|
||||||
|
continuation.resume(throwing: ACPClientError.processTerminated)
|
||||||
|
}
|
||||||
|
performDisconnectCleanup(reason: "write failed (broken pipe)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Safe POSIX Write
|
||||||
|
|
||||||
|
/// Write data to a file descriptor using POSIX write(), returning false on error.
|
||||||
|
/// Handles partial writes and returns false on EPIPE or other errors.
|
||||||
|
private static func safeWrite(fd: Int32, data: Data) -> Bool {
|
||||||
|
data.withUnsafeBytes { buf in
|
||||||
|
guard let base = buf.baseAddress else { return false }
|
||||||
|
var written = 0
|
||||||
|
let total = buf.count
|
||||||
|
while written < total {
|
||||||
|
let result = Darwin.write(fd, base.advanced(by: written), total - written)
|
||||||
|
if result <= 0 { return false }
|
||||||
|
written += result
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum ACPClientError: Error, LocalizedError {
|
||||||
|
case notConnected
|
||||||
|
case encodingFailed
|
||||||
|
case invalidResponse(String)
|
||||||
|
case rpcError(code: Int, message: String)
|
||||||
|
case processTerminated
|
||||||
|
case requestTimeout(method: String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notConnected: return "ACP client is not connected"
|
||||||
|
case .encodingFailed: return "Failed to encode JSON-RPC request"
|
||||||
|
case .invalidResponse(let msg): return "Invalid ACP response: \(msg)"
|
||||||
|
case .rpcError(let code, let msg): return "ACP error \(code): \(msg)"
|
||||||
|
case .processTerminated: return "ACP process terminated unexpectedly"
|
||||||
|
case .requestTimeout(let method): return "ACP request '\(method)' timed out"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ import SQLite3
|
|||||||
|
|
||||||
actor HermesDataService {
|
actor HermesDataService {
|
||||||
private var db: OpaquePointer?
|
private var db: OpaquePointer?
|
||||||
|
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
|
||||||
@@ -14,6 +16,7 @@ actor HermesDataService {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
||||||
|
detectSchema()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,17 +27,39 @@ actor HermesDataService {
|
|||||||
db = nil
|
db = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSessions(limit: Int = 100) -> [HermesSession] {
|
// MARK: - Schema Detection
|
||||||
guard let db else { return [] }
|
|
||||||
let sql = """
|
private func detectSchema() {
|
||||||
SELECT id, source, user_id, model, title, parent_session_id,
|
guard let db else { return }
|
||||||
started_at, ended_at, end_reason, message_count, tool_call_count,
|
var stmt: OpaquePointer?
|
||||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
guard sqlite3_prepare_v2(db, "PRAGMA table_info(sessions)", -1, &stmt, nil) == SQLITE_OK else { return }
|
||||||
estimated_cost_usd
|
defer { sqlite3_finalize(stmt) }
|
||||||
FROM sessions
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
ORDER BY started_at DESC
|
if let name = sqlite3_column_text(stmt, 1), String(cString: name) == "reasoning_tokens" {
|
||||||
LIMIT ?
|
hasV07Schema = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Queries
|
||||||
|
|
||||||
|
private var sessionColumns: String {
|
||||||
|
var cols = """
|
||||||
|
id, source, user_id, model, title, parent_session_id,
|
||||||
|
started_at, ended_at, end_reason, message_count, tool_call_count,
|
||||||
|
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||||
|
estimated_cost_usd
|
||||||
"""
|
"""
|
||||||
|
if hasV07Schema {
|
||||||
|
cols += ", reasoning_tokens, actual_cost_usd, cost_status, billing_provider"
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
|
||||||
|
guard let db else { return [] }
|
||||||
|
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT ?"
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
@@ -47,19 +72,56 @@ actor HermesDataService {
|
|||||||
return sessions
|
return sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchMessages(sessionId: String) -> [HermesMessage] {
|
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = """
|
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? ORDER BY started_at DESC"
|
||||||
SELECT id, session_id, role, content, tool_call_id, tool_calls,
|
|
||||||
tool_name, timestamp, token_count, finish_reason
|
|
||||||
FROM messages
|
|
||||||
WHERE session_id = ?
|
|
||||||
ORDER BY timestamp ASC
|
|
||||||
"""
|
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
sqlite3_bind_text(stmt, 1, sessionId, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
|
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||||
|
|
||||||
|
var sessions: [HermesSession] = []
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
sessions.append(sessionFromRow(stmt!))
|
||||||
|
}
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSubagentSessions(parentId: String) -> [HermesSession] {
|
||||||
|
guard let db else { return [] }
|
||||||
|
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id = ? ORDER BY started_at ASC"
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_text(stmt, 1, parentId, -1, sqliteTransient)
|
||||||
|
|
||||||
|
var sessions: [HermesSession] = []
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
sessions.append(sessionFromRow(stmt!))
|
||||||
|
}
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Message Queries
|
||||||
|
|
||||||
|
private var messageColumns: String {
|
||||||
|
var cols = """
|
||||||
|
id, session_id, role, content, tool_call_id, tool_calls,
|
||||||
|
tool_name, timestamp, token_count, finish_reason
|
||||||
|
"""
|
||||||
|
if hasV07Schema {
|
||||||
|
cols += ", reasoning"
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchMessages(sessionId: String) -> [HermesMessage] {
|
||||||
|
guard let db else { return [] }
|
||||||
|
let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC"
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
|
||||||
|
|
||||||
var messages: [HermesMessage] = []
|
var messages: [HermesMessage] = []
|
||||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
@@ -68,11 +130,15 @@ actor HermesDataService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchMessages(query: String, limit: Int = 50) -> [HermesMessage] {
|
func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
|
let sanitized = sanitizeFTSQuery(query)
|
||||||
|
guard !sanitized.isEmpty else { return [] }
|
||||||
|
let msgCols = hasV07Schema
|
||||||
|
? "m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, m.tool_name, m.timestamp, m.token_count, m.finish_reason, m.reasoning"
|
||||||
|
: "m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, m.tool_name, m.timestamp, m.token_count, m.finish_reason"
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls,
|
SELECT \(msgCols)
|
||||||
m.tool_name, m.timestamp, m.token_count, m.finish_reason
|
|
||||||
FROM messages_fts fts
|
FROM messages_fts fts
|
||||||
JOIN messages m ON m.id = fts.rowid
|
JOIN messages m ON m.id = fts.rowid
|
||||||
WHERE messages_fts MATCH ?
|
WHERE messages_fts MATCH ?
|
||||||
@@ -82,7 +148,7 @@ actor HermesDataService {
|
|||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
sqlite3_bind_text(stmt, 1, query, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
|
sqlite3_bind_text(stmt, 1, sanitized, -1, sqliteTransient)
|
||||||
sqlite3_bind_int(stmt, 2, Int32(limit))
|
sqlite3_bind_int(stmt, 2, Int32(limit))
|
||||||
|
|
||||||
var messages: [HermesMessage] = []
|
var messages: [HermesMessage] = []
|
||||||
@@ -92,11 +158,21 @@ actor HermesDataService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchRecentToolCalls(limit: Int = 50) -> [HermesMessage] {
|
func fetchToolResult(callId: String) -> String? {
|
||||||
|
guard let db else { return nil }
|
||||||
|
let sql = "SELECT content FROM messages WHERE role = 'tool' AND tool_call_id = ? LIMIT 1"
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_text(stmt, 1, callId, -1, sqliteTransient)
|
||||||
|
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
|
||||||
|
return columnText(stmt!, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT id, session_id, role, content, tool_call_id, tool_calls,
|
SELECT \(messageColumns)
|
||||||
tool_name, timestamp, token_count, finish_reason
|
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
|
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
@@ -114,10 +190,10 @@ actor HermesDataService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSessionPreviews(limit: Int = 10) -> [String: String] {
|
func fetchSessionPreviews(limit: Int = QueryDefaults.sessionPreviewLimit) -> [String: String] {
|
||||||
guard let db else { return [:] }
|
guard let db else { return [:] }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT m.session_id, substr(m.content, 1, 100)
|
SELECT m.session_id, substr(m.content, 1, \(QueryDefaults.previewContentLength))
|
||||||
FROM messages m
|
FROM messages m
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT session_id, MIN(id) as min_id
|
SELECT session_id, MIN(id) as min_id
|
||||||
@@ -142,6 +218,83 @@ 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
|
||||||
|
|
||||||
struct SessionStats: Sendable {
|
struct SessionStats: Sendable {
|
||||||
let totalSessions: Int
|
let totalSessions: Int
|
||||||
let totalMessages: Int
|
let totalMessages: Int
|
||||||
@@ -149,40 +302,134 @@ actor HermesDataService {
|
|||||||
let totalInputTokens: Int
|
let totalInputTokens: Int
|
||||||
let totalOutputTokens: Int
|
let totalOutputTokens: Int
|
||||||
let totalCostUSD: Double
|
let totalCostUSD: Double
|
||||||
|
let totalReasoningTokens: Int
|
||||||
|
let totalActualCostUSD: Double
|
||||||
|
|
||||||
|
static let empty = SessionStats(
|
||||||
|
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||||
|
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0,
|
||||||
|
totalReasoningTokens: 0, totalActualCostUSD: 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchStats() -> SessionStats {
|
func fetchStats() -> SessionStats {
|
||||||
guard let db else {
|
guard let db else { return .empty }
|
||||||
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
let sql: String
|
||||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
if hasV07Schema {
|
||||||
|
sql = """
|
||||||
|
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
||||||
|
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||||
|
COALESCE(SUM(estimated_cost_usd),0),
|
||||||
|
COALESCE(SUM(reasoning_tokens),0), COALESCE(SUM(actual_cost_usd),0)
|
||||||
|
FROM sessions
|
||||||
|
"""
|
||||||
|
} else {
|
||||||
|
sql = """
|
||||||
|
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
||||||
|
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||||
|
COALESCE(SUM(estimated_cost_usd),0)
|
||||||
|
FROM sessions
|
||||||
|
"""
|
||||||
}
|
}
|
||||||
let sql = """
|
|
||||||
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
|
||||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
|
||||||
COALESCE(SUM(estimated_cost_usd),0)
|
|
||||||
FROM sessions
|
|
||||||
"""
|
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
|
||||||
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
|
||||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
|
||||||
}
|
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
|
||||||
guard sqlite3_step(stmt) == SQLITE_ROW else {
|
|
||||||
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
|
||||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
|
||||||
}
|
|
||||||
return SessionStats(
|
return SessionStats(
|
||||||
totalSessions: Int(sqlite3_column_int(stmt, 0)),
|
totalSessions: Int(sqlite3_column_int(stmt, 0)),
|
||||||
totalMessages: Int(sqlite3_column_int(stmt, 1)),
|
totalMessages: Int(sqlite3_column_int(stmt, 1)),
|
||||||
totalToolCalls: Int(sqlite3_column_int(stmt, 2)),
|
totalToolCalls: Int(sqlite3_column_int(stmt, 2)),
|
||||||
totalInputTokens: Int(sqlite3_column_int(stmt, 3)),
|
totalInputTokens: Int(sqlite3_column_int(stmt, 3)),
|
||||||
totalOutputTokens: Int(sqlite3_column_int(stmt, 4)),
|
totalOutputTokens: Int(sqlite3_column_int(stmt, 4)),
|
||||||
totalCostUSD: sqlite3_column_double(stmt, 5)
|
totalCostUSD: sqlite3_column_double(stmt, 5),
|
||||||
|
totalReasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 6)) : 0,
|
||||||
|
totalActualCostUSD: hasV07Schema ? sqlite3_column_double(stmt, 7) : 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Insights Queries
|
||||||
|
|
||||||
|
func fetchUserMessageCount(since: Date) -> Int {
|
||||||
|
guard let db else { return 0 }
|
||||||
|
let sql = """
|
||||||
|
SELECT COUNT(*) FROM messages m
|
||||||
|
JOIN sessions s ON m.session_id = s.id
|
||||||
|
WHERE m.role = 'user' AND s.parent_session_id IS NULL AND s.started_at >= ?
|
||||||
|
"""
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||||
|
guard sqlite3_step(stmt) == SQLITE_ROW else { return 0 }
|
||||||
|
return Int(sqlite3_column_int(stmt, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchToolUsage(since: Date) -> [(name: String, count: Int)] {
|
||||||
|
guard let db else { return [] }
|
||||||
|
let sql = """
|
||||||
|
SELECT m.tool_name, COUNT(*) as cnt
|
||||||
|
FROM messages m
|
||||||
|
JOIN sessions s ON m.session_id = s.id
|
||||||
|
WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.parent_session_id IS NULL AND s.started_at >= ?
|
||||||
|
GROUP BY m.tool_name
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
"""
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||||
|
|
||||||
|
var results: [(name: String, count: Int)] = []
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
let name = columnText(stmt!, 0)
|
||||||
|
let count = Int(sqlite3_column_int(stmt!, 1))
|
||||||
|
results.append((name: name, count: count))
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSessionStartHours(since: Date) -> [Int: Int] {
|
||||||
|
guard let db else { return [:] }
|
||||||
|
let sql = """
|
||||||
|
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
|
||||||
|
"""
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||||
|
|
||||||
|
var hours: [Int: Int] = [:]
|
||||||
|
let calendar = Calendar.current
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
let ts = sqlite3_column_double(stmt!, 0)
|
||||||
|
let date = Date(timeIntervalSince1970: ts)
|
||||||
|
let hour = calendar.component(.hour, from: date)
|
||||||
|
hours[hour, default: 0] += 1
|
||||||
|
}
|
||||||
|
return hours
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] {
|
||||||
|
guard let db else { return [:] }
|
||||||
|
let sql = """
|
||||||
|
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
|
||||||
|
"""
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||||
|
|
||||||
|
var days: [Int: Int] = [:]
|
||||||
|
let calendar = Calendar.current
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
let ts = sqlite3_column_double(stmt!, 0)
|
||||||
|
let date = Date(timeIntervalSince1970: ts)
|
||||||
|
let weekday = (calendar.component(.weekday, from: date) + 5) % 7 // Mon=0
|
||||||
|
days[weekday, default: 0] += 1
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
func stateDBModificationDate() -> Date? {
|
func stateDBModificationDate() -> Date? {
|
||||||
let walPath = HermesPaths.stateDB + "-wal"
|
let walPath = HermesPaths.stateDB + "-wal"
|
||||||
let dbPath = HermesPaths.stateDB
|
let dbPath = HermesPaths.stateDB
|
||||||
@@ -214,7 +461,11 @@ actor HermesDataService {
|
|||||||
outputTokens: Int(sqlite3_column_int(stmt, 12)),
|
outputTokens: Int(sqlite3_column_int(stmt, 12)),
|
||||||
cacheReadTokens: Int(sqlite3_column_int(stmt, 13)),
|
cacheReadTokens: Int(sqlite3_column_int(stmt, 13)),
|
||||||
cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)),
|
cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)),
|
||||||
estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil
|
estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil,
|
||||||
|
reasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 16)) : 0,
|
||||||
|
actualCostUSD: hasV07Schema && sqlite3_column_type(stmt, 17) != SQLITE_NULL ? sqlite3_column_double(stmt, 17) : nil,
|
||||||
|
costStatus: hasV07Schema ? columnOptionalText(stmt, 18) : nil,
|
||||||
|
billingProvider: hasV07Schema ? columnOptionalText(stmt, 19) : nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,14 +482,20 @@ actor HermesDataService {
|
|||||||
toolName: columnOptionalText(stmt, 6),
|
toolName: columnOptionalText(stmt, 6),
|
||||||
timestamp: columnDate(stmt, 7),
|
timestamp: columnDate(stmt, 7),
|
||||||
tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil,
|
tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil,
|
||||||
finishReason: columnOptionalText(stmt, 9)
|
finishReason: columnOptionalText(stmt, 9),
|
||||||
|
reasoning: hasV07Schema ? columnOptionalText(stmt, 10) : nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func parseToolCalls(_ json: String?) -> [HermesToolCall] {
|
private func parseToolCalls(_ json: String?) -> [HermesToolCall] {
|
||||||
guard let json, !json.isEmpty,
|
guard let json, !json.isEmpty,
|
||||||
let data = json.data(using: .utf8) else { return [] }
|
let data = json.data(using: .utf8) else { return [] }
|
||||||
return (try? JSONDecoder().decode([HermesToolCall].self, from: data)) ?? []
|
do {
|
||||||
|
return try JSONDecoder().decode([HermesToolCall].self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode tool calls: \(error.localizedDescription)")
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String {
|
private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String {
|
||||||
@@ -259,4 +516,17 @@ actor HermesDataService {
|
|||||||
let value = sqlite3_column_double(stmt, col)
|
let value = sqlite3_column_double(stmt, col)
|
||||||
return Date(timeIntervalSince1970: value)
|
return Date(timeIntervalSince1970: value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wraps each whitespace-delimited token in double quotes to prevent FTS5 parse errors
|
||||||
|
/// on terms containing dots, hyphens, or FTS5 operators (e.g., "v0.7.0", "config.yaml").
|
||||||
|
private func sanitizeFTSQuery(_ raw: String) -> String {
|
||||||
|
raw.split(separator: " ")
|
||||||
|
.map { token in
|
||||||
|
let t = String(token)
|
||||||
|
let stripped = t.replacingOccurrences(of: "\"", with: "")
|
||||||
|
return stripped.isEmpty ? nil : "\"\(stripped)\""
|
||||||
|
}
|
||||||
|
.compactMap { $0 }
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,37 @@ struct HermesFileService: Sendable {
|
|||||||
private func parseConfig(_ yaml: String) -> HermesConfig {
|
private func parseConfig(_ yaml: String) -> HermesConfig {
|
||||||
var values: [String: String] = [:]
|
var values: [String: String] = [:]
|
||||||
var currentSection = ""
|
var currentSection = ""
|
||||||
|
var dockerEnv: [String: String] = [:]
|
||||||
|
var commandAllowlist: [String] = []
|
||||||
|
var inDockerEnv = false
|
||||||
|
var inAllowlist = false
|
||||||
|
|
||||||
for line in yaml.components(separatedBy: "\n") {
|
for line in yaml.components(separatedBy: "\n") {
|
||||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||||
|
|
||||||
let indent = line.prefix(while: { $0 == " " }).count
|
let indent = line.prefix(while: { $0 == " " }).count
|
||||||
|
|
||||||
|
// Detect end of nested blocks when indent returns to section level
|
||||||
|
if indent <= 2 && (inDockerEnv || inAllowlist) {
|
||||||
|
inDockerEnv = false
|
||||||
|
inAllowlist = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect docker_env nested key-value pairs
|
||||||
|
if inDockerEnv, indent >= 4, let colonIdx = trimmed.firstIndex(of: ":") {
|
||||||
|
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||||
|
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||||
|
dockerEnv[key] = val
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect allowlist items
|
||||||
|
if inAllowlist, indent >= 4, trimmed.hasPrefix("- ") {
|
||||||
|
commandAllowlist.append(String(trimmed.dropFirst(2)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if indent == 0 && trimmed.hasSuffix(":") {
|
if indent == 0 && trimmed.hasSuffix(":") {
|
||||||
currentSection = String(trimmed.dropLast())
|
currentSection = String(trimmed.dropLast())
|
||||||
continue
|
continue
|
||||||
@@ -26,6 +51,16 @@ struct HermesFileService: Sendable {
|
|||||||
if let colonIdx = trimmed.firstIndex(of: ":") {
|
if let colonIdx = trimmed.firstIndex(of: ":") {
|
||||||
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||||
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
if key == "docker_env" && val.isEmpty {
|
||||||
|
inDockerEnv = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if key == "permanent_allowlist" && val.isEmpty {
|
||||||
|
inAllowlist = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
values[currentSection + "." + key] = val
|
values[currentSection + "." + key] = val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,7 +77,17 @@ struct HermesFileService: Sendable {
|
|||||||
nudgeInterval: Int(values["memory.nudge_interval"] ?? "") ?? 0,
|
nudgeInterval: Int(values["memory.nudge_interval"] ?? "") ?? 0,
|
||||||
streaming: values["display.streaming"] != "false",
|
streaming: values["display.streaming"] != "false",
|
||||||
showReasoning: values["display.show_reasoning"] == "true",
|
showReasoning: values["display.show_reasoning"] == "true",
|
||||||
verbose: values["agent.verbose"] == "true"
|
verbose: values["agent.verbose"] == "true",
|
||||||
|
autoTTS: values["voice.auto_tts"] != "false",
|
||||||
|
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold,
|
||||||
|
reasoningEffort: values["agent.reasoning_effort"] ?? "medium",
|
||||||
|
showCost: values["display.show_cost"] == "true",
|
||||||
|
approvalMode: values["approvals.mode"] ?? "manual",
|
||||||
|
browserBackend: values["browser.backend"] ?? "",
|
||||||
|
memoryProvider: values["memory.provider"] ?? "",
|
||||||
|
dockerEnv: dockerEnv,
|
||||||
|
commandAllowlist: commandAllowlist,
|
||||||
|
memoryProfile: values["memory.profile"] ?? ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,33 +95,64 @@ struct HermesFileService: Sendable {
|
|||||||
|
|
||||||
func loadGatewayState() -> GatewayState? {
|
func loadGatewayState() -> GatewayState? {
|
||||||
guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil }
|
guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil }
|
||||||
return try? JSONDecoder().decode(GatewayState.self, from: data)
|
do {
|
||||||
|
return try JSONDecoder().decode(GatewayState.self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode gateway state: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Memory
|
// MARK: - Memory
|
||||||
|
|
||||||
func loadMemory() -> String {
|
func loadMemoryProfiles() -> [String] {
|
||||||
readFile(HermesPaths.memoryMD) ?? ""
|
let fm = FileManager.default
|
||||||
|
guard let entries = try? fm.contentsOfDirectory(atPath: HermesPaths.memoriesDir) else { return [] }
|
||||||
|
return entries.filter { name in
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
let path = HermesPaths.memoriesDir + "/" + name
|
||||||
|
return fm.fileExists(atPath: path, isDirectory: &isDir) && isDir.boolValue
|
||||||
|
}.sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadUserProfile() -> String {
|
func loadMemory(profile: String = "") -> String {
|
||||||
readFile(HermesPaths.userMD) ?? ""
|
let path = memoryPath(profile: profile, file: "MEMORY.md")
|
||||||
|
return readFile(path) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveMemory(_ content: String) {
|
func loadUserProfile(profile: String = "") -> String {
|
||||||
writeFile(HermesPaths.memoryMD, content: content)
|
let path = memoryPath(profile: profile, file: "USER.md")
|
||||||
|
return readFile(path) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveUserProfile(_ content: String) {
|
func saveMemory(_ content: String, profile: String = "") {
|
||||||
writeFile(HermesPaths.userMD, content: content)
|
let path = memoryPath(profile: profile, file: "MEMORY.md")
|
||||||
|
writeFile(path, content: content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveUserProfile(_ content: String, profile: String = "") {
|
||||||
|
let path = memoryPath(profile: profile, file: "USER.md")
|
||||||
|
writeFile(path, content: content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func memoryPath(profile: String, file: String) -> String {
|
||||||
|
if profile.isEmpty {
|
||||||
|
return HermesPaths.memoriesDir + "/" + file
|
||||||
|
}
|
||||||
|
return HermesPaths.memoriesDir + "/" + profile + "/" + file
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cron
|
// MARK: - Cron
|
||||||
|
|
||||||
func loadCronJobs() -> [HermesCronJob] {
|
func loadCronJobs() -> [HermesCronJob] {
|
||||||
guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] }
|
guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] }
|
||||||
let file = try? JSONDecoder().decode(CronJobsFile.self, from: data)
|
do {
|
||||||
return file?.jobs ?? []
|
let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
|
||||||
|
return file.jobs
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)")
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadCronOutput(jobId: String) -> String? {
|
func loadCronOutput(jobId: String) -> String? {
|
||||||
@@ -106,12 +182,14 @@ struct HermesFileService: Sendable {
|
|||||||
var isSkillDir: ObjCBool = false
|
var isSkillDir: ObjCBool = false
|
||||||
guard fm.fileExists(atPath: skillPath, isDirectory: &isSkillDir), isSkillDir.boolValue else { return nil }
|
guard fm.fileExists(atPath: skillPath, isDirectory: &isSkillDir), isSkillDir.boolValue else { return nil }
|
||||||
let files = (try? fm.contentsOfDirectory(atPath: skillPath)) ?? []
|
let files = (try? fm.contentsOfDirectory(atPath: skillPath)) ?? []
|
||||||
|
let requiredConfig = parseSkillRequiredConfig(skillPath + "/skill.yaml")
|
||||||
return HermesSkill(
|
return HermesSkill(
|
||||||
id: categoryName + "/" + skillName,
|
id: categoryName + "/" + skillName,
|
||||||
name: skillName,
|
name: skillName,
|
||||||
category: categoryName,
|
category: categoryName,
|
||||||
path: skillPath,
|
path: skillPath,
|
||||||
files: files.sorted()
|
files: files.sorted(),
|
||||||
|
requiredConfig: requiredConfig
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,12 +199,54 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSkillContent(path: String) -> String {
|
func loadSkillContent(path: String) -> String {
|
||||||
readFile(path) ?? ""
|
guard isValidSkillPath(path) else { return "" }
|
||||||
|
return readFile(path) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveSkillContent(path: String, content: String) {
|
||||||
|
guard isValidSkillPath(path) else { return }
|
||||||
|
writeFile(path, content: content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isValidSkillPath(_ path: String) -> Bool {
|
||||||
|
guard !path.contains(".."), path.hasPrefix(HermesPaths.skillsDir) else {
|
||||||
|
print("[Scarf] Rejected skill path outside skills directory: \(path)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseSkillRequiredConfig(_ path: String) -> [String] {
|
||||||
|
guard let content = readFile(path) else { return [] }
|
||||||
|
var result: [String] = []
|
||||||
|
var inRequiredConfig = false
|
||||||
|
for line in content.components(separatedBy: "\n") {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||||
|
let indent = line.prefix(while: { $0 == " " }).count
|
||||||
|
if trimmed == "required_config:" || trimmed.hasPrefix("required_config:") {
|
||||||
|
inRequiredConfig = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inRequiredConfig {
|
||||||
|
if indent < 2 && !trimmed.isEmpty {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if trimmed.hasPrefix("- ") {
|
||||||
|
result.append(String(trimmed.dropFirst(2)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hermes Process
|
// 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")
|
||||||
@@ -137,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? {
|
||||||
@@ -154,6 +283,10 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func writeFile(_ path: String, content: String) {
|
private func writeFile(_ path: String, content: String) {
|
||||||
try? content.write(toFile: path, atomically: true, encoding: .utf8)
|
do {
|
||||||
|
try content.write(toFile: path, atomically: true, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to write \(path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import Foundation
|
|||||||
@Observable
|
@Observable
|
||||||
final class HermesFileWatcher {
|
final class HermesFileWatcher {
|
||||||
private(set) var lastChangeDate = Date()
|
private(set) var lastChangeDate = Date()
|
||||||
private var sources: [DispatchSourceFileSystemObject] = []
|
private var coreSources: [DispatchSourceFileSystemObject] = []
|
||||||
|
private var projectSources: [DispatchSourceFileSystemObject] = []
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
|
|
||||||
func startWatching() {
|
func startWatching() {
|
||||||
@@ -15,12 +16,16 @@ final class HermesFileWatcher {
|
|||||||
HermesPaths.userMD,
|
HermesPaths.userMD,
|
||||||
HermesPaths.cronJobsJSON,
|
HermesPaths.cronJobsJSON,
|
||||||
HermesPaths.gatewayStateJSON,
|
HermesPaths.gatewayStateJSON,
|
||||||
|
HermesPaths.agentLog,
|
||||||
HermesPaths.errorsLog,
|
HermesPaths.errorsLog,
|
||||||
HermesPaths.gatewayLog
|
HermesPaths.gatewayLog,
|
||||||
|
HermesPaths.projectsRegistry
|
||||||
]
|
]
|
||||||
|
|
||||||
for path in paths {
|
for path in paths {
|
||||||
watchFile(path)
|
if let source = makeSource(for: path) {
|
||||||
|
coreSources.append(source)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
||||||
@@ -29,17 +34,30 @@ final class HermesFileWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopWatching() {
|
func stopWatching() {
|
||||||
for source in sources {
|
for source in coreSources + projectSources {
|
||||||
source.cancel()
|
source.cancel()
|
||||||
}
|
}
|
||||||
sources.removeAll()
|
coreSources.removeAll()
|
||||||
|
projectSources.removeAll()
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
timer = nil
|
timer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func watchFile(_ path: String) {
|
func updateProjectWatches(_ dashboardPaths: [String]) {
|
||||||
|
for source in projectSources {
|
||||||
|
source.cancel()
|
||||||
|
}
|
||||||
|
projectSources.removeAll()
|
||||||
|
for path in dashboardPaths {
|
||||||
|
if let source = makeSource(for: path) {
|
||||||
|
projectSources.append(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSource(for path: String) -> DispatchSourceFileSystemObject? {
|
||||||
let fd = Darwin.open(path, O_EVTONLY)
|
let fd = Darwin.open(path, O_EVTONLY)
|
||||||
guard fd >= 0 else { return }
|
guard fd >= 0 else { return nil }
|
||||||
|
|
||||||
let source = DispatchSource.makeFileSystemObjectSource(
|
let source = DispatchSource.makeFileSystemObjectSource(
|
||||||
fileDescriptor: fd,
|
fileDescriptor: fd,
|
||||||
@@ -53,7 +71,7 @@ final class HermesFileWatcher {
|
|||||||
Darwin.close(fd)
|
Darwin.close(fd)
|
||||||
}
|
}
|
||||||
source.resume()
|
source.resume()
|
||||||
sources.append(source)
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
|||||||
@@ -39,12 +39,16 @@ actor HermesLogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func closeLog() {
|
func closeLog() {
|
||||||
try? fileHandle?.close()
|
do {
|
||||||
|
try fileHandle?.close()
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to close log handle: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
fileHandle = nil
|
fileHandle = nil
|
||||||
currentPath = nil
|
currentPath = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readLastLines(count: Int = 200) -> [LogEntry] {
|
func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
|
||||||
guard let path = currentPath,
|
guard let path = currentPath,
|
||||||
let data = FileManager.default.contents(atPath: path) else { return [] }
|
let data = FileManager.default.contents(atPath: path) else { return [] }
|
||||||
let content = String(data: data, encoding: .utf8) ?? ""
|
let content = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ProjectDashboardService: Sendable {
|
||||||
|
|
||||||
|
// MARK: - Registry
|
||||||
|
|
||||||
|
func loadRegistry() -> ProjectRegistry {
|
||||||
|
guard let data = FileManager.default.contents(atPath: HermesPaths.projectsRegistry) else {
|
||||||
|
return ProjectRegistry(projects: [])
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode project registry: \(error.localizedDescription)")
|
||||||
|
return ProjectRegistry(projects: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveRegistry(_ registry: ProjectRegistry) {
|
||||||
|
let dir = HermesPaths.scarfDir
|
||||||
|
if !FileManager.default.fileExists(atPath: dir) {
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let data = try? JSONEncoder().encode(registry) else { return }
|
||||||
|
// Pretty-print for readability (agents may read this file)
|
||||||
|
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
||||||
|
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
||||||
|
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: formatted)
|
||||||
|
} else {
|
||||||
|
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dashboard
|
||||||
|
|
||||||
|
func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? {
|
||||||
|
guard let data = FileManager.default.contents(atPath: project.dashboardPath) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(ProjectDashboard.self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode dashboard for \(project.name): \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dashboardExists(for project: ProjectEntry) -> Bool {
|
||||||
|
FileManager.default.fileExists(atPath: project.dashboardPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dashboardModificationDate(for project: ProjectEntry) -> Date? {
|
||||||
|
guard let attrs = try? FileManager.default.attributesOfItem(atPath: project.dashboardPath) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return attrs[.modificationDate] as? Date
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,21 @@ final class ActivityViewModel {
|
|||||||
|
|
||||||
var toolMessages: [HermesMessage] = []
|
var toolMessages: [HermesMessage] = []
|
||||||
var filterKind: ToolKind?
|
var filterKind: ToolKind?
|
||||||
|
var filterSessionId: String?
|
||||||
var selectedEntry: ActivityEntry?
|
var selectedEntry: ActivityEntry?
|
||||||
|
var toolResult: String?
|
||||||
|
var sessionPreviews: [String: String] = [:]
|
||||||
var isLoading = true
|
var isLoading = true
|
||||||
|
|
||||||
|
var availableSessions: [(id: String, label: String)] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
return toolMessages.compactMap { message in
|
||||||
|
guard seen.insert(message.sessionId).inserted else { return nil }
|
||||||
|
let label = sessionPreviews[message.sessionId] ?? message.sessionId
|
||||||
|
return (id: message.sessionId, label: label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var filteredActivity: [ActivityEntry] {
|
var filteredActivity: [ActivityEntry] {
|
||||||
let entries = toolMessages.flatMap { message in
|
let entries = toolMessages.flatMap { message in
|
||||||
message.toolCalls.map { call in
|
message.toolCalls.map { call in
|
||||||
@@ -24,10 +36,11 @@ final class ActivityViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let filterKind {
|
return entries.filter { entry in
|
||||||
return entries.filter { $0.kind == filterKind }
|
let kindOk = filterKind == nil || entry.kind == filterKind
|
||||||
|
let sessionOk = filterSessionId == nil || entry.sessionId == filterSessionId
|
||||||
|
return kindOk && sessionOk
|
||||||
}
|
}
|
||||||
return entries
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
@@ -38,9 +51,19 @@ final class ActivityViewModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
toolMessages = await dataService.fetchRecentToolCalls(limit: 200)
|
toolMessages = await dataService.fetchRecentToolCalls(limit: 200)
|
||||||
|
sessionPreviews = await dataService.fetchSessionPreviews(limit: 200)
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,31 +21,44 @@ struct ActivityView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var filterBar: some View {
|
private var filterBar: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
HStack(spacing: 12) {
|
||||||
HStack(spacing: 8) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
FilterChip(label: "All", isSelected: viewModel.filterKind == nil) {
|
HStack(spacing: 8) {
|
||||||
viewModel.filterKind = nil
|
FilterChip(label: "All", isSelected: viewModel.filterKind == nil) {
|
||||||
}
|
viewModel.filterKind = nil
|
||||||
ForEach(ToolKind.allCases, id: \.rawValue) { kind in
|
}
|
||||||
FilterChip(label: kind.rawValue.capitalized, isSelected: viewModel.filterKind == kind) {
|
ForEach(ToolKind.allCases, id: \.rawValue) { kind in
|
||||||
viewModel.filterKind = kind
|
FilterChip(label: kind.rawValue.capitalized, isSelected: viewModel.filterKind == kind) {
|
||||||
|
viewModel.filterKind = kind
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
Divider()
|
||||||
.padding(.vertical, 8)
|
.frame(height: 16)
|
||||||
|
Picker(selection: $viewModel.filterSessionId) {
|
||||||
|
Text("All Sessions").tag(String?.none)
|
||||||
|
Divider()
|
||||||
|
ForEach(viewModel.availableSessions, id: \.id) { session in
|
||||||
|
Text(session.label)
|
||||||
|
.lineLimit(1)
|
||||||
|
.tag(String?.some(session.id))
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 250)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var activityList: some View {
|
private var activityList: some 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
|
||||||
@@ -130,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,33 +1,441 @@
|
|||||||
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()
|
||||||
|
|
||||||
var recentSessions: [HermesSession] = []
|
var recentSessions: [HermesSession] = []
|
||||||
var sessionPreviews: [String: String] = [:]
|
var sessionPreviews: [String: String] = [:]
|
||||||
var terminalView: LocalProcessTerminalView?
|
var terminalView: LocalProcessTerminalView?
|
||||||
var hasActiveProcess = false
|
var hasActiveProcess = false
|
||||||
|
var voiceEnabled = false
|
||||||
|
var ttsEnabled = 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() {
|
||||||
launchTerminal(arguments: ["chat"])
|
voiceEnabled = false
|
||||||
|
ttsEnabled = false
|
||||||
|
isRecording = false
|
||||||
|
richChatViewModel.reset()
|
||||||
|
|
||||||
|
if displayMode == .richChat {
|
||||||
|
startACPSession(resume: nil)
|
||||||
|
} else {
|
||||||
|
launchTerminal(arguments: ["chat"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resumeSession(_ sessionId: String) {
|
func resumeSession(_ sessionId: String) {
|
||||||
launchTerminal(arguments: ["chat", "--resume", sessionId])
|
voiceEnabled = false
|
||||||
|
ttsEnabled = false
|
||||||
|
isRecording = false
|
||||||
|
richChatViewModel.reset()
|
||||||
|
|
||||||
|
if displayMode == .richChat {
|
||||||
|
startACPSession(resume: sessionId)
|
||||||
|
} else {
|
||||||
|
richChatViewModel.setSessionId(sessionId)
|
||||||
|
launchTerminal(arguments: ["chat", "--resume", sessionId])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func continueLastSession() {
|
func continueLastSession() {
|
||||||
launchTerminal(arguments: ["chat", "--continue"])
|
voiceEnabled = false
|
||||||
|
ttsEnabled = false
|
||||||
|
isRecording = false
|
||||||
|
richChatViewModel.reset()
|
||||||
|
|
||||||
|
if displayMode == .richChat {
|
||||||
|
// Find most recent session and resume via ACP
|
||||||
|
Task { @MainActor in
|
||||||
|
let opened = await dataService.open()
|
||||||
|
guard opened else { return }
|
||||||
|
let sessionId = await dataService.fetchMostRecentlyActiveSessionId()
|
||||||
|
await dataService.close()
|
||||||
|
if let sessionId {
|
||||||
|
startACPSession(resume: sessionId)
|
||||||
|
} else {
|
||||||
|
startACPSession(resume: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
launchTerminal(arguments: ["chat", "--continue"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Send Message
|
||||||
|
|
||||||
|
func sendText(_ text: String) {
|
||||||
|
if displayMode == .richChat {
|
||||||
|
if let client = acpClient {
|
||||||
|
sendViaACP(client: client, text: text)
|
||||||
|
} else {
|
||||||
|
// Auto-start ACP and send the queued message
|
||||||
|
autoStartACPAndSend(text: text)
|
||||||
|
}
|
||||||
|
} else if let tv = terminalView {
|
||||||
|
sendToTerminal(tv, text: text + "\r")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start ACP for the current or most recent session, then send the queued prompt.
|
||||||
|
private func autoStartACPAndSend(text: String) {
|
||||||
|
// Show the user message immediately
|
||||||
|
richChatViewModel.addUserMessage(text: text)
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
// Find a session to resume: prefer current sessionId, then most recent
|
||||||
|
var sessionToResume = richChatViewModel.sessionId
|
||||||
|
if sessionToResume == nil {
|
||||||
|
let opened = await dataService.open()
|
||||||
|
if opened {
|
||||||
|
sessionToResume = await dataService.fetchMostRecentlyActiveSessionId()
|
||||||
|
await dataService.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = ACPClient()
|
||||||
|
self.acpClient = client
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await client.start()
|
||||||
|
acpStatus = await client.statusMessage
|
||||||
|
startACPEventLoop(client: client)
|
||||||
|
startHealthMonitor(client: client)
|
||||||
|
|
||||||
|
let cwd = NSHomeDirectory()
|
||||||
|
|
||||||
|
hasActiveProcess = true
|
||||||
|
|
||||||
|
let resolvedSessionId: String
|
||||||
|
if let existing = sessionToResume {
|
||||||
|
acpStatus = "Loading session..."
|
||||||
|
do {
|
||||||
|
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: existing)
|
||||||
|
} catch {
|
||||||
|
logger.info("Session \(existing) not found in ACP, creating new session")
|
||||||
|
acpStatus = "Creating new session..."
|
||||||
|
resolvedSessionId = try await client.newSession(cwd: cwd)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
acpStatus = "Creating session..."
|
||||||
|
resolvedSessionId = try await client.newSession(cwd: cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
richChatViewModel.setSessionId(resolvedSessionId)
|
||||||
|
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
|
||||||
|
|
||||||
|
// Now send the queued prompt
|
||||||
|
sendViaACP(client: client, text: text)
|
||||||
|
} catch {
|
||||||
|
let msg = error.localizedDescription
|
||||||
|
logger.error("Auto-start ACP failed: \(msg)")
|
||||||
|
acpStatus = "Failed"
|
||||||
|
acpError = msg
|
||||||
|
hasActiveProcess = false
|
||||||
|
acpClient = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendViaACP(client: ACPClient, text: String) {
|
||||||
|
guard let sessionId = richChatViewModel.sessionId else {
|
||||||
|
acpError = "No session ID — cannot send"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't duplicate user message if autoStartACPAndSend already added it
|
||||||
|
if richChatViewModel.messages.last?.isUser != true
|
||||||
|
|| richChatViewModel.messages.last?.content != text {
|
||||||
|
richChatViewModel.addUserMessage(text: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
acpStatus = "Agent working..."
|
||||||
|
acpPromptTask = Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let result = try await client.sendPrompt(sessionId: sessionId, text: text)
|
||||||
|
acpStatus = "Ready"
|
||||||
|
richChatViewModel.handleACPEvent(
|
||||||
|
.promptComplete(sessionId: sessionId, response: result)
|
||||||
|
)
|
||||||
|
// Re-fetch session from DB to pick up cost/token data Hermes may have written
|
||||||
|
await richChatViewModel.refreshSessionFromDB()
|
||||||
|
} catch is CancellationError {
|
||||||
|
acpStatus = "Cancelled"
|
||||||
|
} catch {
|
||||||
|
let msg = error.localizedDescription
|
||||||
|
logger.error("ACP prompt failed: \(msg)")
|
||||||
|
acpStatus = "Error"
|
||||||
|
acpError = msg
|
||||||
|
richChatViewModel.handleACPEvent(
|
||||||
|
.promptComplete(sessionId: sessionId, response: ACPPromptResult(
|
||||||
|
stopReason: "error",
|
||||||
|
inputTokens: 0, outputTokens: 0,
|
||||||
|
thoughtTokens: 0, cachedReadTokens: 0
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ACP Session Management
|
||||||
|
|
||||||
|
private func startACPSession(resume sessionId: String?) {
|
||||||
|
stopACP()
|
||||||
|
acpError = nil
|
||||||
|
acpStatus = "Starting..."
|
||||||
|
|
||||||
|
let client = ACPClient()
|
||||||
|
self.acpClient = client
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
// Start ACP process and event loop FIRST
|
||||||
|
try await client.start()
|
||||||
|
acpStatus = await client.statusMessage
|
||||||
|
startACPEventLoop(client: client)
|
||||||
|
startHealthMonitor(client: client)
|
||||||
|
|
||||||
|
let cwd = NSHomeDirectory()
|
||||||
|
|
||||||
|
// Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true
|
||||||
|
// and doesn't wipe messages with a DB refresh
|
||||||
|
hasActiveProcess = true
|
||||||
|
|
||||||
|
let resolvedSessionId: String
|
||||||
|
if let sessionId {
|
||||||
|
acpStatus = "Loading session..."
|
||||||
|
do {
|
||||||
|
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
|
||||||
|
} catch {
|
||||||
|
logger.info("Session \(sessionId) not found in ACP, creating new session with history")
|
||||||
|
acpStatus = "Creating new session..."
|
||||||
|
resolvedSessionId = try await client.newSession(cwd: cwd)
|
||||||
|
}
|
||||||
|
// Load messages from both origin CLI session and ACP session
|
||||||
|
await richChatViewModel.loadSessionHistory(
|
||||||
|
sessionId: sessionId,
|
||||||
|
acpSessionId: resolvedSessionId
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
acpStatus = "Creating session..."
|
||||||
|
resolvedSessionId = try await client.newSession(cwd: cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
richChatViewModel.setSessionId(resolvedSessionId)
|
||||||
|
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
|
||||||
|
|
||||||
|
// Refresh session list so the new ACP session appears in the Resume menu
|
||||||
|
await loadRecentSessions()
|
||||||
|
|
||||||
|
logger.info("ACP session ready: \(resolvedSessionId)")
|
||||||
|
} catch {
|
||||||
|
let msg = error.localizedDescription
|
||||||
|
logger.error("Failed to start ACP session: \(msg)")
|
||||||
|
acpStatus = "Failed"
|
||||||
|
acpError = msg
|
||||||
|
hasActiveProcess = false
|
||||||
|
acpClient = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startACPEventLoop(client: ACPClient) {
|
||||||
|
acpEventTask = Task { @MainActor [weak self] in
|
||||||
|
let eventStream = await client.events
|
||||||
|
for await event in eventStream {
|
||||||
|
guard !Task.isCancelled else { break }
|
||||||
|
self?.richChatViewModel.handleACPEvent(event)
|
||||||
|
self?.acpStatus = await client.statusMessage
|
||||||
|
}
|
||||||
|
// Stream ended — if we weren't cancelled, the connection died
|
||||||
|
if !Task.isCancelled {
|
||||||
|
self?.handleConnectionDied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startHealthMonitor(client: ACPClient) {
|
||||||
|
healthMonitorTask = Task { @MainActor [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||||
|
guard !Task.isCancelled else { break }
|
||||||
|
let healthy = await client.isHealthy
|
||||||
|
if !healthy {
|
||||||
|
self?.handleConnectionDied()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleConnectionDied() {
|
||||||
|
guard acpClient != nil, !isHandlingDisconnect else { return }
|
||||||
|
isHandlingDisconnect = true
|
||||||
|
logger.warning("ACP connection died")
|
||||||
|
|
||||||
|
// Finalize any in-progress streaming message before reconnection
|
||||||
|
richChatViewModel.finalizeOnDisconnect()
|
||||||
|
|
||||||
|
// Save session ID for reconnection before cleaning up
|
||||||
|
let savedSessionId = richChatViewModel.sessionId
|
||||||
|
|
||||||
|
// Clean up the dead client
|
||||||
|
acpPromptTask?.cancel()
|
||||||
|
acpPromptTask = nil
|
||||||
|
acpEventTask?.cancel()
|
||||||
|
acpEventTask = nil
|
||||||
|
healthMonitorTask?.cancel()
|
||||||
|
healthMonitorTask = nil
|
||||||
|
if let client = acpClient {
|
||||||
|
Task { await client.stop() }
|
||||||
|
}
|
||||||
|
acpClient = nil
|
||||||
|
hasActiveProcess = false
|
||||||
|
|
||||||
|
// Attempt auto-reconnect if we have a session to restore
|
||||||
|
guard let savedSessionId else {
|
||||||
|
showConnectionFailure()
|
||||||
|
isHandlingDisconnect = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attemptReconnect(sessionId: savedSessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attemptReconnect(sessionId: String) {
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
acpError = nil
|
||||||
|
|
||||||
|
reconnectTask = Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
for attempt in 1...Self.maxReconnectAttempts {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
acpStatus = "Reconnecting (\(attempt)/\(Self.maxReconnectAttempts))..."
|
||||||
|
logger.info("Reconnect attempt \(attempt)/\(Self.maxReconnectAttempts) for session \(sessionId)")
|
||||||
|
|
||||||
|
// Backoff delay (skip on first attempt for fast recovery)
|
||||||
|
if attempt > 1 {
|
||||||
|
let delay = min(
|
||||||
|
Self.reconnectBaseDelay * UInt64(1 << (attempt - 1)),
|
||||||
|
Self.maxReconnectDelay
|
||||||
|
)
|
||||||
|
try? await Task.sleep(nanoseconds: delay)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = ACPClient()
|
||||||
|
do {
|
||||||
|
try await client.start()
|
||||||
|
|
||||||
|
let cwd = NSHomeDirectory()
|
||||||
|
let resolvedSessionId: String
|
||||||
|
|
||||||
|
// Try resumeSession first (designed for reconnection), then loadSession.
|
||||||
|
// NEVER fall back to newSession — that loses all conversation context.
|
||||||
|
do {
|
||||||
|
resolvedSessionId = try await client.resumeSession(cwd: cwd, sessionId: sessionId)
|
||||||
|
} catch {
|
||||||
|
logger.info("session/resume failed, trying session/load: \(error.localizedDescription)")
|
||||||
|
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success — wire up the new client
|
||||||
|
self.acpClient = client
|
||||||
|
self.hasActiveProcess = true
|
||||||
|
richChatViewModel.setSessionId(resolvedSessionId)
|
||||||
|
|
||||||
|
// Reconcile in-memory messages with what Hermes persisted to DB
|
||||||
|
await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId)
|
||||||
|
|
||||||
|
acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))"
|
||||||
|
acpError = nil
|
||||||
|
|
||||||
|
startACPEventLoop(client: client)
|
||||||
|
startHealthMonitor(client: client)
|
||||||
|
|
||||||
|
isHandlingDisconnect = false
|
||||||
|
logger.info("Reconnected successfully on attempt \(attempt)")
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
logger.warning("Reconnect attempt \(attempt) failed: \(error.localizedDescription)")
|
||||||
|
await client.stop()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All attempts exhausted
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
showConnectionFailure()
|
||||||
|
isHandlingDisconnect = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showConnectionFailure() {
|
||||||
|
richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly"))
|
||||||
|
acpStatus = "Connection lost"
|
||||||
|
acpError = "Connection lost. Use the Session menu to reconnect."
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopACP() {
|
||||||
|
reconnectTask?.cancel()
|
||||||
|
reconnectTask = nil
|
||||||
|
acpPromptTask?.cancel()
|
||||||
|
acpPromptTask = nil
|
||||||
|
acpEventTask?.cancel()
|
||||||
|
acpEventTask = nil
|
||||||
|
healthMonitorTask?.cancel()
|
||||||
|
healthMonitorTask = nil
|
||||||
|
if let client = acpClient {
|
||||||
|
Task { await client.stop() }
|
||||||
|
}
|
||||||
|
acpClient = nil
|
||||||
|
hasActiveProcess = false
|
||||||
|
isHandlingDisconnect = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Respond to a permission request from the ACP agent.
|
||||||
|
func respondToPermission(optionId: String) {
|
||||||
|
guard let client = acpClient,
|
||||||
|
let permission = richChatViewModel.pendingPermission else { return }
|
||||||
|
Task {
|
||||||
|
await client.respondToPermission(requestId: permission.requestId, optionId: optionId)
|
||||||
|
}
|
||||||
|
richChatViewModel.pendingPermission = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recent Sessions
|
||||||
|
|
||||||
func loadRecentSessions() async {
|
func loadRecentSessions() async {
|
||||||
let opened = await dataService.open()
|
let opened = await dataService.open()
|
||||||
guard opened else { return }
|
guard opened else { return }
|
||||||
@@ -42,7 +450,44 @@ final class ChatViewModel {
|
|||||||
return session.id
|
return session.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Voice (terminal mode only)
|
||||||
|
|
||||||
|
func toggleVoice() {
|
||||||
|
guard let tv = terminalView else { return }
|
||||||
|
if voiceEnabled {
|
||||||
|
sendToTerminal(tv, text: "/voice off\r")
|
||||||
|
voiceEnabled = false
|
||||||
|
isRecording = false
|
||||||
|
} else {
|
||||||
|
sendToTerminal(tv, text: "/voice on\r")
|
||||||
|
voiceEnabled = true
|
||||||
|
ttsEnabled = fileService.loadConfig().autoTTS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleTTS() {
|
||||||
|
guard let tv = terminalView, voiceEnabled else { return }
|
||||||
|
sendToTerminal(tv, text: "/voice tts\r")
|
||||||
|
ttsEnabled.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushToTalk() {
|
||||||
|
guard let tv = terminalView, voiceEnabled else { return }
|
||||||
|
let ctrlB: [UInt8] = [0x02]
|
||||||
|
tv.send(source: tv, data: ctrlB[0..<1])
|
||||||
|
isRecording.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Terminal Mode
|
||||||
|
|
||||||
|
private func sendToTerminal(_ tv: LocalProcessTerminalView, text: String) {
|
||||||
|
let bytes = Array(text.utf8)
|
||||||
|
tv.send(source: tv, data: bytes[0..<bytes.count])
|
||||||
|
}
|
||||||
|
|
||||||
private func launchTerminal(arguments: [String]) {
|
private func launchTerminal(arguments: [String]) {
|
||||||
|
stopACP()
|
||||||
|
|
||||||
if let existing = terminalView {
|
if let existing = terminalView {
|
||||||
existing.terminate()
|
existing.terminate()
|
||||||
existing.removeFromSuperview()
|
existing.removeFromSuperview()
|
||||||
@@ -55,6 +500,9 @@ final class ChatViewModel {
|
|||||||
|
|
||||||
let coord = Coordinator(onTerminated: { [weak self] in
|
let coord = Coordinator(onTerminated: { [weak self] in
|
||||||
self?.hasActiveProcess = false
|
self?.hasActiveProcess = false
|
||||||
|
self?.voiceEnabled = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,29 +2,60 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ChatView: View {
|
struct ChatView: View {
|
||||||
@Environment(ChatViewModel.self) private var viewModel
|
@Environment(ChatViewModel.self) private var viewModel
|
||||||
|
@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() }
|
||||||
|
.onChange(of: fileWatcher.lastChangeDate) {
|
||||||
|
Task { await viewModel.loadRecentSessions() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -36,6 +67,21 @@ struct ChatView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
if viewModel.hasActiveProcess && viewModel.displayMode == .terminal {
|
||||||
|
voiceControls
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("View", selection: Bindable(viewModel).displayMode) {
|
||||||
|
Image(systemName: "terminal")
|
||||||
|
.help("Terminal")
|
||||||
|
.tag(ChatDisplayMode.terminal)
|
||||||
|
Image(systemName: "bubble.left.and.text.bubble.right")
|
||||||
|
.help("Rich Chat")
|
||||||
|
.tag(ChatDisplayMode.richChat)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.fixedSize()
|
||||||
|
|
||||||
if !viewModel.hermesBinaryExists {
|
if !viewModel.hermesBinaryExists {
|
||||||
Label("Hermes binary not found", systemImage: "exclamationmark.triangle")
|
Label("Hermes binary not found", systemImage: "exclamationmark.triangle")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -43,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()
|
||||||
}
|
}
|
||||||
@@ -52,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)
|
||||||
@@ -67,6 +121,7 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(session.id == activeSessionId || session.id == originSessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@@ -80,6 +135,65 @@ struct ChatView: View {
|
|||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var voiceControls: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button {
|
||||||
|
viewModel.toggleVoice()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: viewModel.voiceEnabled ? "mic.fill" : "mic.slash")
|
||||||
|
.foregroundStyle(viewModel.voiceEnabled ? .green : .secondary)
|
||||||
|
Text(viewModel.voiceEnabled ? "Voice On" : "Voice Off")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(viewModel.voiceEnabled ? .primary : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Toggle voice mode (/voice)")
|
||||||
|
|
||||||
|
if viewModel.voiceEnabled {
|
||||||
|
Button {
|
||||||
|
viewModel.toggleTTS()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: viewModel.ttsEnabled ? "speaker.wave.2.fill" : "speaker.slash")
|
||||||
|
.foregroundStyle(viewModel.ttsEnabled ? .green : .secondary)
|
||||||
|
Text(viewModel.ttsEnabled ? "TTS On" : "TTS Off")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(viewModel.ttsEnabled ? .primary : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Toggle text-to-speech (/voice tts)")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.pushToTalk()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: viewModel.isRecording ? "waveform.circle.fill" : "waveform.circle")
|
||||||
|
.foregroundStyle(viewModel.isRecording ? .red : Color.accentColor)
|
||||||
|
.symbolEffect(.pulse, isActive: viewModel.isRecording)
|
||||||
|
Text(viewModel.isRecording ? "Recording..." : "Push to Talk")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Push to talk (Ctrl+B)")
|
||||||
|
.keyboardShortcut("b", modifiers: .control)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
@@ -100,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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ struct PersistentTerminalView: NSViewRepresentable {
|
|||||||
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
container.addSubview(terminalView)
|
container.addSubview(terminalView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
|
||||||
terminalView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
terminalView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||||
terminalView.topAnchor.constraint(equalTo: container.topAnchor),
|
terminalView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||||
terminalView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
terminalView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||||
@@ -24,7 +24,7 @@ struct PersistentTerminalView: NSViewRepresentable {
|
|||||||
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
nsView.addSubview(terminalView)
|
nsView.addSubview(terminalView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor),
|
terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor, constant: 4),
|
||||||
terminalView.trailingAnchor.constraint(equalTo: nsView.trailingAnchor),
|
terminalView.trailingAnchor.constraint(equalTo: nsView.trailingAnchor),
|
||||||
terminalView.topAnchor.constraint(equalTo: nsView.topAnchor),
|
terminalView.topAnchor.constraint(equalTo: nsView.topAnchor),
|
||||||
terminalView.bottomAnchor.constraint(equalTo: nsView.bottomAnchor),
|
terminalView.bottomAnchor.constraint(equalTo: nsView.bottomAnchor),
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ToolCallCard: View {
|
||||||
|
let call: HermesToolCall
|
||||||
|
let result: HermesMessage?
|
||||||
|
|
||||||
|
@State private var expanded = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
RoundedRectangle(cornerRadius: 1)
|
||||||
|
.fill(toolColor)
|
||||||
|
.frame(width: 3, height: 16)
|
||||||
|
|
||||||
|
Image(systemName: call.toolKind.icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(toolColor)
|
||||||
|
|
||||||
|
Text(call.functionName)
|
||||||
|
.font(.caption.monospaced().bold())
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
Text(call.argumentsSummary)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.mini)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
|
||||||
|
if expanded {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
if !call.arguments.isEmpty && call.arguments != "{}" {
|
||||||
|
Text("Arguments")
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text(formatJSON(call.arguments))
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(6)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let result, !result.content.isEmpty {
|
||||||
|
Text("Result")
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
ToolResultContent(content: result.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var toolColor: Color {
|
||||||
|
switch call.toolKind {
|
||||||
|
case .read: return .green
|
||||||
|
case .edit: return .blue
|
||||||
|
case .execute: return .orange
|
||||||
|
case .fetch: return .purple
|
||||||
|
case .browser: return .indigo
|
||||||
|
case .other: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatJSON(_ raw: String) -> String {
|
||||||
|
guard let data = raw.data(using: .utf8),
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: data),
|
||||||
|
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted),
|
||||||
|
let str = String(data: pretty, encoding: .utf8) else {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ToolResultContent: View {
|
||||||
|
let content: String
|
||||||
|
|
||||||
|
@State private var showAll = false
|
||||||
|
|
||||||
|
private var lines: [String] { content.components(separatedBy: "\n") }
|
||||||
|
private var isLong: Bool { lines.count > 8 }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(showAll ? content : lines.prefix(8).joined(separator: "\n"))
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(6)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
|
||||||
|
if isLong {
|
||||||
|
Button(showAll ? "Show less" : "Show all \(lines.count) lines") {
|
||||||
|
withAnimation { showAll.toggle() }
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,11 @@ struct CronView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if job.silent == true {
|
||||||
|
Text("SILENT")
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundStyle(.purple)
|
||||||
|
}
|
||||||
if !job.enabled {
|
if !job.enabled {
|
||||||
Text("Disabled")
|
Text("Disabled")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
@@ -86,6 +91,20 @@ struct CronView: View {
|
|||||||
.background(.quaternary.opacity(0.5))
|
.background(.quaternary.opacity(0.5))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
}
|
}
|
||||||
|
if let script = job.preRunScript, !script.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Pre-Run Script")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(script)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
if let skills = job.skills, !skills.isEmpty {
|
if let skills = job.skills, !skills.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Skills")
|
Text("Skills")
|
||||||
@@ -118,6 +137,21 @@ struct CronView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
|
if let timeout = job.timeoutSeconds {
|
||||||
|
Label("Timeout: \(timeout)s (\(job.timeoutType ?? "wall_clock"))", systemImage: "timer")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let failures = job.deliveryFailures, failures > 0 {
|
||||||
|
Label("\(failures) delivery failure\(failures == 1 ? "" : "s")", systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
if let deliveryError = job.lastDeliveryError {
|
||||||
|
Label(deliveryError, systemImage: "paperplane.circle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
if let output = viewModel.jobOutput {
|
if let output = viewModel.jobOutput {
|
||||||
Divider()
|
Divider()
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ final class DashboardViewModel {
|
|||||||
private let dataService = HermesDataService()
|
private let dataService = HermesDataService()
|
||||||
private let fileService = HermesFileService()
|
private let fileService = HermesFileService()
|
||||||
|
|
||||||
var stats = HermesDataService.SessionStats(
|
var stats = HermesDataService.SessionStats.empty
|
||||||
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
|
||||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0
|
|
||||||
)
|
|
||||||
var recentSessions: [HermesSession] = []
|
var recentSessions: [HermesSession] = []
|
||||||
var sessionPreviews: [String: String] = [:]
|
var sessionPreviews: [String: String] = [:]
|
||||||
var config = HermesConfig.empty
|
var config = HermesConfig.empty
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
@State private var viewModel = DashboardViewModel()
|
@State private var viewModel = DashboardViewModel()
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -16,6 +17,9 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Dashboard")
|
.navigationTitle("Dashboard")
|
||||||
.task { await viewModel.load() }
|
.task { await viewModel.load() }
|
||||||
|
.onChange(of: fileWatcher.lastChangeDate) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusSection: some View {
|
private var statusSection: some View {
|
||||||
@@ -56,7 +60,10 @@ struct DashboardView: View {
|
|||||||
StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)")
|
StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)")
|
||||||
StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)")
|
StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)")
|
||||||
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
|
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
|
||||||
StatCard(label: "Est. Cost", value: String(format: "$%.2f", viewModel.stats.totalCostUSD))
|
let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
|
||||||
|
if cost > 0 {
|
||||||
|
StatCard(label: "Cost", value: String(format: "$%.2f", cost))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,14 +94,6 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatTokens(_ count: Int) -> String {
|
|
||||||
if count >= 1_000_000 {
|
|
||||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
|
||||||
} else if count >= 1_000 {
|
|
||||||
return String(format: "%.1fK", Double(count) / 1_000)
|
|
||||||
}
|
|
||||||
return "\(count)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StatusCard: View {
|
struct StatusCard: View {
|
||||||
@@ -165,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)
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct GatewayInfo {
|
||||||
|
let pid: Int?
|
||||||
|
let state: String
|
||||||
|
let exitReason: String?
|
||||||
|
let startTime: String?
|
||||||
|
let updatedAt: String?
|
||||||
|
let platforms: [PlatformInfo]
|
||||||
|
let isLoaded: Bool
|
||||||
|
let isStale: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlatformInfo: Identifiable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let state: String
|
||||||
|
let updatedAt: String?
|
||||||
|
|
||||||
|
var isConnected: Bool { state == "connected" }
|
||||||
|
|
||||||
|
var icon: String { KnownPlatforms.icon(for: name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PairedUser: Identifiable {
|
||||||
|
var id: String { platform + userId }
|
||||||
|
let platform: String
|
||||||
|
let userId: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingPairing: Identifiable {
|
||||||
|
var id: String { platform + code }
|
||||||
|
let platform: String
|
||||||
|
let code: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class GatewayViewModel {
|
||||||
|
var gateway = GatewayInfo(pid: nil, state: "unknown", exitReason: nil, startTime: nil, updatedAt: nil, platforms: [], isLoaded: false, isStale: false)
|
||||||
|
var approvedUsers: [PairedUser] = []
|
||||||
|
var pendingPairings: [PendingPairing] = []
|
||||||
|
var isLoading = false
|
||||||
|
var actionMessage: String?
|
||||||
|
|
||||||
|
func load() {
|
||||||
|
isLoading = true
|
||||||
|
loadGatewayStatus()
|
||||||
|
loadPairing()
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func startGateway() {
|
||||||
|
runHermes(["gateway", "start"])
|
||||||
|
actionMessage = "Gateway start requested"
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.loadGatewayStatus()
|
||||||
|
self?.actionMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopGateway() {
|
||||||
|
runHermes(["gateway", "stop"])
|
||||||
|
actionMessage = "Gateway stop requested"
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.loadGatewayStatus()
|
||||||
|
self?.actionMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartGateway() {
|
||||||
|
runHermes(["gateway", "restart"])
|
||||||
|
actionMessage = "Gateway restart requested"
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
self?.loadGatewayStatus()
|
||||||
|
self?.actionMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func approvePairing(platform: String, code: String) {
|
||||||
|
runHermes(["pairing", "approve", platform, code])
|
||||||
|
loadPairing()
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeUser(_ user: PairedUser) {
|
||||||
|
runHermes(["pairing", "revoke", user.platform, user.userId])
|
||||||
|
approvedUsers.removeAll { $0.id == user.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func loadGatewayStatus() {
|
||||||
|
let stateJSON = FileManager.default.contents(atPath: HermesPaths.gatewayStateJSON)
|
||||||
|
var pid: Int?
|
||||||
|
var state = "unknown"
|
||||||
|
var exitReason: String?
|
||||||
|
var startTime: String?
|
||||||
|
var updatedAt: String?
|
||||||
|
var platforms: [PlatformInfo] = []
|
||||||
|
|
||||||
|
if let data = stateJSON,
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
pid = json["pid"] as? Int
|
||||||
|
state = json["gateway_state"] as? String ?? "unknown"
|
||||||
|
exitReason = json["exit_reason"] as? String
|
||||||
|
startTime = json["start_time"] as? String
|
||||||
|
updatedAt = json["updated_at"] as? String
|
||||||
|
if let plats = json["platforms"] as? [String: Any] {
|
||||||
|
platforms = plats.compactMap { key, value in
|
||||||
|
guard let info = value as? [String: Any] else { return nil }
|
||||||
|
return PlatformInfo(
|
||||||
|
name: key,
|
||||||
|
state: info["state"] as? String ?? "unknown",
|
||||||
|
updatedAt: info["updated_at"] as? String
|
||||||
|
)
|
||||||
|
}.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusOutput = runHermes(["gateway", "status"]).output
|
||||||
|
let isLoaded = statusOutput.contains("service is loaded")
|
||||||
|
let isStale = statusOutput.contains("stale")
|
||||||
|
|
||||||
|
gateway = GatewayInfo(
|
||||||
|
pid: pid, state: state, exitReason: exitReason,
|
||||||
|
startTime: startTime, updatedAt: updatedAt,
|
||||||
|
platforms: platforms, isLoaded: isLoaded, isStale: isStale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPairing() {
|
||||||
|
let output = runHermes(["pairing", "list"]).output
|
||||||
|
approvedUsers = []
|
||||||
|
pendingPairings = []
|
||||||
|
|
||||||
|
var inApproved = false
|
||||||
|
var inPending = false
|
||||||
|
|
||||||
|
for line in output.components(separatedBy: "\n") {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.contains("Approved Users") { inApproved = true; inPending = false; continue }
|
||||||
|
if trimmed.contains("Pending") { inPending = true; inApproved = false; continue }
|
||||||
|
if trimmed.isEmpty || trimmed.hasPrefix("Platform") || trimmed.hasPrefix("--------") { continue }
|
||||||
|
|
||||||
|
let parts = trimmed.split(separator: " ", omittingEmptySubsequences: true)
|
||||||
|
if inApproved && parts.count >= 3 {
|
||||||
|
let platform = String(parts[0])
|
||||||
|
let userId = String(parts[1])
|
||||||
|
let name = parts[2...].joined(separator: " ")
|
||||||
|
approvedUsers.append(PairedUser(platform: platform, userId: userId, name: name))
|
||||||
|
}
|
||||||
|
if inPending && parts.count >= 2 {
|
||||||
|
let platform = String(parts[0])
|
||||||
|
let code = String(parts[1])
|
||||||
|
pendingPairings.append(PendingPairing(platform: platform, code: code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||||
|
process.arguments = arguments
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = Pipe()
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||||
|
} catch {
|
||||||
|
return ("", -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GatewayView: View {
|
||||||
|
@State private var viewModel = GatewayViewModel()
|
||||||
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
serviceSection
|
||||||
|
platformsSection
|
||||||
|
pairingSection
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
.navigationTitle("Gateway")
|
||||||
|
.onAppear { viewModel.load() }
|
||||||
|
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Service
|
||||||
|
|
||||||
|
private var serviceSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("Service")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if let msg = viewModel.actionMessage {
|
||||||
|
Text(msg)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button("Start") { viewModel.startGateway() }
|
||||||
|
Button("Stop") { viewModel.stopGateway() }
|
||||||
|
Button("Restart") { viewModel.restartGateway() }
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
StatusBadge(
|
||||||
|
label: viewModel.gateway.state,
|
||||||
|
isActive: viewModel.gateway.state == "running"
|
||||||
|
)
|
||||||
|
if let pid = viewModel.gateway.pid {
|
||||||
|
Label("PID \(pid)", systemImage: "number")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if viewModel.gateway.isLoaded {
|
||||||
|
Label("Loaded", systemImage: "checkmark.circle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
if viewModel.gateway.isStale {
|
||||||
|
Label("Service definition stale", systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let reason = viewModel.gateway.exitReason, !reason.isEmpty {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(reason)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let updated = viewModel.gateway.updatedAt {
|
||||||
|
Text("Last updated: \(updated)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Platforms
|
||||||
|
|
||||||
|
private var platformsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Platforms")
|
||||||
|
.font(.headline)
|
||||||
|
if viewModel.gateway.platforms.isEmpty {
|
||||||
|
Text("No platforms connected")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(viewModel.gateway.platforms) { platform in
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: platform.icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(platform.isConnected ? Color.accentColor : .secondary)
|
||||||
|
Text(platform.name.capitalized)
|
||||||
|
.font(.caption.bold())
|
||||||
|
StatusBadge(
|
||||||
|
label: platform.state,
|
||||||
|
isActive: platform.isConnected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pairing
|
||||||
|
|
||||||
|
private var pairingSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Paired Users")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if !viewModel.pendingPairings.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label("Pending Approvals", systemImage: "clock.badge.questionmark")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
ForEach(viewModel.pendingPairings) { pending in
|
||||||
|
HStack {
|
||||||
|
Label(pending.platform.capitalized, systemImage: platformIcon(pending.platform))
|
||||||
|
Text("Code: \(pending.code)")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
Spacer()
|
||||||
|
Button("Approve") {
|
||||||
|
viewModel.approvePairing(platform: pending.platform, code: pending.code)
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.padding(8)
|
||||||
|
.background(.orange.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.approvedUsers.isEmpty && viewModel.pendingPairings.isEmpty {
|
||||||
|
Text("No paired users")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.approvedUsers) { user in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: platformIcon(user.platform))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 20)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(user.name)
|
||||||
|
Text("\(user.platform.capitalized) · \(user.userId)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Revoke", role: .destructive) {
|
||||||
|
viewModel.revokeUser(user)
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func platformIcon(_ platform: String) -> String {
|
||||||
|
KnownPlatforms.icon(for: platform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatusBadge: View {
|
||||||
|
let label: String
|
||||||
|
let isActive: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(isActive ? .green : .secondary)
|
||||||
|
.frame(width: 6, height: 6)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct HealthCheck: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let label: String
|
||||||
|
let status: CheckStatus
|
||||||
|
let detail: String?
|
||||||
|
|
||||||
|
enum CheckStatus {
|
||||||
|
case ok
|
||||||
|
case warning
|
||||||
|
case error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HealthSection: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let title: String
|
||||||
|
let icon: String
|
||||||
|
let checks: [HealthCheck]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class HealthViewModel {
|
||||||
|
private let fileService = HermesFileService()
|
||||||
|
|
||||||
|
var version = ""
|
||||||
|
var updateInfo = ""
|
||||||
|
var hasUpdate = false
|
||||||
|
var statusSections: [HealthSection] = []
|
||||||
|
var doctorSections: [HealthSection] = []
|
||||||
|
var issueCount = 0
|
||||||
|
var warningCount = 0
|
||||||
|
var okCount = 0
|
||||||
|
var isLoading = false
|
||||||
|
var hermesRunning = false
|
||||||
|
var hermesPID: pid_t?
|
||||||
|
var actionMessage: String?
|
||||||
|
|
||||||
|
func load() {
|
||||||
|
isLoading = true
|
||||||
|
refreshProcessStatus()
|
||||||
|
loadVersion()
|
||||||
|
let statusOutput = runHermes(["status"]).output
|
||||||
|
statusSections = parseOutput(statusOutput)
|
||||||
|
let doctorOutput = runHermes(["doctor"]).output
|
||||||
|
doctorSections = parseOutput(doctorOutput)
|
||||||
|
computeCounts()
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshProcessStatus() {
|
||||||
|
hermesPID = fileService.hermesPID()
|
||||||
|
hermesRunning = hermesPID != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopHermes() {
|
||||||
|
fileService.stopHermes()
|
||||||
|
actionMessage = "Stop signal sent"
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.refreshProcessStatus()
|
||||||
|
self?.actionMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startHermes() {
|
||||||
|
runHermes(["gateway", "start"])
|
||||||
|
actionMessage = "Start requested"
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
self?.refreshProcessStatus()
|
||||||
|
self?.actionMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartHermes() {
|
||||||
|
fileService.stopHermes()
|
||||||
|
actionMessage = "Restarting..."
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.runHermes(["gateway", "start"])
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
self?.refreshProcessStatus()
|
||||||
|
self?.actionMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadVersion() {
|
||||||
|
let output = runHermes(["version"]).output
|
||||||
|
let lines = output.components(separatedBy: "\n")
|
||||||
|
version = lines.first ?? ""
|
||||||
|
if let updateLine = lines.first(where: { $0.contains("commits behind") }) {
|
||||||
|
updateInfo = updateLine.trimmingCharacters(in: .whitespaces)
|
||||||
|
hasUpdate = true
|
||||||
|
} else {
|
||||||
|
updateInfo = ""
|
||||||
|
hasUpdate = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseOutput(_ output: String) -> [HealthSection] {
|
||||||
|
var sections: [HealthSection] = []
|
||||||
|
var currentTitle = ""
|
||||||
|
var currentChecks: [HealthCheck] = []
|
||||||
|
|
||||||
|
for line in output.components(separatedBy: "\n") {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
if trimmed.hasPrefix("◆ ") {
|
||||||
|
if !currentTitle.isEmpty {
|
||||||
|
sections.append(HealthSection(
|
||||||
|
title: currentTitle,
|
||||||
|
icon: iconForSection(currentTitle),
|
||||||
|
checks: currentChecks
|
||||||
|
))
|
||||||
|
}
|
||||||
|
currentTitle = String(trimmed.dropFirst(2))
|
||||||
|
currentChecks = []
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed.hasPrefix("✓ ") {
|
||||||
|
let text = String(trimmed.dropFirst(2))
|
||||||
|
let (label, detail) = splitCheck(text)
|
||||||
|
currentChecks.append(HealthCheck(label: label, status: .ok, detail: detail))
|
||||||
|
} else if trimmed.hasPrefix("⚠ ") || trimmed.hasPrefix("⚠") {
|
||||||
|
let text = trimmed.replacingOccurrences(of: "⚠ ", with: "").replacingOccurrences(of: "⚠", with: "")
|
||||||
|
let (label, detail) = splitCheck(text)
|
||||||
|
currentChecks.append(HealthCheck(label: label, status: .warning, detail: detail))
|
||||||
|
} else if trimmed.hasPrefix("✗ ") {
|
||||||
|
let text = String(trimmed.dropFirst(2))
|
||||||
|
let (label, detail) = splitCheck(text)
|
||||||
|
currentChecks.append(HealthCheck(label: label, status: .error, detail: detail))
|
||||||
|
} else if trimmed.hasPrefix("→ ") || trimmed.hasPrefix("Error:") {
|
||||||
|
if !currentChecks.isEmpty {
|
||||||
|
let last = currentChecks.removeLast()
|
||||||
|
let extra = trimmed.replacingOccurrences(of: "→ ", with: "").replacingOccurrences(of: "Error:", with: "").trimmingCharacters(in: .whitespaces)
|
||||||
|
let combined = [last.detail, extra].compactMap { $0 }.joined(separator: " ")
|
||||||
|
currentChecks.append(HealthCheck(label: last.label, status: last.status, detail: combined))
|
||||||
|
}
|
||||||
|
} else if !trimmed.isEmpty && trimmed.contains(":") && !trimmed.hasPrefix("┌") && !trimmed.hasPrefix("│") && !trimmed.hasPrefix("└") && !trimmed.hasPrefix("─") && !trimmed.hasPrefix("Run ") && !trimmed.hasPrefix("Found ") && !trimmed.hasPrefix("Tip:") {
|
||||||
|
let parts = trimmed.split(separator: ":", maxSplits: 1)
|
||||||
|
if parts.count == 2 {
|
||||||
|
let key = parts[0].trimmingCharacters(in: .whitespaces)
|
||||||
|
let val = parts[1].trimmingCharacters(in: .whitespaces)
|
||||||
|
if !key.isEmpty && key.count < 30 {
|
||||||
|
currentChecks.append(HealthCheck(label: key, status: .ok, detail: val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !currentTitle.isEmpty {
|
||||||
|
sections.append(HealthSection(
|
||||||
|
title: currentTitle,
|
||||||
|
icon: iconForSection(currentTitle),
|
||||||
|
checks: currentChecks
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
private func splitCheck(_ text: String) -> (String, String?) {
|
||||||
|
if let parenStart = text.firstIndex(of: "(") {
|
||||||
|
let label = text[text.startIndex..<parenStart].trimmingCharacters(in: .whitespaces)
|
||||||
|
let detail = String(text[parenStart...]).trimmingCharacters(in: CharacterSet(charactersIn: "()"))
|
||||||
|
return (label, detail)
|
||||||
|
}
|
||||||
|
return (text, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeCounts() {
|
||||||
|
let allChecks = (statusSections + doctorSections).flatMap(\.checks)
|
||||||
|
okCount = allChecks.filter { $0.status == .ok }.count
|
||||||
|
warningCount = allChecks.filter { $0.status == .warning }.count
|
||||||
|
issueCount = allChecks.filter { $0.status == .error }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iconForSection(_ title: String) -> String {
|
||||||
|
switch title {
|
||||||
|
case "Environment": return "gearshape.2"
|
||||||
|
case "API Keys": return "key"
|
||||||
|
case "Auth Providers": return "person.badge.key"
|
||||||
|
case "API-Key Providers": return "key.horizontal"
|
||||||
|
case "Terminal Backend": return "terminal"
|
||||||
|
case "Messaging Platforms": return "bubble.left.and.bubble.right"
|
||||||
|
case "Gateway Service": return "antenna.radiowaves.left.and.right"
|
||||||
|
case "Scheduled Jobs": return "clock.arrow.2.circlepath"
|
||||||
|
case "Sessions": return "text.bubble"
|
||||||
|
case "Python Environment": return "chevron.left.forwardslash.chevron.right"
|
||||||
|
case "Required Packages": return "shippingbox"
|
||||||
|
case "Configuration Files": return "doc.text"
|
||||||
|
case "Directory Structure": return "folder"
|
||||||
|
case "External Tools": return "wrench"
|
||||||
|
case "API Connectivity": return "wifi"
|
||||||
|
case "Submodules": return "arrow.triangle.branch"
|
||||||
|
case "Tool Availability": return "wrench.and.screwdriver"
|
||||||
|
case "Skills Hub": return "lightbulb"
|
||||||
|
case "Honcho Memory": return "brain"
|
||||||
|
default: return "circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||||
|
process.arguments = arguments
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||||
|
} catch {
|
||||||
|
return ("", -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HealthView: View {
|
||||||
|
@State private var viewModel = HealthViewModel()
|
||||||
|
@State private var expandedSection: UUID?
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
headerBar
|
||||||
|
Divider()
|
||||||
|
Picker("", selection: $selectedTab) {
|
||||||
|
Text("Status").tag(0)
|
||||||
|
Text("Diagnostics").tag(1)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: 300)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
sectionGrid(selectedTab == 0 ? viewModel.statusSections : viewModel.doctorSections)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Health")
|
||||||
|
.onAppear { viewModel.load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var headerBar: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
if !viewModel.version.isEmpty {
|
||||||
|
Text(viewModel.version)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.hasUpdate {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
.font(.caption2)
|
||||||
|
Text(viewModel.updateInfo)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
MiniCount(count: viewModel.okCount, color: .green, icon: "checkmark.circle.fill")
|
||||||
|
MiniCount(count: viewModel.warningCount, color: .orange, icon: "exclamationmark.triangle.fill")
|
||||||
|
MiniCount(count: viewModel.issueCount, color: .red, icon: "xmark.circle.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Refresh") { viewModel.load() }
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
private func sectionGrid(_ sections: [HealthSection]) -> some View {
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
|
||||||
|
ForEach(sections) { section in
|
||||||
|
SectionCard(
|
||||||
|
section: section,
|
||||||
|
isExpanded: expandedSection == section.id,
|
||||||
|
onTap: {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
expandedSection = expandedSection == section.id ? nil : section.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Section Card
|
||||||
|
|
||||||
|
struct SectionCard: View {
|
||||||
|
let section: HealthSection
|
||||||
|
let isExpanded: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
private var okCount: Int { section.checks.filter { $0.status == .ok }.count }
|
||||||
|
private var warnCount: Int { section.checks.filter { $0.status == .warning }.count }
|
||||||
|
private var errorCount: Int { section.checks.filter { $0.status == .error }.count }
|
||||||
|
|
||||||
|
private var accentColor: Color {
|
||||||
|
if errorCount > 0 { return .red }
|
||||||
|
if warnCount > 0 { return .orange }
|
||||||
|
return .green
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: section.icon)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(accentColor)
|
||||||
|
.frame(width: 24)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(section.title)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if okCount > 0 {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Circle().fill(.green).frame(width: 5, height: 5)
|
||||||
|
Text("\(okCount)").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if warnCount > 0 {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Circle().fill(.orange).frame(width: 5, height: 5)
|
||||||
|
Text("\(warnCount)").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errorCount > 0 {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Circle().fill(.red).frame(width: 5, height: 5)
|
||||||
|
Text("\(errorCount)").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if isExpanded {
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
ForEach(section.checks) { check in
|
||||||
|
CheckRow(check: check)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.strokeBorder(accentColor.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Check Row
|
||||||
|
|
||||||
|
struct CheckRow: View {
|
||||||
|
let check: HealthCheck
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 6) {
|
||||||
|
Image(systemName: statusIcon)
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.frame(width: 12, alignment: .center)
|
||||||
|
.padding(.top, 2)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(check.label)
|
||||||
|
.font(.caption)
|
||||||
|
if let detail = check.detail {
|
||||||
|
Text(detail)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusIcon: String {
|
||||||
|
switch check.status {
|
||||||
|
case .ok: return "checkmark.circle.fill"
|
||||||
|
case .warning: return "exclamationmark.triangle.fill"
|
||||||
|
case .error: return "xmark.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
switch check.status {
|
||||||
|
case .ok: return .green
|
||||||
|
case .warning: return .orange
|
||||||
|
case .error: return .red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mini Count
|
||||||
|
|
||||||
|
struct MiniCount: View {
|
||||||
|
let count: Int
|
||||||
|
let color: Color
|
||||||
|
let icon: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.font(.caption2)
|
||||||
|
Text("\(count)")
|
||||||
|
.font(.caption.monospaced().bold())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum InsightsPeriod: String, CaseIterable, Identifiable {
|
||||||
|
case week = "7 Days"
|
||||||
|
case month = "30 Days"
|
||||||
|
case quarter = "90 Days"
|
||||||
|
case all = "All Time"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var sinceDate: Date {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
switch self {
|
||||||
|
case .week: return calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date()
|
||||||
|
case .month: return calendar.date(byAdding: .day, value: -30, to: Date()) ?? Date()
|
||||||
|
case .quarter: return calendar.date(byAdding: .day, value: -90, to: Date()) ?? Date()
|
||||||
|
case .all: return Date(timeIntervalSince1970: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ModelUsage: Identifiable {
|
||||||
|
var id: String { model }
|
||||||
|
let model: String
|
||||||
|
let sessions: Int
|
||||||
|
let inputTokens: Int
|
||||||
|
let outputTokens: Int
|
||||||
|
let cacheReadTokens: Int
|
||||||
|
let cacheWriteTokens: Int
|
||||||
|
let reasoningTokens: Int
|
||||||
|
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PlatformUsage: Identifiable {
|
||||||
|
var id: String { platform }
|
||||||
|
let platform: String
|
||||||
|
let sessions: Int
|
||||||
|
let messages: Int
|
||||||
|
let tokens: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ToolUsage: Identifiable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let count: Int
|
||||||
|
let percentage: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NotableSession: Identifiable {
|
||||||
|
var id: String { "\(session.id)-\(label)" }
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
let session: HermesSession
|
||||||
|
let preview: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class InsightsViewModel {
|
||||||
|
private let dataService = HermesDataService()
|
||||||
|
|
||||||
|
var period: InsightsPeriod = .month
|
||||||
|
var isLoading = true
|
||||||
|
|
||||||
|
var sessions: [HermesSession] = []
|
||||||
|
var sessionPreviews: [String: String] = [:]
|
||||||
|
var userMessageCount = 0
|
||||||
|
var totalMessages = 0
|
||||||
|
var totalToolCalls = 0
|
||||||
|
var totalInputTokens = 0
|
||||||
|
var totalOutputTokens = 0
|
||||||
|
var totalCacheReadTokens = 0
|
||||||
|
var totalCacheWriteTokens = 0
|
||||||
|
var totalReasoningTokens = 0
|
||||||
|
var totalTokens = 0
|
||||||
|
var totalCost: Double = 0
|
||||||
|
var activeTime: TimeInterval = 0
|
||||||
|
var avgSessionDuration: TimeInterval = 0
|
||||||
|
|
||||||
|
var modelUsage: [ModelUsage] = []
|
||||||
|
var platformUsage: [PlatformUsage] = []
|
||||||
|
var toolUsage: [ToolUsage] = []
|
||||||
|
var hourlyActivity: [Int: Int] = [:]
|
||||||
|
var dailyActivity: [Int: Int] = [:]
|
||||||
|
var notableSessions: [NotableSession] = []
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
let opened = await dataService.open()
|
||||||
|
guard opened else {
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let since = period.sinceDate
|
||||||
|
sessions = await dataService.fetchSessionsInPeriod(since: since)
|
||||||
|
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
|
||||||
|
userMessageCount = await dataService.fetchUserMessageCount(since: since)
|
||||||
|
let tools = await dataService.fetchToolUsage(since: since)
|
||||||
|
hourlyActivity = await dataService.fetchSessionStartHours(since: since)
|
||||||
|
dailyActivity = await dataService.fetchSessionDaysOfWeek(since: since)
|
||||||
|
|
||||||
|
await dataService.close()
|
||||||
|
|
||||||
|
computeAggregates()
|
||||||
|
computeModelBreakdown()
|
||||||
|
computePlatformBreakdown()
|
||||||
|
computeToolBreakdown(tools)
|
||||||
|
computeNotableSessions()
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewFor(_ session: HermesSession) -> String {
|
||||||
|
if let title = session.title, !title.isEmpty { return title }
|
||||||
|
if let preview = sessionPreviews[session.id], !preview.isEmpty { return preview }
|
||||||
|
return session.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeAggregates() {
|
||||||
|
totalMessages = sessions.reduce(0) { $0 + $1.messageCount }
|
||||||
|
totalToolCalls = sessions.reduce(0) { $0 + $1.toolCallCount }
|
||||||
|
totalInputTokens = sessions.reduce(0) { $0 + $1.inputTokens }
|
||||||
|
totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens }
|
||||||
|
totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens }
|
||||||
|
totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens }
|
||||||
|
totalReasoningTokens = sessions.reduce(0) { $0 + $1.reasoningTokens }
|
||||||
|
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens + totalReasoningTokens
|
||||||
|
totalCost = sessions.reduce(0.0) { $0 + ($1.displayCostUSD ?? 0) }
|
||||||
|
|
||||||
|
var total: TimeInterval = 0
|
||||||
|
var count = 0
|
||||||
|
for session in sessions {
|
||||||
|
if let dur = session.duration, dur > 0 {
|
||||||
|
total += dur
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeTime = total
|
||||||
|
avgSessionDuration = count > 0 ? total / Double(count) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeModelBreakdown() {
|
||||||
|
var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int, reasoning: Int)] = [:]
|
||||||
|
for s in sessions {
|
||||||
|
let model = s.model ?? "unknown"
|
||||||
|
var entry = grouped[model, default: (0, 0, 0, 0, 0, 0)]
|
||||||
|
entry.sessions += 1
|
||||||
|
entry.input += s.inputTokens
|
||||||
|
entry.output += s.outputTokens
|
||||||
|
entry.cacheRead += s.cacheReadTokens
|
||||||
|
entry.cacheWrite += s.cacheWriteTokens
|
||||||
|
entry.reasoning += s.reasoningTokens
|
||||||
|
grouped[model] = entry
|
||||||
|
}
|
||||||
|
modelUsage = grouped.map { key, val in
|
||||||
|
ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input,
|
||||||
|
outputTokens: val.output, cacheReadTokens: val.cacheRead,
|
||||||
|
cacheWriteTokens: val.cacheWrite, reasoningTokens: val.reasoning)
|
||||||
|
}.sorted { $0.totalTokens > $1.totalTokens }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computePlatformBreakdown() {
|
||||||
|
var grouped: [String: (sessions: Int, messages: Int, tokens: Int)] = [:]
|
||||||
|
for s in sessions {
|
||||||
|
var entry = grouped[s.source, default: (0, 0, 0)]
|
||||||
|
entry.sessions += 1
|
||||||
|
entry.messages += s.messageCount
|
||||||
|
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens + s.reasoningTokens
|
||||||
|
grouped[s.source] = entry
|
||||||
|
}
|
||||||
|
platformUsage = grouped.map { key, val in
|
||||||
|
PlatformUsage(platform: key, sessions: val.sessions, messages: val.messages, tokens: val.tokens)
|
||||||
|
}.sorted { $0.sessions > $1.sessions }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeToolBreakdown(_ tools: [(name: String, count: Int)]) {
|
||||||
|
let total = tools.reduce(0) { $0 + $1.count }
|
||||||
|
toolUsage = tools.map { tool in
|
||||||
|
ToolUsage(name: tool.name, count: tool.count,
|
||||||
|
percentage: total > 0 ? Double(tool.count) / Double(total) * 100 : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeNotableSessions() {
|
||||||
|
notableSessions = []
|
||||||
|
|
||||||
|
if let longest = sessions.filter({ $0.duration != nil }).max(by: { ($0.duration ?? 0) < ($1.duration ?? 0) }) {
|
||||||
|
notableSessions.append(NotableSession(
|
||||||
|
label: "Longest Session",
|
||||||
|
value: formatDuration(longest.duration ?? 0),
|
||||||
|
session: longest,
|
||||||
|
preview: previewFor(longest)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mostMsgs = sessions.max(by: { $0.messageCount < $1.messageCount }), mostMsgs.messageCount > 0 {
|
||||||
|
notableSessions.append(NotableSession(
|
||||||
|
label: "Most Messages",
|
||||||
|
value: "\(mostMsgs.messageCount) msgs",
|
||||||
|
session: mostMsgs,
|
||||||
|
preview: previewFor(mostMsgs)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mostTokens = sessions.max(by: { $0.totalTokens < $1.totalTokens }), mostTokens.totalTokens > 0 {
|
||||||
|
notableSessions.append(NotableSession(
|
||||||
|
label: "Most Tokens",
|
||||||
|
value: formatTokens(mostTokens.totalTokens),
|
||||||
|
session: mostTokens,
|
||||||
|
preview: previewFor(mostTokens)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mostTools = sessions.max(by: { $0.toolCallCount < $1.toolCallCount }), mostTools.toolCallCount > 0 {
|
||||||
|
notableSessions.append(NotableSession(
|
||||||
|
label: "Most Tool Calls",
|
||||||
|
value: "\(mostTools.toolCallCount) calls",
|
||||||
|
session: mostTools,
|
||||||
|
preview: previewFor(mostTools)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(_ interval: TimeInterval) -> String {
|
||||||
|
let hours = Int(interval) / 3600
|
||||||
|
let minutes = (Int(interval) % 3600) / 60
|
||||||
|
if hours > 0 {
|
||||||
|
return "\(hours)h \(minutes)m"
|
||||||
|
}
|
||||||
|
return "\(minutes)m"
|
||||||
|
}
|
||||||
|
|
||||||
|
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,312 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct InsightsView: View {
|
||||||
|
@State private var viewModel = InsightsViewModel()
|
||||||
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
periodPicker
|
||||||
|
overviewSection
|
||||||
|
modelSection
|
||||||
|
platformSection
|
||||||
|
toolsSection
|
||||||
|
activitySection
|
||||||
|
notableSection
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
.navigationTitle("Insights")
|
||||||
|
.task { await viewModel.load() }
|
||||||
|
.onChange(of: viewModel.period) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var periodPicker: some View {
|
||||||
|
Picker("Period", selection: $viewModel.period) {
|
||||||
|
ForEach(InsightsPeriod.allCases) { period in
|
||||||
|
Text(period.rawValue).tag(period)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Overview
|
||||||
|
|
||||||
|
private var overviewSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Overview")
|
||||||
|
.font(.headline)
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 12) {
|
||||||
|
InsightCard(label: "Sessions", value: "\(viewModel.sessions.count)")
|
||||||
|
InsightCard(label: "Messages", value: "\(viewModel.totalMessages)")
|
||||||
|
InsightCard(label: "User Messages", value: "\(viewModel.userMessageCount)")
|
||||||
|
InsightCard(label: "Tool Calls", value: "\(viewModel.totalToolCalls)")
|
||||||
|
InsightCard(label: "Input Tokens", value: formatTokens(viewModel.totalInputTokens))
|
||||||
|
InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens))
|
||||||
|
InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens))
|
||||||
|
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
|
||||||
|
InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens))
|
||||||
|
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
|
||||||
|
InsightCard(label: "Total Cost", value: String(format: "$%.2f", viewModel.totalCost))
|
||||||
|
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
|
||||||
|
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration))
|
||||||
|
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Models
|
||||||
|
|
||||||
|
private var modelSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Models")
|
||||||
|
.font(.headline)
|
||||||
|
if viewModel.modelUsage.isEmpty {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.modelUsage) { model in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "cpu")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.frame(width: 20)
|
||||||
|
Text(model.model)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("\(model.sessions) sessions")
|
||||||
|
.font(.caption)
|
||||||
|
Text(formatTokens(model.totalTokens) + " tokens")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Platforms
|
||||||
|
|
||||||
|
private var platformSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Platforms")
|
||||||
|
.font(.headline)
|
||||||
|
if viewModel.platformUsage.isEmpty {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(viewModel.platformUsage) { platform in
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: platformIcon(platform.platform))
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
Text(platform.platform)
|
||||||
|
.font(.caption.bold())
|
||||||
|
Text("\(platform.sessions) sessions")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("\(platform.messages) msgs")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tools
|
||||||
|
|
||||||
|
private var toolsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Top Tools")
|
||||||
|
.font(.headline)
|
||||||
|
if viewModel.toolUsage.isEmpty {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
let maxCount = viewModel.toolUsage.first?.count ?? 1
|
||||||
|
ForEach(viewModel.toolUsage.prefix(15)) { tool in
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(tool.name)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.frame(width: 140, alignment: .trailing)
|
||||||
|
GeometryReader { geo in
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(barColor(for: tool.name))
|
||||||
|
.frame(width: max(4, geo.size.width * Double(tool.count) / Double(maxCount)))
|
||||||
|
}
|
||||||
|
.frame(height: 16)
|
||||||
|
Text("\(tool.count)")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 40, alignment: .trailing)
|
||||||
|
Text(String(format: "%.1f%%", tool.percentage))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.frame(height: 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Activity Patterns
|
||||||
|
|
||||||
|
private var activitySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Activity Patterns")
|
||||||
|
.font(.headline)
|
||||||
|
HStack(alignment: .top, spacing: 24) {
|
||||||
|
dayOfWeekChart
|
||||||
|
hourlyChart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dayOfWeekChart: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("By Day")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
|
let maxVal = max(1, viewModel.dailyActivity.values.max() ?? 1)
|
||||||
|
ForEach(0..<7, id: \.self) { day in
|
||||||
|
let count = viewModel.dailyActivity[day] ?? 0
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(dayNames[day])
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.frame(width: 30, alignment: .trailing)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.accentColor.opacity(0.7))
|
||||||
|
.frame(width: max(0, CGFloat(count) / CGFloat(maxVal) * 120), height: 14)
|
||||||
|
if count > 0 {
|
||||||
|
Text("\(count)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hourlyChart: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("By Hour")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
let maxVal = max(1, viewModel.hourlyActivity.values.max() ?? 1)
|
||||||
|
HStack(alignment: .bottom, spacing: 2) {
|
||||||
|
ForEach(0..<24, id: \.self) { hour in
|
||||||
|
let count = viewModel.hourlyActivity[hour] ?? 0
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(count > 0 ? Color.accentColor.opacity(0.7) : Color.secondary.opacity(0.15))
|
||||||
|
.frame(width: 12, height: max(4, CGFloat(count) / CGFloat(maxVal) * 80))
|
||||||
|
if hour % 6 == 0 {
|
||||||
|
Text("\(hour)")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notable Sessions
|
||||||
|
|
||||||
|
private var notableSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Notable Sessions")
|
||||||
|
.font(.headline)
|
||||||
|
if viewModel.notableSessions.isEmpty {
|
||||||
|
Text("No data")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.notableSessions) { notable in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(notable.label)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(notable.preview)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(notable.value)
|
||||||
|
.font(.system(.body, design: .monospaced, weight: .semibold))
|
||||||
|
Button {
|
||||||
|
coordinator.selectedSessionId = notable.session.id
|
||||||
|
coordinator.selectedSection = .sessions
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.right.circle")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Open session")
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func platformIcon(_ platform: String) -> String {
|
||||||
|
KnownPlatforms.icon(for: platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func barColor(for toolName: String) -> Color {
|
||||||
|
switch toolName {
|
||||||
|
case "terminal", "execute_code": return .orange
|
||||||
|
case "read_file", "search_files": return .green
|
||||||
|
case "write_file", "patch": return .blue
|
||||||
|
case "web_search", "web_extract": return .purple
|
||||||
|
case _ where toolName.hasPrefix("browser"): return .indigo
|
||||||
|
case "memory": return .pink
|
||||||
|
case "vision", "image_gen": return .mint
|
||||||
|
default: return Color.accentColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InsightCard: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(value)
|
||||||
|
.font(.system(.title3, design: .monospaced, weight: .semibold))
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(10)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,13 @@ final class LogsViewModel {
|
|||||||
private let logService = HermesLogService()
|
private let logService = HermesLogService()
|
||||||
|
|
||||||
var entries: [LogEntry] = []
|
var entries: [LogEntry] = []
|
||||||
var selectedLogFile: LogFile = .errors
|
var selectedLogFile: LogFile = .agent
|
||||||
var filterLevel: LogEntry.LogLevel?
|
var filterLevel: LogEntry.LogLevel?
|
||||||
var searchText = ""
|
var searchText = ""
|
||||||
private var pollTimer: Timer?
|
private var pollTimer: Timer?
|
||||||
|
|
||||||
enum LogFile: String, CaseIterable, Identifiable {
|
enum LogFile: String, CaseIterable, Identifiable {
|
||||||
|
case agent = "agent.log"
|
||||||
case errors = "errors.log"
|
case errors = "errors.log"
|
||||||
case gateway = "gateway.log"
|
case gateway = "gateway.log"
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ final class LogsViewModel {
|
|||||||
|
|
||||||
var path: String {
|
var path: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .agent: return HermesPaths.agentLog
|
||||||
case .errors: return HermesPaths.errorsLog
|
case .errors: return HermesPaths.errorsLog
|
||||||
case .gateway: return HermesPaths.gatewayLog
|
case .gateway: return HermesPaths.gatewayLog
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ final class MemoryViewModel {
|
|||||||
|
|
||||||
var memoryContent = ""
|
var memoryContent = ""
|
||||||
var userContent = ""
|
var userContent = ""
|
||||||
|
var memoryProvider = ""
|
||||||
var isEditing = false
|
var isEditing = false
|
||||||
var editingFile: EditTarget = .memory
|
var editingFile: EditTarget = .memory
|
||||||
var editText = ""
|
var editText = ""
|
||||||
|
var profiles: [String] = []
|
||||||
|
var activeProfile = ""
|
||||||
|
|
||||||
enum EditTarget {
|
enum EditTarget {
|
||||||
case memory, user
|
case memory, user
|
||||||
@@ -17,9 +20,30 @@ final class MemoryViewModel {
|
|||||||
var memoryCharCount: Int { memoryContent.count }
|
var memoryCharCount: Int { memoryContent.count }
|
||||||
var userCharCount: Int { userContent.count }
|
var userCharCount: Int { userContent.count }
|
||||||
|
|
||||||
|
var hasExternalProvider: Bool {
|
||||||
|
let stripped = memoryProvider
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "'\""))
|
||||||
|
return !stripped.isEmpty && stripped != "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasMultipleProfiles: Bool { !profiles.isEmpty }
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
memoryContent = fileService.loadMemory()
|
let config = fileService.loadConfig()
|
||||||
userContent = fileService.loadUserProfile()
|
memoryProvider = config.memoryProvider
|
||||||
|
profiles = fileService.loadMemoryProfiles()
|
||||||
|
if activeProfile.isEmpty {
|
||||||
|
activeProfile = config.memoryProfile
|
||||||
|
}
|
||||||
|
memoryContent = fileService.loadMemory(profile: activeProfile)
|
||||||
|
userContent = fileService.loadUserProfile(profile: activeProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchProfile(_ profile: String) {
|
||||||
|
activeProfile = profile
|
||||||
|
memoryContent = fileService.loadMemory(profile: profile)
|
||||||
|
userContent = fileService.loadUserProfile(profile: profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
func startEditing(_ target: EditTarget) {
|
func startEditing(_ target: EditTarget) {
|
||||||
@@ -31,10 +55,10 @@ final class MemoryViewModel {
|
|||||||
func save() {
|
func save() {
|
||||||
switch editingFile {
|
switch editingFile {
|
||||||
case .memory:
|
case .memory:
|
||||||
fileService.saveMemory(editText)
|
fileService.saveMemory(editText, profile: activeProfile)
|
||||||
memoryContent = editText
|
memoryContent = editText
|
||||||
case .user:
|
case .user:
|
||||||
fileService.saveUserProfile(editText)
|
fileService.saveUserProfile(editText, profile: activeProfile)
|
||||||
userContent = editText
|
userContent = editText
|
||||||
}
|
}
|
||||||
isEditing = false
|
isEditing = false
|
||||||
|
|||||||
@@ -7,6 +7,35 @@ struct MemoryView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
if viewModel.hasMultipleProfiles {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("Profile")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Picker("", selection: Binding(
|
||||||
|
get: { viewModel.activeProfile },
|
||||||
|
set: { viewModel.switchProfile($0) }
|
||||||
|
)) {
|
||||||
|
Text("Default").tag("")
|
||||||
|
ForEach(viewModel.profiles, id: \.self) { profile in
|
||||||
|
Text(profile).tag(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if viewModel.hasExternalProvider {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
Text("Memory is managed by \(viewModel.memoryProvider). File contents shown here may be stale.")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.orange.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
memorySection("Agent Memory", content: viewModel.memoryContent, charCount: viewModel.memoryCharCount, target: .memory)
|
memorySection("Agent Memory", content: viewModel.memoryContent, charCount: viewModel.memoryCharCount, target: .memory)
|
||||||
memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user)
|
memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user)
|
||||||
}
|
}
|
||||||
@@ -42,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))
|
||||||
@@ -64,14 +92,17 @@ struct MemoryView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
Divider()
|
Divider()
|
||||||
TextEditor(text: $viewModel.editText)
|
HSplitView {
|
||||||
.font(.system(.body, design: .monospaced))
|
TextEditor(text: $viewModel.editText)
|
||||||
.padding(8)
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.padding(8)
|
||||||
|
ScrollView {
|
||||||
|
MarkdownContentView(content: viewModel.editText)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 600, minHeight: 400)
|
.frame(minWidth: 800, minHeight: 500)
|
||||||
}
|
|
||||||
|
|
||||||
private func markdownAttributed(_ text: String) -> AttributedString {
|
|
||||||
(try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class ProjectsViewModel {
|
||||||
|
private let service = ProjectDashboardService()
|
||||||
|
|
||||||
|
var projects: [ProjectEntry] = []
|
||||||
|
var selectedProject: ProjectEntry?
|
||||||
|
var dashboard: ProjectDashboard?
|
||||||
|
var dashboardError: String?
|
||||||
|
var isLoading = false
|
||||||
|
|
||||||
|
func load() {
|
||||||
|
let registry = service.loadRegistry()
|
||||||
|
projects = registry.projects
|
||||||
|
if let selected = selectedProject, !projects.contains(where: { $0.name == selected.name }) {
|
||||||
|
selectedProject = nil
|
||||||
|
dashboard = nil
|
||||||
|
}
|
||||||
|
if let selected = selectedProject {
|
||||||
|
loadDashboard(for: selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectProject(_ project: ProjectEntry) {
|
||||||
|
selectedProject = project
|
||||||
|
loadDashboard(for: project)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addProject(name: String, path: String) {
|
||||||
|
var registry = service.loadRegistry()
|
||||||
|
guard !registry.projects.contains(where: { $0.name == name }) else { return }
|
||||||
|
let entry = ProjectEntry(name: name, path: path)
|
||||||
|
registry.projects.append(entry)
|
||||||
|
service.saveRegistry(registry)
|
||||||
|
projects = registry.projects
|
||||||
|
selectProject(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeProject(_ project: ProjectEntry) {
|
||||||
|
var registry = service.loadRegistry()
|
||||||
|
registry.projects.removeAll { $0.name == project.name }
|
||||||
|
service.saveRegistry(registry)
|
||||||
|
projects = registry.projects
|
||||||
|
if selectedProject?.name == project.name {
|
||||||
|
selectedProject = nil
|
||||||
|
dashboard = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshDashboard() {
|
||||||
|
guard let project = selectedProject else { return }
|
||||||
|
loadDashboard(for: project)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dashboardPaths: [String] {
|
||||||
|
projects.map(\.dashboardPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadDashboard(for project: ProjectEntry) {
|
||||||
|
dashboardError = nil
|
||||||
|
if !service.dashboardExists(for: project) {
|
||||||
|
dashboard = nil
|
||||||
|
dashboardError = "No dashboard found at \(project.dashboardPath)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let loaded = service.loadDashboard(for: project) {
|
||||||
|
dashboard = loaded
|
||||||
|
} else {
|
||||||
|
dashboard = nil
|
||||||
|
dashboardError = "Failed to parse dashboard JSON"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private enum DashboardTab: String, CaseIterable {
|
||||||
|
case dashboard = "Dashboard"
|
||||||
|
case site = "Site"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectsView: View {
|
||||||
|
@State private var viewModel = ProjectsViewModel()
|
||||||
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
@State private var showingAddSheet = false
|
||||||
|
@State private var selectedTab: DashboardTab = .dashboard
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HSplitView {
|
||||||
|
projectList
|
||||||
|
.frame(minWidth: 180, maxWidth: 220)
|
||||||
|
dashboardArea
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
.navigationTitle("Projects")
|
||||||
|
.task {
|
||||||
|
viewModel.load()
|
||||||
|
if let name = coordinator.selectedProjectName,
|
||||||
|
let project = viewModel.projects.first(where: { $0.name == name }) {
|
||||||
|
viewModel.selectProject(project)
|
||||||
|
}
|
||||||
|
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||||
|
}
|
||||||
|
.onChange(of: fileWatcher.lastChangeDate) {
|
||||||
|
viewModel.load()
|
||||||
|
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Project List
|
||||||
|
|
||||||
|
private var projectList: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
List(viewModel.projects, selection: Binding(
|
||||||
|
get: { viewModel.selectedProject },
|
||||||
|
set: { project in
|
||||||
|
if let project {
|
||||||
|
viewModel.selectProject(project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) { project in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: viewModel.dashboard != nil && viewModel.selectedProject == project
|
||||||
|
? "square.grid.2x2.fill" : "square.grid.2x2")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(project.name)
|
||||||
|
}
|
||||||
|
.tag(project)
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
HStack {
|
||||||
|
Button(action: { showingAddSheet = true }) {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
Spacer()
|
||||||
|
if let selected = viewModel.selectedProject {
|
||||||
|
Button(action: { viewModel.removeProject(selected) }) {
|
||||||
|
Image(systemName: "minus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingAddSheet) {
|
||||||
|
AddProjectSheet { name, path in
|
||||||
|
viewModel.addProject(name: name, path: path)
|
||||||
|
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dashboard Area
|
||||||
|
|
||||||
|
/// First webview widget found across all sections, if any.
|
||||||
|
private var siteWidget: DashboardWidget? {
|
||||||
|
viewModel.dashboard?.sections
|
||||||
|
.flatMap(\.widgets)
|
||||||
|
.first { $0.type == "webview" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var dashboardArea: some View {
|
||||||
|
if let dashboard = viewModel.dashboard {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
dashboardHeader(dashboard)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
if siteWidget != nil {
|
||||||
|
tabBar
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
switch selectedTab {
|
||||||
|
case .dashboard:
|
||||||
|
widgetsTab(dashboard)
|
||||||
|
case .site:
|
||||||
|
if let widget = siteWidget {
|
||||||
|
siteTab(widget)
|
||||||
|
} else {
|
||||||
|
widgetsTab(dashboard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let error = viewModel.dashboardError {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("No Dashboard", systemImage: "square.grid.2x2")
|
||||||
|
} description: {
|
||||||
|
Text(error)
|
||||||
|
}
|
||||||
|
} else if viewModel.projects.isEmpty {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("No Projects", systemImage: "square.grid.2x2")
|
||||||
|
} description: {
|
||||||
|
Text("Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets.")
|
||||||
|
} actions: {
|
||||||
|
Button("Add Project") { showingAddSheet = true }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("Select a Project", systemImage: "square.grid.2x2")
|
||||||
|
} description: {
|
||||||
|
Text("Choose a project from the sidebar to view its dashboard.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tabBar: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(DashboardTab.allCases, id: \.self) { tab in
|
||||||
|
Button {
|
||||||
|
selectedTab = tab
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
|
||||||
|
.font(.caption)
|
||||||
|
Text(tab.rawValue)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear)
|
||||||
|
.foregroundStyle(selectedTab == tab ? .primary : .secondary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func widgetsTab(_ dashboard: ProjectDashboard) -> some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
ForEach(dashboard.sections) { section in
|
||||||
|
DashboardSectionView(section: section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func siteTab(_ widget: DashboardWidget) -> some View {
|
||||||
|
WebviewWidgetView(widget: widget, fullCanvas: true)
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dashboardHeader(_ dashboard: ProjectDashboard) -> some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(dashboard.title)
|
||||||
|
.font(.title2.bold())
|
||||||
|
if let desc = dashboard.description {
|
||||||
|
Text(desc)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let updated = dashboard.updatedAt {
|
||||||
|
Text("Updated: \(updated)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Button(action: { viewModel.refreshDashboard() }) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
if let project = viewModel.selectedProject {
|
||||||
|
Button(action: { openInFinder(project.path) }) {
|
||||||
|
Image(systemName: "folder")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openInFinder(_ path: String) {
|
||||||
|
NSWorkspace.shared.open(URL(fileURLWithPath: path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Section View
|
||||||
|
|
||||||
|
struct DashboardSectionView: View {
|
||||||
|
let section: DashboardSection
|
||||||
|
|
||||||
|
/// Filter out webview widgets — those are rendered in the Site tab instead.
|
||||||
|
private var displayWidgets: [DashboardWidget] {
|
||||||
|
section.widgets.filter { $0.type != "webview" }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !displayWidgets.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(section.title)
|
||||||
|
.font(.headline)
|
||||||
|
LazyVGrid(
|
||||||
|
columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: section.columnCount),
|
||||||
|
spacing: 12
|
||||||
|
) {
|
||||||
|
ForEach(displayWidgets) { widget in
|
||||||
|
WidgetView(widget: widget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget Dispatcher
|
||||||
|
|
||||||
|
struct WidgetView: View {
|
||||||
|
let widget: DashboardWidget
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch widget.type {
|
||||||
|
case "stat":
|
||||||
|
StatWidgetView(widget: widget)
|
||||||
|
case "progress":
|
||||||
|
ProgressWidgetView(widget: widget)
|
||||||
|
case "text":
|
||||||
|
TextWidgetView(widget: widget)
|
||||||
|
case "table":
|
||||||
|
TableWidgetView(widget: widget)
|
||||||
|
case "chart":
|
||||||
|
ChartWidgetView(widget: widget)
|
||||||
|
case "list":
|
||||||
|
ListWidgetView(widget: widget)
|
||||||
|
case "webview":
|
||||||
|
WebviewWidgetView(widget: widget)
|
||||||
|
default:
|
||||||
|
VStack {
|
||||||
|
Image(systemName: "questionmark.square.dashed")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Unknown: \(widget.type)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 60)
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Add Project Sheet
|
||||||
|
|
||||||
|
struct AddProjectSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var projectName = ""
|
||||||
|
@State private var projectPath = ""
|
||||||
|
let onAdd: (String, String) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Add Project")
|
||||||
|
.font(.headline)
|
||||||
|
TextField("Project Name", text: $projectName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
HStack {
|
||||||
|
TextField("Project Path", text: $projectPath)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
Button("Browse...") {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = true
|
||||||
|
panel.canChooseFiles = false
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
if panel.runModal() == .OK, let url = panel.url {
|
||||||
|
projectPath = url.path
|
||||||
|
if projectName.isEmpty {
|
||||||
|
projectName = url.lastPathComponent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Button("Add") {
|
||||||
|
guard !projectName.isEmpty, !projectPath.isEmpty else { return }
|
||||||
|
onAdd(projectName, projectPath)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(projectName.isEmpty || projectPath.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(width: 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
// Flattened data point for Charts to avoid complex nested generic inference
|
||||||
|
private struct PlottablePoint: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let seriesName: String
|
||||||
|
let x: String
|
||||||
|
let y: Double
|
||||||
|
let color: Color
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChartWidgetView: View {
|
||||||
|
let widget: DashboardWidget
|
||||||
|
|
||||||
|
private var points: [PlottablePoint] {
|
||||||
|
guard let series = widget.series else { return [] }
|
||||||
|
return series.flatMap { s in
|
||||||
|
let color = parseColor(s.color)
|
||||||
|
return s.data.map { d in
|
||||||
|
PlottablePoint(seriesName: s.name, x: d.x, y: d.y, color: color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(widget.title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
chartContent
|
||||||
|
.frame(height: 150)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var chartContent: some View {
|
||||||
|
switch widget.chartType {
|
||||||
|
case "pie":
|
||||||
|
pieChart
|
||||||
|
case "bar":
|
||||||
|
barChart
|
||||||
|
default:
|
||||||
|
lineChart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lineChart: some View {
|
||||||
|
Chart(points) { point in
|
||||||
|
LineMark(
|
||||||
|
x: .value("X", point.x),
|
||||||
|
y: .value("Y", point.y)
|
||||||
|
)
|
||||||
|
.foregroundStyle(point.color)
|
||||||
|
.symbol(by: .value("Series", point.seriesName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var barChart: some View {
|
||||||
|
Chart(points) { point in
|
||||||
|
BarMark(
|
||||||
|
x: .value("X", point.x),
|
||||||
|
y: .value("Y", point.y)
|
||||||
|
)
|
||||||
|
.foregroundStyle(point.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pieChart: some View {
|
||||||
|
Chart(points) { point in
|
||||||
|
SectorMark(
|
||||||
|
angle: .value(point.x, point.y),
|
||||||
|
innerRadius: .ratio(0.5)
|
||||||
|
)
|
||||||
|
.foregroundStyle(point.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ListWidgetView: View {
|
||||||
|
let widget: DashboardWidget
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let icon = widget.icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Text(widget.title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let items = widget.items {
|
||||||
|
ForEach(items) { item in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: statusIcon(item.status))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(statusColor(item.status))
|
||||||
|
Text(item.text)
|
||||||
|
.font(.callout)
|
||||||
|
.strikethrough(item.status == "done")
|
||||||
|
.foregroundStyle(item.status == "done" ? .secondary : .primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusIcon(_ status: String?) -> String {
|
||||||
|
switch status {
|
||||||
|
case "done": return "checkmark.circle.fill"
|
||||||
|
case "active": return "circle.inset.filled"
|
||||||
|
case "pending": return "circle"
|
||||||
|
default: return "circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusColor(_ status: String?) -> Color {
|
||||||
|
switch status {
|
||||||
|
case "done": return .green
|
||||||
|
case "active": return .blue
|
||||||
|
default: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ProgressWidgetView: View {
|
||||||
|
let widget: DashboardWidget
|
||||||
|
|
||||||
|
private var progressValue: Double {
|
||||||
|
switch widget.value {
|
||||||
|
case .number(let n): return n
|
||||||
|
default: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(widget.title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
ProgressView(value: progressValue) {
|
||||||
|
if let label = widget.label {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(parseColor(widget.color))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct StatWidgetView: View {
|
||||||
|
let widget: DashboardWidget
|
||||||
|
|
||||||
|
private var widgetColor: Color {
|
||||||
|
parseColor(widget.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let icon = widget.icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(widgetColor)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Text(widget.title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let value = widget.value {
|
||||||
|
Text(value.displayString)
|
||||||
|
.font(.system(.title2, design: .monospaced, weight: .semibold))
|
||||||
|
}
|
||||||
|
if let subtitle = widget.subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(widgetColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TableWidgetView: View {
|
||||||
|
let widget: DashboardWidget
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(widget.title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if let columns = widget.columns, let rows = widget.rows {
|
||||||
|
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) {
|
||||||
|
GridRow {
|
||||||
|
ForEach(columns, id: \.self) { col in
|
||||||
|
Text(col)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||||||
|
GridRow {
|
||||||
|
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
|
||||||
|
Text(cell)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TextWidgetView: View {
|
||||||
|
let widget: DashboardWidget
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(widget.title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if let content = widget.content {
|
||||||
|
if widget.format == "markdown" {
|
||||||
|
MarkdownContentView(content: content)
|
||||||
|
} else {
|
||||||
|
Text(content)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
struct WebviewWidgetView: View {
|
||||||
|
let widget: DashboardWidget
|
||||||
|
var fullCanvas: Bool = false
|
||||||
|
|
||||||
|
private var webURL: URL? {
|
||||||
|
guard let urlString = widget.url else { return nil }
|
||||||
|
return URL(string: urlString)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var viewHeight: CGFloat {
|
||||||
|
CGFloat(widget.height ?? 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if fullCanvas {
|
||||||
|
fullCanvasView
|
||||||
|
} else {
|
||||||
|
cardView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Full Canvas (Site tab)
|
||||||
|
|
||||||
|
private var fullCanvasView: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if let url = webURL {
|
||||||
|
WebViewRepresentable(url: url)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("Invalid URL", systemImage: "globe")
|
||||||
|
} description: {
|
||||||
|
Text(widget.url ?? "No URL provided")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Card (inline widget)
|
||||||
|
|
||||||
|
private var cardView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
if let icon = widget.icon {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
Text(widget.title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
if let urlString = widget.url {
|
||||||
|
Text(urlString)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let url = webURL {
|
||||||
|
WebViewRepresentable(url: url)
|
||||||
|
.frame(height: viewHeight)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("Invalid URL", systemImage: "globe")
|
||||||
|
} description: {
|
||||||
|
Text(widget.url ?? "No URL provided")
|
||||||
|
}
|
||||||
|
.frame(height: viewHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WKWebView Wrapper
|
||||||
|
|
||||||
|
private struct WebViewRepresentable: NSViewRepresentable {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> WKWebView {
|
||||||
|
let config = WKWebViewConfiguration()
|
||||||
|
config.websiteDataStore = .nonPersistent()
|
||||||
|
let webView = WKWebView(frame: .zero, configuration: config)
|
||||||
|
webView.navigationDelegate = context.coordinator
|
||||||
|
webView.load(URLRequest(url: url))
|
||||||
|
return webView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ webView: WKWebView, context: Context) {
|
||||||
|
if webView.url != url {
|
||||||
|
webView.load(URLRequest(url: url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, WKNavigationDelegate {
|
||||||
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
|
print("[Scarf] WebView navigation failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||||
|
print("[Scarf] WebView failed to load: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
func parseColor(_ name: String?) -> Color {
|
||||||
|
switch name?.lowercased() {
|
||||||
|
case "red": return .red
|
||||||
|
case "orange": return .orange
|
||||||
|
case "yellow": return .yellow
|
||||||
|
case "green": return .green
|
||||||
|
case "blue": return .blue
|
||||||
|
case "purple": return .purple
|
||||||
|
case "pink": return .pink
|
||||||
|
case "teal", "cyan": return .teal
|
||||||
|
case "indigo": return .indigo
|
||||||
|
case "mint": return .mint
|
||||||
|
case "brown": return .brown
|
||||||
|
case "gray", "grey": return .gray
|
||||||
|
default: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
struct SessionStoreStats {
|
||||||
|
let totalSessions: Int
|
||||||
|
let totalMessages: Int
|
||||||
|
let databaseSize: String
|
||||||
|
let platformCounts: [(platform: String, count: Int)]
|
||||||
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class SessionsViewModel {
|
final class SessionsViewModel {
|
||||||
@@ -11,12 +20,21 @@ final class SessionsViewModel {
|
|||||||
var searchText = ""
|
var searchText = ""
|
||||||
var searchResults: [HermesMessage] = []
|
var searchResults: [HermesMessage] = []
|
||||||
var isSearching = false
|
var isSearching = false
|
||||||
|
var storeStats: SessionStoreStats?
|
||||||
|
var subagentSessions: [HermesSession] = []
|
||||||
|
|
||||||
|
var renameSessionId: String?
|
||||||
|
var renameText = ""
|
||||||
|
var showRenameSheet = false
|
||||||
|
var showDeleteConfirmation = false
|
||||||
|
var deleteSessionId: String?
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
let opened = await dataService.open()
|
let opened = await dataService.open()
|
||||||
guard opened else { return }
|
guard opened else { return }
|
||||||
sessions = await dataService.fetchSessions(limit: 500)
|
sessions = await dataService.fetchSessions(limit: 500)
|
||||||
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
|
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
|
||||||
|
computeStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
func previewFor(_ session: HermesSession) -> String {
|
func previewFor(_ session: HermesSession) -> String {
|
||||||
@@ -28,6 +46,7 @@ final class SessionsViewModel {
|
|||||||
func selectSession(_ session: HermesSession) async {
|
func selectSession(_ session: HermesSession) async {
|
||||||
selectedSession = session
|
selectedSession = session
|
||||||
messages = await dataService.fetchMessages(sessionId: session.id)
|
messages = await dataService.fetchMessages(sessionId: session.id)
|
||||||
|
subagentSessions = await dataService.fetchSubagentSessions(parentId: session.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectSessionById(_ id: String) async {
|
func selectSessionById(_ id: String) async {
|
||||||
@@ -50,4 +69,122 @@ final class SessionsViewModel {
|
|||||||
func cleanup() async {
|
func cleanup() async {
|
||||||
await dataService.close()
|
await dataService.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Actions
|
||||||
|
|
||||||
|
func beginRename(_ session: HermesSession) {
|
||||||
|
renameSessionId = session.id
|
||||||
|
renameText = previewFor(session)
|
||||||
|
showRenameSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmRename() {
|
||||||
|
guard let sessionId = renameSessionId else { return }
|
||||||
|
let title = renameText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !title.isEmpty else { return }
|
||||||
|
let result = runHermes(["sessions", "rename", sessionId, title])
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
if let idx = sessions.firstIndex(where: { $0.id == sessionId }) {
|
||||||
|
let updated = sessions[idx].withTitle(title)
|
||||||
|
sessions[idx] = updated
|
||||||
|
if selectedSession?.id == sessionId {
|
||||||
|
selectedSession = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessionPreviews[sessionId] = title
|
||||||
|
}
|
||||||
|
showRenameSheet = false
|
||||||
|
renameSessionId = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginDelete(_ session: HermesSession) {
|
||||||
|
deleteSessionId = session.id
|
||||||
|
showDeleteConfirmation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmDelete() {
|
||||||
|
guard let sessionId = deleteSessionId else { return }
|
||||||
|
let result = runHermes(["sessions", "delete", "--yes", sessionId])
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
sessions.removeAll { $0.id == sessionId }
|
||||||
|
if selectedSession?.id == sessionId {
|
||||||
|
selectedSession = nil
|
||||||
|
messages = []
|
||||||
|
}
|
||||||
|
computeStats()
|
||||||
|
}
|
||||||
|
showDeleteConfirmation = false
|
||||||
|
deleteSessionId = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportSession(_ session: HermesSession) {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = "\(session.id).jsonl"
|
||||||
|
panel.allowedContentTypes = [.json]
|
||||||
|
panel.canCreateDirectories = true
|
||||||
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||||
|
runHermes(["sessions", "export", url.path, "--session-id", session.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportAll() {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = "hermes-sessions.jsonl"
|
||||||
|
panel.allowedContentTypes = [.json]
|
||||||
|
panel.canCreateDirectories = true
|
||||||
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||||
|
runHermes(["sessions", "export", url.path])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stats
|
||||||
|
|
||||||
|
private func computeStats() {
|
||||||
|
let totalMessages = sessions.reduce(0) { $0 + $1.messageCount }
|
||||||
|
|
||||||
|
var platformCounts: [String: Int] = [:]
|
||||||
|
for s in sessions {
|
||||||
|
platformCounts[s.source, default: 0] += 1
|
||||||
|
}
|
||||||
|
let sorted = platformCounts.sorted { $0.value > $1.value }.map { (platform: $0.key, count: $0.value) }
|
||||||
|
|
||||||
|
let dbPath = HermesPaths.stateDB
|
||||||
|
let fileSize: String
|
||||||
|
if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath),
|
||||||
|
let size = attrs[.size] as? Int {
|
||||||
|
if Double(size) >= FileSizeUnit.megabyte {
|
||||||
|
fileSize = String(format: "%.1f MB", Double(size) / FileSizeUnit.megabyte)
|
||||||
|
} else {
|
||||||
|
fileSize = String(format: "%.0f KB", Double(size) / FileSizeUnit.kilobyte)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fileSize = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
storeStats = SessionStoreStats(
|
||||||
|
totalSessions: sessions.count,
|
||||||
|
totalMessages: totalMessages,
|
||||||
|
databaseSize: fileSize,
|
||||||
|
platformCounts: sorted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hermes CLI
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||||
|
process.arguments = arguments
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = Pipe()
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
let output = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
return (output, process.terminationStatus)
|
||||||
|
} catch {
|
||||||
|
return ("", -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,19 @@ import SwiftUI
|
|||||||
struct SessionDetailView: View {
|
struct SessionDetailView: View {
|
||||||
let session: HermesSession
|
let session: HermesSession
|
||||||
let messages: [HermesMessage]
|
let messages: [HermesMessage]
|
||||||
|
var subagentSessions: [HermesSession] = []
|
||||||
var preview: String?
|
var preview: String?
|
||||||
|
var onRename: (() -> Void)?
|
||||||
|
var onExport: (() -> Void)?
|
||||||
|
var onDelete: (() -> Void)?
|
||||||
|
var onSelectSubagent: ((HermesSession) -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
sessionHeader
|
sessionHeader
|
||||||
|
if !subagentSessions.isEmpty {
|
||||||
|
subagentSection
|
||||||
|
}
|
||||||
Divider()
|
Divider()
|
||||||
messagesList
|
messagesList
|
||||||
}
|
}
|
||||||
@@ -16,15 +24,43 @@ struct SessionDetailView: View {
|
|||||||
|
|
||||||
private var sessionHeader: some View {
|
private var sessionHeader: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(preview ?? session.displayTitle)
|
HStack {
|
||||||
.font(.title3.bold())
|
Text(preview ?? session.displayTitle)
|
||||||
|
.font(.title3.bold())
|
||||||
|
Spacer()
|
||||||
|
if onRename != nil || onExport != nil || onDelete != nil {
|
||||||
|
Menu {
|
||||||
|
if let onRename { Button("Rename...") { onRename() } }
|
||||||
|
if let onExport { Button("Export...") { onExport() } }
|
||||||
|
if let onDelete {
|
||||||
|
Divider()
|
||||||
|
Button("Delete...", role: .destructive) { onDelete() }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
Label(session.source, systemImage: session.sourceIcon)
|
Label(session.source, systemImage: session.sourceIcon)
|
||||||
|
if session.isSubagent {
|
||||||
|
Label("Subagent", systemImage: "arrow.triangle.branch")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
if let userId = session.userId, !userId.isEmpty, session.source != "cli" {
|
||||||
|
Label(userId, systemImage: "person")
|
||||||
|
}
|
||||||
Label(session.model ?? "unknown", systemImage: "cpu")
|
Label(session.model ?? "unknown", systemImage: "cpu")
|
||||||
Label("\(session.messageCount) msgs", systemImage: "bubble.left")
|
Label("\(session.messageCount) msgs", systemImage: "bubble.left")
|
||||||
Label("\(session.toolCallCount) tools", systemImage: "wrench")
|
Label("\(session.toolCallCount) tools", systemImage: "wrench")
|
||||||
if let cost = session.estimatedCostUSD {
|
if session.reasoningTokens > 0 {
|
||||||
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
|
Label("\(session.reasoningTokens) reasoning", systemImage: "brain")
|
||||||
|
}
|
||||||
|
if let cost = session.displayCostUSD {
|
||||||
|
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
|
||||||
}
|
}
|
||||||
if let date = session.startedAt {
|
if let date = session.startedAt {
|
||||||
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
|
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
|
||||||
@@ -32,10 +68,46 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
Text(session.id)
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.textSelection(.enabled)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var subagentSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Divider()
|
||||||
|
Text("Subagent Sessions (\(subagentSessions.count))")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
ForEach(subagentSessions) { sub in
|
||||||
|
Button {
|
||||||
|
onSelectSubagent?(sub)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "arrow.triangle.branch")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text(sub.displayTitle)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Text(sub.model ?? "")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text("\(sub.messageCount) msgs")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
|
||||||
private var messagesList: some View {
|
private var messagesList: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 12) {
|
LazyVStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -56,9 +128,23 @@ struct MessageBubble: View {
|
|||||||
HStack {
|
HStack {
|
||||||
if message.isUser { Spacer(minLength: 60) }
|
if message.isUser { Spacer(minLength: 60) }
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
if message.hasReasoning {
|
||||||
|
DisclosureGroup("Reasoning") {
|
||||||
|
Text(message.reasoning ?? "")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
if !message.content.isEmpty {
|
if !message.content.isEmpty {
|
||||||
Text(message.content)
|
if message.isAssistant {
|
||||||
.textSelection(.enabled)
|
MarkdownContentView(content: message.content)
|
||||||
|
} else {
|
||||||
|
Text(message.content)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !message.toolCalls.isEmpty {
|
if !message.toolCalls.isEmpty {
|
||||||
ForEach(message.toolCalls) { call in
|
ForEach(message.toolCalls) { call in
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ struct SessionsView: View {
|
|||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HSplitView {
|
VStack(spacing: 0) {
|
||||||
sessionList
|
if let stats = viewModel.storeStats {
|
||||||
.frame(minWidth: 280, idealWidth: 320)
|
statsBar(stats)
|
||||||
sessionDetail
|
Divider()
|
||||||
.frame(minWidth: 400)
|
}
|
||||||
|
HSplitView {
|
||||||
|
sessionList
|
||||||
|
.frame(minWidth: 280, idealWidth: 320)
|
||||||
|
sessionDetail
|
||||||
|
.frame(minWidth: 400)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Sessions")
|
.navigationTitle("Sessions")
|
||||||
.searchable(text: $viewModel.searchText, prompt: "Search messages...")
|
.searchable(text: $viewModel.searchText, prompt: "Search messages...")
|
||||||
@@ -28,6 +34,33 @@ struct SessionsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear { Task { await viewModel.cleanup() } }
|
.onDisappear { Task { await viewModel.cleanup() } }
|
||||||
|
.sheet(isPresented: $viewModel.showRenameSheet) {
|
||||||
|
renameSheet
|
||||||
|
}
|
||||||
|
.confirmationDialog("Delete Session?", isPresented: $viewModel.showDeleteConfirmation) {
|
||||||
|
Button("Delete", role: .destructive) { viewModel.confirmDelete() }
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("This will permanently delete the session and all its messages.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statsBar(_ stats: SessionStoreStats) -> some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Label("\(stats.totalSessions) sessions", systemImage: "bubble.left.and.bubble.right")
|
||||||
|
Label("\(stats.totalMessages) messages", systemImage: "text.bubble")
|
||||||
|
Label(stats.databaseSize, systemImage: "internaldrive")
|
||||||
|
ForEach(stats.platformCounts, id: \.platform) { item in
|
||||||
|
Label("\(item.count) \(item.platform)", systemImage: platformIcon(item.platform))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Export All") { viewModel.exportAll() }
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sessionList: some View {
|
private var sessionList: some View {
|
||||||
@@ -64,6 +97,12 @@ struct SessionsView: View {
|
|||||||
ForEach(viewModel.sessions) { session in
|
ForEach(viewModel.sessions) { session in
|
||||||
SessionRow(session: session, preview: viewModel.previewFor(session))
|
SessionRow(session: session, preview: viewModel.previewFor(session))
|
||||||
.tag(session.id)
|
.tag(session.id)
|
||||||
|
.contextMenu {
|
||||||
|
Button("Rename...") { viewModel.beginRename(session) }
|
||||||
|
Button("Export...") { viewModel.exportSession(session) }
|
||||||
|
Divider()
|
||||||
|
Button("Delete...", role: .destructive) { viewModel.beginDelete(session) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,11 +112,47 @@ struct SessionsView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var sessionDetail: some View {
|
private var sessionDetail: some View {
|
||||||
if let session = viewModel.selectedSession {
|
if let session = viewModel.selectedSession {
|
||||||
SessionDetailView(session: session, messages: viewModel.messages, preview: viewModel.previewFor(session))
|
SessionDetailView(
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
session: session,
|
||||||
|
messages: viewModel.messages,
|
||||||
|
subagentSessions: viewModel.subagentSessions,
|
||||||
|
preview: viewModel.previewFor(session),
|
||||||
|
onRename: { viewModel.beginRename(session) },
|
||||||
|
onExport: { viewModel.exportSession(session) },
|
||||||
|
onDelete: { viewModel.beginDelete(session) },
|
||||||
|
onSelectSubagent: { sub in
|
||||||
|
Task { await viewModel.selectSession(sub) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
} else {
|
} else {
|
||||||
ContentUnavailableView("Select a Session", systemImage: "bubble.left.and.bubble.right", description: Text("Choose a session from the list"))
|
ContentUnavailableView("Select a Session", systemImage: "bubble.left.and.bubble.right", description: Text("Choose a session from the list"))
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var renameSheet: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Rename Session")
|
||||||
|
.font(.headline)
|
||||||
|
TextField("Session title", text: $viewModel.renameText)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onSubmit { viewModel.confirmRename() }
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { viewModel.showRenameSheet = false }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Button("Rename") { viewModel.confirmRename() }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(viewModel.renameText.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(width: 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func platformIcon(_ platform: String) -> String {
|
||||||
|
KnownPlatforms.icon(for: platform)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class SettingsViewModel {
|
final class SettingsViewModel {
|
||||||
@@ -8,11 +9,112 @@ final class SettingsViewModel {
|
|||||||
var gatewayState: GatewayState?
|
var gatewayState: GatewayState?
|
||||||
var hermesRunning = false
|
var hermesRunning = false
|
||||||
var rawConfigYAML = ""
|
var rawConfigYAML = ""
|
||||||
|
var personalities: [String] = []
|
||||||
|
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "google-ai-studio", "xai", "ollama-cloud", "zai", "kimi-coding", "minimax"]
|
||||||
|
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
|
||||||
|
var browserBackends = ["browseruse", "firecrawl", "local"]
|
||||||
|
var saveMessage: String?
|
||||||
|
var showAuthRemoveConfirmation = false
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
config = fileService.loadConfig()
|
config = fileService.loadConfig()
|
||||||
gatewayState = fileService.loadGatewayState()
|
gatewayState = fileService.loadGatewayState()
|
||||||
hermesRunning = fileService.isHermesRunning()
|
hermesRunning = fileService.isHermesRunning()
|
||||||
rawConfigYAML = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
do {
|
||||||
|
rawConfigYAML = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
|
||||||
|
rawConfigYAML = ""
|
||||||
|
}
|
||||||
|
personalities = parsePersonalities()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSetting(_ key: String, value: String) {
|
||||||
|
let result = runHermes(["config", "set", key, value])
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
saveMessage = "Saved \(key)"
|
||||||
|
config = fileService.loadConfig()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.saveMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setModel(_ value: String) { setSetting("model.default", value: value) }
|
||||||
|
func setProvider(_ value: String) { setSetting("model.provider", value: value) }
|
||||||
|
func setPersonality(_ value: String) { setSetting("display.personality", value: value) }
|
||||||
|
func setTerminalBackend(_ value: String) { setSetting("terminal.backend", value: value) }
|
||||||
|
func setMaxTurns(_ value: Int) { setSetting("agent.max_turns", value: String(value)) }
|
||||||
|
func setMemoryEnabled(_ value: Bool) { setSetting("memory.memory_enabled", value: value ? "true" : "false") }
|
||||||
|
func setMemoryCharLimit(_ value: Int) { setSetting("memory.memory_char_limit", value: String(value)) }
|
||||||
|
func setUserCharLimit(_ value: Int) { setSetting("memory.user_char_limit", value: String(value)) }
|
||||||
|
func setNudgeInterval(_ value: Int) { setSetting("memory.nudge_interval", value: String(value)) }
|
||||||
|
func setStreaming(_ value: Bool) { setSetting("display.streaming", value: value ? "true" : "false") }
|
||||||
|
func setShowReasoning(_ value: Bool) { setSetting("display.show_reasoning", value: value ? "true" : "false") }
|
||||||
|
func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") }
|
||||||
|
func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
|
||||||
|
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
|
||||||
|
func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) }
|
||||||
|
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
|
||||||
|
func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) }
|
||||||
|
func setBrowserBackend(_ value: String) { setSetting("browser.backend", value: value) }
|
||||||
|
|
||||||
|
func removeAuth() {
|
||||||
|
let result = runHermes(["auth", "remove"])
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
saveMessage = "Credentials removed"
|
||||||
|
} else {
|
||||||
|
saveMessage = "Failed to remove credentials"
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.saveMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openConfigInEditor() {
|
||||||
|
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parsePersonalities() -> [String] {
|
||||||
|
var names: [String] = []
|
||||||
|
var inPersonalities = false
|
||||||
|
for line in rawConfigYAML.components(separatedBy: "\n") {
|
||||||
|
if line.trimmingCharacters(in: .whitespaces) == "personalities:" && line.hasPrefix(" ") {
|
||||||
|
inPersonalities = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inPersonalities {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty { continue }
|
||||||
|
let indent = line.prefix(while: { $0 == " " }).count
|
||||||
|
if indent <= 2 && !trimmed.isEmpty {
|
||||||
|
inPersonalities = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if indent == 4 && trimmed.contains(":") {
|
||||||
|
let name = String(trimmed.split(separator: ":")[0])
|
||||||
|
names.append(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||||
|
process.arguments = arguments
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = Pipe()
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||||
|
} catch {
|
||||||
|
return ("", -1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,19 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
configSection
|
headerBar
|
||||||
gatewaySection
|
modelSection
|
||||||
|
displaySection
|
||||||
|
terminalSection
|
||||||
|
if !viewModel.config.dockerEnv.isEmpty {
|
||||||
|
dockerEnvSection
|
||||||
|
}
|
||||||
|
if !viewModel.config.commandAllowlist.isEmpty {
|
||||||
|
allowlistSection
|
||||||
|
}
|
||||||
|
voiceSection
|
||||||
|
memorySection
|
||||||
pathsSection
|
pathsSection
|
||||||
rawConfigSection
|
rawConfigSection
|
||||||
}
|
}
|
||||||
@@ -17,64 +27,138 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.onAppear { viewModel.load() }
|
.onAppear { viewModel.load() }
|
||||||
}
|
.confirmationDialog("Remove Credentials?", isPresented: $viewModel.showAuthRemoveConfirmation) {
|
||||||
|
Button("Remove", role: .destructive) { viewModel.removeAuth() }
|
||||||
private var configSection: some View {
|
Button("Cancel", role: .cancel) {}
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
} message: {
|
||||||
Text("Configuration")
|
Text("This will permanently clear all stored provider credentials.")
|
||||||
.font(.headline)
|
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
|
||||||
SettingRow(label: "Model", value: viewModel.config.model)
|
|
||||||
SettingRow(label: "Provider", value: viewModel.config.provider)
|
|
||||||
SettingRow(label: "Personality", value: viewModel.config.personality)
|
|
||||||
SettingRow(label: "Max Turns", value: "\(viewModel.config.maxTurns)")
|
|
||||||
SettingRow(label: "Terminal Backend", value: viewModel.config.terminalBackend)
|
|
||||||
SettingRow(label: "Memory Enabled", value: viewModel.config.memoryEnabled ? "Yes" : "No")
|
|
||||||
SettingRow(label: "Memory Char Limit", value: "\(viewModel.config.memoryCharLimit)")
|
|
||||||
SettingRow(label: "User Char Limit", value: "\(viewModel.config.userCharLimit)")
|
|
||||||
SettingRow(label: "Nudge Interval", value: "\(viewModel.config.nudgeInterval) turns")
|
|
||||||
SettingRow(label: "Streaming", value: viewModel.config.streaming ? "Yes" : "No")
|
|
||||||
SettingRow(label: "Show Reasoning", value: viewModel.config.showReasoning ? "Yes" : "No")
|
|
||||||
SettingRow(label: "Verbose", value: viewModel.config.verbose ? "Yes" : "No")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var gatewaySection: some View {
|
private var headerBar: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
HStack {
|
||||||
Text("Gateway")
|
if let msg = viewModel.saveMessage {
|
||||||
.font(.headline)
|
Label(msg, systemImage: "checkmark.circle.fill")
|
||||||
HStack(spacing: 16) {
|
.font(.caption)
|
||||||
Label(
|
.foregroundStyle(.green)
|
||||||
viewModel.gatewayState?.statusText ?? "unknown",
|
}
|
||||||
systemImage: viewModel.gatewayState?.isRunning == true ? "circle.fill" : "circle"
|
Spacer()
|
||||||
)
|
Button("Open in Editor") { viewModel.openConfigInEditor() }
|
||||||
.foregroundStyle(viewModel.gatewayState?.isRunning == true ? .green : .secondary)
|
.controlSize(.small)
|
||||||
if let reason = viewModel.gatewayState?.exitReason {
|
Button("Reload") { viewModel.load() }
|
||||||
Text(reason)
|
.controlSize(.small)
|
||||||
.font(.caption)
|
}
|
||||||
.foregroundStyle(.secondary)
|
}
|
||||||
|
|
||||||
|
// MARK: - Model & Provider
|
||||||
|
|
||||||
|
private var modelSection: some View {
|
||||||
|
SettingsSection(title: "Model", icon: "cpu") {
|
||||||
|
EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($0) }
|
||||||
|
PickerRow(label: "Provider", selection: viewModel.config.provider, options: viewModel.providers) { viewModel.setProvider($0) }
|
||||||
|
HStack {
|
||||||
|
Text("Credentials")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 130, alignment: .trailing)
|
||||||
|
Button("Remove Credentials", role: .destructive) {
|
||||||
|
viewModel.showAuthRemoveConfirmation = true
|
||||||
}
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Display
|
||||||
|
|
||||||
|
private var displaySection: some View {
|
||||||
|
SettingsSection(title: "Display", icon: "paintbrush") {
|
||||||
|
if !viewModel.personalities.isEmpty {
|
||||||
|
PickerRow(label: "Personality", selection: viewModel.config.personality, options: viewModel.personalities) { viewModel.setPersonality($0) }
|
||||||
|
} else {
|
||||||
|
EditableTextField(label: "Personality", value: viewModel.config.personality) { viewModel.setPersonality($0) }
|
||||||
|
}
|
||||||
|
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
||||||
|
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
||||||
|
ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($0) }
|
||||||
|
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Terminal
|
||||||
|
|
||||||
|
private var terminalSection: some View {
|
||||||
|
SettingsSection(title: "Terminal", icon: "terminal") {
|
||||||
|
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) }
|
||||||
|
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
|
||||||
|
PickerRow(label: "Reasoning Effort", selection: viewModel.config.reasoningEffort, options: ["low", "medium", "high"]) { viewModel.setReasoningEffort($0) }
|
||||||
|
PickerRow(label: "Approval Mode", selection: viewModel.config.approvalMode, options: ["auto", "manual", "smart"]) { viewModel.setApprovalMode($0) }
|
||||||
|
PickerRow(label: "Browser Backend", selection: viewModel.config.browserBackend, options: viewModel.browserBackends) { viewModel.setBrowserBackend($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Docker Environment
|
||||||
|
|
||||||
|
private var dockerEnvSection: some View {
|
||||||
|
SettingsSection(title: "Docker Environment", icon: "shippingbox") {
|
||||||
|
ForEach(viewModel.config.dockerEnv.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
|
||||||
|
ReadOnlyRow(label: key, value: value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Command Allowlist
|
||||||
|
|
||||||
|
private var allowlistSection: some View {
|
||||||
|
SettingsSection(title: "Command Allowlist", icon: "checkmark.shield") {
|
||||||
|
ReadOnlyRow(label: "Commands", value: viewModel.config.commandAllowlist.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Voice
|
||||||
|
|
||||||
|
private var voiceSection: some View {
|
||||||
|
SettingsSection(title: "Voice", icon: "mic") {
|
||||||
|
ToggleRow(label: "Auto TTS", isOn: viewModel.config.autoTTS) { viewModel.setAutoTTS($0) }
|
||||||
|
StepperRow(label: "Silence Threshold", value: viewModel.config.silenceThreshold, range: 50...500) { viewModel.setSilenceThreshold($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Memory
|
||||||
|
|
||||||
|
private var memorySection: some View {
|
||||||
|
SettingsSection(title: "Memory", icon: "brain") {
|
||||||
|
ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) }
|
||||||
|
if !viewModel.config.memoryProfile.isEmpty {
|
||||||
|
ReadOnlyRow(label: "Profile", value: viewModel.config.memoryProfile)
|
||||||
|
}
|
||||||
|
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) }
|
||||||
|
StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) }
|
||||||
|
StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Paths
|
||||||
|
|
||||||
private var pathsSection: some View {
|
private var pathsSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
SettingsSection(title: "Paths", icon: "folder") {
|
||||||
Text("Paths")
|
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
||||||
.font(.headline)
|
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
PathRow(label: "Config", path: HermesPaths.configYAML)
|
||||||
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
|
||||||
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
|
||||||
PathRow(label: "Config", path: HermesPaths.configYAML)
|
PathRow(label: "Skills", path: HermesPaths.skillsDir)
|
||||||
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
|
PathRow(label: "Agent Log", path: HermesPaths.agentLog)
|
||||||
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
|
PathRow(label: "Error Log", path: HermesPaths.errorsLog)
|
||||||
PathRow(label: "Skills", path: HermesPaths.skillsDir)
|
|
||||||
PathRow(label: "Logs", path: HermesPaths.errorsLog)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Raw Config
|
||||||
|
|
||||||
private var rawConfigSection: some View {
|
private var rawConfigSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -98,7 +182,147 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SettingRow: View {
|
// MARK: - Reusable Components
|
||||||
|
|
||||||
|
struct SettingsSection<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
let icon: String
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Label(title, systemImage: icon)
|
||||||
|
.font(.headline)
|
||||||
|
VStack(spacing: 1) {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EditableTextField: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
let onCommit: (String) -> Void
|
||||||
|
@State private var text: String = ""
|
||||||
|
@State private var isEditing = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 130, alignment: .trailing)
|
||||||
|
if isEditing {
|
||||||
|
TextField(label, text: $text, onCommit: {
|
||||||
|
if text != value { onCommit(text) }
|
||||||
|
isEditing = false
|
||||||
|
})
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
Button("Cancel") { isEditing = false }
|
||||||
|
.controlSize(.mini)
|
||||||
|
} else {
|
||||||
|
Text(value)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
Button("Edit") {
|
||||||
|
text = value
|
||||||
|
isEditing = true
|
||||||
|
}
|
||||||
|
.controlSize(.mini)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PickerRow: View {
|
||||||
|
let label: String
|
||||||
|
let selection: String
|
||||||
|
let options: [String]
|
||||||
|
let onChange: (String) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 130, alignment: .trailing)
|
||||||
|
Picker("", selection: Binding(
|
||||||
|
get: { selection },
|
||||||
|
set: { onChange($0) }
|
||||||
|
)) {
|
||||||
|
ForEach(options, id: \.self) { option in
|
||||||
|
Text(option).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 250)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ToggleRow: View {
|
||||||
|
let label: String
|
||||||
|
let isOn: Bool
|
||||||
|
let onChange: (Bool) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 130, alignment: .trailing)
|
||||||
|
Toggle("", isOn: Binding(
|
||||||
|
get: { isOn },
|
||||||
|
set: { onChange($0) }
|
||||||
|
))
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.labelsHidden()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StepperRow: View {
|
||||||
|
let label: String
|
||||||
|
let value: Int
|
||||||
|
let range: ClosedRange<Int>
|
||||||
|
let onChange: (Int) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 130, alignment: .trailing)
|
||||||
|
Text("\(value)")
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.frame(width: 50)
|
||||||
|
Stepper("", value: Binding(
|
||||||
|
get: { value },
|
||||||
|
set: { onChange($0) }
|
||||||
|
), in: range)
|
||||||
|
.labelsHidden()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReadOnlyRow: View {
|
||||||
let label: String
|
let label: String
|
||||||
let value: String
|
let value: String
|
||||||
|
|
||||||
@@ -107,10 +331,15 @@ struct SettingRow: View {
|
|||||||
Text(label)
|
Text(label)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(width: 120, alignment: .trailing)
|
.frame(width: 130, alignment: .trailing)
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,10 +352,11 @@ struct PathRow: View {
|
|||||||
Text(label)
|
Text(label)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(width: 100, alignment: .trailing)
|
.frame(width: 130, alignment: .trailing)
|
||||||
Text(path)
|
Text(path)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
|
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -135,5 +365,8 @@ struct PathRow: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ final class SkillsViewModel {
|
|||||||
var skillContent = ""
|
var skillContent = ""
|
||||||
var selectedFileName: String?
|
var selectedFileName: String?
|
||||||
var searchText = ""
|
var searchText = ""
|
||||||
|
var missingConfig: [String] = []
|
||||||
|
var isEditing = false
|
||||||
|
var editText = ""
|
||||||
|
private var currentConfig = HermesConfig.empty
|
||||||
|
|
||||||
var filteredCategories: [HermesSkillCategory] {
|
var filteredCategories: [HermesSkillCategory] {
|
||||||
guard !searchText.isEmpty else { return categories }
|
guard !searchText.isEmpty else { return categories }
|
||||||
@@ -28,6 +32,7 @@ final class SkillsViewModel {
|
|||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
categories = fileService.loadSkills()
|
categories = fileService.loadSkills()
|
||||||
|
currentConfig = fileService.loadConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectSkill(_ skill: HermesSkill) {
|
func selectSkill(_ skill: HermesSkill) {
|
||||||
@@ -40,6 +45,17 @@ final class SkillsViewModel {
|
|||||||
selectedFileName = nil
|
selectedFileName = nil
|
||||||
skillContent = ""
|
skillContent = ""
|
||||||
}
|
}
|
||||||
|
missingConfig = computeMissingConfig(for: skill)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeMissingConfig(for skill: HermesSkill) -> [String] {
|
||||||
|
guard !skill.requiredConfig.isEmpty else { return [] }
|
||||||
|
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else {
|
||||||
|
return skill.requiredConfig
|
||||||
|
}
|
||||||
|
return skill.requiredConfig.filter { key in
|
||||||
|
!yaml.contains(key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectFile(_ file: String) {
|
func selectFile(_ file: String) {
|
||||||
@@ -47,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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,28 @@ struct SkillsView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Label(skill.category, systemImage: "folder")
|
Label(skill.category, systemImage: "folder")
|
||||||
Label("\(skill.files.count) files", systemImage: "doc")
|
Label("\(skill.files.count) files", systemImage: "doc")
|
||||||
|
if !skill.requiredConfig.isEmpty {
|
||||||
|
Label("\(skill.requiredConfig.count) required config", systemImage: "gearshape")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
if !viewModel.missingConfig.isEmpty {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Missing required config:")
|
||||||
|
.font(.caption.bold())
|
||||||
|
Text(viewModel.missingConfig.joined(separator: ", "))
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.orange.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
Divider()
|
Divider()
|
||||||
if !skill.files.isEmpty {
|
if !skill.files.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
@@ -80,17 +99,57 @@ struct SkillsView: View {
|
|||||||
}
|
}
|
||||||
if !viewModel.skillContent.isEmpty {
|
if !viewModel.skillContent.isEmpty {
|
||||||
Divider()
|
Divider()
|
||||||
Text(viewModel.skillContent)
|
HStack {
|
||||||
.font(.system(.body, design: .monospaced))
|
Spacer()
|
||||||
.textSelection(.enabled)
|
Button("Edit") { viewModel.startEditing() }
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
if viewModel.isMarkdownFile {
|
||||||
|
MarkdownContentView(content: viewModel.skillContent)
|
||||||
|
} else {
|
||||||
|
Text(viewModel.skillContent)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $viewModel.isEditing) {
|
||||||
|
skillEditorSheet
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ContentUnavailableView("Select a Skill", systemImage: "lightbulb", description: Text("Choose a skill from the list"))
|
ContentUnavailableView("Select a Skill", systemImage: "lightbulb", description: Text("Choose a skill from the list"))
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var skillEditorSheet: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Edit \(viewModel.selectedFileName ?? "File")")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Button("Cancel") { viewModel.cancelEditing() }
|
||||||
|
Button("Save") { viewModel.saveEdit() }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
Divider()
|
||||||
|
HSplitView {
|
||||||
|
TextEditor(text: $viewModel.editText)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.padding(8)
|
||||||
|
if viewModel.isMarkdownFile {
|
||||||
|
ScrollView {
|
||||||
|
MarkdownContentView(content: viewModel.editText)
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 800, minHeight: 500)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class ToolsViewModel {
|
||||||
|
private let logger = Logger(subsystem: "com.scarf", category: "ToolsViewModel")
|
||||||
|
|
||||||
|
var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
|
||||||
|
var toolsets: [HermesToolset] = []
|
||||||
|
var mcpStatus: String = ""
|
||||||
|
var isLoading = false
|
||||||
|
var availablePlatforms: [HermesToolPlatform] = []
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
await loadPlatforms()
|
||||||
|
await loadTools(for: selectedPlatform)
|
||||||
|
await loadMCPStatus()
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func switchPlatform(_ platform: HermesToolPlatform) async {
|
||||||
|
selectedPlatform = platform
|
||||||
|
await loadTools(for: platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func toggleTool(_ tool: HermesToolset) async {
|
||||||
|
guard let idx = toolsets.firstIndex(where: { $0.name == tool.name }) else { return }
|
||||||
|
toolsets[idx].enabled.toggle()
|
||||||
|
let newEnabled = toolsets[idx].enabled
|
||||||
|
|
||||||
|
let action = newEnabled ? "enable" : "disable"
|
||||||
|
let result = await runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name])
|
||||||
|
|
||||||
|
if result.exitCode != 0 {
|
||||||
|
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
|
||||||
|
toolsets[idx].enabled = !newEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadPlatforms() async {
|
||||||
|
let config: String
|
||||||
|
do {
|
||||||
|
config = try await Task.detached {
|
||||||
|
try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
||||||
|
}.value
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to read config.yaml: \(error.localizedDescription)")
|
||||||
|
config = ""
|
||||||
|
}
|
||||||
|
var platforms: [HermesToolPlatform] = []
|
||||||
|
var inSection = false
|
||||||
|
for line in config.components(separatedBy: "\n") {
|
||||||
|
if line.hasPrefix("platform_toolsets:") {
|
||||||
|
inSection = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inSection {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty || (!line.hasPrefix(" ") && !line.hasPrefix("\t")) {
|
||||||
|
if !trimmed.isEmpty { break }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if trimmed.hasSuffix(":") && !trimmed.hasPrefix("-") {
|
||||||
|
let name = String(trimmed.dropLast()).trimmingCharacters(in: .whitespaces)
|
||||||
|
if let known = KnownPlatforms.all.first(where: { $0.name == name }) {
|
||||||
|
platforms.append(known)
|
||||||
|
} else {
|
||||||
|
platforms.append(HermesToolPlatform(name: name, displayName: name.capitalized, icon: "bubble.left"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
availablePlatforms = platforms.isEmpty ? [KnownPlatforms.cli] : platforms
|
||||||
|
if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }),
|
||||||
|
let first = availablePlatforms.first {
|
||||||
|
selectedPlatform = first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadTools(for platform: HermesToolPlatform) async {
|
||||||
|
let result = await runHermes(["tools", "list", "--platform", platform.name])
|
||||||
|
toolsets = parseToolsList(result.output)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadMCPStatus() async {
|
||||||
|
let result = await runHermes(["mcp", "list"])
|
||||||
|
mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseToolsList(_ output: String) -> [HermesToolset] {
|
||||||
|
var tools: [HermesToolset] = []
|
||||||
|
for line in output.components(separatedBy: "\n") {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
let isEnabled: Bool
|
||||||
|
if trimmed.hasPrefix("✓ enabled") {
|
||||||
|
isEnabled = true
|
||||||
|
} else if trimmed.hasPrefix("✗ disabled") {
|
||||||
|
isEnabled = false
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let rest = trimmed
|
||||||
|
.replacingOccurrences(of: "✓ enabled", with: "")
|
||||||
|
.replacingOccurrences(of: "✗ disabled", with: "")
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
let parts = rest.split(separator: " ", maxSplits: 1)
|
||||||
|
guard let namePart = parts.first else { continue }
|
||||||
|
let name = String(namePart)
|
||||||
|
let rawDesc = parts.count > 1 ? String(parts[1]) : name
|
||||||
|
|
||||||
|
let icon = extractEmoji(from: rawDesc)
|
||||||
|
let description = rawDesc
|
||||||
|
.unicodeScalars.filter { !$0.properties.isEmoji || $0.isASCII }
|
||||||
|
.map { String($0) }.joined()
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
tools.append(HermesToolset(name: name, description: description, icon: icon, enabled: isEnabled))
|
||||||
|
}
|
||||||
|
return tools
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractEmoji(from text: String) -> String {
|
||||||
|
for scalar in text.unicodeScalars {
|
||||||
|
if scalar.properties.isEmoji && !scalar.isASCII {
|
||||||
|
return String(scalar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "🔧"
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated func runHermes(_ arguments: [String]) async -> (output: String, exitCode: Int32) {
|
||||||
|
await Task.detached {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||||
|
process.arguments = arguments
|
||||||
|
let stdoutPipe = Pipe()
|
||||||
|
let stderrPipe = Pipe()
|
||||||
|
process.standardOutput = stdoutPipe
|
||||||
|
process.standardError = stderrPipe
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
let output = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
try? stdoutPipe.fileHandleForReading.close()
|
||||||
|
try? stdoutPipe.fileHandleForWriting.close()
|
||||||
|
try? stderrPipe.fileHandleForReading.close()
|
||||||
|
try? stderrPipe.fileHandleForWriting.close()
|
||||||
|
return (output, process.terminationStatus)
|
||||||
|
} catch {
|
||||||
|
try? stdoutPipe.fileHandleForReading.close()
|
||||||
|
try? stdoutPipe.fileHandleForWriting.close()
|
||||||
|
try? stderrPipe.fileHandleForReading.close()
|
||||||
|
try? stderrPipe.fileHandleForWriting.close()
|
||||||
|
return ("", -1)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ToolsView: View {
|
||||||
|
@State private var viewModel = ToolsViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
platformPicker
|
||||||
|
Divider()
|
||||||
|
toolsList
|
||||||
|
if !viewModel.mcpStatus.isEmpty {
|
||||||
|
Divider()
|
||||||
|
mcpSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Tools")
|
||||||
|
.task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var platformPicker: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Picker("Platform", selection: Binding(
|
||||||
|
get: { viewModel.selectedPlatform.name },
|
||||||
|
set: { name in
|
||||||
|
if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) {
|
||||||
|
Task { await viewModel.switchPlatform(platform) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
ForEach(viewModel.availablePlatforms) { platform in
|
||||||
|
Text(platform.displayName).tag(platform.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
Spacer()
|
||||||
|
Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var toolsList: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 1) {
|
||||||
|
ForEach(viewModel.toolsets) { tool in
|
||||||
|
ToolRow(tool: tool) {
|
||||||
|
await viewModel.toggleTool(tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
.id(viewModel.selectedPlatform.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mcpSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("MCP Servers")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if viewModel.mcpStatus.contains("No MCP servers") {
|
||||||
|
Label("No MCP servers configured", systemImage: "server.rack")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text(viewModel.mcpStatus)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ToolRow: View {
|
||||||
|
let tool: HermesToolset
|
||||||
|
let onToggle: () async -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(tool.icon)
|
||||||
|
.font(.title3)
|
||||||
|
.frame(width: 28)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(tool.name)
|
||||||
|
.font(.system(.body, design: .monospaced, weight: .medium))
|
||||||
|
Text(tool.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Toggle("", isOn: Binding(
|
||||||
|
get: { tool.enabled },
|
||||||
|
set: { _ in Task { await onToggle() } }
|
||||||
|
))
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,17 @@ import Foundation
|
|||||||
|
|
||||||
enum SidebarSection: String, CaseIterable, Identifiable {
|
enum SidebarSection: String, CaseIterable, Identifiable {
|
||||||
case dashboard = "Dashboard"
|
case dashboard = "Dashboard"
|
||||||
|
case insights = "Insights"
|
||||||
case sessions = "Sessions"
|
case sessions = "Sessions"
|
||||||
case activity = "Activity"
|
case activity = "Activity"
|
||||||
|
case projects = "Projects"
|
||||||
case chat = "Chat"
|
case chat = "Chat"
|
||||||
case memory = "Memory"
|
case memory = "Memory"
|
||||||
case skills = "Skills"
|
case skills = "Skills"
|
||||||
|
case tools = "Tools"
|
||||||
|
case gateway = "Gateway"
|
||||||
case cron = "Cron"
|
case cron = "Cron"
|
||||||
|
case health = "Health"
|
||||||
case logs = "Logs"
|
case logs = "Logs"
|
||||||
case settings = "Settings"
|
case settings = "Settings"
|
||||||
|
|
||||||
@@ -16,12 +21,17 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .dashboard: return "gauge.with.dots.needle.33percent"
|
case .dashboard: return "gauge.with.dots.needle.33percent"
|
||||||
|
case .insights: return "chart.bar"
|
||||||
case .sessions: return "bubble.left.and.bubble.right"
|
case .sessions: return "bubble.left.and.bubble.right"
|
||||||
case .activity: return "bolt.horizontal"
|
case .activity: return "bolt.horizontal"
|
||||||
|
case .projects: return "square.grid.2x2"
|
||||||
case .chat: return "text.bubble"
|
case .chat: return "text.bubble"
|
||||||
case .memory: return "brain"
|
case .memory: return "brain"
|
||||||
case .skills: return "lightbulb"
|
case .skills: return "lightbulb"
|
||||||
|
case .tools: return "wrench.and.screwdriver"
|
||||||
|
case .gateway: return "antenna.radiowaves.left.and.right"
|
||||||
case .cron: return "clock.arrow.2.circlepath"
|
case .cron: return "clock.arrow.2.circlepath"
|
||||||
|
case .health: return "stethoscope"
|
||||||
case .logs: return "doc.text"
|
case .logs: return "doc.text"
|
||||||
case .settings: return "gearshape"
|
case .settings: return "gearshape"
|
||||||
}
|
}
|
||||||
@@ -32,4 +42,5 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
final class AppCoordinator {
|
final class AppCoordinator {
|
||||||
var selectedSection: SidebarSection = .dashboard
|
var selectedSection: SidebarSection = .dashboard
|
||||||
var selectedSessionId: String?
|
var selectedSessionId: String?
|
||||||
|
var selectedProjectName: String?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ struct SidebarView: View {
|
|||||||
@Bindable var coordinator = coordinator
|
@Bindable var coordinator = coordinator
|
||||||
List(selection: $coordinator.selectedSection) {
|
List(selection: $coordinator.selectedSection) {
|
||||||
Section("Monitor") {
|
Section("Monitor") {
|
||||||
ForEach([SidebarSection.dashboard, .sessions, .activity]) { section in
|
ForEach([SidebarSection.dashboard, .insights, .sessions, .activity]) { section in
|
||||||
|
Label(section.rawValue, systemImage: section.icon)
|
||||||
|
.tag(section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section("Projects") {
|
||||||
|
ForEach([SidebarSection.projects]) { section in
|
||||||
Label(section.rawValue, systemImage: section.icon)
|
Label(section.rawValue, systemImage: section.icon)
|
||||||
.tag(section)
|
.tag(section)
|
||||||
}
|
}
|
||||||
@@ -19,7 +25,7 @@ struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section("Manage") {
|
Section("Manage") {
|
||||||
ForEach([SidebarSection.cron, .logs, .settings]) { section in
|
ForEach([SidebarSection.tools, .gateway, .cron, .health, .logs, .settings]) { section in
|
||||||
Label(section.rawValue, systemImage: section.icon)
|
Label(section.rawValue, systemImage: section.icon)
|
||||||
.tag(section)
|
.tag(section)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -54,6 +54,33 @@ final class MenuBarStatus {
|
|||||||
timer = nil
|
timer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startHermes() {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||||
|
process.arguments = ["gateway", "start"]
|
||||||
|
process.standardOutput = Pipe()
|
||||||
|
process.standardError = Pipe()
|
||||||
|
try? process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopHermes() {
|
||||||
|
fileService.stopHermes()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartHermes() {
|
||||||
|
fileService.stopHermes()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.startHermes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func refresh() {
|
private func refresh() {
|
||||||
hermesRunning = fileService.isHermesRunning()
|
hermesRunning = fileService.isHermesRunning()
|
||||||
gatewayRunning = fileService.loadGatewayState()?.isRunning ?? false
|
gatewayRunning = fileService.loadGatewayState()?.isRunning ?? false
|
||||||
@@ -69,6 +96,13 @@ struct MenuBarMenu: View {
|
|||||||
Label(status.hermesRunning ? "Hermes Running" : "Hermes Stopped", systemImage: status.hermesRunning ? "circle.fill" : "circle")
|
Label(status.hermesRunning ? "Hermes Running" : "Hermes Stopped", systemImage: status.hermesRunning ? "circle.fill" : "circle")
|
||||||
Label(status.gatewayRunning ? "Gateway Running" : "Gateway Stopped", systemImage: status.gatewayRunning ? "circle.fill" : "circle")
|
Label(status.gatewayRunning ? "Gateway Running" : "Gateway Stopped", systemImage: status.gatewayRunning ? "circle.fill" : "circle")
|
||||||
Divider()
|
Divider()
|
||||||
|
Button("Start Hermes") { status.startHermes() }
|
||||||
|
.disabled(status.hermesRunning)
|
||||||
|
Button("Stop Hermes") { status.stopHermes() }
|
||||||
|
.disabled(!status.hermesRunning)
|
||||||
|
Button("Restart Hermes") { status.restartHermes() }
|
||||||
|
.disabled(!status.hermesRunning)
|
||||||
|
Divider()
|
||||||
Button("Open Dashboard") {
|
Button("Open Dashboard") {
|
||||||
coordinator.selectedSection = .dashboard
|
coordinator.selectedSection = .dashboard
|
||||||
NSApplication.shared.activate()
|
NSApplication.shared.activate()
|
||||||
|
|||||||
Reference in New Issue
Block a user