From ce001fe2022c363b014d59dc2d0e8d9da49d234e Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 22:28:46 -0400 Subject: [PATCH 1/2] Add left padding to terminal view in chat interface Co-Authored-By: Claude Opus 4.6 (1M context) --- scarf/scarf/Features/Chat/Views/TerminalRepresentable.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scarf/scarf/Features/Chat/Views/TerminalRepresentable.swift b/scarf/scarf/Features/Chat/Views/TerminalRepresentable.swift index 7691baf..b4fe8aa 100644 --- a/scarf/scarf/Features/Chat/Views/TerminalRepresentable.swift +++ b/scarf/scarf/Features/Chat/Views/TerminalRepresentable.swift @@ -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), From dbaadb8037a3410a70bf6e0d9a17b1011186b63d Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Wed, 1 Apr 2026 00:48:13 -0400 Subject: [PATCH 2/2] 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) --- .gitignore | 3 + README.md | 128 ++++++++- scarf/docs/DASHBOARD_SCHEMA.md | 153 +++++++++++ scarf/scarf/ContentView.swift | 2 + scarf/scarf/Core/Models/HermesConstants.swift | 2 + .../scarf/Core/Models/ProjectDashboard.swift | 134 +++++++++ .../Core/Services/HermesFileWatcher.swift | 33 ++- .../Services/ProjectDashboardService.swift | 49 ++++ .../ViewModels/ProjectsViewModel.swift | 74 +++++ .../Projects/Views/ProjectsView.swift | 255 ++++++++++++++++++ .../Views/Widgets/ChartWidgetView.swift | 82 ++++++ .../Views/Widgets/ListWidgetView.swift | 54 ++++ .../Views/Widgets/ProgressWidgetView.swift | 32 +++ .../Views/Widgets/StatWidgetView.swift | 37 +++ .../Views/Widgets/TableWidgetView.swift | 37 +++ .../Views/Widgets/TextWidgetView.swift | 27 ++ .../Views/Widgets/WidgetHelpers.swift | 19 ++ scarf/scarf/Navigation/AppCoordinator.swift | 3 + scarf/scarf/Navigation/SidebarView.swift | 6 + 19 files changed, 1119 insertions(+), 11 deletions(-) create mode 100644 scarf/docs/DASHBOARD_SCHEMA.md create mode 100644 scarf/scarf/Core/Models/ProjectDashboard.swift create mode 100644 scarf/scarf/Core/Services/ProjectDashboardService.swift create mode 100644 scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift create mode 100644 scarf/scarf/Features/Projects/Views/ProjectsView.swift create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/ChartWidgetView.swift create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/ListWidgetView.swift create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/ProgressWidgetView.swift create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/StatWidgetView.swift create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/TableWidgetView.swift create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/TextWidgetView.swift create mode 100644 scarf/scarf/Features/Projects/Views/Widgets/WidgetHelpers.swift diff --git a/.gitignore b/.gitignore index 68d1116..e26532e 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ Package.resolved # Claude Code .claude/ scarf/standards/backups/ + +# Scarf project dashboards (user-specific) +.scarf/ diff --git a/README.md b/README.md index b24c08e..f077b48 100644 --- a/README.md +++ b/README.md @@ -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 @@ -88,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 @@ -95,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 ``` @@ -117,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. @@ -126,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 @@ -138,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. diff --git a/scarf/docs/DASHBOARD_SCHEMA.md b/scarf/docs/DASHBOARD_SCHEMA.md new file mode 100644 index 0000000..65427ea --- /dev/null +++ b/scarf/docs/DASHBOARD_SCHEMA.md @@ -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. diff --git a/scarf/scarf/ContentView.swift b/scarf/scarf/ContentView.swift index ac408a8..93cac0a 100644 --- a/scarf/scarf/ContentView.swift +++ b/scarf/scarf/ContentView.swift @@ -22,6 +22,8 @@ struct ContentView: View { SessionsView() case .activity: ActivityView() + case .projects: + ProjectsView() case .chat: ChatView() case .memory: diff --git a/scarf/scarf/Core/Models/HermesConstants.swift b/scarf/scarf/Core/Models/HermesConstants.swift index 2f092cb..7a19bbd 100644 --- a/scarf/scarf/Core/Models/HermesConstants.swift +++ b/scarf/scarf/Core/Models/HermesConstants.swift @@ -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" } diff --git a/scarf/scarf/Core/Models/ProjectDashboard.swift b/scarf/scarf/Core/Models/ProjectDashboard.swift new file mode 100644 index 0000000..681908c --- /dev/null +++ b/scarf/scarf/Core/Models/ProjectDashboard.swift @@ -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? +} diff --git a/scarf/scarf/Core/Services/HermesFileWatcher.swift b/scarf/scarf/Core/Services/HermesFileWatcher.swift index f00b493..050fa69 100644 --- a/scarf/scarf/Core/Services/HermesFileWatcher.swift +++ b/scarf/scarf/Core/Services/HermesFileWatcher.swift @@ -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 { diff --git a/scarf/scarf/Core/Services/ProjectDashboardService.swift b/scarf/scarf/Core/Services/ProjectDashboardService.swift new file mode 100644 index 0000000..dbc3fe6 --- /dev/null +++ b/scarf/scarf/Core/Services/ProjectDashboardService.swift @@ -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 + } +} diff --git a/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift b/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift new file mode 100644 index 0000000..8827024 --- /dev/null +++ b/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift @@ -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" + } + } +} diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift new file mode 100644 index 0000000..e2bbfc5 --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -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) + } +} diff --git a/scarf/scarf/Features/Projects/Views/Widgets/ChartWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/ChartWidgetView.swift new file mode 100644 index 0000000..14175b4 --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/ChartWidgetView.swift @@ -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) + } + } +} diff --git a/scarf/scarf/Features/Projects/Views/Widgets/ListWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/ListWidgetView.swift new file mode 100644 index 0000000..e3ed9cb --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/ListWidgetView.swift @@ -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 + } + } +} diff --git a/scarf/scarf/Features/Projects/Views/Widgets/ProgressWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/ProgressWidgetView.swift new file mode 100644 index 0000000..94abe93 --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/ProgressWidgetView.swift @@ -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)) + } +} diff --git a/scarf/scarf/Features/Projects/Views/Widgets/StatWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/StatWidgetView.swift new file mode 100644 index 0000000..23ec09e --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/StatWidgetView.swift @@ -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)) + } +} diff --git a/scarf/scarf/Features/Projects/Views/Widgets/TableWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/TableWidgetView.swift new file mode 100644 index 0000000..8da5879 --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/TableWidgetView.swift @@ -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)) + } +} diff --git a/scarf/scarf/Features/Projects/Views/Widgets/TextWidgetView.swift b/scarf/scarf/Features/Projects/Views/Widgets/TextWidgetView.swift new file mode 100644 index 0000000..fc7a8fb --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/TextWidgetView.swift @@ -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)) + } +} diff --git a/scarf/scarf/Features/Projects/Views/Widgets/WidgetHelpers.swift b/scarf/scarf/Features/Projects/Views/Widgets/WidgetHelpers.swift new file mode 100644 index 0000000..57e16cd --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/Widgets/WidgetHelpers.swift @@ -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 + } +} diff --git a/scarf/scarf/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift index d8e45d7..38379f4 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -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? } diff --git a/scarf/scarf/Navigation/SidebarView.swift b/scarf/scarf/Navigation/SidebarView.swift index 3bfd15d..1c6534c 100644 --- a/scarf/scarf/Navigation/SidebarView.swift +++ b/scarf/scarf/Navigation/SidebarView.swift @@ -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)