mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7f3ca9be3 | |||
| dbaadb8037 | |||
| ce001fe202 | |||
| a329eca419 | |||
| 528de938c5 | |||
| 4f791d491e | |||
| dd79891874 |
@@ -43,3 +43,6 @@ Package.resolved
|
||||
# Claude Code
|
||||
.claude/
|
||||
scarf/standards/backups/
|
||||
|
||||
# Scarf project dashboards (user-specific)
|
||||
.scarf/
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
||||
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
|
||||
- **Log Viewer** — Real-time 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, and rich text in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
|
||||
- **Settings** — Structured config editor for all Hermes settings
|
||||
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
||||
|
||||
## Requirements
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
git clone https://github.com/awizemann/scarf.git
|
||||
@@ -61,7 +72,7 @@ open scarf.xcodeproj
|
||||
Or from the command line:
|
||||
|
||||
```bash
|
||||
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
||||
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Release -arch arm64 -arch x86_64 ONLY_ACTIVE_ARCH=NO build
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -78,6 +89,7 @@ scarf/
|
||||
Insights/ Usage analytics and activity patterns
|
||||
Sessions/ Conversation browser with rename, delete, export
|
||||
Activity/ Tool execution feed with inspector
|
||||
Projects/ Agent-generated project dashboards with widget rendering
|
||||
Chat/ Embedded terminal via SwiftTerm with voice controls
|
||||
Memory/ Memory viewer and editor
|
||||
Skills/ Skill browser by category
|
||||
@@ -85,7 +97,7 @@ scarf/
|
||||
Gateway/ Messaging gateway control and pairing
|
||||
Cron/ Scheduled job viewer
|
||||
Logs/ Real-time log viewer
|
||||
Settings/ Configuration display
|
||||
Settings/ Structured config editor
|
||||
Navigation/ AppCoordinator + SidebarView
|
||||
```
|
||||
|
||||
@@ -107,6 +119,8 @@ Scarf reads Hermes data directly from `~/.hermes/`:
|
||||
| `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 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 |
|
||||
|
||||
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
|
||||
|
||||
@@ -128,6 +142,124 @@ 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.
|
||||
|
||||
## 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, and rich text — 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
|
||||
- **Any project status** — if your agent can measure it, Scarf can display it
|
||||
|
||||
### Quick Start
|
||||
|
||||
**1. Create the dashboard file**
|
||||
|
||||
Create `.scarf/dashboard.json` in any project folder:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"title": "My Project",
|
||||
"description": "Project status at a glance",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Overview",
|
||||
"columns": 3,
|
||||
"widgets": [
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Test Coverage",
|
||||
"value": "87%",
|
||||
"icon": "checkmark.shield",
|
||||
"color": "green",
|
||||
"subtitle": "+2.1% this week"
|
||||
},
|
||||
{
|
||||
"type": "progress",
|
||||
"title": "Sprint Progress",
|
||||
"value": 0.73,
|
||||
"label": "73% complete",
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"title": "Tasks",
|
||||
"items": [
|
||||
{ "text": "Write unit tests", "status": "done" },
|
||||
{ "text": "Update API docs", "status": "active" },
|
||||
{ "text": "Deploy to prod", "status": "pending" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**2. Register your project**
|
||||
|
||||
In Scarf, go to **Projects** in the sidebar and click the **+** button to add your project folder. Or have your agent add it directly to the registry at `~/.hermes/scarf/projects.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"projects": [
|
||||
{ "name": "my-project", "path": "/Users/you/Developer/my-project" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**3. View in Scarf**
|
||||
|
||||
Select your project in the Projects sidebar — the dashboard renders immediately. Scarf watches the file for changes and refreshes automatically whenever the JSON is updated.
|
||||
|
||||
### Widget Types
|
||||
|
||||
| Type | Description | Key Fields |
|
||||
|------|-------------|------------|
|
||||
| `stat` | Key metric with large value display | `value`, `icon`, `color`, `subtitle` |
|
||||
| `progress` | Progress bar with label | `value` (0.0–1.0), `label`, `color` |
|
||||
| `text` | Rich text block | `content`, `format` ("markdown" or "plain") |
|
||||
| `table` | Data table with headers | `columns`, `rows` |
|
||||
| `chart` | Line, bar, or pie chart | `chartType`, `series` (each with `name`, `color`, `data`) |
|
||||
| `list` | Checklist with status indicators | `items` (each with `text`, `status`: done/active/pending) |
|
||||
|
||||
**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, and lists for task tracking. Register the project in `~/.hermes/scarf/projects.json` if not already registered.
|
||||
|
||||
Your agent can update the dashboard as part of cron jobs, after builds, or whenever project state changes. Since Scarf watches the file, updates appear in real-time.
|
||||
|
||||
### Dashboard Schema Reference
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"title": "Required — dashboard title",
|
||||
"description": "Optional — subtitle text",
|
||||
"updatedAt": "Optional — ISO 8601 timestamp",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Section Name",
|
||||
"columns": 3,
|
||||
"widgets": [{ "type": "...", "title": "..." }]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each section defines a grid with 1–4 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# 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)
|
||||
|
||||
## 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, and list 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.
|
||||
@@ -22,6 +22,8 @@ struct ContentView: View {
|
||||
SessionsView()
|
||||
case .activity:
|
||||
ActivityView()
|
||||
case .projects:
|
||||
ProjectsView()
|
||||
case .chat:
|
||||
ChatView()
|
||||
case .memory:
|
||||
|
||||
@@ -14,6 +14,7 @@ struct HermesConfig: Sendable {
|
||||
var showReasoning: Bool
|
||||
var verbose: Bool
|
||||
var autoTTS: Bool
|
||||
var silenceThreshold: Int
|
||||
|
||||
static let empty = HermesConfig(
|
||||
model: "unknown",
|
||||
@@ -28,7 +29,8 @@ struct HermesConfig: Sendable {
|
||||
streaming: true,
|
||||
showReasoning: false,
|
||||
verbose: false,
|
||||
autoTTS: true
|
||||
autoTTS: true,
|
||||
silenceThreshold: 200
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,4 +16,6 @@ enum HermesPaths: Sendable {
|
||||
nonisolated static let errorsLog: String = home + "/logs/errors.log"
|
||||
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
|
||||
nonisolated static let hermesBinary: String = ProcessInfo.processInfo.environment["HOME"]! + "/.local/bin/hermes"
|
||||
nonisolated static let scarfDir: String = home + "/scarf"
|
||||
nonisolated static let projectsRegistry: String = scarfDir + "/projects.json"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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]?
|
||||
}
|
||||
|
||||
// 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?
|
||||
}
|
||||
@@ -43,7 +43,8 @@ struct HermesFileService: Sendable {
|
||||
streaming: values["display.streaming"] != "false",
|
||||
showReasoning: values["display.show_reasoning"] == "true",
|
||||
verbose: values["agent.verbose"] == "true",
|
||||
autoTTS: values["voice.auto_tts"] != "false"
|
||||
autoTTS: values["voice.auto_tts"] != "false",
|
||||
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? 200
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import Foundation
|
||||
@Observable
|
||||
final class HermesFileWatcher {
|
||||
private(set) var lastChangeDate = Date()
|
||||
private var sources: [DispatchSourceFileSystemObject] = []
|
||||
private var coreSources: [DispatchSourceFileSystemObject] = []
|
||||
private var projectSources: [DispatchSourceFileSystemObject] = []
|
||||
private var timer: Timer?
|
||||
|
||||
func startWatching() {
|
||||
@@ -16,11 +17,14 @@ final class HermesFileWatcher {
|
||||
HermesPaths.cronJobsJSON,
|
||||
HermesPaths.gatewayStateJSON,
|
||||
HermesPaths.errorsLog,
|
||||
HermesPaths.gatewayLog
|
||||
HermesPaths.gatewayLog,
|
||||
HermesPaths.projectsRegistry
|
||||
]
|
||||
|
||||
for path in paths {
|
||||
watchFile(path)
|
||||
if let source = makeSource(for: path) {
|
||||
coreSources.append(source)
|
||||
}
|
||||
}
|
||||
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
||||
@@ -29,17 +33,30 @@ final class HermesFileWatcher {
|
||||
}
|
||||
|
||||
func stopWatching() {
|
||||
for source in sources {
|
||||
for source in coreSources + projectSources {
|
||||
source.cancel()
|
||||
}
|
||||
sources.removeAll()
|
||||
coreSources.removeAll()
|
||||
projectSources.removeAll()
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func watchFile(_ path: String) {
|
||||
func updateProjectWatches(_ dashboardPaths: [String]) {
|
||||
for source in projectSources {
|
||||
source.cancel()
|
||||
}
|
||||
projectSources.removeAll()
|
||||
for path in dashboardPaths {
|
||||
if let source = makeSource(for: path) {
|
||||
projectSources.append(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSource(for path: String) -> DispatchSourceFileSystemObject? {
|
||||
let fd = Darwin.open(path, O_EVTONLY)
|
||||
guard fd >= 0 else { return }
|
||||
guard fd >= 0 else { return nil }
|
||||
|
||||
let source = DispatchSource.makeFileSystemObjectSource(
|
||||
fileDescriptor: fd,
|
||||
@@ -53,7 +70,7 @@ final class HermesFileWatcher {
|
||||
Darwin.close(fd)
|
||||
}
|
||||
source.resume()
|
||||
sources.append(source)
|
||||
return source
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
struct ProjectDashboardService: Sendable {
|
||||
|
||||
// MARK: - Registry
|
||||
|
||||
func loadRegistry() -> ProjectRegistry {
|
||||
guard let data = FileManager.default.contents(atPath: HermesPaths.projectsRegistry) else {
|
||||
return ProjectRegistry(projects: [])
|
||||
}
|
||||
return (try? JSONDecoder().decode(ProjectRegistry.self, from: data))
|
||||
?? ProjectRegistry(projects: [])
|
||||
}
|
||||
|
||||
func saveRegistry(_ registry: ProjectRegistry) {
|
||||
let dir = HermesPaths.scarfDir
|
||||
if !FileManager.default.fileExists(atPath: dir) {
|
||||
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||
}
|
||||
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
|
||||
}
|
||||
return try? JSONDecoder().decode(ProjectDashboard.self, from: data)
|
||||
}
|
||||
|
||||
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
|
||||
container.addSubview(terminalView)
|
||||
NSLayoutConstraint.activate([
|
||||
terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
|
||||
terminalView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
terminalView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
terminalView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
@@ -24,7 +24,7 @@ struct PersistentTerminalView: NSViewRepresentable {
|
||||
terminalView.translatesAutoresizingMaskIntoConstraints = false
|
||||
nsView.addSubview(terminalView)
|
||||
NSLayoutConstraint.activate([
|
||||
terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor),
|
||||
terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor, constant: 4),
|
||||
terminalView.trailingAnchor.constraint(equalTo: nsView.trailingAnchor),
|
||||
terminalView.topAnchor.constraint(equalTo: nsView.topAnchor),
|
||||
terminalView.bottomAnchor.constraint(equalTo: nsView.bottomAnchor),
|
||||
|
||||
@@ -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,255 @@
|
||||
import SwiftUI
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@ViewBuilder
|
||||
private var dashboardArea: some View {
|
||||
if let dashboard = viewModel.dashboard {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
dashboardHeader(dashboard)
|
||||
ForEach(dashboard.sections) { section in
|
||||
DashboardSectionView(section: section)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
} 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 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
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(section.title)
|
||||
.font(.headline)
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: section.columnCount),
|
||||
spacing: 12
|
||||
) {
|
||||
ForEach(section.widgets) { 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)
|
||||
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,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,5 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
@Observable
|
||||
final class SettingsViewModel {
|
||||
@@ -8,11 +9,89 @@ final class SettingsViewModel {
|
||||
var gatewayState: GatewayState?
|
||||
var hermesRunning = false
|
||||
var rawConfigYAML = ""
|
||||
var personalities: [String] = []
|
||||
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax"]
|
||||
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
|
||||
var saveMessage: String?
|
||||
|
||||
func load() {
|
||||
config = fileService.loadConfig()
|
||||
gatewayState = fileService.loadGatewayState()
|
||||
hermesRunning = fileService.isHermesRunning()
|
||||
rawConfigYAML = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
||||
personalities = parsePersonalities()
|
||||
}
|
||||
|
||||
func setSetting(_ key: String, value: String) {
|
||||
let result = runHermes(["config", "set", key, value])
|
||||
if result.exitCode == 0 {
|
||||
saveMessage = "Saved \(key)"
|
||||
config = fileService.loadConfig()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.saveMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setModel(_ value: String) { setSetting("model.default", value: value) }
|
||||
func setProvider(_ value: String) { setSetting("model.provider", value: value) }
|
||||
func setPersonality(_ value: String) { setSetting("display.personality", value: value) }
|
||||
func setTerminalBackend(_ value: String) { setSetting("terminal.backend", value: value) }
|
||||
func setMaxTurns(_ value: Int) { setSetting("agent.max_turns", value: String(value)) }
|
||||
func setMemoryEnabled(_ value: Bool) { setSetting("memory.memory_enabled", value: value ? "true" : "false") }
|
||||
func setMemoryCharLimit(_ value: Int) { setSetting("memory.memory_char_limit", value: String(value)) }
|
||||
func setUserCharLimit(_ value: Int) { setSetting("memory.user_char_limit", value: String(value)) }
|
||||
func setNudgeInterval(_ value: Int) { setSetting("memory.nudge_interval", value: String(value)) }
|
||||
func setStreaming(_ value: Bool) { setSetting("display.streaming", value: value ? "true" : "false") }
|
||||
func setShowReasoning(_ value: Bool) { setSetting("display.show_reasoning", value: value ? "true" : "false") }
|
||||
func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") }
|
||||
func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
|
||||
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
|
||||
|
||||
func openConfigInEditor() {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||
}
|
||||
|
||||
private func parsePersonalities() -> [String] {
|
||||
var names: [String] = []
|
||||
var inPersonalities = false
|
||||
for line in rawConfigYAML.components(separatedBy: "\n") {
|
||||
if line.trimmingCharacters(in: .whitespaces) == "personalities:" && line.hasPrefix(" ") {
|
||||
inPersonalities = true
|
||||
continue
|
||||
}
|
||||
if inPersonalities {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty { continue }
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
if indent <= 2 && !trimmed.isEmpty {
|
||||
inPersonalities = false
|
||||
continue
|
||||
}
|
||||
if indent == 4 && trimmed.contains(":") {
|
||||
let name = String(trimmed.split(separator: ":")[0])
|
||||
names.append(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||
process.arguments = arguments
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = Pipe()
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||
} catch {
|
||||
return ("", -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@ struct SettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
configSection
|
||||
gatewaySection
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
headerBar
|
||||
modelSection
|
||||
displaySection
|
||||
terminalSection
|
||||
voiceSection
|
||||
memorySection
|
||||
pathsSection
|
||||
rawConfigSection
|
||||
}
|
||||
@@ -19,51 +23,78 @@ struct SettingsView: View {
|
||||
.onAppear { viewModel.load() }
|
||||
}
|
||||
|
||||
private var configSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Configuration")
|
||||
.font(.headline)
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
||||
SettingRow(label: "Model", value: viewModel.config.model)
|
||||
SettingRow(label: "Provider", value: viewModel.config.provider)
|
||||
SettingRow(label: "Personality", value: viewModel.config.personality)
|
||||
SettingRow(label: "Max Turns", value: "\(viewModel.config.maxTurns)")
|
||||
SettingRow(label: "Terminal Backend", value: viewModel.config.terminalBackend)
|
||||
SettingRow(label: "Memory Enabled", value: viewModel.config.memoryEnabled ? "Yes" : "No")
|
||||
SettingRow(label: "Memory Char Limit", value: "\(viewModel.config.memoryCharLimit)")
|
||||
SettingRow(label: "User Char Limit", value: "\(viewModel.config.userCharLimit)")
|
||||
SettingRow(label: "Nudge Interval", value: "\(viewModel.config.nudgeInterval) turns")
|
||||
SettingRow(label: "Streaming", value: viewModel.config.streaming ? "Yes" : "No")
|
||||
SettingRow(label: "Show Reasoning", value: viewModel.config.showReasoning ? "Yes" : "No")
|
||||
SettingRow(label: "Verbose", value: viewModel.config.verbose ? "Yes" : "No")
|
||||
private var headerBar: some View {
|
||||
HStack {
|
||||
if let msg = viewModel.saveMessage {
|
||||
Label(msg, systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Spacer()
|
||||
Button("Open in Editor") { viewModel.openConfigInEditor() }
|
||||
.controlSize(.small)
|
||||
Button("Reload") { viewModel.load() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewaySection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Gateway")
|
||||
.font(.headline)
|
||||
HStack(spacing: 16) {
|
||||
Label(
|
||||
viewModel.gatewayState?.statusText ?? "unknown",
|
||||
systemImage: viewModel.gatewayState?.isRunning == true ? "circle.fill" : "circle"
|
||||
)
|
||||
.foregroundStyle(viewModel.gatewayState?.isRunning == true ? .green : .secondary)
|
||||
if let reason = viewModel.gatewayState?.exitReason {
|
||||
Text(reason)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
// MARK: - Model & Provider
|
||||
|
||||
private var modelSection: some View {
|
||||
SettingsSection(title: "Model", icon: "cpu") {
|
||||
EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($0) }
|
||||
PickerRow(label: "Provider", selection: viewModel.config.provider, options: viewModel.providers) { viewModel.setProvider($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Display
|
||||
|
||||
private var displaySection: some View {
|
||||
SettingsSection(title: "Display", icon: "paintbrush") {
|
||||
if !viewModel.personalities.isEmpty {
|
||||
PickerRow(label: "Personality", selection: viewModel.config.personality, options: viewModel.personalities) { viewModel.setPersonality($0) }
|
||||
} else {
|
||||
EditableTextField(label: "Personality", value: viewModel.config.personality) { viewModel.setPersonality($0) }
|
||||
}
|
||||
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
||||
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
||||
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal
|
||||
|
||||
private var terminalSection: some View {
|
||||
SettingsSection(title: "Terminal", icon: "terminal") {
|
||||
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) }
|
||||
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice
|
||||
|
||||
private var voiceSection: some View {
|
||||
SettingsSection(title: "Voice", icon: "mic") {
|
||||
ToggleRow(label: "Auto TTS", isOn: viewModel.config.autoTTS) { viewModel.setAutoTTS($0) }
|
||||
StepperRow(label: "Silence Threshold", value: viewModel.config.silenceThreshold, range: 50...500) { viewModel.setSilenceThreshold($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Memory
|
||||
|
||||
private var memorySection: some View {
|
||||
SettingsSection(title: "Memory", icon: "brain") {
|
||||
ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) }
|
||||
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) }
|
||||
StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) }
|
||||
StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paths
|
||||
|
||||
private var pathsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paths")
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
SettingsSection(title: "Paths", icon: "folder") {
|
||||
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
||||
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
||||
PathRow(label: "Config", path: HermesPaths.configYAML)
|
||||
@@ -73,7 +104,8 @@ struct SettingsView: View {
|
||||
PathRow(label: "Logs", path: HermesPaths.errorsLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Raw Config
|
||||
|
||||
private var rawConfigSection: some View {
|
||||
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 value: String
|
||||
let onCommit: (String) -> Void
|
||||
@State private var text: String = ""
|
||||
@State private var isEditing = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 120, alignment: .trailing)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
if isEditing {
|
||||
TextField(label, text: $text, onCommit: {
|
||||
if text != value { onCommit(text) }
|
||||
isEditing = false
|
||||
})
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Button("Cancel") { isEditing = false }
|
||||
.controlSize(.mini)
|
||||
} else {
|
||||
Text(value)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
Spacer()
|
||||
Button("Edit") {
|
||||
text = value
|
||||
isEditing = true
|
||||
}
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct PickerRow: View {
|
||||
let label: String
|
||||
let selection: String
|
||||
let options: [String]
|
||||
let onChange: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Picker("", selection: Binding(
|
||||
get: { selection },
|
||||
set: { onChange($0) }
|
||||
)) {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Text(option).tag(option)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 250)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct ToggleRow: View {
|
||||
let label: String
|
||||
let isOn: Bool
|
||||
let onChange: (Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Toggle("", isOn: Binding(
|
||||
get: { isOn },
|
||||
set: { onChange($0) }
|
||||
))
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
struct StepperRow: View {
|
||||
let label: String
|
||||
let value: Int
|
||||
let range: ClosedRange<Int>
|
||||
let onChange: (Int) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Text("\(value)")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(width: 50)
|
||||
Stepper("", value: Binding(
|
||||
get: { value },
|
||||
set: { onChange($0) }
|
||||
), in: range)
|
||||
.labelsHidden()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,10 +279,11 @@ struct PathRow: View {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 100, alignment: .trailing)
|
||||
.frame(width: 130, alignment: .trailing)
|
||||
Text(path)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Spacer()
|
||||
Button {
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
|
||||
} label: {
|
||||
@@ -135,5 +292,8 @@ struct PathRow: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
case insights = "Insights"
|
||||
case sessions = "Sessions"
|
||||
case activity = "Activity"
|
||||
case projects = "Projects"
|
||||
case chat = "Chat"
|
||||
case memory = "Memory"
|
||||
case skills = "Skills"
|
||||
@@ -23,6 +24,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
case .insights: return "chart.bar"
|
||||
case .sessions: return "bubble.left.and.bubble.right"
|
||||
case .activity: return "bolt.horizontal"
|
||||
case .projects: return "square.grid.2x2"
|
||||
case .chat: return "text.bubble"
|
||||
case .memory: return "brain"
|
||||
case .skills: return "lightbulb"
|
||||
@@ -40,4 +42,5 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
final class AppCoordinator {
|
||||
var selectedSection: SidebarSection = .dashboard
|
||||
var selectedSessionId: String?
|
||||
var selectedProjectName: String?
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ struct SidebarView: View {
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
Section("Projects") {
|
||||
ForEach([SidebarSection.projects]) { section in
|
||||
Label(section.rawValue, systemImage: section.icon)
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
Section("Interact") {
|
||||
ForEach([SidebarSection.chat, .memory, .skills]) { section in
|
||||
Label(section.rawValue, systemImage: section.icon)
|
||||
|
||||
Reference in New Issue
Block a user