mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae2872e08f | |||
| 303f4502dd | |||
| 815c9dcbcd | |||
| ef53ac1c93 | |||
| 2a3e8b1422 | |||
| 563f5a702c | |||
| c7f3ca9be3 | |||
| dbaadb8037 | |||
| ce001fe202 | |||
| a329eca419 | |||
| 528de938c5 |
@@ -43,3 +43,6 @@ Package.resolved
|
|||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
scarf/standards/backups/
|
scarf/standards/backups/
|
||||||
|
|
||||||
|
# Scarf project dashboards (user-specific)
|
||||||
|
.scarf/
|
||||||
|
|||||||
@@ -19,18 +19,19 @@
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Dashboard** — System health, token usage, recent sessions with live refresh
|
- **Dashboard** — System health, token usage, cost tracking, 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)
|
- **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
|
||||||
- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, full-text search, rename, delete, and JSONL export
|
- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, 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
|
- **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
|
- **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
|
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, etc.)
|
||||||
- **Skills Browser** — Browse all installed skills by category with file content viewer and file switcher
|
- **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
|
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Webhook, Matrix) with toggle switches, MCP server status
|
||||||
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
||||||
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
|
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
|
||||||
- **Log Viewer** — Real-time log tailing with level filtering and text search
|
- **Log Viewer** — Real-time log tailing with level filtering and text search
|
||||||
- **Settings** — Configuration display with raw YAML viewer and Finder path links
|
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
|
||||||
|
- **Settings** — Structured config editor for all Hermes settings including reasoning effort, approval mode, cost display, and more
|
||||||
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -41,16 +42,26 @@
|
|||||||
|
|
||||||
### Compatibility
|
### Compatibility
|
||||||
|
|
||||||
Scarf reads Hermes's SQLite database (schema v6) and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Tested and verified against:
|
Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Automatic schema detection provides backward compatibility with older databases while supporting new features in newer Hermes versions.
|
||||||
|
|
||||||
| Hermes Version | Status |
|
| Hermes Version | Status |
|
||||||
|----------------|--------|
|
|----------------|--------|
|
||||||
| v0.6.0 (2026-03-30) | Verified |
|
| v0.6.0 (2026-03-30) | Verified |
|
||||||
| v0.6.0 (2026-03-31, latest) | Verified |
|
| v0.7.0 (2026-04-03, 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.
|
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.
|
||||||
|
|
||||||
## Building
|
## Install
|
||||||
|
|
||||||
|
### Pre-built Binary (no Xcode required)
|
||||||
|
|
||||||
|
Download the latest universal binary (Apple Silicon + Intel) from [Releases](https://github.com/awizemann/scarf/releases):
|
||||||
|
|
||||||
|
1. Download `Scarf-vX.X.X-Universal.zip`
|
||||||
|
2. Unzip and drag **Scarf.app** to Applications
|
||||||
|
3. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
|
||||||
|
|
||||||
|
### Build from Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/awizemann/scarf.git
|
git clone https://github.com/awizemann/scarf.git
|
||||||
@@ -61,7 +72,7 @@ open scarf.xcodeproj
|
|||||||
Or from the command line:
|
Or from the command line:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Release -arch arm64 -arch x86_64 ONLY_ACTIVE_ARCH=NO build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -78,6 +89,7 @@ scarf/
|
|||||||
Insights/ Usage analytics and activity patterns
|
Insights/ Usage analytics and activity patterns
|
||||||
Sessions/ Conversation browser with rename, delete, export
|
Sessions/ Conversation browser with rename, delete, export
|
||||||
Activity/ Tool execution feed with inspector
|
Activity/ Tool execution feed with inspector
|
||||||
|
Projects/ Agent-generated project dashboards with widget rendering
|
||||||
Chat/ Embedded terminal via SwiftTerm with voice controls
|
Chat/ Embedded terminal via SwiftTerm with voice controls
|
||||||
Memory/ Memory viewer and editor
|
Memory/ Memory viewer and editor
|
||||||
Skills/ Skill browser by category
|
Skills/ Skill browser by category
|
||||||
@@ -85,7 +97,7 @@ scarf/
|
|||||||
Gateway/ Messaging gateway control and pairing
|
Gateway/ Messaging gateway control and pairing
|
||||||
Cron/ Scheduled job viewer
|
Cron/ Scheduled job viewer
|
||||||
Logs/ Real-time log viewer
|
Logs/ Real-time log viewer
|
||||||
Settings/ Configuration display
|
Settings/ Structured config editor
|
||||||
Navigation/ AppCoordinator + SidebarView
|
Navigation/ AppCoordinator + SidebarView
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -107,6 +119,8 @@ Scarf reads Hermes data directly from `~/.hermes/`:
|
|||||||
| `hermes sessions` | CLI commands | Rename/Delete/Export |
|
| `hermes sessions` | CLI commands | Rename/Delete/Export |
|
||||||
| `hermes gateway` | CLI commands | Start/Stop/Restart |
|
| `hermes gateway` | CLI commands | Start/Stop/Restart |
|
||||||
| `hermes pairing` | CLI commands | Approve/Revoke |
|
| `hermes pairing` | CLI commands | Approve/Revoke |
|
||||||
|
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
|
||||||
|
| `scarf/projects.json` | JSON (registry) | Read/Write |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -116,7 +130,7 @@ The app opens `state.db` in read-only mode to avoid WAL contention with Hermes.
|
|||||||
|---------|---------|
|
|---------|---------|
|
||||||
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
|
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
|
||||||
|
|
||||||
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, GCD file watching.
|
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
@@ -128,6 +142,142 @@ Management actions (renaming sessions, toggling tools, editing memory) call the
|
|||||||
|
|
||||||
The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary.
|
The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary.
|
||||||
|
|
||||||
|
## Project Dashboards
|
||||||
|
|
||||||
|
Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically.
|
||||||
|
|
||||||
|
### What You Can Build
|
||||||
|
|
||||||
|
- **Development dashboards** — test coverage, build status, open issues, sprint progress
|
||||||
|
- **Data project trackers** — pipeline metrics, data quality scores, processing throughput
|
||||||
|
- **Deployment monitors** — deploy history tables, uptime stats, error rate charts
|
||||||
|
- **Research dashboards** — experiment results, key findings, paper status checklists
|
||||||
|
- **Agent activity views** — cron job results, content generation stats, task completion rates
|
||||||
|
- **Embedded web apps** — local dev servers, HTML reports, Grafana dashboards, any web-based tool your agent generates
|
||||||
|
- **Any project status** — if your agent can measure it, Scarf can display it
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
**1. Create the dashboard file**
|
||||||
|
|
||||||
|
Create `.scarf/dashboard.json` in any project folder:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"title": "My Project",
|
||||||
|
"description": "Project status at a glance",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "Overview",
|
||||||
|
"columns": 3,
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Test Coverage",
|
||||||
|
"value": "87%",
|
||||||
|
"icon": "checkmark.shield",
|
||||||
|
"color": "green",
|
||||||
|
"subtitle": "+2.1% this week"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "progress",
|
||||||
|
"title": "Sprint Progress",
|
||||||
|
"value": 0.73,
|
||||||
|
"label": "73% complete",
|
||||||
|
"color": "blue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "list",
|
||||||
|
"title": "Tasks",
|
||||||
|
"items": [
|
||||||
|
{ "text": "Write unit tests", "status": "done" },
|
||||||
|
{ "text": "Update API docs", "status": "active" },
|
||||||
|
{ "text": "Deploy to prod", "status": "pending" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Register your project**
|
||||||
|
|
||||||
|
In Scarf, go to **Projects** in the sidebar and click the **+** button to add your project folder. Or have your agent add it directly to the registry at `~/.hermes/scarf/projects.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projects": [
|
||||||
|
{ "name": "my-project", "path": "/Users/you/Developer/my-project" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. View in Scarf**
|
||||||
|
|
||||||
|
Select your project in the Projects sidebar — the dashboard renders immediately. Scarf watches the file for changes and refreshes automatically whenever the JSON is updated.
|
||||||
|
|
||||||
|
### Widget Types
|
||||||
|
|
||||||
|
| Type | Description | Key Fields |
|
||||||
|
|------|-------------|------------|
|
||||||
|
| `stat` | Key metric with large value display | `value`, `icon`, `color`, `subtitle` |
|
||||||
|
| `progress` | Progress bar with label | `value` (0.0–1.0), `label`, `color` |
|
||||||
|
| `text` | Rich text block | `content`, `format` ("markdown" or "plain") |
|
||||||
|
| `table` | Data table with headers | `columns`, `rows` |
|
||||||
|
| `chart` | Line, bar, or pie chart | `chartType`, `series` (each with `name`, `color`, `data`) |
|
||||||
|
| `list` | Checklist with status indicators | `items` (each with `text`, `status`: done/active/pending) |
|
||||||
|
| `webview` | Embedded web browser | `url`, `height` (default 400) |
|
||||||
|
|
||||||
|
The `webview` widget embeds a live web browser directly in your dashboard — perfect for displaying local dev servers, HTML reports, or any web-based tool your agent generates.
|
||||||
|
|
||||||
|
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows your normal widgets, **Site** shows the web content full-canvas with clean margins — using the entire available space in the app. This gives you the best of both worlds: compact metrics at a glance, and a full embedded browser when you need it.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "webview",
|
||||||
|
"title": "Project Report",
|
||||||
|
"url": "http://localhost:8000/dashboard",
|
||||||
|
"height": 500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `url`: Any URL — typically a local server (`http://localhost:...`) or file path
|
||||||
|
- `height`: Height in points when displayed as an inline widget card (default: 400). The Site tab always uses full available space regardless of this setting.
|
||||||
|
|
||||||
|
**Colors**: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray
|
||||||
|
|
||||||
|
**Icons**: Any [SF Symbol](https://developer.apple.com/sf-symbols/) name (e.g., `checkmark.shield`, `cpu`, `doc.text`, `chart.bar`)
|
||||||
|
|
||||||
|
### Agent-Generated Dashboards
|
||||||
|
|
||||||
|
The real power is letting your Hermes agent build and update dashboards automatically. Add instructions like this to your agent's context:
|
||||||
|
|
||||||
|
> Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, lists for task tracking, and a webview widget if the project has a local web server or HTML reports. Register the project in `~/.hermes/scarf/projects.json` if not already registered.
|
||||||
|
|
||||||
|
Your agent can update the dashboard as part of cron jobs, after builds, or whenever project state changes. Since Scarf watches the file, updates appear in real-time.
|
||||||
|
|
||||||
|
### Dashboard Schema Reference
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"title": "Required — dashboard title",
|
||||||
|
"description": "Optional — subtitle text",
|
||||||
|
"updatedAt": "Optional — ISO 8601 timestamp",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "Section Name",
|
||||||
|
"columns": 3,
|
||||||
|
"widgets": [{ "type": "...", "title": "..." }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each section defines a grid with 1–4 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -407,7 +407,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -443,7 +443,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@@ -457,7 +457,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -475,11 +475,11 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -496,11 +496,11 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -516,10 +516,10 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
@@ -535,10 +535,10 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ struct ContentView: View {
|
|||||||
SessionsView()
|
SessionsView()
|
||||||
case .activity:
|
case .activity:
|
||||||
ActivityView()
|
ActivityView()
|
||||||
|
case .projects:
|
||||||
|
ProjectsView()
|
||||||
case .chat:
|
case .chat:
|
||||||
ChatView()
|
ChatView()
|
||||||
case .memory:
|
case .memory:
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ struct HermesConfig: Sendable {
|
|||||||
var verbose: Bool
|
var verbose: Bool
|
||||||
var autoTTS: Bool
|
var autoTTS: Bool
|
||||||
var silenceThreshold: Int
|
var silenceThreshold: Int
|
||||||
|
var reasoningEffort: String
|
||||||
|
var showCost: Bool
|
||||||
|
var approvalMode: String
|
||||||
|
var browserBackend: String
|
||||||
|
var memoryProvider: String
|
||||||
|
|
||||||
static let empty = HermesConfig(
|
static let empty = HermesConfig(
|
||||||
model: "unknown",
|
model: "unknown",
|
||||||
@@ -30,7 +35,12 @@ struct HermesConfig: Sendable {
|
|||||||
showReasoning: false,
|
showReasoning: false,
|
||||||
verbose: false,
|
verbose: false,
|
||||||
autoTTS: true,
|
autoTTS: true,
|
||||||
silenceThreshold: 200
|
silenceThreshold: 200,
|
||||||
|
reasoningEffort: "medium",
|
||||||
|
showCost: false,
|
||||||
|
approvalMode: "manual",
|
||||||
|
browserBackend: "",
|
||||||
|
memoryProvider: ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SQLite3
|
||||||
|
|
||||||
enum HermesPaths: Sendable {
|
enum HermesPaths: Sendable {
|
||||||
// Using ProcessInfo to avoid main-actor isolation issues with FileManager/NSHomeDirectory
|
private nonisolated static let userHome: String = ProcessInfo.processInfo.environment["HOME"]
|
||||||
nonisolated static let home: String = ProcessInfo.processInfo.environment["HOME"]! + "/.hermes"
|
?? NSHomeDirectory()
|
||||||
|
|
||||||
|
nonisolated static let home: String = userHome + "/.hermes"
|
||||||
nonisolated static let stateDB: String = home + "/state.db"
|
nonisolated static let stateDB: String = home + "/state.db"
|
||||||
nonisolated static let configYAML: String = home + "/config.yaml"
|
nonisolated static let configYAML: String = home + "/config.yaml"
|
||||||
nonisolated static let memoriesDir: String = home + "/memories"
|
nonisolated static let memoriesDir: String = home + "/memories"
|
||||||
@@ -15,5 +18,32 @@ enum HermesPaths: Sendable {
|
|||||||
nonisolated static let skillsDir: String = home + "/skills"
|
nonisolated static let skillsDir: String = home + "/skills"
|
||||||
nonisolated static let errorsLog: String = home + "/logs/errors.log"
|
nonisolated static let errorsLog: String = home + "/logs/errors.log"
|
||||||
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
|
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
|
||||||
nonisolated static let hermesBinary: String = ProcessInfo.processInfo.environment["HOME"]! + "/.local/bin/hermes"
|
nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
|
||||||
|
nonisolated static let scarfDir: String = home + "/scarf"
|
||||||
|
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SQLite Constants
|
||||||
|
|
||||||
|
/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data.
|
||||||
|
/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift.
|
||||||
|
nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||||
|
|
||||||
|
// MARK: - Query Defaults
|
||||||
|
|
||||||
|
enum QueryDefaults: Sendable {
|
||||||
|
nonisolated static let sessionLimit = 100
|
||||||
|
nonisolated static let messageSearchLimit = 50
|
||||||
|
nonisolated static let toolCallLimit = 50
|
||||||
|
nonisolated static let sessionPreviewLimit = 10
|
||||||
|
nonisolated static let previewContentLength = 100
|
||||||
|
nonisolated static let logLineLimit = 200
|
||||||
|
nonisolated static let defaultSilenceThreshold = 200
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - File Size Formatting
|
||||||
|
|
||||||
|
enum FileSizeUnit: Sendable {
|
||||||
|
nonisolated static let kilobyte = 1_024.0
|
||||||
|
nonisolated static let megabyte = 1_048_576.0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ struct HermesMessage: Identifiable, Sendable {
|
|||||||
let timestamp: Date?
|
let timestamp: Date?
|
||||||
let tokenCount: Int?
|
let tokenCount: Int?
|
||||||
let finishReason: String?
|
let finishReason: String?
|
||||||
|
let reasoning: String?
|
||||||
|
|
||||||
var isUser: Bool { role == "user" }
|
var isUser: Bool { role == "user" }
|
||||||
var isAssistant: Bool { role == "assistant" }
|
var isAssistant: Bool { role == "assistant" }
|
||||||
var isToolResult: Bool { role == "tool" }
|
var isToolResult: Bool { role == "tool" }
|
||||||
|
var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HermesToolCall: Identifiable, Sendable, Codable {
|
struct HermesToolCall: Identifiable, Sendable, Codable {
|
||||||
@@ -61,7 +63,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
|
|||||||
switch functionName {
|
switch functionName {
|
||||||
case "read_file", "search_files", "vision_analyze": return .read
|
case "read_file", "search_files", "vision_analyze": return .read
|
||||||
case "write_file", "patch": return .edit
|
case "write_file", "patch": return .edit
|
||||||
case "terminal": return .execute
|
case "terminal", "execute_code": return .execute
|
||||||
case "web_search", "web_extract": return .fetch
|
case "web_search", "web_extract": return .fetch
|
||||||
case "browser_navigate", "browser_click", "browser_screenshot": return .browser
|
case "browser_navigate", "browser_click", "browser_screenshot": return .browser
|
||||||
default: return .other
|
default: return .other
|
||||||
|
|||||||
@@ -17,8 +17,16 @@ struct HermesSession: Identifiable, Sendable {
|
|||||||
let cacheReadTokens: Int
|
let cacheReadTokens: Int
|
||||||
let cacheWriteTokens: Int
|
let cacheWriteTokens: Int
|
||||||
let estimatedCostUSD: Double?
|
let estimatedCostUSD: Double?
|
||||||
|
let reasoningTokens: Int
|
||||||
|
let actualCostUSD: Double?
|
||||||
|
let costStatus: String?
|
||||||
|
let billingProvider: String?
|
||||||
|
|
||||||
var totalTokens: Int { inputTokens + outputTokens }
|
var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
|
||||||
|
|
||||||
|
var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
|
||||||
|
|
||||||
|
var costIsActual: Bool { actualCostUSD != nil }
|
||||||
|
|
||||||
var duration: TimeInterval? {
|
var duration: TimeInterval? {
|
||||||
guard let start = startedAt, let end = endedAt else { return nil }
|
guard let start = startedAt, let end = endedAt else { return nil }
|
||||||
@@ -30,13 +38,20 @@ struct HermesSession: Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sourceIcon: String {
|
var sourceIcon: String {
|
||||||
switch source {
|
KnownPlatforms.icon(for: source)
|
||||||
case "cli": return "terminal"
|
|
||||||
case "telegram": return "paperplane"
|
|
||||||
case "discord": return "bubble.left.and.bubble.right"
|
|
||||||
case "slack": return "number"
|
|
||||||
case "email": return "envelope"
|
|
||||||
default: return "bubble.left"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withTitle(_ newTitle: String) -> HermesSession {
|
||||||
|
HermesSession(
|
||||||
|
id: id, source: source, userId: userId, model: model,
|
||||||
|
title: newTitle, parentSessionId: parentSessionId,
|
||||||
|
startedAt: startedAt, endedAt: endedAt, endReason: endReason,
|
||||||
|
messageCount: messageCount, toolCallCount: toolCallCount,
|
||||||
|
inputTokens: inputTokens, outputTokens: outputTokens,
|
||||||
|
cacheReadTokens: cacheReadTokens, cacheWriteTokens: cacheWriteTokens,
|
||||||
|
estimatedCostUSD: estimatedCostUSD, reasoningTokens: reasoningTokens,
|
||||||
|
actualCostUSD: actualCostUSD, costStatus: costStatus,
|
||||||
|
billingProvider: billingProvider
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,33 @@ struct HermesToolPlatform: Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum KnownPlatforms {
|
enum KnownPlatforms {
|
||||||
|
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
|
||||||
static let all: [HermesToolPlatform] = [
|
static let all: [HermesToolPlatform] = [
|
||||||
HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal"),
|
cli,
|
||||||
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
|
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
|
||||||
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
|
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
|
||||||
HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"),
|
HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"),
|
||||||
HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"),
|
HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"),
|
||||||
HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"),
|
HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"),
|
||||||
HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"),
|
HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"),
|
||||||
|
HermesToolPlatform(name: "homeassistant", displayName: "Home Assistant", icon: "house"),
|
||||||
|
HermesToolPlatform(name: "webhook", displayName: "Webhook", icon: "arrow.up.right.square"),
|
||||||
|
HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
static func icon(for platform: String) -> String {
|
||||||
|
switch platform {
|
||||||
|
case "cli": return "terminal"
|
||||||
|
case "telegram": return "paperplane"
|
||||||
|
case "discord": return "bubble.left.and.bubble.right"
|
||||||
|
case "slack": return "number"
|
||||||
|
case "whatsapp": return "phone.bubble"
|
||||||
|
case "signal": return "lock.shield"
|
||||||
|
case "email": return "envelope"
|
||||||
|
case "homeassistant": return "house"
|
||||||
|
case "webhook": return "arrow.up.right.square"
|
||||||
|
case "matrix": return "lock.rectangle.stack"
|
||||||
|
default: return "bubble.left"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Registry
|
||||||
|
|
||||||
|
struct ProjectRegistry: Codable, Sendable {
|
||||||
|
var projects: [ProjectEntry]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let path: String
|
||||||
|
|
||||||
|
var dashboardPath: String { path + "/.scarf/dashboard.json" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dashboard
|
||||||
|
|
||||||
|
struct ProjectDashboard: Codable, Sendable {
|
||||||
|
let version: Int
|
||||||
|
let title: String
|
||||||
|
let description: String?
|
||||||
|
let updatedAt: String?
|
||||||
|
let theme: DashboardTheme?
|
||||||
|
let sections: [DashboardSection]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DashboardTheme: Codable, Sendable {
|
||||||
|
let accent: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DashboardSection: Codable, Sendable, Identifiable {
|
||||||
|
var id: String { title }
|
||||||
|
let title: String
|
||||||
|
let columns: Int?
|
||||||
|
let widgets: [DashboardWidget]
|
||||||
|
|
||||||
|
var columnCount: Int { columns ?? 3 }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DashboardWidget: Codable, Sendable, Identifiable {
|
||||||
|
var id: String { type + ":" + title }
|
||||||
|
|
||||||
|
let type: String
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
// Stat
|
||||||
|
let value: WidgetValue?
|
||||||
|
let icon: String?
|
||||||
|
let color: String?
|
||||||
|
let subtitle: String?
|
||||||
|
|
||||||
|
// Progress
|
||||||
|
let label: String?
|
||||||
|
|
||||||
|
// Text
|
||||||
|
let content: String?
|
||||||
|
let format: String?
|
||||||
|
|
||||||
|
// Table
|
||||||
|
let columns: [String]?
|
||||||
|
let rows: [[String]]?
|
||||||
|
|
||||||
|
// Chart
|
||||||
|
let chartType: String?
|
||||||
|
let xLabel: String?
|
||||||
|
let yLabel: String?
|
||||||
|
let series: [ChartSeries]?
|
||||||
|
|
||||||
|
// List
|
||||||
|
let items: [ListItem]?
|
||||||
|
|
||||||
|
// Webview
|
||||||
|
let url: String?
|
||||||
|
let height: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget Value (String or Number)
|
||||||
|
|
||||||
|
enum WidgetValue: Codable, Sendable, Hashable {
|
||||||
|
case string(String)
|
||||||
|
case number(Double)
|
||||||
|
|
||||||
|
var displayString: String {
|
||||||
|
switch self {
|
||||||
|
case .string(let s): return s
|
||||||
|
case .number(let n):
|
||||||
|
return n.truncatingRemainder(dividingBy: 1) == 0
|
||||||
|
? String(Int(n))
|
||||||
|
: String(format: "%.1f", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if let d = try? container.decode(Double.self) {
|
||||||
|
self = .number(d)
|
||||||
|
} else if let s = try? container.decode(String.self) {
|
||||||
|
self = .string(s)
|
||||||
|
} else {
|
||||||
|
throw DecodingError.typeMismatch(
|
||||||
|
WidgetValue.self,
|
||||||
|
.init(codingPath: decoder.codingPath, debugDescription: "Expected String or Number")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch self {
|
||||||
|
case .string(let s): try container.encode(s)
|
||||||
|
case .number(let n): try container.encode(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chart Data
|
||||||
|
|
||||||
|
struct ChartSeries: Codable, Sendable, Identifiable {
|
||||||
|
var id: String { name }
|
||||||
|
let name: String
|
||||||
|
let color: String?
|
||||||
|
let data: [ChartDataPoint]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChartDataPoint: Codable, Sendable, Identifiable {
|
||||||
|
var id: String { x }
|
||||||
|
let x: String
|
||||||
|
let y: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - List Data
|
||||||
|
|
||||||
|
struct ListItem: Codable, Sendable, Identifiable {
|
||||||
|
var id: String { text }
|
||||||
|
let text: String
|
||||||
|
let status: String?
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import SQLite3
|
|||||||
|
|
||||||
actor HermesDataService {
|
actor HermesDataService {
|
||||||
private var db: OpaquePointer?
|
private var db: OpaquePointer?
|
||||||
|
private var hasV07Schema = false
|
||||||
|
|
||||||
func open() -> Bool {
|
func open() -> Bool {
|
||||||
let path = HermesPaths.stateDB
|
let path = HermesPaths.stateDB
|
||||||
@@ -14,6 +15,7 @@ actor HermesDataService {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
||||||
|
detectSchema()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,17 +26,39 @@ actor HermesDataService {
|
|||||||
db = nil
|
db = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSessions(limit: Int = 100) -> [HermesSession] {
|
// MARK: - Schema Detection
|
||||||
guard let db else { return [] }
|
|
||||||
let sql = """
|
private func detectSchema() {
|
||||||
SELECT id, source, user_id, model, title, parent_session_id,
|
guard let db else { return }
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, "PRAGMA table_info(sessions)", -1, &stmt, nil) == SQLITE_OK else { return }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
if let name = sqlite3_column_text(stmt, 1), String(cString: name) == "reasoning_tokens" {
|
||||||
|
hasV07Schema = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Queries
|
||||||
|
|
||||||
|
private var sessionColumns: String {
|
||||||
|
var cols = """
|
||||||
|
id, source, user_id, model, title, parent_session_id,
|
||||||
started_at, ended_at, end_reason, message_count, tool_call_count,
|
started_at, ended_at, end_reason, message_count, tool_call_count,
|
||||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||||
estimated_cost_usd
|
estimated_cost_usd
|
||||||
FROM sessions
|
|
||||||
ORDER BY started_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
"""
|
"""
|
||||||
|
if hasV07Schema {
|
||||||
|
cols += ", reasoning_tokens, actual_cost_usd, cost_status, billing_provider"
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
|
||||||
|
guard let db else { return [] }
|
||||||
|
let sql = "SELECT \(sessionColumns) FROM sessions ORDER BY started_at DESC LIMIT ?"
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
@@ -47,19 +71,41 @@ actor HermesDataService {
|
|||||||
return sessions
|
return sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchMessages(sessionId: String) -> [HermesMessage] {
|
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = """
|
let sql = "SELECT \(sessionColumns) FROM sessions WHERE started_at >= ? ORDER BY started_at DESC"
|
||||||
SELECT id, session_id, role, content, tool_call_id, tool_calls,
|
|
||||||
tool_name, timestamp, token_count, finish_reason
|
|
||||||
FROM messages
|
|
||||||
WHERE session_id = ?
|
|
||||||
ORDER BY timestamp ASC
|
|
||||||
"""
|
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
sqlite3_bind_text(stmt, 1, sessionId, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
|
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||||
|
|
||||||
|
var sessions: [HermesSession] = []
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
sessions.append(sessionFromRow(stmt!))
|
||||||
|
}
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Message Queries
|
||||||
|
|
||||||
|
private var messageColumns: String {
|
||||||
|
var cols = """
|
||||||
|
id, session_id, role, content, tool_call_id, tool_calls,
|
||||||
|
tool_name, timestamp, token_count, finish_reason
|
||||||
|
"""
|
||||||
|
if hasV07Schema {
|
||||||
|
cols += ", reasoning"
|
||||||
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchMessages(sessionId: String) -> [HermesMessage] {
|
||||||
|
guard let db else { return [] }
|
||||||
|
let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC"
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
|
||||||
|
|
||||||
var messages: [HermesMessage] = []
|
var messages: [HermesMessage] = []
|
||||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
@@ -68,11 +114,15 @@ actor HermesDataService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchMessages(query: String, limit: Int = 50) -> [HermesMessage] {
|
func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
|
let sanitized = sanitizeFTSQuery(query)
|
||||||
|
guard !sanitized.isEmpty else { return [] }
|
||||||
|
let msgCols = hasV07Schema
|
||||||
|
? "m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, m.tool_name, m.timestamp, m.token_count, m.finish_reason, m.reasoning"
|
||||||
|
: "m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, m.tool_name, m.timestamp, m.token_count, m.finish_reason"
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls,
|
SELECT \(msgCols)
|
||||||
m.tool_name, m.timestamp, m.token_count, m.finish_reason
|
|
||||||
FROM messages_fts fts
|
FROM messages_fts fts
|
||||||
JOIN messages m ON m.id = fts.rowid
|
JOIN messages m ON m.id = fts.rowid
|
||||||
WHERE messages_fts MATCH ?
|
WHERE messages_fts MATCH ?
|
||||||
@@ -82,7 +132,7 @@ actor HermesDataService {
|
|||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
sqlite3_bind_text(stmt, 1, query, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
|
sqlite3_bind_text(stmt, 1, sanitized, -1, sqliteTransient)
|
||||||
sqlite3_bind_int(stmt, 2, Int32(limit))
|
sqlite3_bind_int(stmt, 2, Int32(limit))
|
||||||
|
|
||||||
var messages: [HermesMessage] = []
|
var messages: [HermesMessage] = []
|
||||||
@@ -92,11 +142,10 @@ actor HermesDataService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchRecentToolCalls(limit: Int = 50) -> [HermesMessage] {
|
func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT id, session_id, role, content, tool_call_id, tool_calls,
|
SELECT \(messageColumns)
|
||||||
tool_name, timestamp, token_count, finish_reason
|
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
|
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
@@ -114,10 +163,10 @@ actor HermesDataService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSessionPreviews(limit: Int = 10) -> [String: String] {
|
func fetchSessionPreviews(limit: Int = QueryDefaults.sessionPreviewLimit) -> [String: String] {
|
||||||
guard let db else { return [:] }
|
guard let db else { return [:] }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT m.session_id, substr(m.content, 1, 100)
|
SELECT m.session_id, substr(m.content, 1, \(QueryDefaults.previewContentLength))
|
||||||
FROM messages m
|
FROM messages m
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT session_id, MIN(id) as min_id
|
SELECT session_id, MIN(id) as min_id
|
||||||
@@ -142,6 +191,8 @@ actor HermesDataService {
|
|||||||
return previews
|
return previews
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Stats
|
||||||
|
|
||||||
struct SessionStats: Sendable {
|
struct SessionStats: Sendable {
|
||||||
let totalSessions: Int
|
let totalSessions: Int
|
||||||
let totalMessages: Int
|
let totalMessages: Int
|
||||||
@@ -149,65 +200,53 @@ actor HermesDataService {
|
|||||||
let totalInputTokens: Int
|
let totalInputTokens: Int
|
||||||
let totalOutputTokens: Int
|
let totalOutputTokens: Int
|
||||||
let totalCostUSD: Double
|
let totalCostUSD: Double
|
||||||
|
let totalReasoningTokens: Int
|
||||||
|
let totalActualCostUSD: Double
|
||||||
|
|
||||||
|
static let empty = SessionStats(
|
||||||
|
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||||
|
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0,
|
||||||
|
totalReasoningTokens: 0, totalActualCostUSD: 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchStats() -> SessionStats {
|
func fetchStats() -> SessionStats {
|
||||||
guard let db else {
|
guard let db else { return .empty }
|
||||||
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
let sql: String
|
||||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
if hasV07Schema {
|
||||||
}
|
sql = """
|
||||||
let sql = """
|
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
||||||
|
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||||
|
COALESCE(SUM(estimated_cost_usd),0),
|
||||||
|
COALESCE(SUM(reasoning_tokens),0), COALESCE(SUM(actual_cost_usd),0)
|
||||||
|
FROM sessions
|
||||||
|
"""
|
||||||
|
} else {
|
||||||
|
sql = """
|
||||||
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
||||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||||
COALESCE(SUM(estimated_cost_usd),0)
|
COALESCE(SUM(estimated_cost_usd),0)
|
||||||
FROM sessions
|
FROM sessions
|
||||||
"""
|
"""
|
||||||
|
}
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
|
||||||
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
|
||||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
|
||||||
}
|
|
||||||
defer { sqlite3_finalize(stmt) }
|
defer { sqlite3_finalize(stmt) }
|
||||||
|
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
|
||||||
guard sqlite3_step(stmt) == SQLITE_ROW else {
|
|
||||||
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
|
||||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
|
|
||||||
}
|
|
||||||
return SessionStats(
|
return SessionStats(
|
||||||
totalSessions: Int(sqlite3_column_int(stmt, 0)),
|
totalSessions: Int(sqlite3_column_int(stmt, 0)),
|
||||||
totalMessages: Int(sqlite3_column_int(stmt, 1)),
|
totalMessages: Int(sqlite3_column_int(stmt, 1)),
|
||||||
totalToolCalls: Int(sqlite3_column_int(stmt, 2)),
|
totalToolCalls: Int(sqlite3_column_int(stmt, 2)),
|
||||||
totalInputTokens: Int(sqlite3_column_int(stmt, 3)),
|
totalInputTokens: Int(sqlite3_column_int(stmt, 3)),
|
||||||
totalOutputTokens: Int(sqlite3_column_int(stmt, 4)),
|
totalOutputTokens: Int(sqlite3_column_int(stmt, 4)),
|
||||||
totalCostUSD: sqlite3_column_double(stmt, 5)
|
totalCostUSD: sqlite3_column_double(stmt, 5),
|
||||||
|
totalReasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 6)) : 0,
|
||||||
|
totalActualCostUSD: hasV07Schema ? sqlite3_column_double(stmt, 7) : 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Insights Queries
|
// 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 {
|
func fetchUserMessageCount(since: Date) -> Int {
|
||||||
guard let db else { return 0 }
|
guard let db else { return 0 }
|
||||||
let sql = """
|
let sql = """
|
||||||
@@ -320,7 +359,11 @@ actor HermesDataService {
|
|||||||
outputTokens: Int(sqlite3_column_int(stmt, 12)),
|
outputTokens: Int(sqlite3_column_int(stmt, 12)),
|
||||||
cacheReadTokens: Int(sqlite3_column_int(stmt, 13)),
|
cacheReadTokens: Int(sqlite3_column_int(stmt, 13)),
|
||||||
cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)),
|
cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)),
|
||||||
estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil
|
estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil,
|
||||||
|
reasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 16)) : 0,
|
||||||
|
actualCostUSD: hasV07Schema && sqlite3_column_type(stmt, 17) != SQLITE_NULL ? sqlite3_column_double(stmt, 17) : nil,
|
||||||
|
costStatus: hasV07Schema ? columnOptionalText(stmt, 18) : nil,
|
||||||
|
billingProvider: hasV07Schema ? columnOptionalText(stmt, 19) : nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,14 +380,20 @@ actor HermesDataService {
|
|||||||
toolName: columnOptionalText(stmt, 6),
|
toolName: columnOptionalText(stmt, 6),
|
||||||
timestamp: columnDate(stmt, 7),
|
timestamp: columnDate(stmt, 7),
|
||||||
tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil,
|
tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil,
|
||||||
finishReason: columnOptionalText(stmt, 9)
|
finishReason: columnOptionalText(stmt, 9),
|
||||||
|
reasoning: hasV07Schema ? columnOptionalText(stmt, 10) : nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func parseToolCalls(_ json: String?) -> [HermesToolCall] {
|
private func parseToolCalls(_ json: String?) -> [HermesToolCall] {
|
||||||
guard let json, !json.isEmpty,
|
guard let json, !json.isEmpty,
|
||||||
let data = json.data(using: .utf8) else { return [] }
|
let data = json.data(using: .utf8) else { return [] }
|
||||||
return (try? JSONDecoder().decode([HermesToolCall].self, from: data)) ?? []
|
do {
|
||||||
|
return try JSONDecoder().decode([HermesToolCall].self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode tool calls: \(error.localizedDescription)")
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String {
|
private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String {
|
||||||
@@ -365,4 +414,17 @@ actor HermesDataService {
|
|||||||
let value = sqlite3_column_double(stmt, col)
|
let value = sqlite3_column_double(stmt, col)
|
||||||
return Date(timeIntervalSince1970: value)
|
return Date(timeIntervalSince1970: value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wraps each whitespace-delimited token in double quotes to prevent FTS5 parse errors
|
||||||
|
/// on terms containing dots, hyphens, or FTS5 operators (e.g., "v0.7.0", "config.yaml").
|
||||||
|
private func sanitizeFTSQuery(_ raw: String) -> String {
|
||||||
|
raw.split(separator: " ")
|
||||||
|
.map { token in
|
||||||
|
let t = String(token)
|
||||||
|
let stripped = t.replacingOccurrences(of: "\"", with: "")
|
||||||
|
return stripped.isEmpty ? nil : "\"\(stripped)\""
|
||||||
|
}
|
||||||
|
.compactMap { $0 }
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,12 @@ struct HermesFileService: Sendable {
|
|||||||
showReasoning: values["display.show_reasoning"] == "true",
|
showReasoning: values["display.show_reasoning"] == "true",
|
||||||
verbose: values["agent.verbose"] == "true",
|
verbose: values["agent.verbose"] == "true",
|
||||||
autoTTS: values["voice.auto_tts"] != "false",
|
autoTTS: values["voice.auto_tts"] != "false",
|
||||||
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? 200
|
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold,
|
||||||
|
reasoningEffort: values["agent.reasoning_effort"] ?? "medium",
|
||||||
|
showCost: values["display.show_cost"] == "true",
|
||||||
|
approvalMode: values["approvals.mode"] ?? "manual",
|
||||||
|
browserBackend: values["browser.backend"] ?? "",
|
||||||
|
memoryProvider: values["memory.provider"] ?? ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +57,12 @@ struct HermesFileService: Sendable {
|
|||||||
|
|
||||||
func loadGatewayState() -> GatewayState? {
|
func loadGatewayState() -> GatewayState? {
|
||||||
guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil }
|
guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil }
|
||||||
return try? JSONDecoder().decode(GatewayState.self, from: data)
|
do {
|
||||||
|
return try JSONDecoder().decode(GatewayState.self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode gateway state: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Memory
|
// MARK: - Memory
|
||||||
@@ -77,8 +87,13 @@ struct HermesFileService: Sendable {
|
|||||||
|
|
||||||
func loadCronJobs() -> [HermesCronJob] {
|
func loadCronJobs() -> [HermesCronJob] {
|
||||||
guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] }
|
guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] }
|
||||||
let file = try? JSONDecoder().decode(CronJobsFile.self, from: data)
|
do {
|
||||||
return file?.jobs ?? []
|
let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
|
||||||
|
return file.jobs
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)")
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadCronOutput(jobId: String) -> String? {
|
func loadCronOutput(jobId: String) -> String? {
|
||||||
@@ -123,7 +138,13 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSkillContent(path: String) -> String {
|
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
|
// MARK: - Hermes Process
|
||||||
@@ -156,6 +177,10 @@ struct HermesFileService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func writeFile(_ path: String, content: String) {
|
private func writeFile(_ path: String, content: String) {
|
||||||
try? content.write(toFile: path, atomically: true, encoding: .utf8)
|
do {
|
||||||
|
try content.write(toFile: path, atomically: true, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to write \(path): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import Foundation
|
|||||||
@Observable
|
@Observable
|
||||||
final class HermesFileWatcher {
|
final class HermesFileWatcher {
|
||||||
private(set) var lastChangeDate = Date()
|
private(set) var lastChangeDate = Date()
|
||||||
private var sources: [DispatchSourceFileSystemObject] = []
|
private var coreSources: [DispatchSourceFileSystemObject] = []
|
||||||
|
private var projectSources: [DispatchSourceFileSystemObject] = []
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
|
|
||||||
func startWatching() {
|
func startWatching() {
|
||||||
@@ -16,11 +17,14 @@ final class HermesFileWatcher {
|
|||||||
HermesPaths.cronJobsJSON,
|
HermesPaths.cronJobsJSON,
|
||||||
HermesPaths.gatewayStateJSON,
|
HermesPaths.gatewayStateJSON,
|
||||||
HermesPaths.errorsLog,
|
HermesPaths.errorsLog,
|
||||||
HermesPaths.gatewayLog
|
HermesPaths.gatewayLog,
|
||||||
|
HermesPaths.projectsRegistry
|
||||||
]
|
]
|
||||||
|
|
||||||
for path in paths {
|
for path in paths {
|
||||||
watchFile(path)
|
if let source = makeSource(for: path) {
|
||||||
|
coreSources.append(source)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
||||||
@@ -29,17 +33,30 @@ final class HermesFileWatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopWatching() {
|
func stopWatching() {
|
||||||
for source in sources {
|
for source in coreSources + projectSources {
|
||||||
source.cancel()
|
source.cancel()
|
||||||
}
|
}
|
||||||
sources.removeAll()
|
coreSources.removeAll()
|
||||||
|
projectSources.removeAll()
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
timer = nil
|
timer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func watchFile(_ path: String) {
|
func updateProjectWatches(_ dashboardPaths: [String]) {
|
||||||
|
for source in projectSources {
|
||||||
|
source.cancel()
|
||||||
|
}
|
||||||
|
projectSources.removeAll()
|
||||||
|
for path in dashboardPaths {
|
||||||
|
if let source = makeSource(for: path) {
|
||||||
|
projectSources.append(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSource(for path: String) -> DispatchSourceFileSystemObject? {
|
||||||
let fd = Darwin.open(path, O_EVTONLY)
|
let fd = Darwin.open(path, O_EVTONLY)
|
||||||
guard fd >= 0 else { return }
|
guard fd >= 0 else { return nil }
|
||||||
|
|
||||||
let source = DispatchSource.makeFileSystemObjectSource(
|
let source = DispatchSource.makeFileSystemObjectSource(
|
||||||
fileDescriptor: fd,
|
fileDescriptor: fd,
|
||||||
@@ -53,7 +70,7 @@ final class HermesFileWatcher {
|
|||||||
Darwin.close(fd)
|
Darwin.close(fd)
|
||||||
}
|
}
|
||||||
source.resume()
|
source.resume()
|
||||||
sources.append(source)
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
|||||||
@@ -39,12 +39,16 @@ actor HermesLogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func closeLog() {
|
func closeLog() {
|
||||||
try? fileHandle?.close()
|
do {
|
||||||
|
try fileHandle?.close()
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to close log handle: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
fileHandle = nil
|
fileHandle = nil
|
||||||
currentPath = nil
|
currentPath = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readLastLines(count: Int = 200) -> [LogEntry] {
|
func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
|
||||||
guard let path = currentPath,
|
guard let path = currentPath,
|
||||||
let data = FileManager.default.contents(atPath: path) else { return [] }
|
let data = FileManager.default.contents(atPath: path) else { return [] }
|
||||||
let content = String(data: data, encoding: .utf8) ?? ""
|
let content = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ProjectDashboardService: Sendable {
|
||||||
|
|
||||||
|
// MARK: - Registry
|
||||||
|
|
||||||
|
func loadRegistry() -> ProjectRegistry {
|
||||||
|
guard let data = FileManager.default.contents(atPath: HermesPaths.projectsRegistry) else {
|
||||||
|
return ProjectRegistry(projects: [])
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode project registry: \(error.localizedDescription)")
|
||||||
|
return ProjectRegistry(projects: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveRegistry(_ registry: ProjectRegistry) {
|
||||||
|
let dir = HermesPaths.scarfDir
|
||||||
|
if !FileManager.default.fileExists(atPath: dir) {
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let data = try? JSONEncoder().encode(registry) else { return }
|
||||||
|
// Pretty-print for readability (agents may read this file)
|
||||||
|
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
||||||
|
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
||||||
|
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: formatted)
|
||||||
|
} else {
|
||||||
|
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dashboard
|
||||||
|
|
||||||
|
func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? {
|
||||||
|
guard let data = FileManager.default.contents(atPath: project.dashboardPath) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(ProjectDashboard.self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to decode dashboard for \(project.name): \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dashboardExists(for project: ProjectEntry) -> Bool {
|
||||||
|
FileManager.default.fileExists(atPath: project.dashboardPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dashboardModificationDate(for project: ProjectEntry) -> Date? {
|
||||||
|
guard let attrs = try? FileManager.default.attributesOfItem(atPath: project.dashboardPath) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return attrs[.modificationDate] as? Date
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ struct PersistentTerminalView: NSViewRepresentable {
|
|||||||
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
container.addSubview(terminalView)
|
container.addSubview(terminalView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
|
||||||
terminalView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
terminalView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||||
terminalView.topAnchor.constraint(equalTo: container.topAnchor),
|
terminalView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||||
terminalView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
terminalView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||||
@@ -24,7 +24,7 @@ struct PersistentTerminalView: NSViewRepresentable {
|
|||||||
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
nsView.addSubview(terminalView)
|
nsView.addSubview(terminalView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor),
|
terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor, constant: 4),
|
||||||
terminalView.trailingAnchor.constraint(equalTo: nsView.trailingAnchor),
|
terminalView.trailingAnchor.constraint(equalTo: nsView.trailingAnchor),
|
||||||
terminalView.topAnchor.constraint(equalTo: nsView.topAnchor),
|
terminalView.topAnchor.constraint(equalTo: nsView.topAnchor),
|
||||||
terminalView.bottomAnchor.constraint(equalTo: nsView.bottomAnchor),
|
terminalView.bottomAnchor.constraint(equalTo: nsView.bottomAnchor),
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ final class DashboardViewModel {
|
|||||||
private let dataService = HermesDataService()
|
private let dataService = HermesDataService()
|
||||||
private let fileService = HermesFileService()
|
private let fileService = HermesFileService()
|
||||||
|
|
||||||
var stats = HermesDataService.SessionStats(
|
var stats = HermesDataService.SessionStats.empty
|
||||||
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
|
||||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0
|
|
||||||
)
|
|
||||||
var recentSessions: [HermesSession] = []
|
var recentSessions: [HermesSession] = []
|
||||||
var sessionPreviews: [String: String] = [:]
|
var sessionPreviews: [String: String] = [:]
|
||||||
var config = HermesConfig.empty
|
var config = HermesConfig.empty
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ struct DashboardView: View {
|
|||||||
StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)")
|
StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)")
|
||||||
StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)")
|
StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)")
|
||||||
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
|
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
|
||||||
|
let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
|
||||||
|
if cost > 0 {
|
||||||
|
StatCard(label: "Cost", value: String(format: "$%.2f", cost))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,14 +94,6 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatTokens(_ count: Int) -> String {
|
|
||||||
if count >= 1_000_000 {
|
|
||||||
return String(format: "%.1fM", Double(count) / 1_000_000)
|
|
||||||
} else if count >= 1_000 {
|
|
||||||
return String(format: "%.1fK", Double(count) / 1_000)
|
|
||||||
}
|
|
||||||
return "\(count)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StatusCard: View {
|
struct StatusCard: View {
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ struct ModelUsage: Identifiable {
|
|||||||
let outputTokens: Int
|
let outputTokens: Int
|
||||||
let cacheReadTokens: Int
|
let cacheReadTokens: Int
|
||||||
let cacheWriteTokens: Int
|
let cacheWriteTokens: Int
|
||||||
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens }
|
let reasoningTokens: Int
|
||||||
|
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PlatformUsage: Identifiable {
|
struct PlatformUsage: Identifiable {
|
||||||
@@ -69,7 +70,9 @@ final class InsightsViewModel {
|
|||||||
var totalOutputTokens = 0
|
var totalOutputTokens = 0
|
||||||
var totalCacheReadTokens = 0
|
var totalCacheReadTokens = 0
|
||||||
var totalCacheWriteTokens = 0
|
var totalCacheWriteTokens = 0
|
||||||
|
var totalReasoningTokens = 0
|
||||||
var totalTokens = 0
|
var totalTokens = 0
|
||||||
|
var totalCost: Double = 0
|
||||||
var activeTime: TimeInterval = 0
|
var activeTime: TimeInterval = 0
|
||||||
var avgSessionDuration: TimeInterval = 0
|
var avgSessionDuration: TimeInterval = 0
|
||||||
|
|
||||||
@@ -119,7 +122,9 @@ final class InsightsViewModel {
|
|||||||
totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens }
|
totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens }
|
||||||
totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens }
|
totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens }
|
||||||
totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens }
|
totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens }
|
||||||
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens
|
totalReasoningTokens = sessions.reduce(0) { $0 + $1.reasoningTokens }
|
||||||
|
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens + totalReasoningTokens
|
||||||
|
totalCost = sessions.reduce(0.0) { $0 + ($1.displayCostUSD ?? 0) }
|
||||||
|
|
||||||
var total: TimeInterval = 0
|
var total: TimeInterval = 0
|
||||||
var count = 0
|
var count = 0
|
||||||
@@ -134,21 +139,22 @@ final class InsightsViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func computeModelBreakdown() {
|
private func computeModelBreakdown() {
|
||||||
var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int)] = [:]
|
var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int, reasoning: Int)] = [:]
|
||||||
for s in sessions {
|
for s in sessions {
|
||||||
let model = s.model ?? "unknown"
|
let model = s.model ?? "unknown"
|
||||||
var entry = grouped[model, default: (0, 0, 0, 0, 0)]
|
var entry = grouped[model, default: (0, 0, 0, 0, 0, 0)]
|
||||||
entry.sessions += 1
|
entry.sessions += 1
|
||||||
entry.input += s.inputTokens
|
entry.input += s.inputTokens
|
||||||
entry.output += s.outputTokens
|
entry.output += s.outputTokens
|
||||||
entry.cacheRead += s.cacheReadTokens
|
entry.cacheRead += s.cacheReadTokens
|
||||||
entry.cacheWrite += s.cacheWriteTokens
|
entry.cacheWrite += s.cacheWriteTokens
|
||||||
|
entry.reasoning += s.reasoningTokens
|
||||||
grouped[model] = entry
|
grouped[model] = entry
|
||||||
}
|
}
|
||||||
modelUsage = grouped.map { key, val in
|
modelUsage = grouped.map { key, val in
|
||||||
ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input,
|
ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input,
|
||||||
outputTokens: val.output, cacheReadTokens: val.cacheRead,
|
outputTokens: val.output, cacheReadTokens: val.cacheRead,
|
||||||
cacheWriteTokens: val.cacheWrite)
|
cacheWriteTokens: val.cacheWrite, reasoningTokens: val.reasoning)
|
||||||
}.sorted { $0.totalTokens > $1.totalTokens }
|
}.sorted { $0.totalTokens > $1.totalTokens }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +164,7 @@ final class InsightsViewModel {
|
|||||||
var entry = grouped[s.source, default: (0, 0, 0)]
|
var entry = grouped[s.source, default: (0, 0, 0)]
|
||||||
entry.sessions += 1
|
entry.sessions += 1
|
||||||
entry.messages += s.messageCount
|
entry.messages += s.messageCount
|
||||||
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens
|
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens + s.reasoningTokens
|
||||||
grouped[s.source] = entry
|
grouped[s.source] = entry
|
||||||
}
|
}
|
||||||
platformUsage = grouped.map { key, val in
|
platformUsage = grouped.map { key, val in
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ struct InsightsView: View {
|
|||||||
InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens))
|
InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens))
|
||||||
InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens))
|
InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens))
|
||||||
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
|
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
|
||||||
|
InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens))
|
||||||
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
|
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
|
||||||
|
InsightCard(label: "Total Cost", value: String(format: "$%.2f", viewModel.totalCost))
|
||||||
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
|
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
|
||||||
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration))
|
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)))
|
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count)))
|
||||||
@@ -273,19 +275,12 @@ struct InsightsView: View {
|
|||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func platformIcon(_ platform: String) -> String {
|
private func platformIcon(_ platform: String) -> String {
|
||||||
switch platform {
|
KnownPlatforms.icon(for: 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 {
|
private func barColor(for toolName: String) -> Color {
|
||||||
switch toolName {
|
switch toolName {
|
||||||
case "terminal": return .orange
|
case "terminal", "execute_code": return .orange
|
||||||
case "read_file", "search_files": return .green
|
case "read_file", "search_files": return .green
|
||||||
case "write_file", "patch": return .blue
|
case "write_file", "patch": return .blue
|
||||||
case "web_search", "web_extract": return .purple
|
case "web_search", "web_extract": return .purple
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ final class MemoryViewModel {
|
|||||||
|
|
||||||
var memoryContent = ""
|
var memoryContent = ""
|
||||||
var userContent = ""
|
var userContent = ""
|
||||||
|
var memoryProvider = ""
|
||||||
var isEditing = false
|
var isEditing = false
|
||||||
var editingFile: EditTarget = .memory
|
var editingFile: EditTarget = .memory
|
||||||
var editText = ""
|
var editText = ""
|
||||||
@@ -17,9 +18,14 @@ final class MemoryViewModel {
|
|||||||
var memoryCharCount: Int { memoryContent.count }
|
var memoryCharCount: Int { memoryContent.count }
|
||||||
var userCharCount: Int { userContent.count }
|
var userCharCount: Int { userContent.count }
|
||||||
|
|
||||||
|
var hasExternalProvider: Bool {
|
||||||
|
!memoryProvider.isEmpty && memoryProvider != "file"
|
||||||
|
}
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
memoryContent = fileService.loadMemory()
|
memoryContent = fileService.loadMemory()
|
||||||
userContent = fileService.loadUserProfile()
|
userContent = fileService.loadUserProfile()
|
||||||
|
memoryProvider = fileService.loadConfig().memoryProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
func startEditing(_ target: EditTarget) {
|
func startEditing(_ target: EditTarget) {
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ struct MemoryView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
if viewModel.hasExternalProvider {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
Text("Memory is managed by \(viewModel.memoryProvider). File contents shown here may be stale.")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.orange.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
memorySection("Agent Memory", content: viewModel.memoryContent, charCount: viewModel.memoryCharCount, target: .memory)
|
memorySection("Agent Memory", content: viewModel.memoryContent, charCount: viewModel.memoryCharCount, target: .memory)
|
||||||
memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user)
|
memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,17 +83,7 @@ final class SessionsViewModel {
|
|||||||
let result = runHermes(["sessions", "rename", sessionId, title])
|
let result = runHermes(["sessions", "rename", sessionId, title])
|
||||||
if result.exitCode == 0 {
|
if result.exitCode == 0 {
|
||||||
if let idx = sessions.firstIndex(where: { $0.id == sessionId }) {
|
if let idx = sessions.firstIndex(where: { $0.id == sessionId }) {
|
||||||
let updated = HermesSession(
|
let updated = sessions[idx].withTitle(title)
|
||||||
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
|
sessions[idx] = updated
|
||||||
if selectedSession?.id == sessionId {
|
if selectedSession?.id == sessionId {
|
||||||
selectedSession = updated
|
selectedSession = updated
|
||||||
@@ -158,10 +148,10 @@ final class SessionsViewModel {
|
|||||||
let fileSize: String
|
let fileSize: String
|
||||||
if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath),
|
if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath),
|
||||||
let size = attrs[.size] as? Int {
|
let size = attrs[.size] as? Int {
|
||||||
if size >= 1_048_576 {
|
if Double(size) >= FileSizeUnit.megabyte {
|
||||||
fileSize = String(format: "%.1f MB", Double(size) / 1_048_576)
|
fileSize = String(format: "%.1f MB", Double(size) / FileSizeUnit.megabyte)
|
||||||
} else {
|
} else {
|
||||||
fileSize = String(format: "%.0f KB", Double(size) / 1_024)
|
fileSize = String(format: "%.0f KB", Double(size) / FileSizeUnit.kilobyte)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fileSize = "unknown"
|
fileSize = "unknown"
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ struct SessionDetailView: View {
|
|||||||
Label(session.model ?? "unknown", systemImage: "cpu")
|
Label(session.model ?? "unknown", systemImage: "cpu")
|
||||||
Label("\(session.messageCount) msgs", systemImage: "bubble.left")
|
Label("\(session.messageCount) msgs", systemImage: "bubble.left")
|
||||||
Label("\(session.toolCallCount) tools", systemImage: "wrench")
|
Label("\(session.toolCallCount) tools", systemImage: "wrench")
|
||||||
|
if session.reasoningTokens > 0 {
|
||||||
|
Label("\(session.reasoningTokens) reasoning", systemImage: "brain")
|
||||||
|
}
|
||||||
|
if let cost = session.displayCostUSD {
|
||||||
|
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
|
||||||
|
}
|
||||||
if let date = session.startedAt {
|
if let date = session.startedAt {
|
||||||
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
|
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
|
||||||
}
|
}
|
||||||
@@ -78,6 +84,16 @@ struct MessageBubble: View {
|
|||||||
HStack {
|
HStack {
|
||||||
if message.isUser { Spacer(minLength: 60) }
|
if message.isUser { Spacer(minLength: 60) }
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
if message.hasReasoning {
|
||||||
|
DisclosureGroup("Reasoning") {
|
||||||
|
Text(message.reasoning ?? "")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
if !message.content.isEmpty {
|
if !message.content.isEmpty {
|
||||||
Text(message.content)
|
Text(message.content)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ final class SettingsViewModel {
|
|||||||
config = fileService.loadConfig()
|
config = fileService.loadConfig()
|
||||||
gatewayState = fileService.loadGatewayState()
|
gatewayState = fileService.loadGatewayState()
|
||||||
hermesRunning = fileService.isHermesRunning()
|
hermesRunning = fileService.isHermesRunning()
|
||||||
rawConfigYAML = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
do {
|
||||||
|
rawConfigYAML = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
|
||||||
|
rawConfigYAML = ""
|
||||||
|
}
|
||||||
personalities = parsePersonalities()
|
personalities = parsePersonalities()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +52,9 @@ final class SettingsViewModel {
|
|||||||
func setVerbose(_ value: Bool) { setSetting("agent.verbose", 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 setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
|
||||||
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
|
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
|
||||||
|
func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) }
|
||||||
|
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
|
||||||
|
func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) }
|
||||||
|
|
||||||
func openConfigInEditor() {
|
func openConfigInEditor() {
|
||||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
||||||
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
||||||
|
ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($0) }
|
||||||
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,8 @@ struct SettingsView: View {
|
|||||||
SettingsSection(title: "Terminal", icon: "terminal") {
|
SettingsSection(title: "Terminal", icon: "terminal") {
|
||||||
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) }
|
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) }
|
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
|
||||||
|
PickerRow(label: "Reasoning Effort", selection: viewModel.config.reasoningEffort, options: ["low", "medium", "high"]) { viewModel.setReasoningEffort($0) }
|
||||||
|
PickerRow(label: "Approval Mode", selection: viewModel.config.approvalMode, options: ["auto", "manual", "smart"]) { viewModel.setApprovalMode($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class ToolsViewModel {
|
final class ToolsViewModel {
|
||||||
var selectedPlatform: HermesToolPlatform = KnownPlatforms.all[0]
|
var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
|
||||||
var toolsets: [HermesToolset] = []
|
var toolsets: [HermesToolset] = []
|
||||||
var mcpStatus: String = ""
|
var mcpStatus: String = ""
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
@@ -30,7 +30,13 @@ final class ToolsViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadPlatforms() {
|
private func loadPlatforms() {
|
||||||
let config = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
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 platforms: [HermesToolPlatform] = []
|
||||||
var inSection = false
|
var inSection = false
|
||||||
for line in config.components(separatedBy: "\n") {
|
for line in config.components(separatedBy: "\n") {
|
||||||
@@ -54,9 +60,10 @@ final class ToolsViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
availablePlatforms = platforms.isEmpty ? [KnownPlatforms.all[0]] : platforms
|
availablePlatforms = platforms.isEmpty ? [KnownPlatforms.cli] : platforms
|
||||||
if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }) {
|
if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }),
|
||||||
selectedPlatform = availablePlatforms[0]
|
let first = availablePlatforms.first {
|
||||||
|
selectedPlatform = first
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case insights = "Insights"
|
case insights = "Insights"
|
||||||
case sessions = "Sessions"
|
case sessions = "Sessions"
|
||||||
case activity = "Activity"
|
case activity = "Activity"
|
||||||
|
case projects = "Projects"
|
||||||
case chat = "Chat"
|
case chat = "Chat"
|
||||||
case memory = "Memory"
|
case memory = "Memory"
|
||||||
case skills = "Skills"
|
case skills = "Skills"
|
||||||
@@ -23,6 +24,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
case .insights: return "chart.bar"
|
case .insights: return "chart.bar"
|
||||||
case .sessions: return "bubble.left.and.bubble.right"
|
case .sessions: return "bubble.left.and.bubble.right"
|
||||||
case .activity: return "bolt.horizontal"
|
case .activity: return "bolt.horizontal"
|
||||||
|
case .projects: return "square.grid.2x2"
|
||||||
case .chat: return "text.bubble"
|
case .chat: return "text.bubble"
|
||||||
case .memory: return "brain"
|
case .memory: return "brain"
|
||||||
case .skills: return "lightbulb"
|
case .skills: return "lightbulb"
|
||||||
@@ -40,4 +42,5 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
|||||||
final class AppCoordinator {
|
final class AppCoordinator {
|
||||||
var selectedSection: SidebarSection = .dashboard
|
var selectedSection: SidebarSection = .dashboard
|
||||||
var selectedSessionId: String?
|
var selectedSessionId: String?
|
||||||
|
var selectedProjectName: String?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ struct SidebarView: View {
|
|||||||
.tag(section)
|
.tag(section)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Section("Projects") {
|
||||||
|
ForEach([SidebarSection.projects]) { section in
|
||||||
|
Label(section.rawValue, systemImage: section.icon)
|
||||||
|
.tag(section)
|
||||||
|
}
|
||||||
|
}
|
||||||
Section("Interact") {
|
Section("Interact") {
|
||||||
ForEach([SidebarSection.chat, .memory, .skills]) { section in
|
ForEach([SidebarSection.chat, .memory, .skills]) { section in
|
||||||
Label(section.rawValue, systemImage: section.icon)
|
Label(section.rawValue, systemImage: section.icon)
|
||||||
|
|||||||
Reference in New Issue
Block a user