Compare commits

...

36 Commits

Author SHA1 Message Date
Alan Wizemann 3acf95a824 feat: Add Hermes v0.8.0 compatibility and fix Tools tab toggling (#9)
Hermes v0.8.0 support:
- Filter subagent sessions from main list with parent session drill-down
- Add agent.log support (new default log file)
- Add Feishu and Mattermost platforms
- Add Google AI Studio, xAI, Ollama Cloud providers
- Expand cron job model (pre-run scripts, delivery tracking, timeouts, SILENT)
- Add Docker env, command allowlist, and memory profile to config
- Add profile-scoped memory with profile picker
- Add browser backend picker and credential removal to Settings
- Add skills required config warnings
- Consolidate platform icon resolution to single source of truth
- Filter Insights queries to exclude subagent sessions

Bug fix:
- Fix Tools tab phantom toggling when switching platforms (#9)
  - Add .id() to tool list for proper SwiftUI view identity
  - Replace ambiguous plain buttons with segmented Picker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:12:13 -04:00
Alan Wizemann ae2872e08f Add Hermes v0.7.0 compatibility: reasoning tokens, cost tracking, schema detection
- Auto-detect v0.7.0 database schema with backward compat for older DBs
- Surface reasoning tokens, actual cost, and billing provider from sessions
- Display model reasoning/thinking content in session message bubbles
- Add cost tracking to Dashboard, Insights, and session detail views
- Fix FTS5 search crash on dotted terms (e.g., "config.yaml", "v0.7.0")
- Add missing platforms: Home Assistant, Webhook, Matrix
- Consolidate platform icon mapping into shared KnownPlatforms.icon(for:)
- Map execute_code tool to ToolKind.execute
- Add Settings UI for reasoning effort, approval mode, show cost
- Show memory provider warning when external provider (Honcho) is active
- Replace fragile manual HermesSession init with withTitle() helper
- De-duplicate formatTokens utility function
- Bump version to 1.4.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:46:03 -04:00
Alan Wizemann 303f4502dd Fix version reporting: update MARKETING_VERSION to 1.3.0
All builds were reporting version 1.0 because the Xcode project version
was never updated from its default. Fixes #5, fixes #7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:50:57 -04:00
Alan Wizemann 815c9dcbcd Merge pull request #6 from awizemann/code-quality
Code quality improvements and webview dashboard widget
2026-04-02 12:04:16 -04:00
Alan Wizemann ef53ac1c93 Replace webview split layout with tabbed Dashboard/Site interface
Dashboards with a webview widget now show a tab bar: Dashboard tab
renders all normal widgets, Site tab displays the web content
full-canvas with even margins. Cleaner UX than the split layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:03:50 -04:00
Alan Wizemann 2a3e8b1422 Add webview widget for embedded web browser in project dashboards
New widget type that renders any URL (local dev servers, HTML reports)
directly in the dashboard via WKWebView. Sections with webviews
automatically split layout: grid widgets left, webview right.
Configurable height, non-persistent data store, navigation error logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:29:05 -04:00
Alan Wizemann 563f5a702c Improve code quality: error logging, constants, path validation, safe defaults
- Replace try? with do/catch and [Scarf] error logging in all service-layer
  JSON decoding, file writes, and directory creation
- Extract sqliteTransient constant replacing raw unsafeBitCast(-1, ...) pattern
- Add QueryDefaults and FileSizeUnit enums for all magic numbers
- Guard HOME env var with NSHomeDirectory() fallback instead of force-unwrap
- Add path traversal validation to loadSkillContent()
- Add SessionStats.empty and use it across all initialization sites
- Replace KnownPlatforms array indexing with named .cli constant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 03:15:03 -04:00
Alan Wizemann c7f3ca9be3 Merge feature/project-dashboards into main 2026-04-01 01:30:55 -04:00
Alan Wizemann dbaadb8037 Add Project Dashboards feature with agent-generated widgets
Introduces a new Projects section that renders custom dashboards from
JSON files in project directories. Supports 7 widget types (stat,
progress, text, table, chart, list) with live file-watching refresh.
Includes project registry, SwiftUI Charts integration, schema docs,
and comprehensive README documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:48:13 -04:00
Alan Wizemann ce001fe202 Add left padding to terminal view in chat interface
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 22:28:46 -04:00
Alan Wizemann a329eca419 Merge branch 'development' 2026-03-31 15:30:51 -04:00
Alan Wizemann 528de938c5 Add pre-built binary install instructions to README
Universal binary (arm64 + x86_64) available on Releases page.
Updated Building section to Install with download + build options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:30:50 -04:00
Alan Wizemann 4f791d491e Merge pull request #4 from awizemann/development
Config Editor and Voice Fixes
2026-03-31 14:35:33 -04:00
Alan Wizemann dd79891874 Replace read-only Settings with structured config editor
Settings view now has editable form controls organized by section:

Model: editable model name field, provider dropdown picker
Display: personality picker (parsed from config), streaming/reasoning/verbose toggles
Terminal: backend picker (local/docker/singularity/modal/daytona/ssh), max turns stepper
Voice: auto TTS toggle, silence threshold stepper
Memory: enabled toggle, char limit steppers, nudge interval stepper

All changes write via `hermes config set key value` CLI with save
confirmation feedback. Open in Editor button launches the raw YAML
in the default text editor. Paths and raw config sections retained.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:23:56 -04:00
Alan Wizemann a13288e759 Merge branch 'main' of https://github.com/awizemann/scarf 2026-03-31 14:07:44 -04:00
Alan Wizemann a16c8ec2d9 Merge branch 'development' 2026-03-31 14:07:17 -04:00
Alan Wizemann 0e3712116f Merge pull request #3 from awizemann/development
Development to Main - New Voice Commands, Health Dashboard, Tools and gateway control
2026-03-31 14:05:40 -04:00
Alan Wizemann ab45f95790 Fix TTS toggle state reversed on voice enable
Hermes auto-enables TTS when voice mode turns on (auto_tts config).
Our ttsEnabled started as false, so the UI showed off when TTS was
actually on. Now reads auto_tts from config.yaml when voice enables
and sets the initial state to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:03:34 -04:00
Alan Wizemann d31bc63b6a Add microphone permission for voice chat
Hermes voice mode needs mic access when running as a Scarf subprocess.
- Added NSMicrophoneUsageDescription to Info.plist keys
- Created entitlements file with com.apple.security.device.audio-input
- Applied to both Debug and Release configurations

macOS will prompt for mic permission on first push-to-talk use.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:56:49 -04:00
Alan Wizemann bc8f4b0c25 Add TTS toggle button to voice controls
Voice toolbar now shows three controls when voice is enabled:
- Mic toggle (voice on/off)
- TTS toggle (speaker icon, sends /voice tts)
- Push to Talk (waveform, sends Ctrl+B)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:50:14 -04:00
Alan Wizemann 55ee99c839 Add Hermes version compatibility section to README
Documents tested versions and the interfaces Scarf depends on
(SQLite schema v6, CLI output parsing).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:45:50 -04:00
Alan Wizemann 3477fa733f Redesign Health view with card grid and expandable sections
Replaced the long flat list with a cleaner layout:
- Compact header bar: version, update banner, pass/warn/error counts
- Status/Diagnostics tab switcher (segmented control)
- 2-column card grid: each section is a uniform card showing icon,
  title, and colored status dot counts (green/orange/red)
- Cards have a colored border accent based on worst status
- Click to expand: reveals individual check rows inline
- Only one section expanded at a time for clean scanning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:39:42 -04:00
Alan Wizemann c6f45ac22e Add System Health view with status and diagnostics
New Health section in the Manage group combining hermes status and
hermes doctor output:

- Version header with update available banner (e.g. "47 commits behind")
- Summary badges: passing/warning/issue counts
- Status sections: environment, API keys, auth providers, terminal
  backend, messaging platforms, gateway service, scheduled jobs
- Diagnostics sections: Python environment, required/optional packages,
  config files, directory structure, external tools, API connectivity,
  submodules, tool availability, Skills Hub, Honcho memory
- Each check shows green/orange/red icon with label and detail
- Refresh button to re-run both commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:36:56 -04:00
Alan Wizemann b4c93ac79c Add Gateway Control to README features, architecture, and data sources
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:33:16 -04:00
Alan Wizemann c09f167760 Add Gateway Control Center with service control and pairing management
New Gateway section in the Manage group:
- Service controls: Start/Stop/Restart buttons calling hermes gateway CLI
- Status display: state (running/stopped), PID, loaded indicator, stale
  service warning, exit reason, last update timestamp
- Platform cards: each connected messaging platform with connection state
  (reads from gateway_state.json)
- Pairing management: approved users list with revoke button, pending
  pairing codes with approve button
- Auto-refreshes via HermesFileWatcher when gateway state changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:32:29 -04:00
Alan Wizemann b79200e950 Update README with Tools Manager, session management, and revised docs
- Added Tools Manager to features list
- Updated Sessions Browser with rename/delete/export
- Updated Skills Browser with file switcher
- Updated Dashboard with live refresh
- Updated Log Viewer with text search
- Added hermes tools and hermes sessions to data sources table
- Revised How It Works section to cover management actions
- Updated architecture tree with Tools feature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:02:29 -04:00
Alan Wizemann a800a630a8 Fix session rename not updating across views
After rename:
- Update selectedSession so detail header refreshes immediately
- Update sessionPreviews so previewFor() returns the new title
- Dashboard now observes HermesFileWatcher and reloads on DB changes
- Chat session menu reloads via file watcher (persists across nav)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:01:20 -04:00
Alan Wizemann e4d5bb0364 Add session management: rename, delete, export, and stats bar
Sessions browser enhancements:
- Stats bar: total sessions, messages, DB size, per-platform counts
- Right-click context menu on session rows: Rename, Export, Delete
- Detail view actions menu (ellipsis button): same actions
- Rename: sheet with text field, calls hermes sessions rename
- Delete: confirmation dialog, calls hermes sessions delete --yes
- Export single session: NSSavePanel, calls hermes sessions export
- Export all: button in stats bar, exports everything to JSONL
- Session ID shown in detail header for reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:55:35 -04:00
Alan Wizemann 36757a8c9a Add Tool Management panel with per-platform toggle switches
New Tools section in the Manage group:
- Platform tabs parsed from config.yaml (CLI, Telegram, Discord, etc.)
- Lists all toolsets with emoji icon, name, description, and toggle
- Toggle switches call hermes tools enable/disable under the hood
- Shows enabled count vs total
- MCP server status section at bottom
- Optimistic UI update on toggle with CLI fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:47:25 -04:00
Alan Wizemann cfbf3ea142 Update README with Insights, voice controls, and activity filter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:31:50 -04:00
Alan Wizemann f3cb1eb86b Add Insights Dashboard with usage analytics
New sidebar section showing rich analytics from the sessions database:
- Overview grid: sessions, messages, tokens (input/output/cache), active
  time, avg session duration, avg messages per session
- Model breakdown: sessions and total tokens per model
- Platform breakdown: CLI vs Telegram etc with session/message counts
- Top tools bar chart: ranked by call count with percentages
- Activity patterns: day-of-week bars and hourly heatmap
- Notable sessions: longest, most messages, most tokens, most tool calls
  with clickable links to open in Sessions browser
- Time period selector: 7/30/90 days or all time

Also adds ROADMAP.md documenting the full feature expansion plan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:45:35 -04:00
Alan Wizemann 2b57025f3c Merge pull request #1 from awizemann/development
Buy Me a Coffee (seriously, I am tired).
2026-03-31 03:44:49 -04:00
Alan Wizemann 2a14e28589 Fix Buy Me a Coffee button image URL
Use CDN-hosted default button instead of API-generated image.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:27:43 -04:00
Alan Wizemann 39bac7d2be Add Buy Me a Coffee button to README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:26:12 -04:00
Alan Wizemann af8e120c9f Remove cost stat card from dashboard
Hermes cost tracking returns $0.00 for models not in its static
pricing table (including claude-haiku-4-5). Token counts remain
displayed since those are always accurate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:12:04 -04:00
Alan Wizemann 0d38856b3e Add session filter to Activity view
Dropdown in the filter bar lets users scope activity to a single
session or view all. Sessions are labeled with their first user
message preview. Combines with the existing tool-kind filter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:03:28 -04:00
58 changed files with 4582 additions and 219 deletions
+3
View File
@@ -43,3 +43,6 @@ Package.resolved
# Claude Code # Claude Code
.claude/ .claude/
scarf/standards/backups/ scarf/standards/backups/
# Scarf project dashboards (user-specific)
.scarf/
+202 -18
View File
@@ -13,28 +13,56 @@
<img src="https://img.shields.io/badge/macOS-26.2+-blue" alt="macOS"> <img src="https://img.shields.io/badge/macOS-26.2+-blue" alt="macOS">
<img src="https://img.shields.io/badge/Swift-6-orange" alt="Swift"> <img src="https://img.shields.io/badge/Swift-6-orange" alt="Swift">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br><br>
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
</p> </p>
## Features ## Features
- **Dashboard** — System health, token usage, cost tracking, recent sessions at a glance - **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, and full-text search (FTS5) - **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
- **Activity Feed** — Recent tool execution log with filtering by kind (read/edit/execute/fetch/browser) and detail inspector - **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export. Subagent sessions are filtered from the main list and accessible via parent session drill-down
- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) - **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live refresh - **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
- **Skills Browser** — Browse all installed skills by category with file content viewer - **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker
- **Cron Manager** — View scheduled jobs, their status, prompts, and output - **Skills Browser** — Browse all installed skills by category with file content viewer, file switcher, and required config warnings for skills that need specific settings
- **Log Viewer** — Real-time tailing of error and gateway logs with level filtering - **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Webhook, Matrix, Feishu, Mattermost) with toggle switches and segmented platform picker, MCP server status
- **Settings** — Read-only config display with raw YAML viewer and Finder path links - **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators
- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering and text search
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Docker environment, command allowlist, credential management, and more
- **Menu Bar** — Status icon showing Hermes running state with quick actions - **Menu Bar** — Status icon showing Hermes running state with quick actions
## Requirements ## Requirements
- macOS 26.2+ - macOS 26.2+
- Xcode 26.3+ - Xcode 26.3+
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) installed at `~/.hermes/` - [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.8.0 recommended for full feature support)
## Building ### Compatibility
Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Automatic schema detection provides backward compatibility with older databases while supporting new features in newer Hermes versions.
| Hermes Version | Status |
|----------------|--------|
| v0.6.0 (2026-03-30) | Verified |
| v0.7.0 (2026-04-03) | Verified |
| v0.8.0 (2026-04-08, latest) | Verified |
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
## Install
### Pre-built Binary (no Xcode required)
Download the latest universal binary (Apple Silicon + Intel) from [Releases](https://github.com/awizemann/scarf/releases):
1. Download `Scarf-vX.X.X-Universal.zip`
2. Unzip and drag **Scarf.app** to Applications
3. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
### Build from Source
```bash ```bash
git clone https://github.com/awizemann/scarf.git git clone https://github.com/awizemann/scarf.git
@@ -45,7 +73,7 @@ open scarf.xcodeproj
Or from the command line: Or from the command line:
```bash ```bash
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Release -arch arm64 -arch x86_64 ONLY_ACTIVE_ARCH=NO build
``` ```
## Architecture ## Architecture
@@ -59,14 +87,18 @@ scarf/
Services/ Data access (SQLite reader, file I/O, log tailing, file watcher) Services/ Data access (SQLite reader, file I/O, log tailing, file watcher)
Features/ Self-contained feature modules Features/ Self-contained feature modules
Dashboard/ System overview and stats Dashboard/ System overview and stats
Sessions/ Conversation browser with detail view Insights/ Usage analytics and activity patterns
Sessions/ Conversation browser with rename, delete, export
Activity/ Tool execution feed with inspector Activity/ Tool execution feed with inspector
Chat/ Embedded terminal via SwiftTerm Projects/ Agent-generated project dashboards with widget rendering
Chat/ 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
Tools/ Toolset management per platform
Gateway/ Messaging gateway control and pairing
Cron/ Scheduled job viewer Cron/ Scheduled job viewer
Logs/ Real-time log viewer Logs/ Real-time log viewer
Settings/ Configuration display Settings/ Structured config editor
Navigation/ AppCoordinator + SidebarView Navigation/ AppCoordinator + SidebarView
``` ```
@@ -84,8 +116,14 @@ Scarf reads Hermes data directly from `~/.hermes/`:
| `gateway_state.json` | JSON | Read-only | | `gateway_state.json` | JSON | Read-only |
| `skills/` | Directory tree | Read-only | | `skills/` | Directory tree | Read-only |
| `hermes chat` | Terminal subprocess | Interactive | | `hermes chat` | Terminal subprocess | Interactive |
| `hermes tools` | CLI commands | Enable/Disable |
| `hermes sessions` | CLI commands | Rename/Delete/Export |
| `hermes gateway` | CLI commands | Start/Stop/Restart |
| `hermes pairing` | CLI commands | Approve/Revoke |
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
| `scarf/projects.json` | JSON (registry) | Read/Write |
The app **never writes** to `state.db` — it opens in read-only mode to avoid WAL contention with Hermes. The app opens `state.db` in read-only mode to avoid WAL contention with Hermes. Management actions (tool toggles, session rename/delete/export) go through the Hermes CLI.
### Dependencies ### Dependencies
@@ -93,14 +131,154 @@ The app **never writes** to `state.db` — it opens in read-only mode to avoid W
|---------|---------| |---------|---------|
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature | | [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, GCD file watching. Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
## How It Works ## How It Works
Scarf is a passive observer. It watches `~/.hermes/` for file changes and polls the SQLite database for new sessions and messages. The Chat tab spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive Hermes CLI experience with proper ANSI rendering. Scarf watches `~/.hermes/` for file changes and queries the SQLite database for sessions, messages, and analytics. Views refresh automatically when Hermes writes new data.
The Chat tab spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation — switch tabs and come back without losing your conversation.
Management actions (renaming sessions, toggling tools, editing memory) call the Hermes CLI or write directly to the appropriate files, keeping Scarf and Hermes in sync.
The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary. 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.01.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 14 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type.
## Contributing ## Contributing
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR. Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
@@ -111,6 +289,12 @@ Contributions are welcome. Please open an issue to discuss what you'd like to ch
4. Push to the branch (`git push origin feature/my-feature`) 4. Push to the branch (`git push origin feature/my-feature`)
5. Open a Pull Request 5. Open a Pull Request
## Support
If you find Scarf useful, consider buying me a coffee.
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="40"></a>
## License ## License
[MIT](LICENSE) [MIT](LICENSE)
+169
View File
@@ -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.
+58
View File
@@ -0,0 +1,58 @@
# Scarf — Feature Roadmap
## Tier 1 — High Value, Data Already Available
### 1. Insights Dashboard
Rich usage analytics pulled from the sessions and messages SQLite tables:
- Overview stats: sessions, messages, tool calls, tokens, active time, avg session duration
- Model breakdown: sessions and tokens per model
- Platform breakdown: CLI vs Telegram vs Discord usage
- Top tools chart: ranked tool usage with call counts and percentages
- Activity patterns: sessions by day-of-week, peak hours heatmap
- Notable sessions: longest, most messages, most tokens, most tool calls
- Time period selector: last 7/30/90 days
### 2. Tool Management Panel
- List all toolsets with enabled/disabled status and descriptions
- Toggle switches to enable/disable tools (via `hermes tools enable/disable`)
- Per-platform tool configuration
- MCP tool status
### 3. Session Management Enhancements
- Rename sessions from the Sessions browser (via `hermes sessions rename`)
- Delete sessions (via `hermes sessions delete`)
- Export sessions to JSONL (via `hermes sessions export`)
- Session stats card (total count, DB size, per-platform breakdown)
## Tier 2 — Medium Value, New Service Code Required
### 4. Skills Hub
- Search remote registries for new skills (6 sources)
- Install/uninstall skills from GUI
- Skill update indicator
- Trust level badges (builtin, local, hub)
### 5. Gateway Control Center
- Start/stop/restart gateway from GUI
- Real-time status: PID, uptime, connected platforms
- Pairing management: view approved users, approve/revoke
- Platform status per messaging service
### 6. System Health View
- Mirror `hermes status` and `hermes doctor` output
- API key validation, auth provider status, external tools
- Update available indicator
## Tier 3 — Nice to Have
### 7. Profile Management
- List/create/switch profiles (isolated Hermes instances)
### 8. Plugin Management
- Install from Git, enable/disable, update
### 9. MCP Server Management
- Add/remove/test MCP servers, toggle tools per server
### 10. Config Editor
- Structured form editor for config.yaml with validation
+16 -12
View File
@@ -404,9 +404,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -415,11 +416,12 @@
INFOPLIST_KEY_CFBundleDisplayName = Scarf; INFOPLIST_KEY_CFBundleDisplayName = Scarf;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.5.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;
@@ -438,9 +440,10 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 3Q6X2L86C4; DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -449,11 +452,12 @@
INFOPLIST_KEY_CFBundleDisplayName = Scarf; INFOPLIST_KEY_CFBundleDisplayName = Scarf;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.5.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;
@@ -471,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.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -492,11 +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.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -512,10 +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.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -531,10 +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.5.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests; PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO; STRING_CATALOG_GENERATE_SYMBOLS = NO;
+10
View File
@@ -16,18 +16,28 @@ struct ContentView: View {
switch coordinator.selectedSection { switch coordinator.selectedSection {
case .dashboard: case .dashboard:
DashboardView() DashboardView()
case .insights:
InsightsView()
case .sessions: case .sessions:
SessionsView() SessionsView()
case .activity: case .activity:
ActivityView() ActivityView()
case .projects:
ProjectsView()
case .chat: case .chat:
ChatView() ChatView()
case .memory: case .memory:
MemoryView() MemoryView()
case .skills: case .skills:
SkillsView() SkillsView()
case .tools:
ToolsView()
case .gateway:
GatewayView()
case .cron: case .cron:
CronView() CronView()
case .health:
HealthView()
case .logs: case .logs:
LogsView() LogsView()
case .settings: case .settings:
+21 -1
View File
@@ -13,6 +13,16 @@ struct HermesConfig: Sendable {
var streaming: Bool var streaming: Bool
var showReasoning: Bool var showReasoning: Bool
var verbose: Bool var verbose: Bool
var autoTTS: Bool
var silenceThreshold: Int
var reasoningEffort: String
var showCost: Bool
var approvalMode: String
var browserBackend: String
var memoryProvider: String
var dockerEnv: [String: String]
var commandAllowlist: [String]
var memoryProfile: String
static let empty = HermesConfig( static let empty = HermesConfig(
model: "unknown", model: "unknown",
@@ -26,7 +36,17 @@ struct HermesConfig: Sendable {
nudgeInterval: 0, nudgeInterval: 0,
streaming: true, streaming: true,
showReasoning: false, showReasoning: false,
verbose: false verbose: false,
autoTTS: true,
silenceThreshold: 200,
reasoningEffort: "medium",
showCost: false,
approvalMode: "manual",
browserBackend: "",
memoryProvider: "",
dockerEnv: [:],
commandAllowlist: [],
memoryProfile: ""
) )
} }
+34 -3
View File
@@ -1,8 +1,11 @@
import Foundation import Foundation
import SQLite3
enum HermesPaths: Sendable { enum HermesPaths: Sendable {
// Using ProcessInfo to avoid main-actor isolation issues with FileManager/NSHomeDirectory private nonisolated static let userHome: String = ProcessInfo.processInfo.environment["HOME"]
nonisolated static let home: String = ProcessInfo.processInfo.environment["HOME"]! + "/.hermes" ?? NSHomeDirectory()
nonisolated static let home: String = userHome + "/.hermes"
nonisolated static let stateDB: String = home + "/state.db" nonisolated static let stateDB: String = home + "/state.db"
nonisolated static let configYAML: String = home + "/config.yaml" nonisolated static let configYAML: String = home + "/config.yaml"
nonisolated static let memoriesDir: String = home + "/memories" nonisolated static let memoriesDir: String = home + "/memories"
@@ -14,6 +17,34 @@ enum HermesPaths: Sendable {
nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json" nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json"
nonisolated static let skillsDir: String = home + "/skills" nonisolated static let skillsDir: String = home + "/skills"
nonisolated static let errorsLog: String = home + "/logs/errors.log" nonisolated static let errorsLog: String = home + "/logs/errors.log"
nonisolated static let agentLog: String = home + "/logs/agent.log"
nonisolated static let gatewayLog: String = home + "/logs/gateway.log" nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
nonisolated static let hermesBinary: String = ProcessInfo.processInfo.environment["HOME"]! + "/.local/bin/hermes" nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
nonisolated static let scarfDir: String = home + "/scarf"
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
}
// MARK: - SQLite Constants
/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data.
/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift.
nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
// MARK: - Query Defaults
enum QueryDefaults: Sendable {
nonisolated static let sessionLimit = 100
nonisolated static let messageSearchLimit = 50
nonisolated static let toolCallLimit = 50
nonisolated static let sessionPreviewLimit = 10
nonisolated static let previewContentLength = 100
nonisolated static let logLineLimit = 200
nonisolated static let defaultSilenceThreshold = 200
}
// MARK: - File Size Formatting
enum FileSizeUnit: Sendable {
nonisolated static let kilobyte = 1_024.0
nonisolated static let megabyte = 1_048_576.0
} }
+12 -1
View File
@@ -13,12 +13,23 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
let nextRunAt: String? let nextRunAt: String?
let lastRunAt: String? let lastRunAt: String?
let lastError: String? let lastError: String?
let preRunScript: String?
let deliveryFailures: Int?
let lastDeliveryError: String?
let timeoutType: String?
let timeoutSeconds: Int?
let silent: Bool?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, name, prompt, skills, model, schedule, enabled, state, deliver case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
case nextRunAt = "next_run_at" case nextRunAt = "next_run_at"
case lastRunAt = "last_run_at" case lastRunAt = "last_run_at"
case lastError = "last_error" case lastError = "last_error"
case preRunScript = "pre_run_script"
case deliveryFailures = "delivery_failures"
case lastDeliveryError = "last_delivery_error"
case timeoutType = "timeout_type"
case timeoutSeconds = "timeout_seconds"
} }
var stateIcon: String { var stateIcon: String {
+3 -1
View File
@@ -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
+26 -9
View File
@@ -17,8 +17,18 @@ struct HermesSession: Identifiable, Sendable {
let cacheReadTokens: Int let cacheReadTokens: Int
let cacheWriteTokens: Int let cacheWriteTokens: Int
let estimatedCostUSD: Double? let estimatedCostUSD: Double?
let reasoningTokens: Int
let actualCostUSD: Double?
let costStatus: String?
let billingProvider: String?
var totalTokens: Int { inputTokens + outputTokens } var isSubagent: Bool { parentSessionId != nil }
var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
var costIsActual: Bool { actualCostUSD != nil }
var duration: TimeInterval? { var duration: TimeInterval? {
guard let start = startedAt, let end = endedAt else { return nil } guard let start = startedAt, let end = endedAt else { return nil }
@@ -30,13 +40,20 @@ struct HermesSession: Identifiable, Sendable {
} }
var sourceIcon: String { var sourceIcon: String {
switch source { KnownPlatforms.icon(for: source)
case "cli": return "terminal" }
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right" func withTitle(_ newTitle: String) -> HermesSession {
case "slack": return "number" HermesSession(
case "email": return "envelope" id: id, source: source, userId: userId, model: model,
default: return "bubble.left" title: newTitle, parentSessionId: parentSessionId,
} startedAt: startedAt, endedAt: endedAt, endReason: endReason,
messageCount: messageCount, toolCallCount: toolCallCount,
inputTokens: inputTokens, outputTokens: outputTokens,
cacheReadTokens: cacheReadTokens, cacheWriteTokens: cacheWriteTokens,
estimatedCostUSD: estimatedCostUSD, reasoningTokens: reasoningTokens,
actualCostUSD: actualCostUSD, costStatus: costStatus,
billingProvider: billingProvider
)
} }
} }
@@ -12,4 +12,5 @@ struct HermesSkill: Identifiable, Sendable {
let category: String let category: String
let path: String let path: String
let files: [String] let files: [String]
let requiredConfig: [String]
} }
+52
View File
@@ -0,0 +1,52 @@
import Foundation
struct HermesToolset: Identifiable, Sendable {
var id: String { name }
let name: String
let description: String
let icon: String
var enabled: Bool
}
struct HermesToolPlatform: Identifiable, Sendable {
var id: String { name }
let name: String
let displayName: String
let icon: String
}
enum KnownPlatforms {
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
static let all: [HermesToolPlatform] = [
cli,
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"),
HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"),
HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"),
HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"),
HermesToolPlatform(name: "homeassistant", displayName: "Home Assistant", icon: "house"),
HermesToolPlatform(name: "webhook", displayName: "Webhook", icon: "arrow.up.right.square"),
HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"),
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
]
static func icon(for platform: String) -> String {
switch platform {
case "cli": return "terminal"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "whatsapp": return "phone.bubble"
case "signal": return "lock.shield"
case "email": return "envelope"
case "homeassistant": return "house"
case "webhook": return "arrow.up.right.square"
case "matrix": return "lock.rectangle.stack"
case "feishu": return "message.badge.circle"
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
default: return "bubble.left"
}
}
}
@@ -0,0 +1,138 @@
import Foundation
// MARK: - Registry
struct ProjectRegistry: Codable, Sendable {
var projects: [ProjectEntry]
}
struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
var id: String { name }
let name: String
let path: String
var dashboardPath: String { path + "/.scarf/dashboard.json" }
}
// MARK: - Dashboard
struct ProjectDashboard: Codable, Sendable {
let version: Int
let title: String
let description: String?
let updatedAt: String?
let theme: DashboardTheme?
let sections: [DashboardSection]
}
struct DashboardTheme: Codable, Sendable {
let accent: String?
}
struct DashboardSection: Codable, Sendable, Identifiable {
var id: String { title }
let title: String
let columns: Int?
let widgets: [DashboardWidget]
var columnCount: Int { columns ?? 3 }
}
struct DashboardWidget: Codable, Sendable, Identifiable {
var id: String { type + ":" + title }
let type: String
let title: String
// Stat
let value: WidgetValue?
let icon: String?
let color: String?
let subtitle: String?
// Progress
let label: String?
// Text
let content: String?
let format: String?
// Table
let columns: [String]?
let rows: [[String]]?
// Chart
let chartType: String?
let xLabel: String?
let yLabel: String?
let series: [ChartSeries]?
// List
let items: [ListItem]?
// Webview
let url: String?
let height: Double?
}
// MARK: - Widget Value (String or Number)
enum WidgetValue: Codable, Sendable, Hashable {
case string(String)
case number(Double)
var displayString: String {
switch self {
case .string(let s): return s
case .number(let n):
return n.truncatingRemainder(dividingBy: 1) == 0
? String(Int(n))
: String(format: "%.1f", n)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let d = try? container.decode(Double.self) {
self = .number(d)
} else if let s = try? container.decode(String.self) {
self = .string(s)
} else {
throw DecodingError.typeMismatch(
WidgetValue.self,
.init(codingPath: decoder.codingPath, debugDescription: "Expected String or Number")
)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let s): try container.encode(s)
case .number(let n): try container.encode(n)
}
}
}
// MARK: - Chart Data
struct ChartSeries: Codable, Sendable, Identifiable {
var id: String { name }
let name: String
let color: String?
let data: [ChartDataPoint]
}
struct ChartDataPoint: Codable, Sendable, Identifiable {
var id: String { x }
let x: String
let y: Double
}
// MARK: - List Data
struct ListItem: Codable, Sendable, Identifiable {
var id: String { text }
let text: String
let status: String?
}
+233 -50
View File
@@ -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 }
started_at, ended_at, end_reason, message_count, tool_call_count, var stmt: OpaquePointer?
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, guard sqlite3_prepare_v2(db, "PRAGMA table_info(sessions)", -1, &stmt, nil) == SQLITE_OK else { return }
estimated_cost_usd defer { sqlite3_finalize(stmt) }
FROM sessions while sqlite3_step(stmt) == SQLITE_ROW {
ORDER BY started_at DESC if let name = sqlite3_column_text(stmt, 1), String(cString: name) == "reasoning_tokens" {
LIMIT ? hasV07Schema = true
return
}
}
}
// MARK: - Session Queries
private var sessionColumns: String {
var cols = """
id, source, user_id, model, title, parent_session_id,
started_at, ended_at, end_reason, message_count, tool_call_count,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
estimated_cost_usd
""" """
if hasV07Schema {
cols += ", reasoning_tokens, actual_cost_usd, cost_status, billing_provider"
}
return cols
}
func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
guard let db else { return [] }
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT ?"
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) } defer { sqlite3_finalize(stmt) }
@@ -47,19 +71,56 @@ actor HermesDataService {
return sessions return sessions
} }
func fetchMessages(sessionId: String) -> [HermesMessage] { func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
guard let db else { return [] } guard let db else { return [] }
let sql = """ let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? ORDER BY started_at DESC"
SELECT id, session_id, role, content, tool_call_id, tool_calls,
tool_name, timestamp, token_count, finish_reason
FROM messages
WHERE session_id = ?
ORDER BY timestamp ASC
"""
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) } defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, sessionId, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
var sessions: [HermesSession] = []
while sqlite3_step(stmt) == SQLITE_ROW {
sessions.append(sessionFromRow(stmt!))
}
return sessions
}
func fetchSubagentSessions(parentId: String) -> [HermesSession] {
guard let db else { return [] }
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id = ? ORDER BY started_at ASC"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, parentId, -1, sqliteTransient)
var sessions: [HermesSession] = []
while sqlite3_step(stmt) == SQLITE_ROW {
sessions.append(sessionFromRow(stmt!))
}
return sessions
}
// MARK: - Message Queries
private var messageColumns: String {
var cols = """
id, session_id, role, content, tool_call_id, tool_calls,
tool_name, timestamp, token_count, finish_reason
"""
if hasV07Schema {
cols += ", reasoning"
}
return cols
}
func fetchMessages(sessionId: String) -> [HermesMessage] {
guard let db else { return [] }
let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
var messages: [HermesMessage] = [] var messages: [HermesMessage] = []
while sqlite3_step(stmt) == SQLITE_ROW { while sqlite3_step(stmt) == SQLITE_ROW {
@@ -68,11 +129,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 +147,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 +157,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 +178,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 +206,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,40 +215,134 @@ actor HermesDataService {
let totalInputTokens: Int let totalInputTokens: Int
let totalOutputTokens: Int let totalOutputTokens: Int
let totalCostUSD: Double let totalCostUSD: Double
let totalReasoningTokens: Int
let totalActualCostUSD: Double
static let empty = SessionStats(
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0,
totalReasoningTokens: 0, totalActualCostUSD: 0
)
} }
func fetchStats() -> SessionStats { func fetchStats() -> SessionStats {
guard let db else { guard let db else { return .empty }
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0, let sql: String
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0) if hasV07Schema {
sql = """
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
COALESCE(SUM(estimated_cost_usd),0),
COALESCE(SUM(reasoning_tokens),0), COALESCE(SUM(actual_cost_usd),0)
FROM sessions
"""
} else {
sql = """
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
COALESCE(SUM(estimated_cost_usd),0)
FROM sessions
"""
} }
let sql = """
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
COALESCE(SUM(estimated_cost_usd),0)
FROM sessions
"""
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
}
defer { sqlite3_finalize(stmt) } defer { sqlite3_finalize(stmt) }
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
guard sqlite3_step(stmt) == SQLITE_ROW else {
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
}
return SessionStats( return SessionStats(
totalSessions: Int(sqlite3_column_int(stmt, 0)), totalSessions: Int(sqlite3_column_int(stmt, 0)),
totalMessages: Int(sqlite3_column_int(stmt, 1)), totalMessages: Int(sqlite3_column_int(stmt, 1)),
totalToolCalls: Int(sqlite3_column_int(stmt, 2)), totalToolCalls: Int(sqlite3_column_int(stmt, 2)),
totalInputTokens: Int(sqlite3_column_int(stmt, 3)), totalInputTokens: Int(sqlite3_column_int(stmt, 3)),
totalOutputTokens: Int(sqlite3_column_int(stmt, 4)), totalOutputTokens: Int(sqlite3_column_int(stmt, 4)),
totalCostUSD: sqlite3_column_double(stmt, 5) totalCostUSD: sqlite3_column_double(stmt, 5),
totalReasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 6)) : 0,
totalActualCostUSD: hasV07Schema ? sqlite3_column_double(stmt, 7) : 0
) )
} }
// MARK: - Insights Queries
func fetchUserMessageCount(since: Date) -> Int {
guard let db else { return 0 }
let sql = """
SELECT COUNT(*) FROM messages m
JOIN sessions s ON m.session_id = s.id
WHERE m.role = 'user' AND s.parent_session_id IS NULL AND s.started_at >= ?
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
guard sqlite3_step(stmt) == SQLITE_ROW else { return 0 }
return Int(sqlite3_column_int(stmt, 0))
}
func fetchToolUsage(since: Date) -> [(name: String, count: Int)] {
guard let db else { return [] }
let sql = """
SELECT m.tool_name, COUNT(*) as cnt
FROM messages m
JOIN sessions s ON m.session_id = s.id
WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.parent_session_id IS NULL AND s.started_at >= ?
GROUP BY m.tool_name
ORDER BY cnt DESC
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
var results: [(name: String, count: Int)] = []
while sqlite3_step(stmt) == SQLITE_ROW {
let name = columnText(stmt!, 0)
let count = Int(sqlite3_column_int(stmt!, 1))
results.append((name: name, count: count))
}
return results
}
func fetchSessionStartHours(since: Date) -> [Int: Int] {
guard let db else { return [:] }
let sql = """
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
var hours: [Int: Int] = [:]
let calendar = Calendar.current
while sqlite3_step(stmt) == SQLITE_ROW {
let ts = sqlite3_column_double(stmt!, 0)
let date = Date(timeIntervalSince1970: ts)
let hour = calendar.component(.hour, from: date)
hours[hour, default: 0] += 1
}
return hours
}
func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] {
guard let db else { return [:] }
let sql = """
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
var days: [Int: Int] = [:]
let calendar = Calendar.current
while sqlite3_step(stmt) == SQLITE_ROW {
let ts = sqlite3_column_double(stmt!, 0)
let date = Date(timeIntervalSince1970: ts)
let weekday = (calendar.component(.weekday, from: date) + 5) % 7 // Mon=0
days[weekday, default: 0] += 1
}
return days
}
func stateDBModificationDate() -> Date? { func stateDBModificationDate() -> Date? {
let walPath = HermesPaths.stateDB + "-wal" let walPath = HermesPaths.stateDB + "-wal"
let dbPath = HermesPaths.stateDB let dbPath = HermesPaths.stateDB
@@ -214,7 +374,11 @@ actor HermesDataService {
outputTokens: Int(sqlite3_column_int(stmt, 12)), outputTokens: Int(sqlite3_column_int(stmt, 12)),
cacheReadTokens: Int(sqlite3_column_int(stmt, 13)), cacheReadTokens: Int(sqlite3_column_int(stmt, 13)),
cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)), cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)),
estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil,
reasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 16)) : 0,
actualCostUSD: hasV07Schema && sqlite3_column_type(stmt, 17) != SQLITE_NULL ? sqlite3_column_double(stmt, 17) : nil,
costStatus: hasV07Schema ? columnOptionalText(stmt, 18) : nil,
billingProvider: hasV07Schema ? columnOptionalText(stmt, 19) : nil
) )
} }
@@ -231,14 +395,20 @@ actor HermesDataService {
toolName: columnOptionalText(stmt, 6), toolName: columnOptionalText(stmt, 6),
timestamp: columnDate(stmt, 7), timestamp: columnDate(stmt, 7),
tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil, tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil,
finishReason: columnOptionalText(stmt, 9) finishReason: columnOptionalText(stmt, 9),
reasoning: hasV07Schema ? columnOptionalText(stmt, 10) : nil
) )
} }
private func parseToolCalls(_ json: String?) -> [HermesToolCall] { private func parseToolCalls(_ json: String?) -> [HermesToolCall] {
guard let json, !json.isEmpty, guard let json, !json.isEmpty,
let data = json.data(using: .utf8) else { return [] } let data = json.data(using: .utf8) else { return [] }
return (try? JSONDecoder().decode([HermesToolCall].self, from: data)) ?? [] do {
return try JSONDecoder().decode([HermesToolCall].self, from: data)
} catch {
print("[Scarf] Failed to decode tool calls: \(error.localizedDescription)")
return []
}
} }
private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String { private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String {
@@ -259,4 +429,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: " ")
}
} }
+127 -15
View File
@@ -12,12 +12,37 @@ struct HermesFileService: Sendable {
private func parseConfig(_ yaml: String) -> HermesConfig { private func parseConfig(_ yaml: String) -> HermesConfig {
var values: [String: String] = [:] var values: [String: String] = [:]
var currentSection = "" var currentSection = ""
var dockerEnv: [String: String] = [:]
var commandAllowlist: [String] = []
var inDockerEnv = false
var inAllowlist = false
for line in yaml.components(separatedBy: "\n") { for line in yaml.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces) let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = line.prefix(while: { $0 == " " }).count let indent = line.prefix(while: { $0 == " " }).count
// Detect end of nested blocks when indent returns to section level
if indent <= 2 && (inDockerEnv || inAllowlist) {
inDockerEnv = false
inAllowlist = false
}
// Collect docker_env nested key-value pairs
if inDockerEnv, indent >= 4, let colonIdx = trimmed.firstIndex(of: ":") {
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
dockerEnv[key] = val
continue
}
// Collect allowlist items
if inAllowlist, indent >= 4, trimmed.hasPrefix("- ") {
commandAllowlist.append(String(trimmed.dropFirst(2)))
continue
}
if indent == 0 && trimmed.hasSuffix(":") { if indent == 0 && trimmed.hasSuffix(":") {
currentSection = String(trimmed.dropLast()) currentSection = String(trimmed.dropLast())
continue continue
@@ -26,6 +51,16 @@ struct HermesFileService: Sendable {
if let colonIdx = trimmed.firstIndex(of: ":") { if let colonIdx = trimmed.firstIndex(of: ":") {
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces) let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces) let val = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
if key == "docker_env" && val.isEmpty {
inDockerEnv = true
continue
}
if key == "permanent_allowlist" && val.isEmpty {
inAllowlist = true
continue
}
values[currentSection + "." + key] = val values[currentSection + "." + key] = val
} }
} }
@@ -42,7 +77,17 @@ struct HermesFileService: Sendable {
nudgeInterval: Int(values["memory.nudge_interval"] ?? "") ?? 0, nudgeInterval: Int(values["memory.nudge_interval"] ?? "") ?? 0,
streaming: values["display.streaming"] != "false", streaming: values["display.streaming"] != "false",
showReasoning: values["display.show_reasoning"] == "true", showReasoning: values["display.show_reasoning"] == "true",
verbose: values["agent.verbose"] == "true" verbose: values["agent.verbose"] == "true",
autoTTS: values["voice.auto_tts"] != "false",
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold,
reasoningEffort: values["agent.reasoning_effort"] ?? "medium",
showCost: values["display.show_cost"] == "true",
approvalMode: values["approvals.mode"] ?? "manual",
browserBackend: values["browser.backend"] ?? "",
memoryProvider: values["memory.provider"] ?? "",
dockerEnv: dockerEnv,
commandAllowlist: commandAllowlist,
memoryProfile: values["memory.profile"] ?? ""
) )
} }
@@ -50,33 +95,64 @@ struct HermesFileService: Sendable {
func loadGatewayState() -> GatewayState? { func loadGatewayState() -> GatewayState? {
guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil } guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil }
return try? JSONDecoder().decode(GatewayState.self, from: data) do {
return try JSONDecoder().decode(GatewayState.self, from: data)
} catch {
print("[Scarf] Failed to decode gateway state: \(error.localizedDescription)")
return nil
}
} }
// MARK: - Memory // MARK: - Memory
func loadMemory() -> String { func loadMemoryProfiles() -> [String] {
readFile(HermesPaths.memoryMD) ?? "" let fm = FileManager.default
guard let entries = try? fm.contentsOfDirectory(atPath: HermesPaths.memoriesDir) else { return [] }
return entries.filter { name in
var isDir: ObjCBool = false
let path = HermesPaths.memoriesDir + "/" + name
return fm.fileExists(atPath: path, isDirectory: &isDir) && isDir.boolValue
}.sorted()
} }
func loadUserProfile() -> String { func loadMemory(profile: String = "") -> String {
readFile(HermesPaths.userMD) ?? "" let path = memoryPath(profile: profile, file: "MEMORY.md")
return readFile(path) ?? ""
} }
func saveMemory(_ content: String) { func loadUserProfile(profile: String = "") -> String {
writeFile(HermesPaths.memoryMD, content: content) let path = memoryPath(profile: profile, file: "USER.md")
return readFile(path) ?? ""
} }
func saveUserProfile(_ content: String) { func saveMemory(_ content: String, profile: String = "") {
writeFile(HermesPaths.userMD, content: content) let path = memoryPath(profile: profile, file: "MEMORY.md")
writeFile(path, content: content)
}
func saveUserProfile(_ content: String, profile: String = "") {
let path = memoryPath(profile: profile, file: "USER.md")
writeFile(path, content: content)
}
private func memoryPath(profile: String, file: String) -> String {
if profile.isEmpty {
return HermesPaths.memoriesDir + "/" + file
}
return HermesPaths.memoriesDir + "/" + profile + "/" + file
} }
// MARK: - Cron // MARK: - Cron
func loadCronJobs() -> [HermesCronJob] { func loadCronJobs() -> [HermesCronJob] {
guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] } guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] }
let file = try? JSONDecoder().decode(CronJobsFile.self, from: data) do {
return file?.jobs ?? [] let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
return file.jobs
} catch {
print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)")
return []
}
} }
func loadCronOutput(jobId: String) -> String? { func loadCronOutput(jobId: String) -> String? {
@@ -106,12 +182,14 @@ struct HermesFileService: Sendable {
var isSkillDir: ObjCBool = false var isSkillDir: ObjCBool = false
guard fm.fileExists(atPath: skillPath, isDirectory: &isSkillDir), isSkillDir.boolValue else { return nil } guard fm.fileExists(atPath: skillPath, isDirectory: &isSkillDir), isSkillDir.boolValue else { return nil }
let files = (try? fm.contentsOfDirectory(atPath: skillPath)) ?? [] let files = (try? fm.contentsOfDirectory(atPath: skillPath)) ?? []
let requiredConfig = parseSkillRequiredConfig(skillPath + "/skill.yaml")
return HermesSkill( return HermesSkill(
id: categoryName + "/" + skillName, id: categoryName + "/" + skillName,
name: skillName, name: skillName,
category: categoryName, category: categoryName,
path: skillPath, path: skillPath,
files: files.sorted() files: files.sorted(),
requiredConfig: requiredConfig
) )
} }
@@ -121,7 +199,37 @@ 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) ?? ""
}
private func parseSkillRequiredConfig(_ path: String) -> [String] {
guard let content = readFile(path) else { return [] }
var result: [String] = []
var inRequiredConfig = false
for line in content.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = line.prefix(while: { $0 == " " }).count
if trimmed == "required_config:" || trimmed.hasPrefix("required_config:") {
inRequiredConfig = true
continue
}
if inRequiredConfig {
if indent < 2 && !trimmed.isEmpty {
break
}
if trimmed.hasPrefix("- ") {
result.append(String(trimmed.dropFirst(2)))
}
}
}
return result
} }
// MARK: - Hermes Process // MARK: - Hermes Process
@@ -154,6 +262,10 @@ struct HermesFileService: Sendable {
} }
private func writeFile(_ path: String, content: String) { private func writeFile(_ path: String, content: String) {
try? content.write(toFile: path, atomically: true, encoding: .utf8) do {
try content.write(toFile: path, atomically: true, encoding: .utf8)
} catch {
print("[Scarf] Failed to write \(path): \(error.localizedDescription)")
}
} }
} }
@@ -3,7 +3,8 @@ import Foundation
@Observable @Observable
final class HermesFileWatcher { final class HermesFileWatcher {
private(set) var lastChangeDate = Date() private(set) var lastChangeDate = Date()
private var sources: [DispatchSourceFileSystemObject] = [] private var coreSources: [DispatchSourceFileSystemObject] = []
private var projectSources: [DispatchSourceFileSystemObject] = []
private var timer: Timer? private var timer: Timer?
func startWatching() { func startWatching() {
@@ -15,12 +16,16 @@ final class HermesFileWatcher {
HermesPaths.userMD, HermesPaths.userMD,
HermesPaths.cronJobsJSON, HermesPaths.cronJobsJSON,
HermesPaths.gatewayStateJSON, HermesPaths.gatewayStateJSON,
HermesPaths.agentLog,
HermesPaths.errorsLog, HermesPaths.errorsLog,
HermesPaths.gatewayLog HermesPaths.gatewayLog,
HermesPaths.projectsRegistry
] ]
for path in paths { for path in paths {
watchFile(path) if let source = makeSource(for: path) {
coreSources.append(source)
}
} }
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
@@ -29,17 +34,30 @@ final class HermesFileWatcher {
} }
func stopWatching() { func stopWatching() {
for source in sources { for source in coreSources + projectSources {
source.cancel() source.cancel()
} }
sources.removeAll() coreSources.removeAll()
projectSources.removeAll()
timer?.invalidate() timer?.invalidate()
timer = nil timer = nil
} }
private func watchFile(_ path: String) { func updateProjectWatches(_ dashboardPaths: [String]) {
for source in projectSources {
source.cancel()
}
projectSources.removeAll()
for path in dashboardPaths {
if let source = makeSource(for: path) {
projectSources.append(source)
}
}
}
private func makeSource(for path: String) -> DispatchSourceFileSystemObject? {
let fd = Darwin.open(path, O_EVTONLY) let fd = Darwin.open(path, O_EVTONLY)
guard fd >= 0 else { return } guard fd >= 0 else { return nil }
let source = DispatchSource.makeFileSystemObjectSource( let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd, fileDescriptor: fd,
@@ -53,7 +71,7 @@ final class HermesFileWatcher {
Darwin.close(fd) Darwin.close(fd)
} }
source.resume() source.resume()
sources.append(source) return source
} }
deinit { deinit {
@@ -39,12 +39,16 @@ actor HermesLogService {
} }
func closeLog() { func closeLog() {
try? fileHandle?.close() do {
try fileHandle?.close()
} catch {
print("[Scarf] Failed to close log handle: \(error.localizedDescription)")
}
fileHandle = nil fileHandle = nil
currentPath = nil currentPath = nil
} }
func readLastLines(count: Int = 200) -> [LogEntry] { func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
guard let path = currentPath, guard let path = currentPath,
let data = FileManager.default.contents(atPath: path) else { return [] } let data = FileManager.default.contents(atPath: path) else { return [] }
let content = String(data: data, encoding: .utf8) ?? "" let content = String(data: data, encoding: .utf8) ?? ""
@@ -0,0 +1,63 @@
import Foundation
struct ProjectDashboardService: Sendable {
// MARK: - Registry
func loadRegistry() -> ProjectRegistry {
guard let data = FileManager.default.contents(atPath: HermesPaths.projectsRegistry) else {
return ProjectRegistry(projects: [])
}
do {
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
} catch {
print("[Scarf] Failed to decode project registry: \(error.localizedDescription)")
return ProjectRegistry(projects: [])
}
}
func saveRegistry(_ registry: ProjectRegistry) {
let dir = HermesPaths.scarfDir
if !FileManager.default.fileExists(atPath: dir) {
do {
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
} catch {
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
return
}
}
guard let data = try? JSONEncoder().encode(registry) else { return }
// Pretty-print for readability (agents may read this file)
if let pretty = try? JSONSerialization.jsonObject(with: data),
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: formatted)
} else {
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: data)
}
}
// MARK: - Dashboard
func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? {
guard let data = FileManager.default.contents(atPath: project.dashboardPath) else {
return nil
}
do {
return try JSONDecoder().decode(ProjectDashboard.self, from: data)
} catch {
print("[Scarf] Failed to decode dashboard for \(project.name): \(error.localizedDescription)")
return nil
}
}
func dashboardExists(for project: ProjectEntry) -> Bool {
FileManager.default.fileExists(atPath: project.dashboardPath)
}
func dashboardModificationDate(for project: ProjectEntry) -> Date? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: project.dashboardPath) else {
return nil
}
return attrs[.modificationDate] as? Date
}
}
@@ -6,9 +6,20 @@ final class ActivityViewModel {
var toolMessages: [HermesMessage] = [] var toolMessages: [HermesMessage] = []
var filterKind: ToolKind? var filterKind: ToolKind?
var filterSessionId: String?
var selectedEntry: ActivityEntry? var selectedEntry: ActivityEntry?
var sessionPreviews: [String: String] = [:]
var isLoading = true var isLoading = true
var availableSessions: [(id: String, label: String)] {
var seen = Set<String>()
return toolMessages.compactMap { message in
guard seen.insert(message.sessionId).inserted else { return nil }
let label = sessionPreviews[message.sessionId] ?? message.sessionId
return (id: message.sessionId, label: label)
}
}
var filteredActivity: [ActivityEntry] { var filteredActivity: [ActivityEntry] {
let entries = toolMessages.flatMap { message in let entries = toolMessages.flatMap { message in
message.toolCalls.map { call in message.toolCalls.map { call in
@@ -24,10 +35,11 @@ final class ActivityViewModel {
) )
} }
} }
if let filterKind { return entries.filter { entry in
return entries.filter { $0.kind == filterKind } let kindOk = filterKind == nil || entry.kind == filterKind
let sessionOk = filterSessionId == nil || entry.sessionId == filterSessionId
return kindOk && sessionOk
} }
return entries
} }
func load() async { func load() async {
@@ -38,6 +50,7 @@ final class ActivityViewModel {
return return
} }
toolMessages = await dataService.fetchRecentToolCalls(limit: 200) toolMessages = await dataService.fetchRecentToolCalls(limit: 200)
sessionPreviews = await dataService.fetchSessionPreviews(limit: 200)
isLoading = false isLoading = false
} }
@@ -21,20 +21,36 @@ struct ActivityView: View {
} }
private var filterBar: some View { private var filterBar: some View {
ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) {
HStack(spacing: 8) { ScrollView(.horizontal, showsIndicators: false) {
FilterChip(label: "All", isSelected: viewModel.filterKind == nil) { HStack(spacing: 8) {
viewModel.filterKind = nil FilterChip(label: "All", isSelected: viewModel.filterKind == nil) {
} viewModel.filterKind = nil
ForEach(ToolKind.allCases, id: \.rawValue) { kind in }
FilterChip(label: kind.rawValue.capitalized, isSelected: viewModel.filterKind == kind) { ForEach(ToolKind.allCases, id: \.rawValue) { kind in
viewModel.filterKind = kind FilterChip(label: kind.rawValue.capitalized, isSelected: viewModel.filterKind == kind) {
viewModel.filterKind = kind
}
} }
} }
} }
.padding(.horizontal) Divider()
.padding(.vertical, 8) .frame(height: 16)
Picker(selection: $viewModel.filterSessionId) {
Text("All Sessions").tag(String?.none)
Divider()
ForEach(viewModel.availableSessions, id: \.id) { session in
Text(session.label)
.lineLimit(1)
.tag(String?.some(session.id))
}
} label: {
EmptyView()
}
.frame(maxWidth: 250)
} }
.padding(.horizontal)
.padding(.vertical, 8)
} }
private var activityList: some View { private var activityList: some View {
@@ -5,11 +5,15 @@ import SwiftTerm
@Observable @Observable
final class ChatViewModel { final class ChatViewModel {
private let dataService = HermesDataService() private let dataService = HermesDataService()
private let fileService = HermesFileService()
var recentSessions: [HermesSession] = [] var recentSessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:] var sessionPreviews: [String: String] = [:]
var terminalView: LocalProcessTerminalView? var terminalView: LocalProcessTerminalView?
var hasActiveProcess = false var hasActiveProcess = false
var voiceEnabled = false
var ttsEnabled = false
var isRecording = false
private var coordinator: Coordinator? private var coordinator: Coordinator?
var hermesBinaryExists: Bool { var hermesBinaryExists: Bool {
@@ -17,14 +21,23 @@ final class ChatViewModel {
} }
func startNewSession() { func startNewSession() {
voiceEnabled = false
ttsEnabled = false
isRecording = false
launchTerminal(arguments: ["chat"]) launchTerminal(arguments: ["chat"])
} }
func resumeSession(_ sessionId: String) { func resumeSession(_ sessionId: String) {
voiceEnabled = false
ttsEnabled = false
isRecording = false
launchTerminal(arguments: ["chat", "--resume", sessionId]) launchTerminal(arguments: ["chat", "--resume", sessionId])
} }
func continueLastSession() { func continueLastSession() {
voiceEnabled = false
ttsEnabled = false
isRecording = false
launchTerminal(arguments: ["chat", "--continue"]) launchTerminal(arguments: ["chat", "--continue"])
} }
@@ -42,6 +55,38 @@ final class ChatViewModel {
return session.id return session.id
} }
func toggleVoice() {
guard let tv = terminalView else { return }
if voiceEnabled {
sendToTerminal(tv, text: "/voice off\r")
voiceEnabled = false
isRecording = false
} else {
sendToTerminal(tv, text: "/voice on\r")
voiceEnabled = true
ttsEnabled = fileService.loadConfig().autoTTS
}
}
func toggleTTS() {
guard let tv = terminalView, voiceEnabled else { return }
sendToTerminal(tv, text: "/voice tts\r")
ttsEnabled.toggle()
}
func pushToTalk() {
guard let tv = terminalView, voiceEnabled else { return }
// Ctrl+B = ASCII 0x02
let ctrlB: [UInt8] = [0x02]
tv.send(source: tv, data: ctrlB[0..<1])
isRecording.toggle()
}
private func sendToTerminal(_ tv: LocalProcessTerminalView, text: String) {
let bytes = Array(text.utf8)
tv.send(source: tv, data: bytes[0..<bytes.count])
}
private func launchTerminal(arguments: [String]) { private func launchTerminal(arguments: [String]) {
if let existing = terminalView { if let existing = terminalView {
existing.terminate() existing.terminate()
@@ -55,6 +100,8 @@ final class ChatViewModel {
let coord = Coordinator(onTerminated: { [weak self] in let coord = Coordinator(onTerminated: { [weak self] in
self?.hasActiveProcess = false self?.hasActiveProcess = false
self?.voiceEnabled = false
self?.isRecording = false
}) })
terminal.processDelegate = coord terminal.processDelegate = coord
self.coordinator = coord self.coordinator = coord
@@ -2,6 +2,7 @@ import SwiftUI
struct ChatView: View { struct ChatView: View {
@Environment(ChatViewModel.self) private var viewModel @Environment(ChatViewModel.self) private var viewModel
@Environment(HermesFileWatcher.self) private var fileWatcher
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -11,6 +12,9 @@ struct ChatView: View {
} }
.navigationTitle("Chat") .navigationTitle("Chat")
.task { await viewModel.loadRecentSessions() } .task { await viewModel.loadRecentSessions() }
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.loadRecentSessions() }
}
} }
private var toolbar: some View { private var toolbar: some View {
@@ -36,6 +40,10 @@ struct ChatView: View {
Spacer() Spacer()
if viewModel.hasActiveProcess {
voiceControls
}
if !viewModel.hermesBinaryExists { if !viewModel.hermesBinaryExists {
Label("Hermes binary not found", systemImage: "exclamationmark.triangle") Label("Hermes binary not found", systemImage: "exclamationmark.triangle")
.font(.caption) .font(.caption)
@@ -80,6 +88,55 @@ struct ChatView: View {
.padding(.vertical, 6) .padding(.vertical, 6)
} }
private var voiceControls: some View {
HStack(spacing: 8) {
Button {
viewModel.toggleVoice()
} label: {
HStack(spacing: 4) {
Image(systemName: viewModel.voiceEnabled ? "mic.fill" : "mic.slash")
.foregroundStyle(viewModel.voiceEnabled ? .green : .secondary)
Text(viewModel.voiceEnabled ? "Voice On" : "Voice Off")
.font(.caption)
.foregroundStyle(viewModel.voiceEnabled ? .primary : .secondary)
}
}
.buttonStyle(.plain)
.help("Toggle voice mode (/voice)")
if viewModel.voiceEnabled {
Button {
viewModel.toggleTTS()
} label: {
HStack(spacing: 4) {
Image(systemName: viewModel.ttsEnabled ? "speaker.wave.2.fill" : "speaker.slash")
.foregroundStyle(viewModel.ttsEnabled ? .green : .secondary)
Text(viewModel.ttsEnabled ? "TTS On" : "TTS Off")
.font(.caption)
.foregroundStyle(viewModel.ttsEnabled ? .primary : .secondary)
}
}
.buttonStyle(.plain)
.help("Toggle text-to-speech (/voice tts)")
Button {
viewModel.pushToTalk()
} label: {
HStack(spacing: 4) {
Image(systemName: viewModel.isRecording ? "waveform.circle.fill" : "waveform.circle")
.foregroundStyle(viewModel.isRecording ? .red : Color.accentColor)
.symbolEffect(.pulse, isActive: viewModel.isRecording)
Text(viewModel.isRecording ? "Recording..." : "Push to Talk")
.font(.caption)
}
}
.buttonStyle(.plain)
.help("Push to talk (Ctrl+B)")
.keyboardShortcut("b", modifiers: .control)
}
}
}
@ViewBuilder @ViewBuilder
private var terminalArea: some View { private var terminalArea: some View {
if let terminal = viewModel.terminalView { if let terminal = viewModel.terminalView {
@@ -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),
@@ -38,6 +38,11 @@ struct CronView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
if job.silent == true {
Text("SILENT")
.font(.caption2.bold())
.foregroundStyle(.purple)
}
if !job.enabled { if !job.enabled {
Text("Disabled") Text("Disabled")
.font(.caption2) .font(.caption2)
@@ -86,6 +91,20 @@ struct CronView: View {
.background(.quaternary.opacity(0.5)) .background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6)) .clipShape(RoundedRectangle(cornerRadius: 6))
} }
if let script = job.preRunScript, !script.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Pre-Run Script")
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(script)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
if let skills = job.skills, !skills.isEmpty { if let skills = job.skills, !skills.isEmpty {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Skills") Text("Skills")
@@ -118,6 +137,21 @@ struct CronView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.red) .foregroundStyle(.red)
} }
if let timeout = job.timeoutSeconds {
Label("Timeout: \(timeout)s (\(job.timeoutType ?? "wall_clock"))", systemImage: "timer")
.font(.caption)
.foregroundStyle(.secondary)
}
if let failures = job.deliveryFailures, failures > 0 {
Label("\(failures) delivery failure\(failures == 1 ? "" : "s")", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
if let deliveryError = job.lastDeliveryError {
Label(deliveryError, systemImage: "paperplane.circle")
.font(.caption)
.foregroundStyle(.orange)
}
if let output = viewModel.jobOutput { if let output = viewModel.jobOutput {
Divider() Divider()
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -5,10 +5,7 @@ final class DashboardViewModel {
private let dataService = HermesDataService() private let dataService = HermesDataService()
private let fileService = HermesFileService() private let fileService = HermesFileService()
var stats = HermesDataService.SessionStats( var stats = HermesDataService.SessionStats.empty
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0
)
var recentSessions: [HermesSession] = [] var recentSessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:] var sessionPreviews: [String: String] = [:]
var config = HermesConfig.empty var config = HermesConfig.empty
@@ -3,6 +3,7 @@ import SwiftUI
struct DashboardView: View { struct DashboardView: View {
@State private var viewModel = DashboardViewModel() @State private var viewModel = DashboardViewModel()
@Environment(AppCoordinator.self) private var coordinator @Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -16,6 +17,9 @@ struct DashboardView: View {
} }
.navigationTitle("Dashboard") .navigationTitle("Dashboard")
.task { await viewModel.load() } .task { await viewModel.load() }
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.load() }
}
} }
private var statusSection: some View { private var statusSection: some View {
@@ -56,7 +60,10 @@ struct DashboardView: View {
StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)") StatCard(label: "Messages", value: "\(viewModel.stats.totalMessages)")
StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)") StatCard(label: "Tool Calls", value: "\(viewModel.stats.totalToolCalls)")
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens)) StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
StatCard(label: "Est. Cost", value: String(format: "$%.2f", viewModel.stats.totalCostUSD)) let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
if cost > 0 {
StatCard(label: "Cost", value: String(format: "$%.2f", cost))
}
} }
} }
} }
@@ -87,14 +94,6 @@ struct DashboardView: View {
} }
} }
private func formatTokens(_ count: Int) -> String {
if count >= 1_000_000 {
return String(format: "%.1fM", Double(count) / 1_000_000)
} else if count >= 1_000 {
return String(format: "%.1fK", Double(count) / 1_000)
}
return "\(count)"
}
} }
struct StatusCard: View { struct StatusCard: View {
@@ -0,0 +1,177 @@
import Foundation
struct GatewayInfo {
let pid: Int?
let state: String
let exitReason: String?
let startTime: String?
let updatedAt: String?
let platforms: [PlatformInfo]
let isLoaded: Bool
let isStale: Bool
}
struct PlatformInfo: Identifiable {
var id: String { name }
let name: String
let state: String
let updatedAt: String?
var isConnected: Bool { state == "connected" }
var icon: String { KnownPlatforms.icon(for: name) }
}
struct PairedUser: Identifiable {
var id: String { platform + userId }
let platform: String
let userId: String
let name: String
}
struct PendingPairing: Identifiable {
var id: String { platform + code }
let platform: String
let code: String
}
@Observable
final class GatewayViewModel {
var gateway = GatewayInfo(pid: nil, state: "unknown", exitReason: nil, startTime: nil, updatedAt: nil, platforms: [], isLoaded: false, isStale: false)
var approvedUsers: [PairedUser] = []
var pendingPairings: [PendingPairing] = []
var isLoading = false
var actionMessage: String?
func load() {
isLoading = true
loadGatewayStatus()
loadPairing()
isLoading = false
}
func startGateway() {
runHermes(["gateway", "start"])
actionMessage = "Gateway start requested"
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.loadGatewayStatus()
self?.actionMessage = nil
}
}
func stopGateway() {
runHermes(["gateway", "stop"])
actionMessage = "Gateway stop requested"
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.loadGatewayStatus()
self?.actionMessage = nil
}
}
func restartGateway() {
runHermes(["gateway", "restart"])
actionMessage = "Gateway restart requested"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.loadGatewayStatus()
self?.actionMessage = nil
}
}
func approvePairing(platform: String, code: String) {
runHermes(["pairing", "approve", platform, code])
loadPairing()
}
func revokeUser(_ user: PairedUser) {
runHermes(["pairing", "revoke", user.platform, user.userId])
approvedUsers.removeAll { $0.id == user.id }
}
// MARK: - Private
private func loadGatewayStatus() {
let stateJSON = FileManager.default.contents(atPath: HermesPaths.gatewayStateJSON)
var pid: Int?
var state = "unknown"
var exitReason: String?
var startTime: String?
var updatedAt: String?
var platforms: [PlatformInfo] = []
if let data = stateJSON,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
pid = json["pid"] as? Int
state = json["gateway_state"] as? String ?? "unknown"
exitReason = json["exit_reason"] as? String
startTime = json["start_time"] as? String
updatedAt = json["updated_at"] as? String
if let plats = json["platforms"] as? [String: Any] {
platforms = plats.compactMap { key, value in
guard let info = value as? [String: Any] else { return nil }
return PlatformInfo(
name: key,
state: info["state"] as? String ?? "unknown",
updatedAt: info["updated_at"] as? String
)
}.sorted { $0.name < $1.name }
}
}
let statusOutput = runHermes(["gateway", "status"]).output
let isLoaded = statusOutput.contains("service is loaded")
let isStale = statusOutput.contains("stale")
gateway = GatewayInfo(
pid: pid, state: state, exitReason: exitReason,
startTime: startTime, updatedAt: updatedAt,
platforms: platforms, isLoaded: isLoaded, isStale: isStale
)
}
private func loadPairing() {
let output = runHermes(["pairing", "list"]).output
approvedUsers = []
pendingPairings = []
var inApproved = false
var inPending = false
for line in output.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.contains("Approved Users") { inApproved = true; inPending = false; continue }
if trimmed.contains("Pending") { inPending = true; inApproved = false; continue }
if trimmed.isEmpty || trimmed.hasPrefix("Platform") || trimmed.hasPrefix("--------") { continue }
let parts = trimmed.split(separator: " ", omittingEmptySubsequences: true)
if inApproved && parts.count >= 3 {
let platform = String(parts[0])
let userId = String(parts[1])
let name = parts[2...].joined(separator: " ")
approvedUsers.append(PairedUser(platform: platform, userId: userId, name: name))
}
if inPending && parts.count >= 2 {
let platform = String(parts[0])
let code = String(parts[1])
pendingPairings.append(PendingPairing(platform: platform, code: code))
}
}
}
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
} catch {
return ("", -1)
}
}
}
@@ -0,0 +1,197 @@
import SwiftUI
struct GatewayView: View {
@State private var viewModel = GatewayViewModel()
@Environment(HermesFileWatcher.self) private var fileWatcher
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
serviceSection
platformsSection
pairingSection
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.navigationTitle("Gateway")
.onAppear { viewModel.load() }
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
}
// MARK: - Service
private var serviceSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Service")
.font(.headline)
Spacer()
if let msg = viewModel.actionMessage {
Text(msg)
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
Button("Start") { viewModel.startGateway() }
Button("Stop") { viewModel.stopGateway() }
Button("Restart") { viewModel.restartGateway() }
}
.controlSize(.small)
}
HStack(spacing: 16) {
StatusBadge(
label: viewModel.gateway.state,
isActive: viewModel.gateway.state == "running"
)
if let pid = viewModel.gateway.pid {
Label("PID \(pid)", systemImage: "number")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
if viewModel.gateway.isLoaded {
Label("Loaded", systemImage: "checkmark.circle")
.font(.caption)
.foregroundStyle(.green)
}
if viewModel.gateway.isStale {
Label("Service definition stale", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
}
}
if let reason = viewModel.gateway.exitReason, !reason.isEmpty {
HStack(spacing: 4) {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
Text(reason)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let updated = viewModel.gateway.updatedAt {
Text("Last updated: \(updated)")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
// MARK: - Platforms
private var platformsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Platforms")
.font(.headline)
if viewModel.gateway.platforms.isEmpty {
Text("No platforms connected")
.font(.caption)
.foregroundStyle(.secondary)
} else {
HStack(spacing: 12) {
ForEach(viewModel.gateway.platforms) { platform in
VStack(spacing: 6) {
Image(systemName: platform.icon)
.font(.title2)
.foregroundStyle(platform.isConnected ? Color.accentColor : .secondary)
Text(platform.name.capitalized)
.font(.caption.bold())
StatusBadge(
label: platform.state,
isActive: platform.isConnected
)
}
.frame(maxWidth: .infinity)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
}
// MARK: - Pairing
private var pairingSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Paired Users")
.font(.headline)
if !viewModel.pendingPairings.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Label("Pending Approvals", systemImage: "clock.badge.questionmark")
.font(.caption.bold())
.foregroundStyle(.orange)
ForEach(viewModel.pendingPairings) { pending in
HStack {
Label(pending.platform.capitalized, systemImage: platformIcon(pending.platform))
Text("Code: \(pending.code)")
.font(.caption.monospaced())
Spacer()
Button("Approve") {
viewModel.approvePairing(platform: pending.platform, code: pending.code)
}
.controlSize(.small)
.buttonStyle(.borderedProminent)
}
.font(.caption)
.padding(8)
.background(.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
if viewModel.approvedUsers.isEmpty && viewModel.pendingPairings.isEmpty {
Text("No paired users")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.approvedUsers) { user in
HStack {
Image(systemName: platformIcon(user.platform))
.foregroundStyle(.secondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(user.name)
Text("\(user.platform.capitalized) · \(user.userId)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Revoke", role: .destructive) {
viewModel.revokeUser(user)
}
.controlSize(.small)
}
.padding(8)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
}
}
private func platformIcon(_ platform: String) -> String {
KnownPlatforms.icon(for: platform)
}
}
struct StatusBadge: View {
let label: String
let isActive: Bool
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(isActive ? .green : .secondary)
.frame(width: 6, height: 6)
Text(label)
.font(.caption)
}
}
}
@@ -0,0 +1,179 @@
import Foundation
struct HealthCheck: Identifiable {
let id = UUID()
let label: String
let status: CheckStatus
let detail: String?
enum CheckStatus {
case ok
case warning
case error
}
}
struct HealthSection: Identifiable {
let id = UUID()
let title: String
let icon: String
let checks: [HealthCheck]
}
@Observable
final class HealthViewModel {
var version = ""
var updateInfo = ""
var hasUpdate = false
var statusSections: [HealthSection] = []
var doctorSections: [HealthSection] = []
var issueCount = 0
var warningCount = 0
var okCount = 0
var isLoading = false
func load() {
isLoading = true
loadVersion()
let statusOutput = runHermes(["status"]).output
statusSections = parseOutput(statusOutput)
let doctorOutput = runHermes(["doctor"]).output
doctorSections = parseOutput(doctorOutput)
computeCounts()
isLoading = false
}
private func loadVersion() {
let output = runHermes(["version"]).output
let lines = output.components(separatedBy: "\n")
version = lines.first ?? ""
if let updateLine = lines.first(where: { $0.contains("commits behind") }) {
updateInfo = updateLine.trimmingCharacters(in: .whitespaces)
hasUpdate = true
} else {
updateInfo = ""
hasUpdate = false
}
}
private func parseOutput(_ output: String) -> [HealthSection] {
var sections: [HealthSection] = []
var currentTitle = ""
var currentChecks: [HealthCheck] = []
for line in output.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("") {
if !currentTitle.isEmpty {
sections.append(HealthSection(
title: currentTitle,
icon: iconForSection(currentTitle),
checks: currentChecks
))
}
currentTitle = String(trimmed.dropFirst(2))
currentChecks = []
continue
}
if trimmed.hasPrefix("") {
let text = String(trimmed.dropFirst(2))
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .ok, detail: detail))
} else if trimmed.hasPrefix("") || trimmed.hasPrefix("") {
let text = trimmed.replacingOccurrences(of: "", with: "").replacingOccurrences(of: "", with: "")
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .warning, detail: detail))
} else if trimmed.hasPrefix("") {
let text = String(trimmed.dropFirst(2))
let (label, detail) = splitCheck(text)
currentChecks.append(HealthCheck(label: label, status: .error, detail: detail))
} else if trimmed.hasPrefix("") || trimmed.hasPrefix("Error:") {
if !currentChecks.isEmpty {
let last = currentChecks.removeLast()
let extra = trimmed.replacingOccurrences(of: "", with: "").replacingOccurrences(of: "Error:", with: "").trimmingCharacters(in: .whitespaces)
let combined = [last.detail, extra].compactMap { $0 }.joined(separator: " ")
currentChecks.append(HealthCheck(label: last.label, status: last.status, detail: combined))
}
} else if !trimmed.isEmpty && trimmed.contains(":") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("") && !trimmed.hasPrefix("Run ") && !trimmed.hasPrefix("Found ") && !trimmed.hasPrefix("Tip:") {
let parts = trimmed.split(separator: ":", maxSplits: 1)
if parts.count == 2 {
let key = parts[0].trimmingCharacters(in: .whitespaces)
let val = parts[1].trimmingCharacters(in: .whitespaces)
if !key.isEmpty && key.count < 30 {
currentChecks.append(HealthCheck(label: key, status: .ok, detail: val))
}
}
}
}
if !currentTitle.isEmpty {
sections.append(HealthSection(
title: currentTitle,
icon: iconForSection(currentTitle),
checks: currentChecks
))
}
return sections
}
private func splitCheck(_ text: String) -> (String, String?) {
if let parenStart = text.firstIndex(of: "(") {
let label = text[text.startIndex..<parenStart].trimmingCharacters(in: .whitespaces)
let detail = String(text[parenStart...]).trimmingCharacters(in: CharacterSet(charactersIn: "()"))
return (label, detail)
}
return (text, nil)
}
private func computeCounts() {
let allChecks = (statusSections + doctorSections).flatMap(\.checks)
okCount = allChecks.filter { $0.status == .ok }.count
warningCount = allChecks.filter { $0.status == .warning }.count
issueCount = allChecks.filter { $0.status == .error }.count
}
private func iconForSection(_ title: String) -> String {
switch title {
case "Environment": return "gearshape.2"
case "API Keys": return "key"
case "Auth Providers": return "person.badge.key"
case "API-Key Providers": return "key.horizontal"
case "Terminal Backend": return "terminal"
case "Messaging Platforms": return "bubble.left.and.bubble.right"
case "Gateway Service": return "antenna.radiowaves.left.and.right"
case "Scheduled Jobs": return "clock.arrow.2.circlepath"
case "Sessions": return "text.bubble"
case "Python Environment": return "chevron.left.forwardslash.chevron.right"
case "Required Packages": return "shippingbox"
case "Configuration Files": return "doc.text"
case "Directory Structure": return "folder"
case "External Tools": return "wrench"
case "API Connectivity": return "wifi"
case "Submodules": return "arrow.triangle.branch"
case "Tool Availability": return "wrench.and.screwdriver"
case "Skills Hub": return "lightbulb"
case "Honcho Memory": return "brain"
default: return "circle"
}
}
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
} catch {
return ("", -1)
}
}
}
@@ -0,0 +1,219 @@
import SwiftUI
struct HealthView: View {
@State private var viewModel = HealthViewModel()
@State private var expandedSection: UUID?
@State private var selectedTab = 0
var body: some View {
VStack(spacing: 0) {
headerBar
Divider()
Picker("", selection: $selectedTab) {
Text("Status").tag(0)
Text("Diagnostics").tag(1)
}
.pickerStyle(.segmented)
.frame(maxWidth: 300)
.padding(.vertical, 8)
Divider()
ScrollView {
sectionGrid(selectedTab == 0 ? viewModel.statusSections : viewModel.doctorSections)
.padding()
}
}
.navigationTitle("Health")
.onAppear { viewModel.load() }
}
// MARK: - Header
private var headerBar: some View {
HStack(spacing: 16) {
if !viewModel.version.isEmpty {
Text(viewModel.version)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
if viewModel.hasUpdate {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.caption2)
Text(viewModel.updateInfo)
.font(.caption)
}
.foregroundStyle(.orange)
}
Spacer()
HStack(spacing: 12) {
MiniCount(count: viewModel.okCount, color: .green, icon: "checkmark.circle.fill")
MiniCount(count: viewModel.warningCount, color: .orange, icon: "exclamationmark.triangle.fill")
MiniCount(count: viewModel.issueCount, color: .red, icon: "xmark.circle.fill")
}
Button("Refresh") { viewModel.load() }
.controlSize(.small)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
// MARK: - Grid
private func sectionGrid(_ sections: [HealthSection]) -> some View {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
ForEach(sections) { section in
SectionCard(
section: section,
isExpanded: expandedSection == section.id,
onTap: {
withAnimation(.easeInOut(duration: 0.2)) {
expandedSection = expandedSection == section.id ? nil : section.id
}
}
)
}
}
}
}
// MARK: - Section Card
struct SectionCard: View {
let section: HealthSection
let isExpanded: Bool
let onTap: () -> Void
private var okCount: Int { section.checks.filter { $0.status == .ok }.count }
private var warnCount: Int { section.checks.filter { $0.status == .warning }.count }
private var errorCount: Int { section.checks.filter { $0.status == .error }.count }
private var accentColor: Color {
if errorCount > 0 { return .red }
if warnCount > 0 { return .orange }
return .green
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button(action: onTap) {
HStack(spacing: 10) {
Image(systemName: section.icon)
.font(.title3)
.foregroundStyle(accentColor)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(section.title)
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
HStack(spacing: 8) {
if okCount > 0 {
HStack(spacing: 2) {
Circle().fill(.green).frame(width: 5, height: 5)
Text("\(okCount)").font(.caption2).foregroundStyle(.secondary)
}
}
if warnCount > 0 {
HStack(spacing: 2) {
Circle().fill(.orange).frame(width: 5, height: 5)
Text("\(warnCount)").font(.caption2).foregroundStyle(.secondary)
}
}
if errorCount > 0 {
HStack(spacing: 2) {
Circle().fill(.red).frame(width: 5, height: 5)
Text("\(errorCount)").font(.caption2).foregroundStyle(.secondary)
}
}
}
}
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(12)
}
.buttonStyle(.plain)
if isExpanded {
Divider()
.padding(.horizontal, 12)
VStack(alignment: .leading, spacing: 3) {
ForEach(section.checks) { check in
CheckRow(check: check)
}
}
.padding(12)
}
}
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(accentColor.opacity(0.3), lineWidth: 1)
)
}
}
// MARK: - Check Row
struct CheckRow: View {
let check: HealthCheck
var body: some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: statusIcon)
.foregroundStyle(statusColor)
.font(.system(size: 9))
.frame(width: 12, alignment: .center)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 0) {
Text(check.label)
.font(.caption)
if let detail = check.detail {
Text(detail)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
private var statusIcon: String {
switch check.status {
case .ok: return "checkmark.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .error: return "xmark.circle.fill"
}
}
private var statusColor: Color {
switch check.status {
case .ok: return .green
case .warning: return .orange
case .error: return .red
}
}
}
// MARK: - Mini Count
struct MiniCount: View {
let count: Int
let color: Color
let icon: String
var body: some View {
HStack(spacing: 3) {
Image(systemName: icon)
.foregroundStyle(color)
.font(.caption2)
Text("\(count)")
.font(.caption.monospaced().bold())
}
}
}
@@ -0,0 +1,240 @@
import Foundation
enum InsightsPeriod: String, CaseIterable, Identifiable {
case week = "7 Days"
case month = "30 Days"
case quarter = "90 Days"
case all = "All Time"
var id: String { rawValue }
var sinceDate: Date {
let calendar = Calendar.current
switch self {
case .week: return calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date()
case .month: return calendar.date(byAdding: .day, value: -30, to: Date()) ?? Date()
case .quarter: return calendar.date(byAdding: .day, value: -90, to: Date()) ?? Date()
case .all: return Date(timeIntervalSince1970: 0)
}
}
}
struct ModelUsage: Identifiable {
var id: String { model }
let model: String
let sessions: Int
let inputTokens: Int
let outputTokens: Int
let cacheReadTokens: Int
let cacheWriteTokens: Int
let reasoningTokens: Int
var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens + reasoningTokens }
}
struct PlatformUsage: Identifiable {
var id: String { platform }
let platform: String
let sessions: Int
let messages: Int
let tokens: Int
}
struct ToolUsage: Identifiable {
var id: String { name }
let name: String
let count: Int
let percentage: Double
}
struct NotableSession: Identifiable {
var id: String { session.id }
let label: String
let value: String
let session: HermesSession
let preview: String
}
@Observable
final class InsightsViewModel {
private let dataService = HermesDataService()
var period: InsightsPeriod = .month
var isLoading = true
var sessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:]
var userMessageCount = 0
var totalMessages = 0
var totalToolCalls = 0
var totalInputTokens = 0
var totalOutputTokens = 0
var totalCacheReadTokens = 0
var totalCacheWriteTokens = 0
var totalReasoningTokens = 0
var totalTokens = 0
var totalCost: Double = 0
var activeTime: TimeInterval = 0
var avgSessionDuration: TimeInterval = 0
var modelUsage: [ModelUsage] = []
var platformUsage: [PlatformUsage] = []
var toolUsage: [ToolUsage] = []
var hourlyActivity: [Int: Int] = [:]
var dailyActivity: [Int: Int] = [:]
var notableSessions: [NotableSession] = []
func load() async {
isLoading = true
let opened = await dataService.open()
guard opened else {
isLoading = false
return
}
let since = period.sinceDate
sessions = await dataService.fetchSessionsInPeriod(since: since)
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
userMessageCount = await dataService.fetchUserMessageCount(since: since)
let tools = await dataService.fetchToolUsage(since: since)
hourlyActivity = await dataService.fetchSessionStartHours(since: since)
dailyActivity = await dataService.fetchSessionDaysOfWeek(since: since)
await dataService.close()
computeAggregates()
computeModelBreakdown()
computePlatformBreakdown()
computeToolBreakdown(tools)
computeNotableSessions()
isLoading = false
}
func previewFor(_ session: HermesSession) -> String {
if let title = session.title, !title.isEmpty { return title }
if let preview = sessionPreviews[session.id], !preview.isEmpty { return preview }
return session.id
}
private func computeAggregates() {
totalMessages = sessions.reduce(0) { $0 + $1.messageCount }
totalToolCalls = sessions.reduce(0) { $0 + $1.toolCallCount }
totalInputTokens = sessions.reduce(0) { $0 + $1.inputTokens }
totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens }
totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens }
totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens }
totalReasoningTokens = sessions.reduce(0) { $0 + $1.reasoningTokens }
totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens + totalReasoningTokens
totalCost = sessions.reduce(0.0) { $0 + ($1.displayCostUSD ?? 0) }
var total: TimeInterval = 0
var count = 0
for session in sessions {
if let dur = session.duration, dur > 0 {
total += dur
count += 1
}
}
activeTime = total
avgSessionDuration = count > 0 ? total / Double(count) : 0
}
private func computeModelBreakdown() {
var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int, reasoning: Int)] = [:]
for s in sessions {
let model = s.model ?? "unknown"
var entry = grouped[model, default: (0, 0, 0, 0, 0, 0)]
entry.sessions += 1
entry.input += s.inputTokens
entry.output += s.outputTokens
entry.cacheRead += s.cacheReadTokens
entry.cacheWrite += s.cacheWriteTokens
entry.reasoning += s.reasoningTokens
grouped[model] = entry
}
modelUsage = grouped.map { key, val in
ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input,
outputTokens: val.output, cacheReadTokens: val.cacheRead,
cacheWriteTokens: val.cacheWrite, reasoningTokens: val.reasoning)
}.sorted { $0.totalTokens > $1.totalTokens }
}
private func computePlatformBreakdown() {
var grouped: [String: (sessions: Int, messages: Int, tokens: Int)] = [:]
for s in sessions {
var entry = grouped[s.source, default: (0, 0, 0)]
entry.sessions += 1
entry.messages += s.messageCount
entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens + s.reasoningTokens
grouped[s.source] = entry
}
platformUsage = grouped.map { key, val in
PlatformUsage(platform: key, sessions: val.sessions, messages: val.messages, tokens: val.tokens)
}.sorted { $0.sessions > $1.sessions }
}
private func computeToolBreakdown(_ tools: [(name: String, count: Int)]) {
let total = tools.reduce(0) { $0 + $1.count }
toolUsage = tools.map { tool in
ToolUsage(name: tool.name, count: tool.count,
percentage: total > 0 ? Double(tool.count) / Double(total) * 100 : 0)
}
}
private func computeNotableSessions() {
notableSessions = []
if let longest = sessions.filter({ $0.duration != nil }).max(by: { ($0.duration ?? 0) < ($1.duration ?? 0) }) {
notableSessions.append(NotableSession(
label: "Longest Session",
value: formatDuration(longest.duration ?? 0),
session: longest,
preview: previewFor(longest)
))
}
if let mostMsgs = sessions.max(by: { $0.messageCount < $1.messageCount }), mostMsgs.messageCount > 0 {
notableSessions.append(NotableSession(
label: "Most Messages",
value: "\(mostMsgs.messageCount) msgs",
session: mostMsgs,
preview: previewFor(mostMsgs)
))
}
if let mostTokens = sessions.max(by: { $0.totalTokens < $1.totalTokens }), mostTokens.totalTokens > 0 {
notableSessions.append(NotableSession(
label: "Most Tokens",
value: formatTokens(mostTokens.totalTokens),
session: mostTokens,
preview: previewFor(mostTokens)
))
}
if let mostTools = sessions.max(by: { $0.toolCallCount < $1.toolCallCount }), mostTools.toolCallCount > 0 {
notableSessions.append(NotableSession(
label: "Most Tool Calls",
value: "\(mostTools.toolCallCount) calls",
session: mostTools,
preview: previewFor(mostTools)
))
}
}
}
func formatDuration(_ interval: TimeInterval) -> String {
let hours = Int(interval) / 3600
let minutes = (Int(interval) % 3600) / 60
if hours > 0 {
return "\(hours)h \(minutes)m"
}
return "\(minutes)m"
}
func formatTokens(_ count: Int) -> String {
if count >= 1_000_000 {
return String(format: "%.1fM", Double(count) / 1_000_000)
} else if count >= 1_000 {
return String(format: "%.1fK", Double(count) / 1_000)
}
return "\(count)"
}
@@ -0,0 +1,312 @@
import SwiftUI
struct InsightsView: View {
@State private var viewModel = InsightsViewModel()
@Environment(AppCoordinator.self) private var coordinator
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
periodPicker
overviewSection
modelSection
platformSection
toolsSection
activitySection
notableSection
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.navigationTitle("Insights")
.task { await viewModel.load() }
.onChange(of: viewModel.period) {
Task { await viewModel.load() }
}
}
private var periodPicker: some View {
Picker("Period", selection: $viewModel.period) {
ForEach(InsightsPeriod.allCases) { period in
Text(period.rawValue).tag(period)
}
}
.pickerStyle(.segmented)
.frame(maxWidth: 400)
}
// MARK: - Overview
private var overviewSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Overview")
.font(.headline)
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 12) {
InsightCard(label: "Sessions", value: "\(viewModel.sessions.count)")
InsightCard(label: "Messages", value: "\(viewModel.totalMessages)")
InsightCard(label: "User Messages", value: "\(viewModel.userMessageCount)")
InsightCard(label: "Tool Calls", value: "\(viewModel.totalToolCalls)")
InsightCard(label: "Input Tokens", value: formatTokens(viewModel.totalInputTokens))
InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens))
InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens))
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens))
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
InsightCard(label: "Total Cost", value: String(format: "$%.2f", viewModel.totalCost))
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration))
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count)))
}
}
}
// MARK: - Models
private var modelSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Models")
.font(.headline)
if viewModel.modelUsage.isEmpty {
Text("No data")
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.modelUsage) { model in
HStack {
Image(systemName: "cpu")
.foregroundStyle(.blue)
.frame(width: 20)
Text(model.model)
.font(.system(.body, design: .monospaced))
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(model.sessions) sessions")
.font(.caption)
Text(formatTokens(model.totalTokens) + " tokens")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(10)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
// MARK: - Platforms
private var platformSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Platforms")
.font(.headline)
if viewModel.platformUsage.isEmpty {
Text("No data")
.foregroundStyle(.secondary)
} else {
HStack(spacing: 12) {
ForEach(viewModel.platformUsage) { platform in
VStack(spacing: 6) {
Image(systemName: platformIcon(platform.platform))
.font(.title2)
.foregroundStyle(Color.accentColor)
Text(platform.platform)
.font(.caption.bold())
Text("\(platform.sessions) sessions")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(platform.messages) msgs")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
}
// MARK: - Tools
private var toolsSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Top Tools")
.font(.headline)
if viewModel.toolUsage.isEmpty {
Text("No data")
.foregroundStyle(.secondary)
} else {
let maxCount = viewModel.toolUsage.first?.count ?? 1
ForEach(viewModel.toolUsage.prefix(15)) { tool in
HStack(spacing: 10) {
Text(tool.name)
.font(.system(.caption, design: .monospaced))
.frame(width: 140, alignment: .trailing)
GeometryReader { geo in
RoundedRectangle(cornerRadius: 3)
.fill(barColor(for: tool.name))
.frame(width: max(4, geo.size.width * Double(tool.count) / Double(maxCount)))
}
.frame(height: 16)
Text("\(tool.count)")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.frame(width: 40, alignment: .trailing)
Text(String(format: "%.1f%%", tool.percentage))
.font(.caption)
.foregroundStyle(.tertiary)
.frame(width: 50, alignment: .trailing)
}
.frame(height: 20)
}
}
}
}
// MARK: - Activity Patterns
private var activitySection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Activity Patterns")
.font(.headline)
HStack(alignment: .top, spacing: 24) {
dayOfWeekChart
hourlyChart
}
}
}
private var dayOfWeekChart: some View {
VStack(alignment: .leading, spacing: 4) {
Text("By Day")
.font(.caption.bold())
.foregroundStyle(.secondary)
let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
let maxVal = max(1, viewModel.dailyActivity.values.max() ?? 1)
ForEach(0..<7, id: \.self) { day in
let count = viewModel.dailyActivity[day] ?? 0
HStack(spacing: 6) {
Text(dayNames[day])
.font(.caption.monospaced())
.frame(width: 30, alignment: .trailing)
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor.opacity(0.7))
.frame(width: max(0, CGFloat(count) / CGFloat(maxVal) * 120), height: 14)
if count > 0 {
Text("\(count)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
}
private var hourlyChart: some View {
VStack(alignment: .leading, spacing: 4) {
Text("By Hour")
.font(.caption.bold())
.foregroundStyle(.secondary)
let maxVal = max(1, viewModel.hourlyActivity.values.max() ?? 1)
HStack(alignment: .bottom, spacing: 2) {
ForEach(0..<24, id: \.self) { hour in
let count = viewModel.hourlyActivity[hour] ?? 0
VStack(spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(count > 0 ? Color.accentColor.opacity(0.7) : Color.secondary.opacity(0.15))
.frame(width: 12, height: max(4, CGFloat(count) / CGFloat(maxVal) * 80))
if hour % 6 == 0 {
Text("\(hour)")
.font(.system(size: 8))
.foregroundStyle(.secondary)
} else {
Text("")
.font(.system(size: 8))
}
}
}
}
}
}
// MARK: - Notable Sessions
private var notableSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Notable Sessions")
.font(.headline)
if viewModel.notableSessions.isEmpty {
Text("No data")
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.notableSessions) { notable in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(notable.label)
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(notable.preview)
.lineLimit(1)
}
Spacer()
Text(notable.value)
.font(.system(.body, design: .monospaced, weight: .semibold))
Button {
coordinator.selectedSessionId = notable.session.id
coordinator.selectedSection = .sessions
} label: {
Image(systemName: "arrow.right.circle")
.foregroundStyle(Color.accentColor)
}
.buttonStyle(.plain)
.help("Open session")
}
.padding(10)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
// MARK: - Helpers
private func platformIcon(_ platform: String) -> String {
KnownPlatforms.icon(for: platform)
}
private func barColor(for toolName: String) -> Color {
switch toolName {
case "terminal", "execute_code": return .orange
case "read_file", "search_files": return .green
case "write_file", "patch": return .blue
case "web_search", "web_extract": return .purple
case _ where toolName.hasPrefix("browser"): return .indigo
case "memory": return .pink
case "vision", "image_gen": return .mint
default: return Color.accentColor
}
}
}
struct InsightCard: View {
let label: String
let value: String
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.system(.title3, design: .monospaced, weight: .semibold))
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(10)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -5,12 +5,13 @@ final class LogsViewModel {
private let logService = HermesLogService() private let logService = HermesLogService()
var entries: [LogEntry] = [] var entries: [LogEntry] = []
var selectedLogFile: LogFile = .errors var selectedLogFile: LogFile = .agent
var filterLevel: LogEntry.LogLevel? var filterLevel: LogEntry.LogLevel?
var searchText = "" var searchText = ""
private var pollTimer: Timer? private var pollTimer: Timer?
enum LogFile: String, CaseIterable, Identifiable { enum LogFile: String, CaseIterable, Identifiable {
case agent = "agent.log"
case errors = "errors.log" case errors = "errors.log"
case gateway = "gateway.log" case gateway = "gateway.log"
@@ -18,6 +19,7 @@ final class LogsViewModel {
var path: String { var path: String {
switch self { switch self {
case .agent: return HermesPaths.agentLog
case .errors: return HermesPaths.errorsLog case .errors: return HermesPaths.errorsLog
case .gateway: return HermesPaths.gatewayLog case .gateway: return HermesPaths.gatewayLog
} }
@@ -6,9 +6,12 @@ final class MemoryViewModel {
var memoryContent = "" var memoryContent = ""
var userContent = "" var userContent = ""
var memoryProvider = ""
var isEditing = false var isEditing = false
var editingFile: EditTarget = .memory var editingFile: EditTarget = .memory
var editText = "" var editText = ""
var profiles: [String] = []
var activeProfile = ""
enum EditTarget { enum EditTarget {
case memory, user case memory, user
@@ -17,9 +20,27 @@ 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"
}
var hasMultipleProfiles: Bool { !profiles.isEmpty }
func load() { func load() {
memoryContent = fileService.loadMemory() let config = fileService.loadConfig()
userContent = fileService.loadUserProfile() memoryProvider = config.memoryProvider
profiles = fileService.loadMemoryProfiles()
if activeProfile.isEmpty {
activeProfile = config.memoryProfile
}
memoryContent = fileService.loadMemory(profile: activeProfile)
userContent = fileService.loadUserProfile(profile: activeProfile)
}
func switchProfile(_ profile: String) {
activeProfile = profile
memoryContent = fileService.loadMemory(profile: profile)
userContent = fileService.loadUserProfile(profile: profile)
} }
func startEditing(_ target: EditTarget) { func startEditing(_ target: EditTarget) {
@@ -31,10 +52,10 @@ final class MemoryViewModel {
func save() { func save() {
switch editingFile { switch editingFile {
case .memory: case .memory:
fileService.saveMemory(editText) fileService.saveMemory(editText, profile: activeProfile)
memoryContent = editText memoryContent = editText
case .user: case .user:
fileService.saveUserProfile(editText) fileService.saveUserProfile(editText, profile: activeProfile)
userContent = editText userContent = editText
} }
isEditing = false isEditing = false
@@ -7,6 +7,35 @@ struct MemoryView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
if viewModel.hasMultipleProfiles {
HStack(spacing: 8) {
Text("Profile")
.font(.caption.bold())
.foregroundStyle(.secondary)
Picker("", selection: Binding(
get: { viewModel.activeProfile },
set: { viewModel.switchProfile($0) }
)) {
Text("Default").tag("")
ForEach(viewModel.profiles, id: \.self) { profile in
Text(profile).tag(profile)
}
}
.frame(maxWidth: 200)
}
}
if viewModel.hasExternalProvider {
HStack(spacing: 8) {
Image(systemName: "info.circle")
Text("Memory is managed by \(viewModel.memoryProvider). File contents shown here may be stale.")
}
.font(.caption)
.foregroundStyle(.orange)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
memorySection("Agent Memory", content: viewModel.memoryContent, charCount: viewModel.memoryCharCount, target: .memory) memorySection("Agent Memory", content: viewModel.memoryContent, charCount: viewModel.memoryCharCount, target: .memory)
memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user) memorySection("User Profile", content: viewModel.userContent, charCount: viewModel.userCharCount, target: .user)
} }
@@ -0,0 +1,74 @@
import Foundation
@Observable
final class ProjectsViewModel {
private let service = ProjectDashboardService()
var projects: [ProjectEntry] = []
var selectedProject: ProjectEntry?
var dashboard: ProjectDashboard?
var dashboardError: String?
var isLoading = false
func load() {
let registry = service.loadRegistry()
projects = registry.projects
if let selected = selectedProject, !projects.contains(where: { $0.name == selected.name }) {
selectedProject = nil
dashboard = nil
}
if let selected = selectedProject {
loadDashboard(for: selected)
}
}
func selectProject(_ project: ProjectEntry) {
selectedProject = project
loadDashboard(for: project)
}
func addProject(name: String, path: String) {
var registry = service.loadRegistry()
guard !registry.projects.contains(where: { $0.name == name }) else { return }
let entry = ProjectEntry(name: name, path: path)
registry.projects.append(entry)
service.saveRegistry(registry)
projects = registry.projects
selectProject(entry)
}
func removeProject(_ project: ProjectEntry) {
var registry = service.loadRegistry()
registry.projects.removeAll { $0.name == project.name }
service.saveRegistry(registry)
projects = registry.projects
if selectedProject?.name == project.name {
selectedProject = nil
dashboard = nil
}
}
func refreshDashboard() {
guard let project = selectedProject else { return }
loadDashboard(for: project)
}
var dashboardPaths: [String] {
projects.map(\.dashboardPath)
}
private func loadDashboard(for project: ProjectEntry) {
dashboardError = nil
if !service.dashboardExists(for: project) {
dashboard = nil
dashboardError = "No dashboard found at \(project.dashboardPath)"
return
}
if let loaded = service.loadDashboard(for: project) {
dashboard = loaded
} else {
dashboard = nil
dashboardError = "Failed to parse dashboard JSON"
}
}
}
@@ -0,0 +1,330 @@
import SwiftUI
private enum DashboardTab: String, CaseIterable {
case dashboard = "Dashboard"
case site = "Site"
}
struct ProjectsView: View {
@State private var viewModel = ProjectsViewModel()
@Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
@State private var showingAddSheet = false
@State private var selectedTab: DashboardTab = .dashboard
var body: some View {
HSplitView {
projectList
.frame(minWidth: 180, maxWidth: 220)
dashboardArea
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.navigationTitle("Projects")
.task {
viewModel.load()
if let name = coordinator.selectedProjectName,
let project = viewModel.projects.first(where: { $0.name == name }) {
viewModel.selectProject(project)
}
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
.onChange(of: fileWatcher.lastChangeDate) {
viewModel.load()
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
}
// MARK: - Project List
private var projectList: some View {
VStack(spacing: 0) {
List(viewModel.projects, selection: Binding(
get: { viewModel.selectedProject },
set: { project in
if let project {
viewModel.selectProject(project)
}
}
)) { project in
HStack {
Image(systemName: viewModel.dashboard != nil && viewModel.selectedProject == project
? "square.grid.2x2.fill" : "square.grid.2x2")
.foregroundStyle(.secondary)
Text(project.name)
}
.tag(project)
}
.listStyle(.sidebar)
Divider()
HStack {
Button(action: { showingAddSheet = true }) {
Image(systemName: "plus")
}
.buttonStyle(.borderless)
Spacer()
if let selected = viewModel.selectedProject {
Button(action: { viewModel.removeProject(selected) }) {
Image(systemName: "minus")
}
.buttonStyle(.borderless)
}
}
.padding(8)
}
.sheet(isPresented: $showingAddSheet) {
AddProjectSheet { name, path in
viewModel.addProject(name: name, path: path)
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
}
}
// MARK: - Dashboard Area
/// First webview widget found across all sections, if any.
private var siteWidget: DashboardWidget? {
viewModel.dashboard?.sections
.flatMap(\.widgets)
.first { $0.type == "webview" }
}
@ViewBuilder
private var dashboardArea: some View {
if let dashboard = viewModel.dashboard {
VStack(spacing: 0) {
dashboardHeader(dashboard)
.padding(.horizontal)
.padding(.top)
.padding(.bottom, 8)
if siteWidget != nil {
tabBar
.padding(.horizontal)
.padding(.bottom, 8)
}
switch selectedTab {
case .dashboard:
widgetsTab(dashboard)
case .site:
if let widget = siteWidget {
siteTab(widget)
} else {
widgetsTab(dashboard)
}
}
}
} else if let error = viewModel.dashboardError {
ContentUnavailableView {
Label("No Dashboard", systemImage: "square.grid.2x2")
} description: {
Text(error)
}
} else if viewModel.projects.isEmpty {
ContentUnavailableView {
Label("No Projects", systemImage: "square.grid.2x2")
} description: {
Text("Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets.")
} actions: {
Button("Add Project") { showingAddSheet = true }
}
} else {
ContentUnavailableView {
Label("Select a Project", systemImage: "square.grid.2x2")
} description: {
Text("Choose a project from the sidebar to view its dashboard.")
}
}
}
private var tabBar: some View {
HStack(spacing: 0) {
ForEach(DashboardTab.allCases, id: \.self) { tab in
Button {
selectedTab = tab
} label: {
HStack(spacing: 4) {
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
.font(.caption)
Text(tab.rawValue)
.font(.subheadline)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear)
.foregroundStyle(selectedTab == tab ? .primary : .secondary)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
Spacer()
}
}
private func widgetsTab(_ dashboard: ProjectDashboard) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
ForEach(dashboard.sections) { section in
DashboardSectionView(section: section)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
private func siteTab(_ widget: DashboardWidget) -> some View {
WebviewWidgetView(widget: widget, fullCanvas: true)
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func dashboardHeader(_ dashboard: ProjectDashboard) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(dashboard.title)
.font(.title2.bold())
if let desc = dashboard.description {
Text(desc)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Spacer()
if let updated = dashboard.updatedAt {
Text("Updated: \(updated)")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(action: { viewModel.refreshDashboard() }) {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
if let project = viewModel.selectedProject {
Button(action: { openInFinder(project.path) }) {
Image(systemName: "folder")
}
.buttonStyle(.borderless)
}
}
}
private func openInFinder(_ path: String) {
NSWorkspace.shared.open(URL(fileURLWithPath: path))
}
}
// MARK: - Section View
struct DashboardSectionView: View {
let section: DashboardSection
/// Filter out webview widgets those are rendered in the Site tab instead.
private var displayWidgets: [DashboardWidget] {
section.widgets.filter { $0.type != "webview" }
}
var body: some View {
if !displayWidgets.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text(section.title)
.font(.headline)
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: section.columnCount),
spacing: 12
) {
ForEach(displayWidgets) { widget in
WidgetView(widget: widget)
}
}
}
}
}
}
// MARK: - Widget Dispatcher
struct WidgetView: View {
let widget: DashboardWidget
var body: some View {
Group {
switch widget.type {
case "stat":
StatWidgetView(widget: widget)
case "progress":
ProgressWidgetView(widget: widget)
case "text":
TextWidgetView(widget: widget)
case "table":
TableWidgetView(widget: widget)
case "chart":
ChartWidgetView(widget: widget)
case "list":
ListWidgetView(widget: widget)
case "webview":
WebviewWidgetView(widget: widget)
default:
VStack {
Image(systemName: "questionmark.square.dashed")
.font(.title2)
.foregroundStyle(.secondary)
Text("Unknown: \(widget.type)")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 60)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
// MARK: - Add Project Sheet
struct AddProjectSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var projectName = ""
@State private var projectPath = ""
let onAdd: (String, String) -> Void
var body: some View {
VStack(spacing: 16) {
Text("Add Project")
.font(.headline)
TextField("Project Name", text: $projectName)
.textFieldStyle(.roundedBorder)
HStack {
TextField("Project Path", text: $projectPath)
.textFieldStyle(.roundedBorder)
Button("Browse...") {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
projectPath = url.path
if projectName.isEmpty {
projectName = url.lastPathComponent
}
}
}
}
HStack {
Button("Cancel") { dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Add") {
guard !projectName.isEmpty, !projectPath.isEmpty else { return }
onAdd(projectName, projectPath)
dismiss()
}
.keyboardShortcut(.defaultAction)
.disabled(projectName.isEmpty || projectPath.isEmpty)
}
}
.padding()
.frame(width: 400)
}
}
@@ -0,0 +1,82 @@
import SwiftUI
import Charts
// Flattened data point for Charts to avoid complex nested generic inference
private struct PlottablePoint: Identifiable {
let id = UUID()
let seriesName: String
let x: String
let y: Double
let color: Color
}
struct ChartWidgetView: View {
let widget: DashboardWidget
private var points: [PlottablePoint] {
guard let series = widget.series else { return [] }
return series.flatMap { s in
let color = parseColor(s.color)
return s.data.map { d in
PlottablePoint(seriesName: s.name, x: d.x, y: d.y, color: color)
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
chartContent
.frame(height: 150)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
@ViewBuilder
private var chartContent: some View {
switch widget.chartType {
case "pie":
pieChart
case "bar":
barChart
default:
lineChart
}
}
private var lineChart: some View {
Chart(points) { point in
LineMark(
x: .value("X", point.x),
y: .value("Y", point.y)
)
.foregroundStyle(point.color)
.symbol(by: .value("Series", point.seriesName))
}
}
private var barChart: some View {
Chart(points) { point in
BarMark(
x: .value("X", point.x),
y: .value("Y", point.y)
)
.foregroundStyle(point.color)
}
}
private var pieChart: some View {
Chart(points) { point in
SectorMark(
angle: .value(point.x, point.y),
innerRadius: .ratio(0.5)
)
.foregroundStyle(point.color)
}
}
}
@@ -0,0 +1,54 @@
import SwiftUI
struct ListWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(.secondary)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
}
if let items = widget.items {
ForEach(items) { item in
HStack(spacing: 6) {
Image(systemName: statusIcon(item.status))
.font(.caption2)
.foregroundStyle(statusColor(item.status))
Text(item.text)
.font(.callout)
.strikethrough(item.status == "done")
.foregroundStyle(item.status == "done" ? .secondary : .primary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private func statusIcon(_ status: String?) -> String {
switch status {
case "done": return "checkmark.circle.fill"
case "active": return "circle.inset.filled"
case "pending": return "circle"
default: return "circle"
}
}
private func statusColor(_ status: String?) -> Color {
switch status {
case "done": return .green
case "active": return .blue
default: return .secondary
}
}
}
@@ -0,0 +1,32 @@
import SwiftUI
struct ProgressWidgetView: View {
let widget: DashboardWidget
private var progressValue: Double {
switch widget.value {
case .number(let n): return n
default: return 0
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
ProgressView(value: progressValue) {
if let label = widget.label {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.tint(parseColor(widget.color))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,37 @@
import SwiftUI
struct StatWidgetView: View {
let widget: DashboardWidget
private var widgetColor: Color {
parseColor(widget.color)
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(widgetColor)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
}
if let value = widget.value {
Text(value.displayString)
.font(.system(.title2, design: .monospaced, weight: .semibold))
}
if let subtitle = widget.subtitle {
Text(subtitle)
.font(.caption2)
.foregroundStyle(widgetColor)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,37 @@
import SwiftUI
struct TableWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
if let columns = widget.columns, let rows = widget.rows {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) {
GridRow {
ForEach(columns, id: \.self) { col in
Text(col)
.font(.caption.bold())
.foregroundStyle(.secondary)
}
}
Divider()
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
GridRow {
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
Text(cell)
.font(.callout)
}
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,27 @@
import SwiftUI
struct TextWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
if let content = widget.content {
if widget.format == "markdown",
let attributed = try? AttributedString(markdown: content) {
Text(attributed)
.font(.callout)
} else {
Text(content)
.font(.callout)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,116 @@
import SwiftUI
import WebKit
struct WebviewWidgetView: View {
let widget: DashboardWidget
var fullCanvas: Bool = false
private var webURL: URL? {
guard let urlString = widget.url else { return nil }
return URL(string: urlString)
}
private var viewHeight: CGFloat {
CGFloat(widget.height ?? 400)
}
var body: some View {
if fullCanvas {
fullCanvasView
} else {
cardView
}
}
// MARK: - Full Canvas (Site tab)
private var fullCanvasView: some View {
VStack(spacing: 0) {
if let url = webURL {
WebViewRepresentable(url: url)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
ContentUnavailableView {
Label("Invalid URL", systemImage: "globe")
} description: {
Text(widget.url ?? "No URL provided")
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Card (inline widget)
private var cardView: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(.secondary)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if let urlString = widget.url {
Text(urlString)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
if let url = webURL {
WebViewRepresentable(url: url)
.frame(height: viewHeight)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
ContentUnavailableView {
Label("Invalid URL", systemImage: "globe")
} description: {
Text(widget.url ?? "No URL provided")
}
.frame(height: viewHeight)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - WKWebView Wrapper
private struct WebViewRepresentable: NSViewRepresentable {
let url: URL
func makeNSView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: url))
return webView
}
func updateNSView(_ webView: WKWebView, context: Context) {
if webView.url != url {
webView.load(URLRequest(url: url))
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, WKNavigationDelegate {
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
print("[Scarf] WebView navigation failed: \(error.localizedDescription)")
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
print("[Scarf] WebView failed to load: \(error.localizedDescription)")
}
}
}
@@ -0,0 +1,19 @@
import SwiftUI
func parseColor(_ name: String?) -> Color {
switch name?.lowercased() {
case "red": return .red
case "orange": return .orange
case "yellow": return .yellow
case "green": return .green
case "blue": return .blue
case "purple": return .purple
case "pink": return .pink
case "teal", "cyan": return .teal
case "indigo": return .indigo
case "mint": return .mint
case "brown": return .brown
case "gray", "grey": return .gray
default: return .blue
}
}
@@ -1,4 +1,13 @@
import Foundation import Foundation
import AppKit
import UniformTypeIdentifiers
struct SessionStoreStats {
let totalSessions: Int
let totalMessages: Int
let databaseSize: String
let platformCounts: [(platform: String, count: Int)]
}
@Observable @Observable
final class SessionsViewModel { final class SessionsViewModel {
@@ -11,12 +20,21 @@ final class SessionsViewModel {
var searchText = "" var searchText = ""
var searchResults: [HermesMessage] = [] var searchResults: [HermesMessage] = []
var isSearching = false var isSearching = false
var storeStats: SessionStoreStats?
var subagentSessions: [HermesSession] = []
var renameSessionId: String?
var renameText = ""
var showRenameSheet = false
var showDeleteConfirmation = false
var deleteSessionId: String?
func load() async { func load() async {
let opened = await dataService.open() let opened = await dataService.open()
guard opened else { return } guard opened else { return }
sessions = await dataService.fetchSessions(limit: 500) sessions = await dataService.fetchSessions(limit: 500)
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500) sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
computeStats()
} }
func previewFor(_ session: HermesSession) -> String { func previewFor(_ session: HermesSession) -> String {
@@ -28,6 +46,7 @@ final class SessionsViewModel {
func selectSession(_ session: HermesSession) async { func selectSession(_ session: HermesSession) async {
selectedSession = session selectedSession = session
messages = await dataService.fetchMessages(sessionId: session.id) messages = await dataService.fetchMessages(sessionId: session.id)
subagentSessions = await dataService.fetchSubagentSessions(parentId: session.id)
} }
func selectSessionById(_ id: String) async { func selectSessionById(_ id: String) async {
@@ -50,4 +69,122 @@ final class SessionsViewModel {
func cleanup() async { func cleanup() async {
await dataService.close() await dataService.close()
} }
// MARK: - Session Actions
func beginRename(_ session: HermesSession) {
renameSessionId = session.id
renameText = previewFor(session)
showRenameSheet = true
}
func confirmRename() {
guard let sessionId = renameSessionId else { return }
let title = renameText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else { return }
let result = runHermes(["sessions", "rename", sessionId, title])
if result.exitCode == 0 {
if let idx = sessions.firstIndex(where: { $0.id == sessionId }) {
let updated = sessions[idx].withTitle(title)
sessions[idx] = updated
if selectedSession?.id == sessionId {
selectedSession = updated
}
}
sessionPreviews[sessionId] = title
}
showRenameSheet = false
renameSessionId = nil
}
func beginDelete(_ session: HermesSession) {
deleteSessionId = session.id
showDeleteConfirmation = true
}
func confirmDelete() {
guard let sessionId = deleteSessionId else { return }
let result = runHermes(["sessions", "delete", "--yes", sessionId])
if result.exitCode == 0 {
sessions.removeAll { $0.id == sessionId }
if selectedSession?.id == sessionId {
selectedSession = nil
messages = []
}
computeStats()
}
showDeleteConfirmation = false
deleteSessionId = nil
}
func exportSession(_ session: HermesSession) {
let panel = NSSavePanel()
panel.nameFieldStringValue = "\(session.id).jsonl"
panel.allowedContentTypes = [.json]
panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
runHermes(["sessions", "export", url.path, "--session-id", session.id])
}
func exportAll() {
let panel = NSSavePanel()
panel.nameFieldStringValue = "hermes-sessions.jsonl"
panel.allowedContentTypes = [.json]
panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
runHermes(["sessions", "export", url.path])
}
// MARK: - Stats
private func computeStats() {
let totalMessages = sessions.reduce(0) { $0 + $1.messageCount }
var platformCounts: [String: Int] = [:]
for s in sessions {
platformCounts[s.source, default: 0] += 1
}
let sorted = platformCounts.sorted { $0.value > $1.value }.map { (platform: $0.key, count: $0.value) }
let dbPath = HermesPaths.stateDB
let fileSize: String
if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath),
let size = attrs[.size] as? Int {
if Double(size) >= FileSizeUnit.megabyte {
fileSize = String(format: "%.1f MB", Double(size) / FileSizeUnit.megabyte)
} else {
fileSize = String(format: "%.0f KB", Double(size) / FileSizeUnit.kilobyte)
}
} else {
fileSize = "unknown"
}
storeStats = SessionStoreStats(
totalSessions: sessions.count,
totalMessages: totalMessages,
databaseSize: fileSize,
platformCounts: sorted
)
}
// MARK: - Hermes CLI
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
return (output, process.terminationStatus)
} catch {
return ("", -1)
}
}
} }
@@ -3,11 +3,19 @@ import SwiftUI
struct SessionDetailView: View { struct SessionDetailView: View {
let session: HermesSession let session: HermesSession
let messages: [HermesMessage] let messages: [HermesMessage]
var subagentSessions: [HermesSession] = []
var preview: String? var preview: String?
var onRename: (() -> Void)?
var onExport: (() -> Void)?
var onDelete: (() -> Void)?
var onSelectSubagent: ((HermesSession) -> Void)?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
sessionHeader sessionHeader
if !subagentSessions.isEmpty {
subagentSection
}
Divider() Divider()
messagesList messagesList
} }
@@ -16,15 +24,43 @@ struct SessionDetailView: View {
private var sessionHeader: some View { private var sessionHeader: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(preview ?? session.displayTitle) HStack {
.font(.title3.bold()) Text(preview ?? session.displayTitle)
.font(.title3.bold())
Spacer()
if onRename != nil || onExport != nil || onDelete != nil {
Menu {
if let onRename { Button("Rename...") { onRename() } }
if let onExport { Button("Export...") { onExport() } }
if let onDelete {
Divider()
Button("Delete...", role: .destructive) { onDelete() }
}
} label: {
Image(systemName: "ellipsis.circle")
.foregroundStyle(.secondary)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
}
HStack(spacing: 16) { HStack(spacing: 16) {
Label(session.source, systemImage: session.sourceIcon) Label(session.source, systemImage: session.sourceIcon)
if session.isSubagent {
Label("Subagent", systemImage: "arrow.triangle.branch")
.foregroundStyle(.orange)
}
if let userId = session.userId, !userId.isEmpty, session.source != "cli" {
Label(userId, systemImage: "person")
}
Label(session.model ?? "unknown", systemImage: "cpu") Label(session.model ?? "unknown", systemImage: "cpu")
Label("\(session.messageCount) msgs", systemImage: "bubble.left") Label("\(session.messageCount) msgs", systemImage: "bubble.left")
Label("\(session.toolCallCount) tools", systemImage: "wrench") Label("\(session.toolCallCount) tools", systemImage: "wrench")
if let cost = session.estimatedCostUSD { if session.reasoningTokens > 0 {
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle") Label("\(session.reasoningTokens) reasoning", systemImage: "brain")
}
if let cost = session.displayCostUSD {
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
} }
if let date = session.startedAt { if let date = session.startedAt {
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar") Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
@@ -32,10 +68,46 @@ struct SessionDetailView: View {
} }
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text(session.id)
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
.textSelection(.enabled)
} }
.padding() .padding()
} }
private var subagentSection: some View {
VStack(alignment: .leading, spacing: 6) {
Divider()
Text("Subagent Sessions (\(subagentSessions.count))")
.font(.caption.bold())
.foregroundStyle(.secondary)
ForEach(subagentSessions) { sub in
Button {
onSelectSubagent?(sub)
} label: {
HStack(spacing: 8) {
Image(systemName: "arrow.triangle.branch")
.foregroundStyle(.orange)
Text(sub.displayTitle)
.lineLimit(1)
Spacer()
Text(sub.model ?? "")
.font(.caption2)
.foregroundStyle(.tertiary)
Text("\(sub.messageCount) msgs")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.font(.caption)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.bottom, 8)
}
private var messagesList: some View { private var messagesList: some View {
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 12) { LazyVStack(alignment: .leading, spacing: 12) {
@@ -56,6 +128,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)
@@ -5,11 +5,17 @@ struct SessionsView: View {
@Environment(AppCoordinator.self) private var coordinator @Environment(AppCoordinator.self) private var coordinator
var body: some View { var body: some View {
HSplitView { VStack(spacing: 0) {
sessionList if let stats = viewModel.storeStats {
.frame(minWidth: 280, idealWidth: 320) statsBar(stats)
sessionDetail Divider()
.frame(minWidth: 400) }
HSplitView {
sessionList
.frame(minWidth: 280, idealWidth: 320)
sessionDetail
.frame(minWidth: 400)
}
} }
.navigationTitle("Sessions") .navigationTitle("Sessions")
.searchable(text: $viewModel.searchText, prompt: "Search messages...") .searchable(text: $viewModel.searchText, prompt: "Search messages...")
@@ -28,6 +34,33 @@ struct SessionsView: View {
} }
} }
.onDisappear { Task { await viewModel.cleanup() } } .onDisappear { Task { await viewModel.cleanup() } }
.sheet(isPresented: $viewModel.showRenameSheet) {
renameSheet
}
.confirmationDialog("Delete Session?", isPresented: $viewModel.showDeleteConfirmation) {
Button("Delete", role: .destructive) { viewModel.confirmDelete() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This will permanently delete the session and all its messages.")
}
}
private func statsBar(_ stats: SessionStoreStats) -> some View {
HStack(spacing: 16) {
Label("\(stats.totalSessions) sessions", systemImage: "bubble.left.and.bubble.right")
Label("\(stats.totalMessages) messages", systemImage: "text.bubble")
Label(stats.databaseSize, systemImage: "internaldrive")
ForEach(stats.platformCounts, id: \.platform) { item in
Label("\(item.count) \(item.platform)", systemImage: platformIcon(item.platform))
}
Spacer()
Button("Export All") { viewModel.exportAll() }
.controlSize(.small)
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal)
.padding(.vertical, 6)
} }
private var sessionList: some View { private var sessionList: some View {
@@ -64,6 +97,12 @@ struct SessionsView: View {
ForEach(viewModel.sessions) { session in ForEach(viewModel.sessions) { session in
SessionRow(session: session, preview: viewModel.previewFor(session)) SessionRow(session: session, preview: viewModel.previewFor(session))
.tag(session.id) .tag(session.id)
.contextMenu {
Button("Rename...") { viewModel.beginRename(session) }
Button("Export...") { viewModel.exportSession(session) }
Divider()
Button("Delete...", role: .destructive) { viewModel.beginDelete(session) }
}
} }
} }
} }
@@ -73,11 +112,47 @@ struct SessionsView: View {
@ViewBuilder @ViewBuilder
private var sessionDetail: some View { private var sessionDetail: some View {
if let session = viewModel.selectedSession { if let session = viewModel.selectedSession {
SessionDetailView(session: session, messages: viewModel.messages, preview: viewModel.previewFor(session)) SessionDetailView(
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) session: session,
messages: viewModel.messages,
subagentSessions: viewModel.subagentSessions,
preview: viewModel.previewFor(session),
onRename: { viewModel.beginRename(session) },
onExport: { viewModel.exportSession(session) },
onDelete: { viewModel.beginDelete(session) },
onSelectSubagent: { sub in
Task { await viewModel.selectSession(sub) }
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} else { } else {
ContentUnavailableView("Select a Session", systemImage: "bubble.left.and.bubble.right", description: Text("Choose a session from the list")) ContentUnavailableView("Select a Session", systemImage: "bubble.left.and.bubble.right", description: Text("Choose a session from the list"))
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
private var renameSheet: some View {
VStack(spacing: 16) {
Text("Rename Session")
.font(.headline)
TextField("Session title", text: $viewModel.renameText)
.textFieldStyle(.roundedBorder)
.onSubmit { viewModel.confirmRename() }
HStack {
Button("Cancel") { viewModel.showRenameSheet = false }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Rename") { viewModel.confirmRename() }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(viewModel.renameText.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.padding()
.frame(width: 400)
}
private func platformIcon(_ platform: String) -> String {
KnownPlatforms.icon(for: platform)
}
} }
@@ -1,4 +1,5 @@
import Foundation import Foundation
import AppKit
@Observable @Observable
final class SettingsViewModel { final class SettingsViewModel {
@@ -8,11 +9,112 @@ final class SettingsViewModel {
var gatewayState: GatewayState? var gatewayState: GatewayState?
var hermesRunning = false var hermesRunning = false
var rawConfigYAML = "" var rawConfigYAML = ""
var personalities: [String] = []
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "google-ai-studio", "xai", "ollama-cloud", "zai", "kimi-coding", "minimax"]
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
var browserBackends = ["browseruse", "firecrawl", "local"]
var saveMessage: String?
var showAuthRemoveConfirmation = false
func load() { func load() {
config = fileService.loadConfig() config = fileService.loadConfig()
gatewayState = fileService.loadGatewayState() gatewayState = fileService.loadGatewayState()
hermesRunning = fileService.isHermesRunning() hermesRunning = fileService.isHermesRunning()
rawConfigYAML = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? "" do {
rawConfigYAML = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
} catch {
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
rawConfigYAML = ""
}
personalities = parsePersonalities()
}
func setSetting(_ key: String, value: String) {
let result = runHermes(["config", "set", key, value])
if result.exitCode == 0 {
saveMessage = "Saved \(key)"
config = fileService.loadConfig()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.saveMessage = nil
}
}
}
func setModel(_ value: String) { setSetting("model.default", value: value) }
func setProvider(_ value: String) { setSetting("model.provider", value: value) }
func setPersonality(_ value: String) { setSetting("display.personality", value: value) }
func setTerminalBackend(_ value: String) { setSetting("terminal.backend", value: value) }
func setMaxTurns(_ value: Int) { setSetting("agent.max_turns", value: String(value)) }
func setMemoryEnabled(_ value: Bool) { setSetting("memory.memory_enabled", value: value ? "true" : "false") }
func setMemoryCharLimit(_ value: Int) { setSetting("memory.memory_char_limit", value: String(value)) }
func setUserCharLimit(_ value: Int) { setSetting("memory.user_char_limit", value: String(value)) }
func setNudgeInterval(_ value: Int) { setSetting("memory.nudge_interval", value: String(value)) }
func setStreaming(_ value: Bool) { setSetting("display.streaming", value: value ? "true" : "false") }
func setShowReasoning(_ value: Bool) { setSetting("display.show_reasoning", value: value ? "true" : "false") }
func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") }
func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
func setReasoningEffort(_ value: String) { setSetting("agent.reasoning_effort", value: value) }
func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") }
func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) }
func setBrowserBackend(_ value: String) { setSetting("browser.backend", value: value) }
func removeAuth() {
let result = runHermes(["auth", "remove"])
if result.exitCode == 0 {
saveMessage = "Credentials removed"
} else {
saveMessage = "Failed to remove credentials"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.saveMessage = nil
}
}
func openConfigInEditor() {
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
}
private func parsePersonalities() -> [String] {
var names: [String] = []
var inPersonalities = false
for line in rawConfigYAML.components(separatedBy: "\n") {
if line.trimmingCharacters(in: .whitespaces) == "personalities:" && line.hasPrefix(" ") {
inPersonalities = true
continue
}
if inPersonalities {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { continue }
let indent = line.prefix(while: { $0 == " " }).count
if indent <= 2 && !trimmed.isEmpty {
inPersonalities = false
continue
}
if indent == 4 && trimmed.contains(":") {
let name = String(trimmed.split(separator: ":")[0])
names.append(name)
}
}
}
return names
}
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
} catch {
return ("", -1)
}
} }
} }
@@ -6,9 +6,19 @@ struct SettingsView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 24) {
configSection headerBar
gatewaySection modelSection
displaySection
terminalSection
if !viewModel.config.dockerEnv.isEmpty {
dockerEnvSection
}
if !viewModel.config.commandAllowlist.isEmpty {
allowlistSection
}
voiceSection
memorySection
pathsSection pathsSection
rawConfigSection rawConfigSection
} }
@@ -17,64 +27,138 @@ struct SettingsView: View {
} }
.navigationTitle("Settings") .navigationTitle("Settings")
.onAppear { viewModel.load() } .onAppear { viewModel.load() }
} .confirmationDialog("Remove Credentials?", isPresented: $viewModel.showAuthRemoveConfirmation) {
Button("Remove", role: .destructive) { viewModel.removeAuth() }
private var configSection: some View { Button("Cancel", role: .cancel) {}
VStack(alignment: .leading, spacing: 12) { } message: {
Text("Configuration") Text("This will permanently clear all stored provider credentials.")
.font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
SettingRow(label: "Model", value: viewModel.config.model)
SettingRow(label: "Provider", value: viewModel.config.provider)
SettingRow(label: "Personality", value: viewModel.config.personality)
SettingRow(label: "Max Turns", value: "\(viewModel.config.maxTurns)")
SettingRow(label: "Terminal Backend", value: viewModel.config.terminalBackend)
SettingRow(label: "Memory Enabled", value: viewModel.config.memoryEnabled ? "Yes" : "No")
SettingRow(label: "Memory Char Limit", value: "\(viewModel.config.memoryCharLimit)")
SettingRow(label: "User Char Limit", value: "\(viewModel.config.userCharLimit)")
SettingRow(label: "Nudge Interval", value: "\(viewModel.config.nudgeInterval) turns")
SettingRow(label: "Streaming", value: viewModel.config.streaming ? "Yes" : "No")
SettingRow(label: "Show Reasoning", value: viewModel.config.showReasoning ? "Yes" : "No")
SettingRow(label: "Verbose", value: viewModel.config.verbose ? "Yes" : "No")
}
} }
} }
private var gatewaySection: some View { private var headerBar: some View {
VStack(alignment: .leading, spacing: 8) { HStack {
Text("Gateway") if let msg = viewModel.saveMessage {
.font(.headline) Label(msg, systemImage: "checkmark.circle.fill")
HStack(spacing: 16) { .font(.caption)
Label( .foregroundStyle(.green)
viewModel.gatewayState?.statusText ?? "unknown", }
systemImage: viewModel.gatewayState?.isRunning == true ? "circle.fill" : "circle" Spacer()
) Button("Open in Editor") { viewModel.openConfigInEditor() }
.foregroundStyle(viewModel.gatewayState?.isRunning == true ? .green : .secondary) .controlSize(.small)
if let reason = viewModel.gatewayState?.exitReason { Button("Reload") { viewModel.load() }
Text(reason) .controlSize(.small)
.font(.caption) }
.foregroundStyle(.secondary) }
// MARK: - Model & Provider
private var modelSection: some View {
SettingsSection(title: "Model", icon: "cpu") {
EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($0) }
PickerRow(label: "Provider", selection: viewModel.config.provider, options: viewModel.providers) { viewModel.setProvider($0) }
HStack {
Text("Credentials")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Button("Remove Credentials", role: .destructive) {
viewModel.showAuthRemoveConfirmation = true
} }
.controlSize(.small)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
// MARK: - Display
private var displaySection: some View {
SettingsSection(title: "Display", icon: "paintbrush") {
if !viewModel.personalities.isEmpty {
PickerRow(label: "Personality", selection: viewModel.config.personality, options: viewModel.personalities) { viewModel.setPersonality($0) }
} else {
EditableTextField(label: "Personality", value: viewModel.config.personality) { viewModel.setPersonality($0) }
}
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($0) }
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
}
}
// MARK: - Terminal
private var terminalSection: some View {
SettingsSection(title: "Terminal", icon: "terminal") {
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) }
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
PickerRow(label: "Reasoning Effort", selection: viewModel.config.reasoningEffort, options: ["low", "medium", "high"]) { viewModel.setReasoningEffort($0) }
PickerRow(label: "Approval Mode", selection: viewModel.config.approvalMode, options: ["auto", "manual", "smart"]) { viewModel.setApprovalMode($0) }
PickerRow(label: "Browser Backend", selection: viewModel.config.browserBackend, options: viewModel.browserBackends) { viewModel.setBrowserBackend($0) }
}
}
// MARK: - Docker Environment
private var dockerEnvSection: some View {
SettingsSection(title: "Docker Environment", icon: "shippingbox") {
ForEach(viewModel.config.dockerEnv.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
ReadOnlyRow(label: key, value: value)
} }
} }
} }
// MARK: - Command Allowlist
private var allowlistSection: some View {
SettingsSection(title: "Command Allowlist", icon: "checkmark.shield") {
ReadOnlyRow(label: "Commands", value: viewModel.config.commandAllowlist.joined(separator: ", "))
}
}
// MARK: - Voice
private var voiceSection: some View {
SettingsSection(title: "Voice", icon: "mic") {
ToggleRow(label: "Auto TTS", isOn: viewModel.config.autoTTS) { viewModel.setAutoTTS($0) }
StepperRow(label: "Silence Threshold", value: viewModel.config.silenceThreshold, range: 50...500) { viewModel.setSilenceThreshold($0) }
}
}
// MARK: - Memory
private var memorySection: some View {
SettingsSection(title: "Memory", icon: "brain") {
ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) }
if !viewModel.config.memoryProfile.isEmpty {
ReadOnlyRow(label: "Profile", value: viewModel.config.memoryProfile)
}
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) }
StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) }
StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) }
}
}
// MARK: - Paths
private var pathsSection: some View { private var pathsSection: some View {
VStack(alignment: .leading, spacing: 8) { SettingsSection(title: "Paths", icon: "folder") {
Text("Paths") PathRow(label: "Hermes Home", path: HermesPaths.home)
.font(.headline) PathRow(label: "State DB", path: HermesPaths.stateDB)
VStack(alignment: .leading, spacing: 4) { PathRow(label: "Config", path: HermesPaths.configYAML)
PathRow(label: "Hermes Home", path: HermesPaths.home) PathRow(label: "Memory", path: HermesPaths.memoriesDir)
PathRow(label: "State DB", path: HermesPaths.stateDB) PathRow(label: "Sessions", path: HermesPaths.sessionsDir)
PathRow(label: "Config", path: HermesPaths.configYAML) PathRow(label: "Skills", path: HermesPaths.skillsDir)
PathRow(label: "Memory", path: HermesPaths.memoriesDir) PathRow(label: "Agent Log", path: HermesPaths.agentLog)
PathRow(label: "Sessions", path: HermesPaths.sessionsDir) PathRow(label: "Error Log", path: HermesPaths.errorsLog)
PathRow(label: "Skills", path: HermesPaths.skillsDir)
PathRow(label: "Logs", path: HermesPaths.errorsLog)
}
} }
} }
// MARK: - Raw Config
private var rawConfigSection: some View { private var rawConfigSection: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
@@ -98,7 +182,147 @@ struct SettingsView: View {
} }
} }
struct SettingRow: View { // MARK: - Reusable Components
struct SettingsSection<Content: View>: View {
let title: String
let icon: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Label(title, systemImage: icon)
.font(.headline)
VStack(spacing: 1) {
content
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
struct EditableTextField: View {
let label: String
let value: String
let onCommit: (String) -> Void
@State private var text: String = ""
@State private var isEditing = false
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
if isEditing {
TextField(label, text: $text, onCommit: {
if text != value { onCommit(text) }
isEditing = false
})
.textFieldStyle(.roundedBorder)
.font(.system(.caption, design: .monospaced))
Button("Cancel") { isEditing = false }
.controlSize(.mini)
} else {
Text(value)
.font(.system(.caption, design: .monospaced))
Spacer()
Button("Edit") {
text = value
isEditing = true
}
.controlSize(.mini)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct PickerRow: View {
let label: String
let selection: String
let options: [String]
let onChange: (String) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Picker("", selection: Binding(
get: { selection },
set: { onChange($0) }
)) {
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
.frame(maxWidth: 250)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct ToggleRow: View {
let label: String
let isOn: Bool
let onChange: (Bool) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Toggle("", isOn: Binding(
get: { isOn },
set: { onChange($0) }
))
.toggleStyle(.switch)
.labelsHidden()
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct StepperRow: View {
let label: String
let value: Int
let range: ClosedRange<Int>
let onChange: (Int) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Text("\(value)")
.font(.system(.caption, design: .monospaced))
.frame(width: 50)
Stepper("", value: Binding(
get: { value },
set: { onChange($0) }
), in: range)
.labelsHidden()
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct ReadOnlyRow: View {
let label: String let label: String
let value: String let value: String
@@ -107,10 +331,15 @@ struct SettingRow: View {
Text(label) Text(label)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: 120, alignment: .trailing) .frame(width: 130, alignment: .trailing)
Text(value) Text(value)
.font(.system(.caption, design: .monospaced)) .font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
Spacer()
} }
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
} }
} }
@@ -123,10 +352,11 @@ struct PathRow: View {
Text(label) Text(label)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: 100, alignment: .trailing) .frame(width: 130, alignment: .trailing)
Text(path) Text(path)
.font(.system(.caption, design: .monospaced)) .font(.system(.caption, design: .monospaced))
.textSelection(.enabled) .textSelection(.enabled)
Spacer()
Button { Button {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path) NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
} label: { } label: {
@@ -135,5 +365,8 @@ struct PathRow: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
} }
} }
@@ -9,6 +9,8 @@ final class SkillsViewModel {
var skillContent = "" var skillContent = ""
var selectedFileName: String? var selectedFileName: String?
var searchText = "" var searchText = ""
var missingConfig: [String] = []
private var currentConfig = HermesConfig.empty
var filteredCategories: [HermesSkillCategory] { var filteredCategories: [HermesSkillCategory] {
guard !searchText.isEmpty else { return categories } guard !searchText.isEmpty else { return categories }
@@ -28,6 +30,7 @@ final class SkillsViewModel {
func load() { func load() {
categories = fileService.loadSkills() categories = fileService.loadSkills()
currentConfig = fileService.loadConfig()
} }
func selectSkill(_ skill: HermesSkill) { func selectSkill(_ skill: HermesSkill) {
@@ -40,6 +43,17 @@ final class SkillsViewModel {
selectedFileName = nil selectedFileName = nil
skillContent = "" skillContent = ""
} }
missingConfig = computeMissingConfig(for: skill)
}
private func computeMissingConfig(for skill: HermesSkill) -> [String] {
guard !skill.requiredConfig.isEmpty else { return [] }
guard let yaml = try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8) else {
return skill.requiredConfig
}
return skill.requiredConfig.filter { key in
!yaml.contains(key)
}
} }
func selectFile(_ file: String) { func selectFile(_ file: String) {
@@ -53,9 +53,28 @@ struct SkillsView: View {
HStack { HStack {
Label(skill.category, systemImage: "folder") Label(skill.category, systemImage: "folder")
Label("\(skill.files.count) files", systemImage: "doc") Label("\(skill.files.count) files", systemImage: "doc")
if !skill.requiredConfig.isEmpty {
Label("\(skill.requiredConfig.count) required config", systemImage: "gearshape")
}
} }
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if !viewModel.missingConfig.isEmpty {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
VStack(alignment: .leading, spacing: 2) {
Text("Missing required config:")
.font(.caption.bold())
Text(viewModel.missingConfig.joined(separator: ", "))
.font(.caption.monospaced())
}
}
.foregroundStyle(.orange)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Divider() Divider()
if !skill.files.isEmpty { if !skill.files.isEmpty {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@@ -0,0 +1,141 @@
import Foundation
@Observable
final class ToolsViewModel {
var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
var toolsets: [HermesToolset] = []
var mcpStatus: String = ""
var isLoading = false
var availablePlatforms: [HermesToolPlatform] = []
func load() {
loadPlatforms()
loadTools(for: selectedPlatform)
loadMCPStatus()
}
func switchPlatform(_ platform: HermesToolPlatform) {
selectedPlatform = platform
loadTools(for: platform)
}
func toggleTool(_ tool: HermesToolset) {
let action = tool.enabled ? "disable" : "enable"
let result = runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name])
if result.exitCode == 0 {
if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) {
toolsets[idx].enabled.toggle()
}
}
}
private func loadPlatforms() {
let config: String
do {
config = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
} catch {
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
config = ""
}
var platforms: [HermesToolPlatform] = []
var inSection = false
for line in config.components(separatedBy: "\n") {
if line.hasPrefix("platform_toolsets:") {
inSection = true
continue
}
if inSection {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || (!line.hasPrefix(" ") && !line.hasPrefix("\t")) {
if !trimmed.isEmpty { break }
continue
}
if trimmed.hasSuffix(":") && !trimmed.hasPrefix("-") {
let name = String(trimmed.dropLast()).trimmingCharacters(in: .whitespaces)
if let known = KnownPlatforms.all.first(where: { $0.name == name }) {
platforms.append(known)
} else {
platforms.append(HermesToolPlatform(name: name, displayName: name.capitalized, icon: "bubble.left"))
}
}
}
}
availablePlatforms = platforms.isEmpty ? [KnownPlatforms.cli] : platforms
if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }),
let first = availablePlatforms.first {
selectedPlatform = first
}
}
private func loadTools(for platform: HermesToolPlatform) {
isLoading = true
let result = runHermes(["tools", "list", "--platform", platform.name])
toolsets = parseToolsList(result.output)
isLoading = false
}
private func loadMCPStatus() {
let result = runHermes(["mcp", "list"])
mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func parseToolsList(_ output: String) -> [HermesToolset] {
var tools: [HermesToolset] = []
for line in output.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
let isEnabled: Bool
if trimmed.hasPrefix("✓ enabled") {
isEnabled = true
} else if trimmed.hasPrefix("✗ disabled") {
isEnabled = false
} else {
continue
}
let rest = trimmed
.replacingOccurrences(of: "✓ enabled", with: "")
.replacingOccurrences(of: "✗ disabled", with: "")
.trimmingCharacters(in: .whitespaces)
let parts = rest.split(separator: " ", maxSplits: 1)
guard let namePart = parts.first else { continue }
let name = String(namePart)
let rawDesc = parts.count > 1 ? String(parts[1]) : name
let icon = extractEmoji(from: rawDesc)
let description = rawDesc
.unicodeScalars.filter { !$0.properties.isEmoji || $0.isASCII }
.map { String($0) }.joined()
.trimmingCharacters(in: .whitespaces)
tools.append(HermesToolset(name: name, description: description, icon: icon, enabled: isEnabled))
}
return tools
}
private func extractEmoji(from text: String) -> String {
for scalar in text.unicodeScalars {
if scalar.properties.isEmoji && !scalar.isASCII {
return String(scalar)
}
}
return "🔧"
}
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
return (output, process.terminationStatus)
} catch {
return ("", -1)
}
}
}
@@ -0,0 +1,108 @@
import SwiftUI
struct ToolsView: View {
@State private var viewModel = ToolsViewModel()
var body: some View {
VStack(spacing: 0) {
platformPicker
Divider()
toolsList
if !viewModel.mcpStatus.isEmpty {
Divider()
mcpSection
}
}
.navigationTitle("Tools")
.onAppear { viewModel.load() }
}
private var platformPicker: some View {
HStack(spacing: 12) {
Picker("Platform", selection: Binding(
get: { viewModel.selectedPlatform.name },
set: { name in
if let platform = viewModel.availablePlatforms.first(where: { $0.name == name }) {
viewModel.switchPlatform(platform)
}
}
)) {
ForEach(viewModel.availablePlatforms) { platform in
Text(platform.displayName).tag(platform.name)
}
}
.pickerStyle(.segmented)
Spacer()
Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
private var toolsList: some View {
ScrollView {
LazyVStack(spacing: 1) {
ForEach(viewModel.toolsets) { tool in
ToolRow(tool: tool) {
viewModel.toggleTool(tool)
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.id(viewModel.selectedPlatform.name)
}
private var mcpSection: some View {
VStack(alignment: .leading, spacing: 6) {
Text("MCP Servers")
.font(.caption.bold())
.foregroundStyle(.secondary)
if viewModel.mcpStatus.contains("No MCP servers") {
Label("No MCP servers configured", systemImage: "server.rack")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(viewModel.mcpStatus)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct ToolRow: View {
let tool: HermesToolset
let onToggle: () -> Void
var body: some View {
HStack(spacing: 12) {
Text(tool.icon)
.font(.title3)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(tool.name)
.font(.system(.body, design: .monospaced, weight: .medium))
Text(tool.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Toggle("", isOn: Binding(
get: { tool.enabled },
set: { _ in onToggle() }
))
.toggleStyle(.switch)
.labelsHidden()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -2,12 +2,17 @@ import Foundation
enum SidebarSection: String, CaseIterable, Identifiable { enum SidebarSection: String, CaseIterable, Identifiable {
case dashboard = "Dashboard" case dashboard = "Dashboard"
case insights = "Insights"
case sessions = "Sessions" case sessions = "Sessions"
case activity = "Activity" case activity = "Activity"
case projects = "Projects"
case chat = "Chat" case chat = "Chat"
case memory = "Memory" case memory = "Memory"
case skills = "Skills" case skills = "Skills"
case tools = "Tools"
case gateway = "Gateway"
case cron = "Cron" case cron = "Cron"
case health = "Health"
case logs = "Logs" case logs = "Logs"
case settings = "Settings" case settings = "Settings"
@@ -16,12 +21,17 @@ enum SidebarSection: String, CaseIterable, Identifiable {
var icon: String { var icon: String {
switch self { switch self {
case .dashboard: return "gauge.with.dots.needle.33percent" case .dashboard: return "gauge.with.dots.needle.33percent"
case .insights: return "chart.bar"
case .sessions: return "bubble.left.and.bubble.right" case .sessions: return "bubble.left.and.bubble.right"
case .activity: return "bolt.horizontal" case .activity: return "bolt.horizontal"
case .projects: return "square.grid.2x2"
case .chat: return "text.bubble" case .chat: return "text.bubble"
case .memory: return "brain" case .memory: return "brain"
case .skills: return "lightbulb" case .skills: return "lightbulb"
case .tools: return "wrench.and.screwdriver"
case .gateway: return "antenna.radiowaves.left.and.right"
case .cron: return "clock.arrow.2.circlepath" case .cron: return "clock.arrow.2.circlepath"
case .health: return "stethoscope"
case .logs: return "doc.text" case .logs: return "doc.text"
case .settings: return "gearshape" case .settings: return "gearshape"
} }
@@ -32,4 +42,5 @@ enum SidebarSection: String, CaseIterable, Identifiable {
final class AppCoordinator { final class AppCoordinator {
var selectedSection: SidebarSection = .dashboard var selectedSection: SidebarSection = .dashboard
var selectedSessionId: String? var selectedSessionId: String?
var selectedProjectName: String?
} }
+8 -2
View File
@@ -7,7 +7,13 @@ struct SidebarView: View {
@Bindable var coordinator = coordinator @Bindable var coordinator = coordinator
List(selection: $coordinator.selectedSection) { List(selection: $coordinator.selectedSection) {
Section("Monitor") { Section("Monitor") {
ForEach([SidebarSection.dashboard, .sessions, .activity]) { section in ForEach([SidebarSection.dashboard, .insights, .sessions, .activity]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
}
}
Section("Projects") {
ForEach([SidebarSection.projects]) { section in
Label(section.rawValue, systemImage: section.icon) Label(section.rawValue, systemImage: section.icon)
.tag(section) .tag(section)
} }
@@ -19,7 +25,7 @@ struct SidebarView: View {
} }
} }
Section("Manage") { Section("Manage") {
ForEach([SidebarSection.cron, .logs, .settings]) { section in ForEach([SidebarSection.tools, .gateway, .cron, .health, .logs, .settings]) { section in
Label(section.rawValue, systemImage: section.icon) Label(section.rawValue, systemImage: section.icon)
.tag(section) .tag(section)
} }
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>