Compare commits

...

5 Commits

Author SHA1 Message Date
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
20 changed files with 1133 additions and 15 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/
+137 -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, 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 - **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,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. 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.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) |
**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 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.
+153
View File
@@ -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.
+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:
@@ -16,4 +16,6 @@ enum HermesPaths: Sendable {
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 = 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?
}
@@ -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 {
@@ -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 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),
@@ -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
}
}
@@ -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)