Compare commits

...

11 Commits

Author SHA1 Message Date
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
31 changed files with 1779 additions and 114 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/
+155 -5
View File
@@ -30,7 +30,8 @@
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke) - **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
- **Cron Manager** — View scheduled jobs, their status, prompts, and output - **Cron Manager** — View scheduled jobs, their status, prompts, and output
- **Log Viewer** — Real-time log tailing with level filtering and text search - **Log Viewer** — Real-time log tailing with level filtering and text search
- **Settings** — Configuration display with raw YAML viewer and Finder path links - **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
- **Settings** — Structured config editor for all Hermes settings
- **Menu Bar** — Status icon showing Hermes running state with quick actions - **Menu Bar** — Status icon showing Hermes running state with quick actions
## Requirements ## Requirements
@@ -50,7 +51,17 @@ Scarf reads Hermes's SQLite database (schema v6) and parses CLI output from `her
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings. If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
## Building ## Install
### Pre-built Binary (no Xcode required)
Download the latest universal binary (Apple Silicon + Intel) from [Releases](https://github.com/awizemann/scarf/releases):
1. Download `Scarf-vX.X.X-Universal.zip`
2. Unzip and drag **Scarf.app** to Applications
3. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
### Build from Source
```bash ```bash
git clone https://github.com/awizemann/scarf.git git clone https://github.com/awizemann/scarf.git
@@ -61,7 +72,7 @@ open scarf.xcodeproj
Or from the command line: Or from the command line:
```bash ```bash
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Release -arch arm64 -arch x86_64 ONLY_ACTIVE_ARCH=NO build
``` ```
## Architecture ## Architecture
@@ -78,6 +89,7 @@ scarf/
Insights/ Usage analytics and activity patterns Insights/ Usage analytics and activity patterns
Sessions/ Conversation browser with rename, delete, export Sessions/ Conversation browser with rename, delete, export
Activity/ Tool execution feed with inspector Activity/ Tool execution feed with inspector
Projects/ Agent-generated project dashboards with widget rendering
Chat/ Embedded terminal via SwiftTerm with voice controls Chat/ Embedded terminal via SwiftTerm with voice controls
Memory/ Memory viewer and editor Memory/ Memory viewer and editor
Skills/ Skill browser by category Skills/ Skill browser by category
@@ -85,7 +97,7 @@ scarf/
Gateway/ Messaging gateway control and pairing Gateway/ Messaging gateway control and pairing
Cron/ Scheduled job viewer Cron/ Scheduled job viewer
Logs/ Real-time log viewer Logs/ Real-time log viewer
Settings/ Configuration display Settings/ Structured config editor
Navigation/ AppCoordinator + SidebarView Navigation/ AppCoordinator + SidebarView
``` ```
@@ -107,6 +119,8 @@ Scarf reads Hermes data directly from `~/.hermes/`:
| `hermes sessions` | CLI commands | Rename/Delete/Export | | `hermes sessions` | CLI commands | Rename/Delete/Export |
| `hermes gateway` | CLI commands | Start/Stop/Restart | | `hermes gateway` | CLI commands | Start/Stop/Restart |
| `hermes pairing` | CLI commands | Approve/Revoke | | `hermes pairing` | CLI commands | Approve/Revoke |
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
| `scarf/projects.json` | JSON (registry) | Read/Write |
The app opens `state.db` in read-only mode to avoid WAL contention with Hermes. Management actions (tool toggles, session rename/delete/export) go through the Hermes CLI. The app opens `state.db` in read-only mode to avoid WAL contention with Hermes. Management actions (tool toggles, session rename/delete/export) go through the Hermes CLI.
@@ -116,7 +130,7 @@ The app opens `state.db` in read-only mode to avoid WAL contention with Hermes.
|---------|---------| |---------|---------|
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature | | [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, GCD file watching. Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
## How It Works ## How It Works
@@ -128,6 +142,142 @@ Management actions (renaming sessions, toggling tools, editing memory) call the
The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary. The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary.
## Project Dashboards
Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically.
### What You Can Build
- **Development dashboards** — test coverage, build status, open issues, sprint progress
- **Data project trackers** — pipeline metrics, data quality scores, processing throughput
- **Deployment monitors** — deploy history tables, uptime stats, error rate charts
- **Research dashboards** — experiment results, key findings, paper status checklists
- **Agent activity views** — cron job results, content generation stats, task completion rates
- **Embedded web apps** — local dev servers, HTML reports, Grafana dashboards, any web-based tool your agent generates
- **Any project status** — if your agent can measure it, Scarf can display it
### Quick Start
**1. Create the dashboard file**
Create `.scarf/dashboard.json` in any project folder:
```json
{
"version": 1,
"title": "My Project",
"description": "Project status at a glance",
"sections": [
{
"title": "Overview",
"columns": 3,
"widgets": [
{
"type": "stat",
"title": "Test Coverage",
"value": "87%",
"icon": "checkmark.shield",
"color": "green",
"subtitle": "+2.1% this week"
},
{
"type": "progress",
"title": "Sprint Progress",
"value": 0.73,
"label": "73% complete",
"color": "blue"
},
{
"type": "list",
"title": "Tasks",
"items": [
{ "text": "Write unit tests", "status": "done" },
{ "text": "Update API docs", "status": "active" },
{ "text": "Deploy to prod", "status": "pending" }
]
}
]
}
]
}
```
**2. Register your project**
In Scarf, go to **Projects** in the sidebar and click the **+** button to add your project folder. Or have your agent add it directly to the registry at `~/.hermes/scarf/projects.json`:
```json
{
"projects": [
{ "name": "my-project", "path": "/Users/you/Developer/my-project" }
]
}
```
**3. View in Scarf**
Select your project in the Projects sidebar — the dashboard renders immediately. Scarf watches the file for changes and refreshes automatically whenever the JSON is updated.
### Widget Types
| Type | Description | Key Fields |
|------|-------------|------------|
| `stat` | Key metric with large value display | `value`, `icon`, `color`, `subtitle` |
| `progress` | Progress bar with label | `value` (0.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.
+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.
+2
View File
@@ -22,6 +22,8 @@ struct ContentView: View {
SessionsView() SessionsView()
case .activity: case .activity:
ActivityView() ActivityView()
case .projects:
ProjectsView()
case .chat: case .chat:
ChatView() ChatView()
case .memory: case .memory:
+3 -1
View File
@@ -14,6 +14,7 @@ struct HermesConfig: Sendable {
var showReasoning: Bool var showReasoning: Bool
var verbose: Bool var verbose: Bool
var autoTTS: Bool var autoTTS: Bool
var silenceThreshold: Int
static let empty = HermesConfig( static let empty = HermesConfig(
model: "unknown", model: "unknown",
@@ -28,7 +29,8 @@ struct HermesConfig: Sendable {
streaming: true, streaming: true,
showReasoning: false, showReasoning: false,
verbose: false, verbose: false,
autoTTS: true autoTTS: true,
silenceThreshold: 200
) )
} }
+33 -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"
@@ -15,5 +18,32 @@ enum HermesPaths: Sendable {
nonisolated static let skillsDir: String = home + "/skills" nonisolated static let skillsDir: String = home + "/skills"
nonisolated static let errorsLog: String = home + "/logs/errors.log" nonisolated static let errorsLog: String = home + "/logs/errors.log"
nonisolated static let gatewayLog: String = home + "/logs/gateway.log" nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
nonisolated static let hermesBinary: String = ProcessInfo.processInfo.environment["HOME"]! + "/.local/bin/hermes" nonisolated static let hermesBinary: String = userHome + "/.local/bin/hermes"
nonisolated static let scarfDir: String = home + "/scarf"
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
}
// MARK: - SQLite Constants
/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data.
/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift.
nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
// MARK: - Query Defaults
enum QueryDefaults: Sendable {
nonisolated static let sessionLimit = 100
nonisolated static let messageSearchLimit = 50
nonisolated static let toolCallLimit = 50
nonisolated static let sessionPreviewLimit = 10
nonisolated static let previewContentLength = 100
nonisolated static let logLineLimit = 200
nonisolated static let defaultSilenceThreshold = 200
}
// MARK: - File Size Formatting
enum FileSizeUnit: Sendable {
nonisolated static let kilobyte = 1_024.0
nonisolated static let megabyte = 1_048_576.0
} }
+2 -1
View File
@@ -16,8 +16,9 @@ struct HermesToolPlatform: Identifiable, Sendable {
} }
enum KnownPlatforms { enum KnownPlatforms {
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
static let all: [HermesToolPlatform] = [ static let all: [HermesToolPlatform] = [
HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal"), cli,
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"), HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"), HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"), HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"),
@@ -0,0 +1,138 @@
import Foundation
// MARK: - Registry
struct ProjectRegistry: Codable, Sendable {
var projects: [ProjectEntry]
}
struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
var id: String { name }
let name: String
let path: String
var dashboardPath: String { path + "/.scarf/dashboard.json" }
}
// MARK: - Dashboard
struct ProjectDashboard: Codable, Sendable {
let version: Int
let title: String
let description: String?
let updatedAt: String?
let theme: DashboardTheme?
let sections: [DashboardSection]
}
struct DashboardTheme: Codable, Sendable {
let accent: String?
}
struct DashboardSection: Codable, Sendable, Identifiable {
var id: String { title }
let title: String
let columns: Int?
let widgets: [DashboardWidget]
var columnCount: Int { columns ?? 3 }
}
struct DashboardWidget: Codable, Sendable, Identifiable {
var id: String { type + ":" + title }
let type: String
let title: String
// Stat
let value: WidgetValue?
let icon: String?
let color: String?
let subtitle: String?
// Progress
let label: String?
// Text
let content: String?
let format: String?
// Table
let columns: [String]?
let rows: [[String]]?
// Chart
let chartType: String?
let xLabel: String?
let yLabel: String?
let series: [ChartSeries]?
// List
let items: [ListItem]?
// Webview
let url: String?
let height: Double?
}
// MARK: - Widget Value (String or Number)
enum WidgetValue: Codable, Sendable, Hashable {
case string(String)
case number(Double)
var displayString: String {
switch self {
case .string(let s): return s
case .number(let n):
return n.truncatingRemainder(dividingBy: 1) == 0
? String(Int(n))
: String(format: "%.1f", n)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let d = try? container.decode(Double.self) {
self = .number(d)
} else if let s = try? container.decode(String.self) {
self = .string(s)
} else {
throw DecodingError.typeMismatch(
WidgetValue.self,
.init(codingPath: decoder.codingPath, debugDescription: "Expected String or Number")
)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let s): try container.encode(s)
case .number(let n): try container.encode(n)
}
}
}
// MARK: - Chart Data
struct ChartSeries: Codable, Sendable, Identifiable {
var id: String { name }
let name: String
let color: String?
let data: [ChartDataPoint]
}
struct ChartDataPoint: Codable, Sendable, Identifiable {
var id: String { x }
let x: String
let y: Double
}
// MARK: - List Data
struct ListItem: Codable, Sendable, Identifiable {
var id: String { text }
let text: String
let status: String?
}
@@ -24,7 +24,7 @@ actor HermesDataService {
db = nil db = nil
} }
func fetchSessions(limit: Int = 100) -> [HermesSession] { func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
guard let db else { return [] } guard let db else { return [] }
let sql = """ let sql = """
SELECT id, source, user_id, model, title, parent_session_id, SELECT id, source, user_id, model, title, parent_session_id,
@@ -59,7 +59,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, sessionId, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) 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,7 +68,7 @@ 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 sql = """ let sql = """
SELECT m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, SELECT m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls,
@@ -82,7 +82,7 @@ actor HermesDataService {
var stmt: OpaquePointer? 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, query, -1, sqliteTransient)
sqlite3_bind_int(stmt, 2, Int32(limit)) sqlite3_bind_int(stmt, 2, Int32(limit))
var messages: [HermesMessage] = [] var messages: [HermesMessage] = []
@@ -92,7 +92,7 @@ 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 id, session_id, role, content, tool_call_id, tool_calls,
@@ -114,10 +114,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
@@ -149,13 +149,15 @@ actor HermesDataService {
let totalInputTokens: Int let totalInputTokens: Int
let totalOutputTokens: Int let totalOutputTokens: Int
let totalCostUSD: Double let totalCostUSD: Double
static let empty = SessionStats(
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 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,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
}
let sql = """ let sql = """
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0), SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
@@ -163,16 +165,9 @@ actor HermesDataService {
FROM sessions FROM sessions
""" """
var stmt: OpaquePointer? var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
}
defer { sqlite3_finalize(stmt) } defer { sqlite3_finalize(stmt) }
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
guard sqlite3_step(stmt) == SQLITE_ROW else {
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
}
return SessionStats( return SessionStats(
totalSessions: Int(sqlite3_column_int(stmt, 0)), totalSessions: Int(sqlite3_column_int(stmt, 0)),
totalMessages: Int(sqlite3_column_int(stmt, 1)), totalMessages: Int(sqlite3_column_int(stmt, 1)),
@@ -344,7 +339,12 @@ actor HermesDataService {
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 {
@@ -43,7 +43,8 @@ struct HermesFileService: Sendable {
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" autoTTS: values["voice.auto_tts"] != "false",
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? QueryDefaults.defaultSilenceThreshold
) )
} }
@@ -51,7 +52,12 @@ struct HermesFileService: Sendable {
func loadGatewayState() -> GatewayState? { func loadGatewayState() -> GatewayState? {
guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil } guard let data = readFileData(HermesPaths.gatewayStateJSON) else { return nil }
return try? JSONDecoder().decode(GatewayState.self, from: data) do {
return try JSONDecoder().decode(GatewayState.self, from: data)
} catch {
print("[Scarf] Failed to decode gateway state: \(error.localizedDescription)")
return nil
}
} }
// MARK: - Memory // MARK: - Memory
@@ -76,8 +82,13 @@ struct HermesFileService: Sendable {
func loadCronJobs() -> [HermesCronJob] { func loadCronJobs() -> [HermesCronJob] {
guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] } guard let data = readFileData(HermesPaths.cronJobsJSON) else { return [] }
let file = try? JSONDecoder().decode(CronJobsFile.self, from: data) do {
return file?.jobs ?? [] let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
return file.jobs
} catch {
print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)")
return []
}
} }
func loadCronOutput(jobId: String) -> String? { func loadCronOutput(jobId: String) -> String? {
@@ -122,7 +133,13 @@ struct HermesFileService: Sendable {
} }
func loadSkillContent(path: String) -> String { func loadSkillContent(path: String) -> String {
readFile(path) ?? "" // Validate path stays within the skills directory to prevent traversal
guard !path.contains(".."),
path.hasPrefix(HermesPaths.skillsDir) else {
print("[Scarf] Rejected skill path outside skills directory: \(path)")
return ""
}
return readFile(path) ?? ""
} }
// MARK: - Hermes Process // MARK: - Hermes Process
@@ -155,6 +172,10 @@ struct HermesFileService: Sendable {
} }
private func writeFile(_ path: String, content: String) { private func writeFile(_ path: String, content: String) {
try? content.write(toFile: path, atomically: true, encoding: .utf8) do {
try content.write(toFile: path, atomically: true, encoding: .utf8)
} catch {
print("[Scarf] Failed to write \(path): \(error.localizedDescription)")
}
} }
} }
@@ -3,7 +3,8 @@ import Foundation
@Observable @Observable
final class HermesFileWatcher { final class HermesFileWatcher {
private(set) var lastChangeDate = Date() private(set) var lastChangeDate = Date()
private var sources: [DispatchSourceFileSystemObject] = [] private var coreSources: [DispatchSourceFileSystemObject] = []
private var projectSources: [DispatchSourceFileSystemObject] = []
private var timer: Timer? private var timer: Timer?
func startWatching() { func startWatching() {
@@ -16,11 +17,14 @@ final class HermesFileWatcher {
HermesPaths.cronJobsJSON, HermesPaths.cronJobsJSON,
HermesPaths.gatewayStateJSON, HermesPaths.gatewayStateJSON,
HermesPaths.errorsLog, HermesPaths.errorsLog,
HermesPaths.gatewayLog HermesPaths.gatewayLog,
HermesPaths.projectsRegistry
] ]
for path in paths { for path in paths {
watchFile(path) if let source = makeSource(for: path) {
coreSources.append(source)
}
} }
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
@@ -29,17 +33,30 @@ final class HermesFileWatcher {
} }
func stopWatching() { func stopWatching() {
for source in sources { for source in coreSources + projectSources {
source.cancel() source.cancel()
} }
sources.removeAll() coreSources.removeAll()
projectSources.removeAll()
timer?.invalidate() timer?.invalidate()
timer = nil timer = nil
} }
private func watchFile(_ path: String) { func updateProjectWatches(_ dashboardPaths: [String]) {
for source in projectSources {
source.cancel()
}
projectSources.removeAll()
for path in dashboardPaths {
if let source = makeSource(for: path) {
projectSources.append(source)
}
}
}
private func makeSource(for path: String) -> DispatchSourceFileSystemObject? {
let fd = Darwin.open(path, O_EVTONLY) let fd = Darwin.open(path, O_EVTONLY)
guard fd >= 0 else { return } guard fd >= 0 else { return nil }
let source = DispatchSource.makeFileSystemObjectSource( let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd, fileDescriptor: fd,
@@ -53,7 +70,7 @@ final class HermesFileWatcher {
Darwin.close(fd) Darwin.close(fd)
} }
source.resume() source.resume()
sources.append(source) return source
} }
deinit { deinit {
@@ -39,12 +39,16 @@ actor HermesLogService {
} }
func closeLog() { func closeLog() {
try? fileHandle?.close() do {
try fileHandle?.close()
} catch {
print("[Scarf] Failed to close log handle: \(error.localizedDescription)")
}
fileHandle = nil fileHandle = nil
currentPath = nil currentPath = nil
} }
func readLastLines(count: Int = 200) -> [LogEntry] { func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
guard let path = currentPath, guard let path = currentPath,
let data = FileManager.default.contents(atPath: path) else { return [] } let data = FileManager.default.contents(atPath: path) else { return [] }
let content = String(data: data, encoding: .utf8) ?? "" let content = String(data: data, encoding: .utf8) ?? ""
@@ -0,0 +1,63 @@
import Foundation
struct ProjectDashboardService: Sendable {
// MARK: - Registry
func loadRegistry() -> ProjectRegistry {
guard let data = FileManager.default.contents(atPath: HermesPaths.projectsRegistry) else {
return ProjectRegistry(projects: [])
}
do {
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
} catch {
print("[Scarf] Failed to decode project registry: \(error.localizedDescription)")
return ProjectRegistry(projects: [])
}
}
func saveRegistry(_ registry: ProjectRegistry) {
let dir = HermesPaths.scarfDir
if !FileManager.default.fileExists(atPath: dir) {
do {
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
} catch {
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
return
}
}
guard let data = try? JSONEncoder().encode(registry) else { return }
// Pretty-print for readability (agents may read this file)
if let pretty = try? JSONSerialization.jsonObject(with: data),
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: formatted)
} else {
FileManager.default.createFile(atPath: HermesPaths.projectsRegistry, contents: data)
}
}
// MARK: - Dashboard
func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? {
guard let data = FileManager.default.contents(atPath: project.dashboardPath) else {
return nil
}
do {
return try JSONDecoder().decode(ProjectDashboard.self, from: data)
} catch {
print("[Scarf] Failed to decode dashboard for \(project.name): \(error.localizedDescription)")
return nil
}
}
func dashboardExists(for project: ProjectEntry) -> Bool {
FileManager.default.fileExists(atPath: project.dashboardPath)
}
func dashboardModificationDate(for project: ProjectEntry) -> Date? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: project.dashboardPath) else {
return nil
}
return attrs[.modificationDate] as? Date
}
}
@@ -10,7 +10,7 @@ struct PersistentTerminalView: NSViewRepresentable {
terminalView.translatesAutoresizingMaskIntoConstraints = false terminalView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(terminalView) container.addSubview(terminalView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor), terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
terminalView.trailingAnchor.constraint(equalTo: container.trailingAnchor), terminalView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
terminalView.topAnchor.constraint(equalTo: container.topAnchor), terminalView.topAnchor.constraint(equalTo: container.topAnchor),
terminalView.bottomAnchor.constraint(equalTo: container.bottomAnchor), terminalView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
@@ -24,7 +24,7 @@ struct PersistentTerminalView: NSViewRepresentable {
terminalView.translatesAutoresizingMaskIntoConstraints = false terminalView.translatesAutoresizingMaskIntoConstraints = false
nsView.addSubview(terminalView) nsView.addSubview(terminalView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor), terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor, constant: 4),
terminalView.trailingAnchor.constraint(equalTo: nsView.trailingAnchor), terminalView.trailingAnchor.constraint(equalTo: nsView.trailingAnchor),
terminalView.topAnchor.constraint(equalTo: nsView.topAnchor), terminalView.topAnchor.constraint(equalTo: nsView.topAnchor),
terminalView.bottomAnchor.constraint(equalTo: nsView.bottomAnchor), terminalView.bottomAnchor.constraint(equalTo: nsView.bottomAnchor),
@@ -5,10 +5,7 @@ final class DashboardViewModel {
private let dataService = HermesDataService() private let dataService = HermesDataService()
private let fileService = HermesFileService() private let fileService = HermesFileService()
var stats = HermesDataService.SessionStats( var stats = HermesDataService.SessionStats.empty
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0
)
var recentSessions: [HermesSession] = [] var recentSessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:] var sessionPreviews: [String: String] = [:]
var config = HermesConfig.empty var config = HermesConfig.empty
@@ -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
}
}
@@ -158,10 +158,10 @@ final class SessionsViewModel {
let fileSize: String let fileSize: String
if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath), if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath),
let size = attrs[.size] as? Int { let size = attrs[.size] as? Int {
if size >= 1_048_576 { if Double(size) >= FileSizeUnit.megabyte {
fileSize = String(format: "%.1f MB", Double(size) / 1_048_576) fileSize = String(format: "%.1f MB", Double(size) / FileSizeUnit.megabyte)
} else { } else {
fileSize = String(format: "%.0f KB", Double(size) / 1_024) fileSize = String(format: "%.0f KB", Double(size) / FileSizeUnit.kilobyte)
} }
} else { } else {
fileSize = "unknown" fileSize = "unknown"
@@ -1,4 +1,5 @@
import Foundation import Foundation
import AppKit
@Observable @Observable
final class SettingsViewModel { final class SettingsViewModel {
@@ -8,11 +9,94 @@ 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", "zai", "kimi-coding", "minimax"]
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
var saveMessage: String?
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 openConfigInEditor() {
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
}
private func parsePersonalities() -> [String] {
var names: [String] = []
var inPersonalities = false
for line in rawConfigYAML.components(separatedBy: "\n") {
if line.trimmingCharacters(in: .whitespaces) == "personalities:" && line.hasPrefix(" ") {
inPersonalities = true
continue
}
if inPersonalities {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { continue }
let indent = line.prefix(while: { $0 == " " }).count
if indent <= 2 && !trimmed.isEmpty {
inPersonalities = false
continue
}
if indent == 4 && trimmed.contains(":") {
let name = String(trimmed.split(separator: ":")[0])
names.append(name)
}
}
}
return names
}
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
} catch {
return ("", -1)
}
} }
} }
@@ -6,9 +6,13 @@ struct SettingsView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 24) {
configSection headerBar
gatewaySection modelSection
displaySection
terminalSection
voiceSection
memorySection
pathsSection pathsSection
rawConfigSection rawConfigSection
} }
@@ -19,51 +23,78 @@ struct SettingsView: View {
.onAppear { viewModel.load() } .onAppear { viewModel.load() }
} }
private var configSection: some View { private var headerBar: some View {
VStack(alignment: .leading, spacing: 12) { HStack {
Text("Configuration") if let msg = viewModel.saveMessage {
.font(.headline) Label(msg, systemImage: "checkmark.circle.fill")
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { .font(.caption)
SettingRow(label: "Model", value: viewModel.config.model) .foregroundStyle(.green)
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")
} }
Spacer()
Button("Open in Editor") { viewModel.openConfigInEditor() }
.controlSize(.small)
Button("Reload") { viewModel.load() }
.controlSize(.small)
} }
} }
private var gatewaySection: some View { // MARK: - Model & Provider
VStack(alignment: .leading, spacing: 8) {
Text("Gateway") private var modelSection: some View {
.font(.headline) SettingsSection(title: "Model", icon: "cpu") {
HStack(spacing: 16) { EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($0) }
Label( PickerRow(label: "Provider", selection: viewModel.config.provider, options: viewModel.providers) { viewModel.setProvider($0) }
viewModel.gatewayState?.statusText ?? "unknown",
systemImage: viewModel.gatewayState?.isRunning == true ? "circle.fill" : "circle"
)
.foregroundStyle(viewModel.gatewayState?.isRunning == true ? .green : .secondary)
if let reason = viewModel.gatewayState?.exitReason {
Text(reason)
.font(.caption)
.foregroundStyle(.secondary)
} }
} }
// MARK: - Display
private var displaySection: some View {
SettingsSection(title: "Display", icon: "paintbrush") {
if !viewModel.personalities.isEmpty {
PickerRow(label: "Personality", selection: viewModel.config.personality, options: viewModel.personalities) { viewModel.setPersonality($0) }
} else {
EditableTextField(label: "Personality", value: viewModel.config.personality) { viewModel.setPersonality($0) }
}
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
} }
} }
// MARK: - Terminal
private var terminalSection: some View {
SettingsSection(title: "Terminal", icon: "terminal") {
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) }
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
}
}
// MARK: - Voice
private var voiceSection: some View {
SettingsSection(title: "Voice", icon: "mic") {
ToggleRow(label: "Auto TTS", isOn: viewModel.config.autoTTS) { viewModel.setAutoTTS($0) }
StepperRow(label: "Silence Threshold", value: viewModel.config.silenceThreshold, range: 50...500) { viewModel.setSilenceThreshold($0) }
}
}
// MARK: - Memory
private var memorySection: some View {
SettingsSection(title: "Memory", icon: "brain") {
ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) }
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) }
StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) }
StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) }
}
}
// MARK: - Paths
private var pathsSection: some View { private var pathsSection: some View {
VStack(alignment: .leading, spacing: 8) { SettingsSection(title: "Paths", icon: "folder") {
Text("Paths")
.font(.headline)
VStack(alignment: .leading, spacing: 4) {
PathRow(label: "Hermes Home", path: HermesPaths.home) PathRow(label: "Hermes Home", path: HermesPaths.home)
PathRow(label: "State DB", path: HermesPaths.stateDB) PathRow(label: "State DB", path: HermesPaths.stateDB)
PathRow(label: "Config", path: HermesPaths.configYAML) PathRow(label: "Config", path: HermesPaths.configYAML)
@@ -73,7 +104,8 @@ struct SettingsView: View {
PathRow(label: "Logs", path: HermesPaths.errorsLog) 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) {
@@ -98,19 +130,143 @@ struct SettingsView: View {
} }
} }
struct SettingRow: View { // MARK: - Reusable Components
struct SettingsSection<Content: View>: View {
let title: String
let icon: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Label(title, systemImage: icon)
.font(.headline)
VStack(spacing: 1) {
content
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
struct EditableTextField: View {
let label: String let label: String
let value: String let value: String
let onCommit: (String) -> Void
@State private var text: String = ""
@State private var isEditing = false
var body: some View { var body: some View {
HStack { HStack {
Text(label) Text(label)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: 120, alignment: .trailing) .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) Text(value)
.font(.system(.caption, design: .monospaced)) .font(.system(.caption, design: .monospaced))
Spacer()
Button("Edit") {
text = value
isEditing = true
} }
.controlSize(.mini)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct PickerRow: View {
let label: String
let selection: String
let options: [String]
let onChange: (String) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Picker("", selection: Binding(
get: { selection },
set: { onChange($0) }
)) {
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
.frame(maxWidth: 250)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct ToggleRow: View {
let label: String
let isOn: Bool
let onChange: (Bool) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Toggle("", isOn: Binding(
get: { isOn },
set: { onChange($0) }
))
.toggleStyle(.switch)
.labelsHidden()
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
}
}
struct StepperRow: View {
let label: String
let value: Int
let range: ClosedRange<Int>
let onChange: (Int) -> Void
var body: some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .trailing)
Text("\(value)")
.font(.system(.caption, design: .monospaced))
.frame(width: 50)
Stepper("", value: Binding(
get: { value },
set: { onChange($0) }
), in: range)
.labelsHidden()
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
} }
} }
@@ -123,10 +279,11 @@ struct PathRow: View {
Text(label) 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 +292,8 @@ struct PathRow: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.3))
} }
} }
@@ -2,7 +2,7 @@ import Foundation
@Observable @Observable
final class ToolsViewModel { final class ToolsViewModel {
var selectedPlatform: HermesToolPlatform = KnownPlatforms.all[0] var selectedPlatform: HermesToolPlatform = KnownPlatforms.cli
var toolsets: [HermesToolset] = [] var toolsets: [HermesToolset] = []
var mcpStatus: String = "" var mcpStatus: String = ""
var isLoading = false var isLoading = false
@@ -30,7 +30,13 @@ final class ToolsViewModel {
} }
private func loadPlatforms() { private func loadPlatforms() {
let config = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? "" let config: String
do {
config = try String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)
} catch {
print("[Scarf] Failed to read config.yaml: \(error.localizedDescription)")
config = ""
}
var platforms: [HermesToolPlatform] = [] var platforms: [HermesToolPlatform] = []
var inSection = false var inSection = false
for line in config.components(separatedBy: "\n") { for line in config.components(separatedBy: "\n") {
@@ -54,9 +60,10 @@ final class ToolsViewModel {
} }
} }
} }
availablePlatforms = platforms.isEmpty ? [KnownPlatforms.all[0]] : platforms availablePlatforms = platforms.isEmpty ? [KnownPlatforms.cli] : platforms
if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }) { if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }),
selectedPlatform = availablePlatforms[0] let first = availablePlatforms.first {
selectedPlatform = first
} }
} }
@@ -5,6 +5,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case insights = "Insights" case insights = "Insights"
case sessions = "Sessions" case sessions = "Sessions"
case activity = "Activity" case activity = "Activity"
case projects = "Projects"
case chat = "Chat" case chat = "Chat"
case memory = "Memory" case memory = "Memory"
case skills = "Skills" case skills = "Skills"
@@ -23,6 +24,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case .insights: return "chart.bar" case .insights: return "chart.bar"
case .sessions: return "bubble.left.and.bubble.right" case .sessions: return "bubble.left.and.bubble.right"
case .activity: return "bolt.horizontal" case .activity: return "bolt.horizontal"
case .projects: return "square.grid.2x2"
case .chat: return "text.bubble" case .chat: return "text.bubble"
case .memory: return "brain" case .memory: return "brain"
case .skills: return "lightbulb" case .skills: return "lightbulb"
@@ -40,4 +42,5 @@ enum SidebarSection: String, CaseIterable, Identifiable {
final class AppCoordinator { final class AppCoordinator {
var selectedSection: SidebarSection = .dashboard var selectedSection: SidebarSection = .dashboard
var selectedSessionId: String? var selectedSessionId: String?
var selectedProjectName: String?
} }
+6
View File
@@ -12,6 +12,12 @@ struct SidebarView: View {
.tag(section) .tag(section)
} }
} }
Section("Projects") {
ForEach([SidebarSection.projects]) { section in
Label(section.rawValue, systemImage: section.icon)
.tag(section)
}
}
Section("Interact") { Section("Interact") {
ForEach([SidebarSection.chat, .memory, .skills]) { section in ForEach([SidebarSection.chat, .memory, .skills]) { section in
Label(section.rawValue, systemImage: section.icon) Label(section.rawValue, systemImage: section.icon)