mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -43,3 +43,6 @@ Package.resolved
|
||||
# Claude Code
|
||||
.claude/
|
||||
scarf/standards/backups/
|
||||
|
||||
# Scarf project dashboards (user-specific)
|
||||
.scarf/
|
||||
|
||||
@@ -13,28 +13,55 @@
|
||||
<img src="https://img.shields.io/badge/macOS-26.2+-blue" alt="macOS">
|
||||
<img src="https://img.shields.io/badge/Swift-6-orange" alt="Swift">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br><br>
|
||||
<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>
|
||||
|
||||
## Features
|
||||
|
||||
- **Dashboard** — System health, token usage, recent sessions at a glance
|
||||
- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, and full-text search (FTS5)
|
||||
- **Activity Feed** — Recent tool execution log with filtering by kind (read/edit/execute/fetch/browser) and detail inspector
|
||||
- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm)
|
||||
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live refresh
|
||||
- **Skills Browser** — Browse all installed skills by category with file content viewer
|
||||
- **Dashboard** — System health, token usage, recent sessions with live refresh
|
||||
- **Insights** — Usage analytics with token breakdown, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
|
||||
- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, full-text search, rename, delete, and JSONL export
|
||||
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments
|
||||
- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm), session persistence across navigation, resume/continue previous sessions, and voice mode controls
|
||||
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh
|
||||
- **Skills Browser** — Browse all installed skills by category with file content viewer and file switcher
|
||||
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, etc.) with toggle switches, MCP server status
|
||||
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
||||
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
|
||||
- **Log Viewer** — Real-time tailing of error and gateway logs with level filtering
|
||||
- **Settings** — Read-only config display with raw YAML viewer and Finder path links
|
||||
- **Log Viewer** — Real-time log tailing 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
|
||||
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 26.2+
|
||||
- Xcode 26.3+
|
||||
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) installed at `~/.hermes/`
|
||||
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/`
|
||||
|
||||
## Building
|
||||
### Compatibility
|
||||
|
||||
Scarf reads Hermes's SQLite database (schema v6) and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Tested and verified against:
|
||||
|
||||
| Hermes Version | Status |
|
||||
|----------------|--------|
|
||||
| v0.6.0 (2026-03-30) | Verified |
|
||||
| v0.6.0 (2026-03-31, 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
|
||||
git clone https://github.com/awizemann/scarf.git
|
||||
@@ -45,7 +72,7 @@ open scarf.xcodeproj
|
||||
Or from the command line:
|
||||
|
||||
```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
|
||||
@@ -59,14 +86,18 @@ scarf/
|
||||
Services/ Data access (SQLite reader, file I/O, log tailing, file watcher)
|
||||
Features/ Self-contained feature modules
|
||||
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
|
||||
Chat/ Embedded terminal via SwiftTerm
|
||||
Projects/ Agent-generated project dashboards with widget rendering
|
||||
Chat/ Embedded terminal via SwiftTerm with voice controls
|
||||
Memory/ Memory viewer and editor
|
||||
Skills/ Skill browser by category
|
||||
Tools/ Toolset management per platform
|
||||
Gateway/ Messaging gateway control and pairing
|
||||
Cron/ Scheduled job viewer
|
||||
Logs/ Real-time log viewer
|
||||
Settings/ Configuration display
|
||||
Settings/ Structured config editor
|
||||
Navigation/ AppCoordinator + SidebarView
|
||||
```
|
||||
|
||||
@@ -84,8 +115,14 @@ Scarf reads Hermes data directly from `~/.hermes/`:
|
||||
| `gateway_state.json` | JSON | Read-only |
|
||||
| `skills/` | Directory tree | Read-only |
|
||||
| `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
|
||||
|
||||
@@ -93,14 +130,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 |
|
||||
|
||||
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
|
||||
|
||||
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 spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation — switch tabs and come back without losing your conversation.
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
||||
@@ -111,6 +288,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`)
|
||||
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
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -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,6 +404,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -415,6 +416,7 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
@@ -438,6 +440,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -449,6 +452,7 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
|
||||
@@ -16,18 +16,28 @@ struct ContentView: View {
|
||||
switch coordinator.selectedSection {
|
||||
case .dashboard:
|
||||
DashboardView()
|
||||
case .insights:
|
||||
InsightsView()
|
||||
case .sessions:
|
||||
SessionsView()
|
||||
case .activity:
|
||||
ActivityView()
|
||||
case .projects:
|
||||
ProjectsView()
|
||||
case .chat:
|
||||
ChatView()
|
||||
case .memory:
|
||||
MemoryView()
|
||||
case .skills:
|
||||
SkillsView()
|
||||
case .tools:
|
||||
ToolsView()
|
||||
case .gateway:
|
||||
GatewayView()
|
||||
case .cron:
|
||||
CronView()
|
||||
case .health:
|
||||
HealthView()
|
||||
case .logs:
|
||||
LogsView()
|
||||
case .settings:
|
||||
|
||||
@@ -13,6 +13,8 @@ struct HermesConfig: Sendable {
|
||||
var streaming: Bool
|
||||
var showReasoning: Bool
|
||||
var verbose: Bool
|
||||
var autoTTS: Bool
|
||||
var silenceThreshold: Int
|
||||
|
||||
static let empty = HermesConfig(
|
||||
model: "unknown",
|
||||
@@ -26,7 +28,9 @@ struct HermesConfig: Sendable {
|
||||
nudgeInterval: 0,
|
||||
streaming: true,
|
||||
showReasoning: false,
|
||||
verbose: false
|
||||
verbose: false,
|
||||
autoTTS: true,
|
||||
silenceThreshold: 200
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
enum HermesPaths: Sendable {
|
||||
// Using ProcessInfo to avoid main-actor isolation issues with FileManager/NSHomeDirectory
|
||||
nonisolated static let home: String = ProcessInfo.processInfo.environment["HOME"]! + "/.hermes"
|
||||
private nonisolated static let userHome: String = ProcessInfo.processInfo.environment["HOME"]
|
||||
?? NSHomeDirectory()
|
||||
|
||||
nonisolated static let home: String = userHome + "/.hermes"
|
||||
nonisolated static let stateDB: String = home + "/state.db"
|
||||
nonisolated static let configYAML: String = home + "/config.yaml"
|
||||
nonisolated static let memoriesDir: String = home + "/memories"
|
||||
@@ -15,5 +18,32 @@ enum HermesPaths: Sendable {
|
||||
nonisolated static let skillsDir: String = home + "/skills"
|
||||
nonisolated static let errorsLog: String = home + "/logs/errors.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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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"),
|
||||
]
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -24,7 +24,7 @@ actor HermesDataService {
|
||||
db = nil
|
||||
}
|
||||
|
||||
func fetchSessions(limit: Int = 100) -> [HermesSession] {
|
||||
func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT id, source, user_id, model, title, parent_session_id,
|
||||
@@ -59,7 +59,7 @@ actor HermesDataService {
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, sessionId, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
|
||||
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
@@ -68,7 +68,7 @@ actor HermesDataService {
|
||||
return messages
|
||||
}
|
||||
|
||||
func searchMessages(query: String, limit: Int = 50) -> [HermesMessage] {
|
||||
func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls,
|
||||
@@ -82,7 +82,7 @@ actor HermesDataService {
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, query, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
|
||||
sqlite3_bind_text(stmt, 1, query, -1, sqliteTransient)
|
||||
sqlite3_bind_int(stmt, 2, Int32(limit))
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
@@ -92,7 +92,7 @@ actor HermesDataService {
|
||||
return messages
|
||||
}
|
||||
|
||||
func fetchRecentToolCalls(limit: Int = 50) -> [HermesMessage] {
|
||||
func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT id, session_id, role, content, tool_call_id, tool_calls,
|
||||
@@ -114,10 +114,10 @@ actor HermesDataService {
|
||||
return messages
|
||||
}
|
||||
|
||||
func fetchSessionPreviews(limit: Int = 10) -> [String: String] {
|
||||
func fetchSessionPreviews(limit: Int = QueryDefaults.sessionPreviewLimit) -> [String: String] {
|
||||
guard let db else { return [:] }
|
||||
let sql = """
|
||||
SELECT m.session_id, substr(m.content, 1, 100)
|
||||
SELECT m.session_id, substr(m.content, 1, \(QueryDefaults.previewContentLength))
|
||||
FROM messages m
|
||||
INNER JOIN (
|
||||
SELECT session_id, MIN(id) as min_id
|
||||
@@ -149,13 +149,15 @@ actor HermesDataService {
|
||||
let totalInputTokens: Int
|
||||
let totalOutputTokens: Int
|
||||
let totalCostUSD: Double
|
||||
|
||||
static let empty = SessionStats(
|
||||
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0
|
||||
)
|
||||
}
|
||||
|
||||
func fetchStats() -> SessionStats {
|
||||
guard let db else {
|
||||
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
||||
}
|
||||
guard let db else { return .empty }
|
||||
let sql = """
|
||||
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||
@@ -163,16 +165,9 @@ actor HermesDataService {
|
||||
FROM sessions
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
|
||||
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
||||
}
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else {
|
||||
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
||||
}
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
|
||||
return SessionStats(
|
||||
totalSessions: Int(sqlite3_column_int(stmt, 0)),
|
||||
totalMessages: Int(sqlite3_column_int(stmt, 1)),
|
||||
@@ -183,6 +178,112 @@ actor HermesDataService {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Insights Queries
|
||||
|
||||
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT id, source, user_id, model, title, parent_session_id,
|
||||
started_at, ended_at, end_reason, message_count, tool_call_count,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||
estimated_cost_usd
|
||||
FROM sessions
|
||||
WHERE started_at >= ?
|
||||
ORDER BY started_at DESC
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||
|
||||
var sessions: [HermesSession] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
sessions.append(sessionFromRow(stmt!))
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
func fetchUserMessageCount(since: Date) -> Int {
|
||||
guard let db else { return 0 }
|
||||
let sql = """
|
||||
SELECT COUNT(*) FROM messages m
|
||||
JOIN sessions s ON m.session_id = s.id
|
||||
WHERE m.role = 'user' AND s.started_at >= ?
|
||||
"""
|
||||
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.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 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 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? {
|
||||
let walPath = HermesPaths.stateDB + "-wal"
|
||||
let dbPath = HermesPaths.stateDB
|
||||
@@ -238,7 +339,12 @@ actor HermesDataService {
|
||||
private func parseToolCalls(_ json: String?) -> [HermesToolCall] {
|
||||
guard let json, !json.isEmpty,
|
||||
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 {
|
||||
|
||||
@@ -42,7 +42,9 @@ struct HermesFileService: Sendable {
|
||||
nudgeInterval: Int(values["memory.nudge_interval"] ?? "") ?? 0,
|
||||
streaming: values["display.streaming"] != "false",
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,7 +52,12 @@ struct HermesFileService: Sendable {
|
||||
|
||||
func loadGatewayState() -> GatewayState? {
|
||||
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
|
||||
@@ -75,8 +82,13 @@ struct HermesFileService: Sendable {
|
||||
|
||||
func loadCronJobs() -> [HermesCronJob] {
|
||||
guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] }
|
||||
let file = try? JSONDecoder().decode(CronJobsFile.self, from: data)
|
||||
return file?.jobs ?? []
|
||||
do {
|
||||
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? {
|
||||
@@ -121,7 +133,13 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
|
||||
func loadSkillContent(path: String) -> String {
|
||||
readFile(path) ?? ""
|
||||
// Validate path stays within the skills directory to prevent traversal
|
||||
guard !path.contains(".."),
|
||||
path.hasPrefix(HermesPaths.skillsDir) else {
|
||||
print("[Scarf] Rejected skill path outside skills directory: \(path)")
|
||||
return ""
|
||||
}
|
||||
return readFile(path) ?? ""
|
||||
}
|
||||
|
||||
// MARK: - Hermes Process
|
||||
@@ -154,6 +172,10 @@ struct HermesFileService: Sendable {
|
||||
}
|
||||
|
||||
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
|
||||
final class HermesFileWatcher {
|
||||
private(set) var lastChangeDate = Date()
|
||||
private var sources: [DispatchSourceFileSystemObject] = []
|
||||
private var coreSources: [DispatchSourceFileSystemObject] = []
|
||||
private var projectSources: [DispatchSourceFileSystemObject] = []
|
||||
private var timer: Timer?
|
||||
|
||||
func startWatching() {
|
||||
@@ -16,11 +17,14 @@ final class HermesFileWatcher {
|
||||
HermesPaths.cronJobsJSON,
|
||||
HermesPaths.gatewayStateJSON,
|
||||
HermesPaths.errorsLog,
|
||||
HermesPaths.gatewayLog
|
||||
HermesPaths.gatewayLog,
|
||||
HermesPaths.projectsRegistry
|
||||
]
|
||||
|
||||
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
|
||||
@@ -29,17 +33,30 @@ final class HermesFileWatcher {
|
||||
}
|
||||
|
||||
func stopWatching() {
|
||||
for source in sources {
|
||||
for source in coreSources + projectSources {
|
||||
source.cancel()
|
||||
}
|
||||
sources.removeAll()
|
||||
coreSources.removeAll()
|
||||
projectSources.removeAll()
|
||||
timer?.invalidate()
|
||||
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)
|
||||
guard fd >= 0 else { return }
|
||||
guard fd >= 0 else { return nil }
|
||||
|
||||
let source = DispatchSource.makeFileSystemObjectSource(
|
||||
fileDescriptor: fd,
|
||||
@@ -53,7 +70,7 @@ final class HermesFileWatcher {
|
||||
Darwin.close(fd)
|
||||
}
|
||||
source.resume()
|
||||
sources.append(source)
|
||||
return source
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
||||
@@ -39,12 +39,16 @@ actor HermesLogService {
|
||||
}
|
||||
|
||||
func closeLog() {
|
||||
try? fileHandle?.close()
|
||||
do {
|
||||
try fileHandle?.close()
|
||||
} catch {
|
||||
print("[Scarf] Failed to close log handle: \(error.localizedDescription)")
|
||||
}
|
||||
fileHandle = nil
|
||||
currentPath = nil
|
||||
}
|
||||
|
||||
func readLastLines(count: Int = 200) -> [LogEntry] {
|
||||
func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
|
||||
guard let path = currentPath,
|
||||
let data = FileManager.default.contents(atPath: path) else { return [] }
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,15 @@ import SwiftTerm
|
||||
@Observable
|
||||
final class ChatViewModel {
|
||||
private let dataService = HermesDataService()
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var recentSessions: [HermesSession] = []
|
||||
var sessionPreviews: [String: String] = [:]
|
||||
var terminalView: LocalProcessTerminalView?
|
||||
var hasActiveProcess = false
|
||||
var voiceEnabled = false
|
||||
var ttsEnabled = false
|
||||
var isRecording = false
|
||||
private var coordinator: Coordinator?
|
||||
|
||||
var hermesBinaryExists: Bool {
|
||||
@@ -17,14 +21,23 @@ final class ChatViewModel {
|
||||
}
|
||||
|
||||
func startNewSession() {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
launchTerminal(arguments: ["chat"])
|
||||
}
|
||||
|
||||
func resumeSession(_ sessionId: String) {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
launchTerminal(arguments: ["chat", "--resume", sessionId])
|
||||
}
|
||||
|
||||
func continueLastSession() {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
launchTerminal(arguments: ["chat", "--continue"])
|
||||
}
|
||||
|
||||
@@ -42,6 +55,38 @@ final class ChatViewModel {
|
||||
return session.id
|
||||
}
|
||||
|
||||
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 }
|
||||
// Ctrl+B = ASCII 0x02
|
||||
let ctrlB: [UInt8] = [0x02]
|
||||
tv.send(source: tv, data: ctrlB[0..<1])
|
||||
isRecording.toggle()
|
||||
}
|
||||
|
||||
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]) {
|
||||
if let existing = terminalView {
|
||||
existing.terminate()
|
||||
@@ -55,6 +100,8 @@ final class ChatViewModel {
|
||||
|
||||
let coord = Coordinator(onTerminated: { [weak self] in
|
||||
self?.hasActiveProcess = false
|
||||
self?.voiceEnabled = false
|
||||
self?.isRecording = false
|
||||
})
|
||||
terminal.processDelegate = coord
|
||||
self.coordinator = coord
|
||||
|
||||
@@ -2,6 +2,7 @@ import SwiftUI
|
||||
|
||||
struct ChatView: View {
|
||||
@Environment(ChatViewModel.self) private var viewModel
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -11,6 +12,9 @@ struct ChatView: View {
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
.task { await viewModel.loadRecentSessions() }
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
Task { await viewModel.loadRecentSessions() }
|
||||
}
|
||||
}
|
||||
|
||||
private var toolbar: some View {
|
||||
@@ -36,6 +40,10 @@ struct ChatView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if viewModel.hasActiveProcess {
|
||||
voiceControls
|
||||
}
|
||||
|
||||
if !viewModel.hermesBinaryExists {
|
||||
Label("Hermes binary not found", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
@@ -80,6 +88,55 @@ struct ChatView: View {
|
||||
.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 terminalArea: some View {
|
||||
if let terminal = viewModel.terminalView {
|
||||
|
||||
@@ -10,7 +10,7 @@ struct PersistentTerminalView: NSViewRepresentable {
|
||||
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(terminalView)
|
||||
NSLayoutConstraint.activate([
|
||||
terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
|
||||
terminalView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
terminalView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
terminalView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
@@ -24,7 +24,7 @@ struct PersistentTerminalView: NSViewRepresentable {
|
||||
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
||||
nsView.addSubview(terminalView)
|
||||
NSLayoutConstraint.activate([
|
||||
terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor),
|
||||
terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor, constant: 4),
|
||||
terminalView.trailingAnchor.constraint(equalTo: nsView.trailingAnchor),
|
||||
terminalView.topAnchor.constraint(equalTo: nsView.topAnchor),
|
||||
terminalView.bottomAnchor.constraint(equalTo: nsView.bottomAnchor),
|
||||
|
||||
@@ -5,10 +5,7 @@ final class DashboardViewModel {
|
||||
private let dataService = HermesDataService()
|
||||
private let fileService = HermesFileService()
|
||||
|
||||
var stats = HermesDataService.SessionStats(
|
||||
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0
|
||||
)
|
||||
var stats = HermesDataService.SessionStats.empty
|
||||
var recentSessions: [HermesSession] = []
|
||||
var sessionPreviews: [String: String] = [:]
|
||||
var config = HermesConfig.empty
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct DashboardView: View {
|
||||
@State private var viewModel = DashboardViewModel()
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -16,6 +17,9 @@ struct DashboardView: View {
|
||||
}
|
||||
.navigationTitle("Dashboard")
|
||||
.task { await viewModel.load() }
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
}
|
||||
|
||||
private var statusSection: some View {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
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 {
|
||||
switch name {
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
case "slack": return "number"
|
||||
case "whatsapp": return "phone.bubble"
|
||||
case "signal": return "lock.shield"
|
||||
case "email": return "envelope"
|
||||
default: return "bubble.left"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,205 @@
|
||||
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 {
|
||||
switch platform {
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
case "slack": return "number"
|
||||
case "whatsapp": return "phone.bubble"
|
||||
case "signal": return "lock.shield"
|
||||
case "email": return "envelope"
|
||||
default: return "bubble.left"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,179 @@
|
||||
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 {
|
||||
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
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
loadVersion()
|
||||
let statusOutput = runHermes(["status"]).output
|
||||
statusSections = parseOutput(statusOutput)
|
||||
let doctorOutput = runHermes(["doctor"]).output
|
||||
doctorSections = parseOutput(doctorOutput)
|
||||
computeCounts()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
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,219 @@
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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,234 @@
|
||||
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
|
||||
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens }
|
||||
}
|
||||
|
||||
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 }
|
||||
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 totalTokens = 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 }
|
||||
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens
|
||||
|
||||
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)] = [:]
|
||||
for s in sessions {
|
||||
let model = s.model ?? "unknown"
|
||||
var entry = grouped[model, default: (0, 0, 0, 0, 0)]
|
||||
entry.sessions += 1
|
||||
entry.input += s.inputTokens
|
||||
entry.output += s.outputTokens
|
||||
entry.cacheRead += s.cacheReadTokens
|
||||
entry.cacheWrite += s.cacheWriteTokens
|
||||
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)
|
||||
}.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
|
||||
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,317 @@
|
||||
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: "Total Tokens", value: formatTokens(viewModel.totalTokens))
|
||||
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 {
|
||||
switch platform {
|
||||
case "cli": return "terminal"
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
case "slack": return "number"
|
||||
case "email": return "envelope"
|
||||
default: return "bubble.left"
|
||||
}
|
||||
}
|
||||
|
||||
private func barColor(for toolName: String) -> Color {
|
||||
switch toolName {
|
||||
case "terminal": 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))
|
||||
}
|
||||
}
|
||||
@@ -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,27 @@
|
||||
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",
|
||||
let attributed = try? AttributedString(markdown: content) {
|
||||
Text(attributed)
|
||||
.font(.callout)
|
||||
} 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 AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct SessionStoreStats {
|
||||
let totalSessions: Int
|
||||
let totalMessages: Int
|
||||
let databaseSize: String
|
||||
let platformCounts: [(platform: String, count: Int)]
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class SessionsViewModel {
|
||||
@@ -11,12 +20,20 @@ final class SessionsViewModel {
|
||||
var searchText = ""
|
||||
var searchResults: [HermesMessage] = []
|
||||
var isSearching = false
|
||||
var storeStats: SessionStoreStats?
|
||||
|
||||
var renameSessionId: String?
|
||||
var renameText = ""
|
||||
var showRenameSheet = false
|
||||
var showDeleteConfirmation = false
|
||||
var deleteSessionId: String?
|
||||
|
||||
func load() async {
|
||||
let opened = await dataService.open()
|
||||
guard opened else { return }
|
||||
sessions = await dataService.fetchSessions(limit: 500)
|
||||
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
|
||||
computeStats()
|
||||
}
|
||||
|
||||
func previewFor(_ session: HermesSession) -> String {
|
||||
@@ -50,4 +67,132 @@ final class SessionsViewModel {
|
||||
func cleanup() async {
|
||||
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 = HermesSession(
|
||||
id: sessions[idx].id, source: sessions[idx].source,
|
||||
userId: sessions[idx].userId, model: sessions[idx].model,
|
||||
title: title, parentSessionId: sessions[idx].parentSessionId,
|
||||
startedAt: sessions[idx].startedAt, endedAt: sessions[idx].endedAt,
|
||||
endReason: sessions[idx].endReason, messageCount: sessions[idx].messageCount,
|
||||
toolCallCount: sessions[idx].toolCallCount, inputTokens: sessions[idx].inputTokens,
|
||||
outputTokens: sessions[idx].outputTokens, cacheReadTokens: sessions[idx].cacheReadTokens,
|
||||
cacheWriteTokens: sessions[idx].cacheWriteTokens,
|
||||
estimatedCostUSD: sessions[idx].estimatedCostUSD
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ struct SessionDetailView: View {
|
||||
let session: HermesSession
|
||||
let messages: [HermesMessage]
|
||||
var preview: String?
|
||||
var onRename: (() -> Void)?
|
||||
var onExport: (() -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -16,22 +19,41 @@ struct SessionDetailView: View {
|
||||
|
||||
private var sessionHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(preview ?? session.displayTitle)
|
||||
.font(.title3.bold())
|
||||
HStack {
|
||||
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) {
|
||||
Label(session.source, systemImage: session.sourceIcon)
|
||||
Label(session.model ?? "unknown", systemImage: "cpu")
|
||||
Label("\(session.messageCount) msgs", systemImage: "bubble.left")
|
||||
Label("\(session.toolCallCount) tools", systemImage: "wrench")
|
||||
if let cost = session.estimatedCostUSD {
|
||||
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
|
||||
}
|
||||
if let date = session.startedAt {
|
||||
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(session.id)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.tertiary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@@ -5,11 +5,17 @@ struct SessionsView: View {
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
sessionList
|
||||
.frame(minWidth: 280, idealWidth: 320)
|
||||
sessionDetail
|
||||
.frame(minWidth: 400)
|
||||
VStack(spacing: 0) {
|
||||
if let stats = viewModel.storeStats {
|
||||
statsBar(stats)
|
||||
Divider()
|
||||
}
|
||||
HSplitView {
|
||||
sessionList
|
||||
.frame(minWidth: 280, idealWidth: 320)
|
||||
sessionDetail
|
||||
.frame(minWidth: 400)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sessions")
|
||||
.searchable(text: $viewModel.searchText, prompt: "Search messages...")
|
||||
@@ -28,6 +34,33 @@ struct SessionsView: View {
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
@@ -64,6 +97,12 @@ struct SessionsView: View {
|
||||
ForEach(viewModel.sessions) { session in
|
||||
SessionRow(session: session, preview: viewModel.previewFor(session))
|
||||
.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,50 @@ struct SessionsView: View {
|
||||
@ViewBuilder
|
||||
private var sessionDetail: some View {
|
||||
if let session = viewModel.selectedSession {
|
||||
SessionDetailView(session: session, messages: viewModel.messages, preview: viewModel.previewFor(session))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
SessionDetailView(
|
||||
session: session,
|
||||
messages: viewModel.messages,
|
||||
preview: viewModel.previewFor(session),
|
||||
onRename: { viewModel.beginRename(session) },
|
||||
onExport: { viewModel.exportSession(session) },
|
||||
onDelete: { viewModel.beginDelete(session) }
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
} else {
|
||||
ContentUnavailableView("Select a Session", systemImage: "bubble.left.and.bubble.right", description: Text("Choose a session from the list"))
|
||||
.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 {
|
||||
switch platform {
|
||||
case "cli": return "terminal"
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
case "slack": return "number"
|
||||
case "email": return "envelope"
|
||||
default: return "bubble.left"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
@Observable
|
||||
final class SettingsViewModel {
|
||||
@@ -8,11 +9,94 @@ final class SettingsViewModel {
|
||||
var gatewayState: GatewayState?
|
||||
var hermesRunning = false
|
||||
var rawConfigYAML = ""
|
||||
var personalities: [String] = []
|
||||
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax"]
|
||||
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
|
||||
var saveMessage: String?
|
||||
|
||||
func load() {
|
||||
config = fileService.loadConfig()
|
||||
gatewayState = fileService.loadGatewayState()
|
||||
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 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,13 @@ struct SettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
configSection
|
||||
gatewaySection
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
headerBar
|
||||
modelSection
|
||||
displaySection
|
||||
terminalSection
|
||||
voiceSection
|
||||
memorySection
|
||||
pathsSection
|
||||
rawConfigSection
|
||||
}
|
||||
@@ -19,62 +23,90 @@ struct SettingsView: View {
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var configSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Configuration")
|
||||
.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 headerBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.saveMessage {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Open in Editor") { viewModel.openConfigInEditor() }
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewaySection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Gateway")
|
||||
.font(.headline)
|
||||
HStack(spacing: 16) {
|
||||
Label(
|
||||
viewModel.gatewayState?.statusText ?? "unknown",
|
||||
systemImage: viewModel.gatewayState?.isRunning == true ? "circle.fill" : "circle"
|
||||
)
|
||||
.foregroundStyle(viewModel.gatewayState?.isRunning == true ? .green : .secondary)
|
||||
if let reason = viewModel.gatewayState?.exitReason {
|
||||
Text(reason)
|
||||
.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) }
|
||||
}
|
||||
}
|
||||
|
||||
// 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: "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) }
|
||||
}
|
||||
}
|
||||
|
||||
// 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) }
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paths")
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
||||
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
||||
PathRow(label: "Config", path: HermesPaths.configYAML)
|
||||
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
|
||||
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
|
||||
PathRow(label: "Skills", path: HermesPaths.skillsDir)
|
||||
PathRow(label: "Logs", path: HermesPaths.errorsLog)
|
||||
}
|
||||
SettingsSection(title: "Paths", icon: "folder") {
|
||||
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
||||
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
||||
PathRow(label: "Config", path: HermesPaths.configYAML)
|
||||
PathRow(label: "Memory", path: HermesPaths.memoriesDir)
|
||||
PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
|
||||
PathRow(label: "Skills", path: HermesPaths.skillsDir)
|
||||
PathRow(label: "Logs", path: HermesPaths.errorsLog)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Raw Config
|
||||
|
||||
private var rawConfigSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
@@ -98,19 +130,143 @@ 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: 120, alignment: .trailing)
|
||||
Text(value)
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,10 +279,11 @@ struct PathRow: View {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 100, alignment: .trailing)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Text(path)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Spacer()
|
||||
Button {
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
|
||||
} label: {
|
||||
@@ -135,5 +292,8 @@ struct PathRow: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
final class ToolsViewModel {
|
||||
var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
|
||||
var toolsets: [HermesToolset] = []
|
||||
var mcpStatus: String = ""
|
||||
var isLoading = false
|
||||
var availablePlatforms: [HermesToolPlatform] = []
|
||||
|
||||
func load() {
|
||||
loadPlatforms()
|
||||
loadTools(for: selectedPlatform)
|
||||
loadMCPStatus()
|
||||
}
|
||||
|
||||
func switchPlatform(_ platform: HermesToolPlatform) {
|
||||
selectedPlatform = platform
|
||||
loadTools(for: platform)
|
||||
}
|
||||
|
||||
func toggleTool(_ tool: HermesToolset) {
|
||||
let action = tool.enabled ? "disable" : "enable"
|
||||
let result = runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name])
|
||||
if result.exitCode == 0 {
|
||||
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
|
||||
toolsets[idx].enabled.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPlatforms() {
|
||||
let config: String
|
||||
do {
|
||||
config = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
||||
} catch {
|
||||
print("[Scarf] 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
|
||||
}
|
||||
}
|
||||
|
||||
private func loadTools(for platform: HermesToolPlatform) {
|
||||
isLoading = true
|
||||
let result = runHermes(["tools", "list", "--platform", platform.name])
|
||||
toolsets = parseToolsList(result.output)
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func loadMCPStatus() {
|
||||
let result = 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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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")
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var platformPicker: some View {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(viewModel.availablePlatforms) { platform in
|
||||
Button {
|
||||
viewModel.switchPlatform(platform)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: platform.icon)
|
||||
Text(platform.displayName)
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(viewModel.selectedPlatform.name == platform.name ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
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) {
|
||||
viewModel.toggleTool(tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
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: () -> 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 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 {
|
||||
case dashboard = "Dashboard"
|
||||
case insights = "Insights"
|
||||
case sessions = "Sessions"
|
||||
case activity = "Activity"
|
||||
case projects = "Projects"
|
||||
case chat = "Chat"
|
||||
case memory = "Memory"
|
||||
case skills = "Skills"
|
||||
case tools = "Tools"
|
||||
case gateway = "Gateway"
|
||||
case cron = "Cron"
|
||||
case health = "Health"
|
||||
case logs = "Logs"
|
||||
case settings = "Settings"
|
||||
|
||||
@@ -16,12 +21,17 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .dashboard: return "gauge.with.dots.needle.33percent"
|
||||
case .insights: return "chart.bar"
|
||||
case .sessions: return "bubble.left.and.bubble.right"
|
||||
case .activity: return "bolt.horizontal"
|
||||
case .projects: return "square.grid.2x2"
|
||||
case .chat: return "text.bubble"
|
||||
case .memory: return "brain"
|
||||
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 .health: return "stethoscope"
|
||||
case .logs: return "doc.text"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
@@ -32,4 +42,5 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
final class AppCoordinator {
|
||||
var selectedSection: SidebarSection = .dashboard
|
||||
var selectedSessionId: String?
|
||||
var selectedProjectName: String?
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@ struct SidebarView: View {
|
||||
@Bindable var coordinator = coordinator
|
||||
List(selection: $coordinator.selectedSection) {
|
||||
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)
|
||||
.tag(section)
|
||||
}
|
||||
@@ -19,7 +25,7 @@ struct SidebarView: View {
|
||||
}
|
||||
}
|
||||
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)
|
||||
.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>
|
||||
Reference in New Issue
Block a user