diff --git a/README.md b/README.md index f3be841..39990d0 100644 --- a/README.md +++ b/README.md @@ -19,22 +19,36 @@ ## Features -- **Dashboard** — System health, token usage, recent sessions at a glance -- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, and full-text search (FTS5) -- **Activity Feed** — Recent tool execution log with filtering by kind (read/edit/execute/fetch/browser) and detail inspector -- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) -- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live refresh -- **Skills Browser** — Browse all installed skills by category with file content viewer +- **Dashboard** — System health, token usage, recent sessions with live refresh +- **Insights** — Usage analytics with token breakdown, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time) +- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, full-text search, rename, delete, and JSONL export +- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments +- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm), session persistence across navigation, resume/continue previous sessions, and voice mode controls +- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh +- **Skills Browser** — Browse all installed skills by category with file content viewer and file switcher +- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, etc.) with toggle switches, MCP server status +- **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 tailing of error and gateway logs with level filtering -- **Settings** — Read-only config display with raw YAML viewer and Finder path links +- **Log Viewer** — Real-time log tailing with level filtering and text search +- **Settings** — Configuration display with raw YAML viewer and Finder path links - **Menu Bar** — Status icon showing Hermes running state with quick actions ## Requirements - macOS 26.2+ - Xcode 26.3+ -- [Hermes agent](https://github.com/hermes-ai/hermes-agent) installed at `~/.hermes/` +- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` + +### Compatibility + +Scarf reads Hermes's SQLite database (schema v6) and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Tested and verified against: + +| Hermes Version | Status | +|----------------|--------| +| v0.6.0 (2026-03-30) | Verified | +| v0.6.0 (2026-03-31, latest) | Verified | + +If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings. ## Building @@ -61,11 +75,14 @@ scarf/ Services/ Data access (SQLite reader, file I/O, log tailing, file watcher) Features/ Self-contained feature modules Dashboard/ System overview and stats - Sessions/ Conversation browser with detail view + Insights/ Usage analytics and activity patterns + Sessions/ Conversation browser with rename, delete, export Activity/ Tool execution feed with inspector - Chat/ Embedded terminal via SwiftTerm + Chat/ Embedded terminal via SwiftTerm with voice controls Memory/ Memory viewer and editor Skills/ Skill browser by category + Tools/ Toolset management per platform + Gateway/ Messaging gateway control and pairing Cron/ Scheduled job viewer Logs/ Real-time log viewer Settings/ Configuration display @@ -86,8 +103,12 @@ Scarf reads Hermes data directly from `~/.hermes/`: | `gateway_state.json` | JSON | Read-only | | `skills/` | Directory tree | Read-only | | `hermes chat` | Terminal subprocess | Interactive | +| `hermes tools` | CLI commands | Enable/Disable | +| `hermes sessions` | CLI commands | Rename/Delete/Export | +| `hermes gateway` | CLI commands | Start/Stop/Restart | +| `hermes pairing` | CLI commands | Approve/Revoke | -The app **never writes** to `state.db` — it opens in read-only mode to avoid WAL contention with Hermes. +The app opens `state.db` in read-only mode to avoid WAL contention with Hermes. Management actions (tool toggles, session rename/delete/export) go through the Hermes CLI. ### Dependencies @@ -99,7 +120,11 @@ Everything else uses system frameworks: SQLite3 C API, Foundation JSON, Attribut ## How It Works -Scarf is a passive observer. It watches `~/.hermes/` for file changes and polls the SQLite database for new sessions and messages. The Chat tab spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive Hermes CLI experience with proper ANSI rendering. +Scarf watches `~/.hermes/` for file changes and queries the SQLite database for sessions, messages, and analytics. Views refresh automatically when Hermes writes new data. + +The Chat tab spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation — switch tabs and come back without losing your conversation. + +Management actions (renaming sessions, toggling tools, editing memory) call the Hermes CLI or write directly to the appropriate files, keeping Scarf and Hermes in sync. The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary. diff --git a/scarf/docs/ROADMAP.md b/scarf/docs/ROADMAP.md new file mode 100644 index 0000000..3318b47 --- /dev/null +++ b/scarf/docs/ROADMAP.md @@ -0,0 +1,58 @@ +# Scarf — Feature Roadmap + +## Tier 1 — High Value, Data Already Available + +### 1. Insights Dashboard +Rich usage analytics pulled from the sessions and messages SQLite tables: +- Overview stats: sessions, messages, tool calls, tokens, active time, avg session duration +- Model breakdown: sessions and tokens per model +- Platform breakdown: CLI vs Telegram vs Discord usage +- Top tools chart: ranked tool usage with call counts and percentages +- Activity patterns: sessions by day-of-week, peak hours heatmap +- Notable sessions: longest, most messages, most tokens, most tool calls +- Time period selector: last 7/30/90 days + +### 2. Tool Management Panel +- List all toolsets with enabled/disabled status and descriptions +- Toggle switches to enable/disable tools (via `hermes tools enable/disable`) +- Per-platform tool configuration +- MCP tool status + +### 3. Session Management Enhancements +- Rename sessions from the Sessions browser (via `hermes sessions rename`) +- Delete sessions (via `hermes sessions delete`) +- Export sessions to JSONL (via `hermes sessions export`) +- Session stats card (total count, DB size, per-platform breakdown) + +## Tier 2 — Medium Value, New Service Code Required + +### 4. Skills Hub +- Search remote registries for new skills (6 sources) +- Install/uninstall skills from GUI +- Skill update indicator +- Trust level badges (builtin, local, hub) + +### 5. Gateway Control Center +- Start/stop/restart gateway from GUI +- Real-time status: PID, uptime, connected platforms +- Pairing management: view approved users, approve/revoke +- Platform status per messaging service + +### 6. System Health View +- Mirror `hermes status` and `hermes doctor` output +- API key validation, auth provider status, external tools +- Update available indicator + +## Tier 3 — Nice to Have + +### 7. Profile Management +- List/create/switch profiles (isolated Hermes instances) + +### 8. Plugin Management +- Install from Git, enable/disable, update + +### 9. MCP Server Management +- Add/remove/test MCP servers, toggle tools per server + +### 10. Config Editor +- Structured form editor for config.yaml with validation diff --git a/scarf/scarf.xcodeproj/project.pbxproj b/scarf/scarf.xcodeproj/project.pbxproj index cc82d7d..f890b07 100644 --- a/scarf/scarf.xcodeproj/project.pbxproj +++ b/scarf/scarf.xcodeproj/project.pbxproj @@ -404,6 +404,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; @@ -415,6 +416,7 @@ INFOPLIST_KEY_CFBundleDisplayName = Scarf; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -438,6 +440,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; @@ -449,6 +452,7 @@ INFOPLIST_KEY_CFBundleDisplayName = Scarf; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", diff --git a/scarf/scarf/ContentView.swift b/scarf/scarf/ContentView.swift index 895ab67..ac408a8 100644 --- a/scarf/scarf/ContentView.swift +++ b/scarf/scarf/ContentView.swift @@ -16,6 +16,8 @@ struct ContentView: View { switch coordinator.selectedSection { case .dashboard: DashboardView() + case .insights: + InsightsView() case .sessions: SessionsView() case .activity: @@ -26,8 +28,14 @@ struct ContentView: View { MemoryView() case .skills: SkillsView() + case .tools: + ToolsView() + case .gateway: + GatewayView() case .cron: CronView() + case .health: + HealthView() case .logs: LogsView() case .settings: diff --git a/scarf/scarf/Core/Models/HermesConfig.swift b/scarf/scarf/Core/Models/HermesConfig.swift index 9cb2107..bd5d91a 100644 --- a/scarf/scarf/Core/Models/HermesConfig.swift +++ b/scarf/scarf/Core/Models/HermesConfig.swift @@ -13,6 +13,7 @@ struct HermesConfig: Sendable { var streaming: Bool var showReasoning: Bool var verbose: Bool + var autoTTS: Bool static let empty = HermesConfig( model: "unknown", @@ -26,7 +27,8 @@ struct HermesConfig: Sendable { nudgeInterval: 0, streaming: true, showReasoning: false, - verbose: false + verbose: false, + autoTTS: true ) } diff --git a/scarf/scarf/Core/Models/HermesTool.swift b/scarf/scarf/Core/Models/HermesTool.swift new file mode 100644 index 0000000..21f6943 --- /dev/null +++ b/scarf/scarf/Core/Models/HermesTool.swift @@ -0,0 +1,28 @@ +import Foundation + +struct HermesToolset: Identifiable, Sendable { + var id: String { name } + let name: String + let description: String + let icon: String + var enabled: Bool +} + +struct HermesToolPlatform: Identifiable, Sendable { + var id: String { name } + let name: String + let displayName: String + let icon: String +} + +enum KnownPlatforms { + static let all: [HermesToolPlatform] = [ + HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal"), + HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"), + HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"), + HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"), + HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"), + HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"), + HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"), + ] +} diff --git a/scarf/scarf/Core/Services/HermesDataService.swift b/scarf/scarf/Core/Services/HermesDataService.swift index f7f0793..fa5fbbb 100644 --- a/scarf/scarf/Core/Services/HermesDataService.swift +++ b/scarf/scarf/Core/Services/HermesDataService.swift @@ -183,6 +183,112 @@ actor HermesDataService { ) } + // MARK: - Insights Queries + + func fetchSessionsInPeriod(since: Date) -> [HermesSession] { + guard let db else { return [] } + let sql = """ + SELECT id, source, user_id, model, title, parent_session_id, + started_at, ended_at, end_reason, message_count, tool_call_count, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + estimated_cost_usd + FROM sessions + WHERE started_at >= ? + ORDER BY started_at DESC + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970) + + var sessions: [HermesSession] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + sessions.append(sessionFromRow(stmt!)) + } + return sessions + } + + func fetchUserMessageCount(since: Date) -> Int { + guard let db else { return 0 } + let sql = """ + SELECT COUNT(*) FROM messages m + JOIN sessions s ON m.session_id = s.id + WHERE m.role = 'user' AND s.started_at >= ? + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970) + guard sqlite3_step(stmt) == SQLITE_ROW else { return 0 } + return Int(sqlite3_column_int(stmt, 0)) + } + + func fetchToolUsage(since: Date) -> [(name: String, count: Int)] { + guard let db else { return [] } + let sql = """ + SELECT m.tool_name, COUNT(*) as cnt + FROM messages m + JOIN sessions s ON m.session_id = s.id + WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.started_at >= ? + GROUP BY m.tool_name + ORDER BY cnt DESC + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970) + + var results: [(name: String, count: Int)] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + let name = columnText(stmt!, 0) + let count = Int(sqlite3_column_int(stmt!, 1)) + results.append((name: name, count: count)) + } + return results + } + + func fetchSessionStartHours(since: Date) -> [Int: Int] { + guard let db else { return [:] } + let sql = """ + SELECT started_at FROM sessions WHERE started_at >= ? + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970) + + var hours: [Int: Int] = [:] + let calendar = Calendar.current + while sqlite3_step(stmt) == SQLITE_ROW { + let ts = sqlite3_column_double(stmt!, 0) + let date = Date(timeIntervalSince1970: ts) + let hour = calendar.component(.hour, from: date) + hours[hour, default: 0] += 1 + } + return hours + } + + func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] { + guard let db else { return [:] } + let sql = """ + SELECT started_at FROM sessions WHERE started_at >= ? + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970) + + var days: [Int: Int] = [:] + let calendar = Calendar.current + while sqlite3_step(stmt) == SQLITE_ROW { + let ts = sqlite3_column_double(stmt!, 0) + let date = Date(timeIntervalSince1970: ts) + let weekday = (calendar.component(.weekday, from: date) + 5) % 7 // Mon=0 + days[weekday, default: 0] += 1 + } + return days + } + func stateDBModificationDate() -> Date? { let walPath = HermesPaths.stateDB + "-wal" let dbPath = HermesPaths.stateDB diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 2b764e2..e94d785 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -42,7 +42,8 @@ struct HermesFileService: Sendable { nudgeInterval: Int(values["memory.nudge_interval"] ?? "") ?? 0, streaming: values["display.streaming"] != "false", showReasoning: values["display.show_reasoning"] == "true", - verbose: values["agent.verbose"] == "true" + verbose: values["agent.verbose"] == "true", + autoTTS: values["voice.auto_tts"] != "false" ) } diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 261088b..7b70175 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -5,11 +5,15 @@ import SwiftTerm @Observable final class ChatViewModel { private let dataService = HermesDataService() + private let fileService = HermesFileService() var recentSessions: [HermesSession] = [] var sessionPreviews: [String: String] = [:] var terminalView: LocalProcessTerminalView? var hasActiveProcess = false + var voiceEnabled = false + var ttsEnabled = false + var isRecording = false private var coordinator: Coordinator? var hermesBinaryExists: Bool { @@ -17,14 +21,23 @@ final class ChatViewModel { } func startNewSession() { + voiceEnabled = false + ttsEnabled = false + isRecording = false launchTerminal(arguments: ["chat"]) } func resumeSession(_ sessionId: String) { + voiceEnabled = false + ttsEnabled = false + isRecording = false launchTerminal(arguments: ["chat", "--resume", sessionId]) } func continueLastSession() { + voiceEnabled = false + ttsEnabled = false + isRecording = false launchTerminal(arguments: ["chat", "--continue"]) } @@ -42,6 +55,38 @@ final class ChatViewModel { return session.id } + func toggleVoice() { + guard let tv = terminalView else { return } + if voiceEnabled { + sendToTerminal(tv, text: "/voice off\r") + voiceEnabled = false + isRecording = false + } else { + sendToTerminal(tv, text: "/voice on\r") + voiceEnabled = true + ttsEnabled = fileService.loadConfig().autoTTS + } + } + + func toggleTTS() { + guard let tv = terminalView, voiceEnabled else { return } + sendToTerminal(tv, text: "/voice tts\r") + ttsEnabled.toggle() + } + + func pushToTalk() { + guard let tv = terminalView, voiceEnabled else { return } + // Ctrl+B = ASCII 0x02 + let ctrlB: [UInt8] = [0x02] + tv.send(source: tv, data: ctrlB[0..<1]) + isRecording.toggle() + } + + private func sendToTerminal(_ tv: LocalProcessTerminalView, text: String) { + let bytes = Array(text.utf8) + tv.send(source: tv, data: bytes[0..= 3 { + let platform = String(parts[0]) + let userId = String(parts[1]) + let name = parts[2...].joined(separator: " ") + approvedUsers.append(PairedUser(platform: platform, userId: userId, name: name)) + } + if inPending && parts.count >= 2 { + let platform = String(parts[0]) + let code = String(parts[1]) + pendingPairings.append(PendingPairing(platform: platform, code: code)) + } + } + } + + @discardableResult + private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { + let process = Process() + process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus) + } catch { + return ("", -1) + } + } +} diff --git a/scarf/scarf/Features/Gateway/Views/GatewayView.swift b/scarf/scarf/Features/Gateway/Views/GatewayView.swift new file mode 100644 index 0000000..f4bfc6a --- /dev/null +++ b/scarf/scarf/Features/Gateway/Views/GatewayView.swift @@ -0,0 +1,205 @@ +import SwiftUI + +struct GatewayView: View { + @State private var viewModel = GatewayViewModel() + @Environment(HermesFileWatcher.self) private var fileWatcher + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + serviceSection + platformsSection + pairingSection + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .navigationTitle("Gateway") + .onAppear { viewModel.load() } + .onChange(of: fileWatcher.lastChangeDate) { viewModel.load() } + } + + // MARK: - Service + + private var serviceSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Service") + .font(.headline) + Spacer() + if let msg = viewModel.actionMessage { + Text(msg) + .font(.caption) + .foregroundStyle(.secondary) + } + HStack(spacing: 8) { + Button("Start") { viewModel.startGateway() } + Button("Stop") { viewModel.stopGateway() } + Button("Restart") { viewModel.restartGateway() } + } + .controlSize(.small) + } + + HStack(spacing: 16) { + StatusBadge( + label: viewModel.gateway.state, + isActive: viewModel.gateway.state == "running" + ) + if let pid = viewModel.gateway.pid { + Label("PID \(pid)", systemImage: "number") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + if viewModel.gateway.isLoaded { + Label("Loaded", systemImage: "checkmark.circle") + .font(.caption) + .foregroundStyle(.green) + } + if viewModel.gateway.isStale { + Label("Service definition stale", systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.orange) + } + } + + if let reason = viewModel.gateway.exitReason, !reason.isEmpty { + HStack(spacing: 4) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + Text(reason) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let updated = viewModel.gateway.updatedAt { + Text("Last updated: \(updated)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + // MARK: - Platforms + + private var platformsSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Platforms") + .font(.headline) + if viewModel.gateway.platforms.isEmpty { + Text("No platforms connected") + .font(.caption) + .foregroundStyle(.secondary) + } else { + HStack(spacing: 12) { + ForEach(viewModel.gateway.platforms) { platform in + VStack(spacing: 6) { + Image(systemName: platform.icon) + .font(.title2) + .foregroundStyle(platform.isConnected ? Color.accentColor : .secondary) + Text(platform.name.capitalized) + .font(.caption.bold()) + StatusBadge( + label: platform.state, + isActive: platform.isConnected + ) + } + .frame(maxWidth: .infinity) + .padding(12) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + } + + // MARK: - Pairing + + private var pairingSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Paired Users") + .font(.headline) + + if !viewModel.pendingPairings.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Label("Pending Approvals", systemImage: "clock.badge.questionmark") + .font(.caption.bold()) + .foregroundStyle(.orange) + ForEach(viewModel.pendingPairings) { pending in + HStack { + Label(pending.platform.capitalized, systemImage: platformIcon(pending.platform)) + Text("Code: \(pending.code)") + .font(.caption.monospaced()) + Spacer() + Button("Approve") { + viewModel.approvePairing(platform: pending.platform, code: pending.code) + } + .controlSize(.small) + .buttonStyle(.borderedProminent) + } + .font(.caption) + .padding(8) + .background(.orange.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + + if viewModel.approvedUsers.isEmpty && viewModel.pendingPairings.isEmpty { + Text("No paired users") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.approvedUsers) { user in + HStack { + Image(systemName: platformIcon(user.platform)) + .foregroundStyle(.secondary) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text(user.name) + Text("\(user.platform.capitalized) · \(user.userId)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Revoke", role: .destructive) { + viewModel.revokeUser(user) + } + .controlSize(.small) + } + .padding(8) + .background(.quaternary.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + } + + private func platformIcon(_ platform: String) -> String { + switch platform { + case "telegram": return "paperplane" + case "discord": return "bubble.left.and.bubble.right" + case "slack": return "number" + case "whatsapp": return "phone.bubble" + case "signal": return "lock.shield" + case "email": return "envelope" + default: return "bubble.left" + } + } +} + +struct StatusBadge: View { + let label: String + let isActive: Bool + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(isActive ? .green : .secondary) + .frame(width: 6, height: 6) + Text(label) + .font(.caption) + } + } +} diff --git a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift new file mode 100644 index 0000000..c1c1962 --- /dev/null +++ b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift @@ -0,0 +1,179 @@ +import Foundation + +struct HealthCheck: Identifiable { + let id = UUID() + let label: String + let status: CheckStatus + let detail: String? + + enum CheckStatus { + case ok + case warning + case error + } +} + +struct HealthSection: Identifiable { + let id = UUID() + let title: String + let icon: String + let checks: [HealthCheck] +} + +@Observable +final class HealthViewModel { + var version = "" + var updateInfo = "" + var hasUpdate = false + var statusSections: [HealthSection] = [] + var doctorSections: [HealthSection] = [] + var issueCount = 0 + var warningCount = 0 + var okCount = 0 + var isLoading = false + + func load() { + isLoading = true + loadVersion() + let statusOutput = runHermes(["status"]).output + statusSections = parseOutput(statusOutput) + let doctorOutput = runHermes(["doctor"]).output + doctorSections = parseOutput(doctorOutput) + computeCounts() + isLoading = false + } + + private func loadVersion() { + let output = runHermes(["version"]).output + let lines = output.components(separatedBy: "\n") + version = lines.first ?? "" + if let updateLine = lines.first(where: { $0.contains("commits behind") }) { + updateInfo = updateLine.trimmingCharacters(in: .whitespaces) + hasUpdate = true + } else { + updateInfo = "" + hasUpdate = false + } + } + + private func parseOutput(_ output: String) -> [HealthSection] { + var sections: [HealthSection] = [] + var currentTitle = "" + var currentChecks: [HealthCheck] = [] + + for line in output.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("◆ ") { + if !currentTitle.isEmpty { + sections.append(HealthSection( + title: currentTitle, + icon: iconForSection(currentTitle), + checks: currentChecks + )) + } + currentTitle = String(trimmed.dropFirst(2)) + currentChecks = [] + continue + } + + if trimmed.hasPrefix("✓ ") { + let text = String(trimmed.dropFirst(2)) + let (label, detail) = splitCheck(text) + currentChecks.append(HealthCheck(label: label, status: .ok, detail: detail)) + } else if trimmed.hasPrefix("⚠ ") || trimmed.hasPrefix("⚠") { + let text = trimmed.replacingOccurrences(of: "⚠ ", with: "").replacingOccurrences(of: "⚠", with: "") + let (label, detail) = splitCheck(text) + currentChecks.append(HealthCheck(label: label, status: .warning, detail: detail)) + } else if trimmed.hasPrefix("✗ ") { + let text = String(trimmed.dropFirst(2)) + let (label, detail) = splitCheck(text) + currentChecks.append(HealthCheck(label: label, status: .error, detail: detail)) + } else if trimmed.hasPrefix("→ ") || trimmed.hasPrefix("Error:") { + if !currentChecks.isEmpty { + let last = currentChecks.removeLast() + let extra = trimmed.replacingOccurrences(of: "→ ", with: "").replacingOccurrences(of: "Error:", with: "").trimmingCharacters(in: .whitespaces) + let combined = [last.detail, extra].compactMap { $0 }.joined(separator: " ") + currentChecks.append(HealthCheck(label: last.label, status: last.status, detail: combined)) + } + } else if !trimmed.isEmpty && trimmed.contains(":") && !trimmed.hasPrefix("┌") && !trimmed.hasPrefix("│") && !trimmed.hasPrefix("└") && !trimmed.hasPrefix("─") && !trimmed.hasPrefix("Run ") && !trimmed.hasPrefix("Found ") && !trimmed.hasPrefix("Tip:") { + let parts = trimmed.split(separator: ":", maxSplits: 1) + if parts.count == 2 { + let key = parts[0].trimmingCharacters(in: .whitespaces) + let val = parts[1].trimmingCharacters(in: .whitespaces) + if !key.isEmpty && key.count < 30 { + currentChecks.append(HealthCheck(label: key, status: .ok, detail: val)) + } + } + } + } + + if !currentTitle.isEmpty { + sections.append(HealthSection( + title: currentTitle, + icon: iconForSection(currentTitle), + checks: currentChecks + )) + } + + return sections + } + + private func splitCheck(_ text: String) -> (String, String?) { + if let parenStart = text.firstIndex(of: "(") { + let label = text[text.startIndex.. String { + switch title { + case "Environment": return "gearshape.2" + case "API Keys": return "key" + case "Auth Providers": return "person.badge.key" + case "API-Key Providers": return "key.horizontal" + case "Terminal Backend": return "terminal" + case "Messaging Platforms": return "bubble.left.and.bubble.right" + case "Gateway Service": return "antenna.radiowaves.left.and.right" + case "Scheduled Jobs": return "clock.arrow.2.circlepath" + case "Sessions": return "text.bubble" + case "Python Environment": return "chevron.left.forwardslash.chevron.right" + case "Required Packages": return "shippingbox" + case "Configuration Files": return "doc.text" + case "Directory Structure": return "folder" + case "External Tools": return "wrench" + case "API Connectivity": return "wifi" + case "Submodules": return "arrow.triangle.branch" + case "Tool Availability": return "wrench.and.screwdriver" + case "Skills Hub": return "lightbulb" + case "Honcho Memory": return "brain" + default: return "circle" + } + } + + private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { + let process = Process() + process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus) + } catch { + return ("", -1) + } + } +} diff --git a/scarf/scarf/Features/Health/Views/HealthView.swift b/scarf/scarf/Features/Health/Views/HealthView.swift new file mode 100644 index 0000000..e50aafa --- /dev/null +++ b/scarf/scarf/Features/Health/Views/HealthView.swift @@ -0,0 +1,219 @@ +import SwiftUI + +struct HealthView: View { + @State private var viewModel = HealthViewModel() + @State private var expandedSection: UUID? + @State private var selectedTab = 0 + + var body: some View { + VStack(spacing: 0) { + headerBar + Divider() + Picker("", selection: $selectedTab) { + Text("Status").tag(0) + Text("Diagnostics").tag(1) + } + .pickerStyle(.segmented) + .frame(maxWidth: 300) + .padding(.vertical, 8) + Divider() + ScrollView { + sectionGrid(selectedTab == 0 ? viewModel.statusSections : viewModel.doctorSections) + .padding() + } + } + .navigationTitle("Health") + .onAppear { viewModel.load() } + } + + // MARK: - Header + + private var headerBar: some View { + HStack(spacing: 16) { + if !viewModel.version.isEmpty { + Text(viewModel.version) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + + if viewModel.hasUpdate { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.caption2) + Text(viewModel.updateInfo) + .font(.caption) + } + .foregroundStyle(.orange) + } + + Spacer() + + HStack(spacing: 12) { + MiniCount(count: viewModel.okCount, color: .green, icon: "checkmark.circle.fill") + MiniCount(count: viewModel.warningCount, color: .orange, icon: "exclamationmark.triangle.fill") + MiniCount(count: viewModel.issueCount, color: .red, icon: "xmark.circle.fill") + } + + Button("Refresh") { viewModel.load() } + .controlSize(.small) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + + // MARK: - Grid + + private func sectionGrid(_ sections: [HealthSection]) -> some View { + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { + ForEach(sections) { section in + SectionCard( + section: section, + isExpanded: expandedSection == section.id, + onTap: { + withAnimation(.easeInOut(duration: 0.2)) { + expandedSection = expandedSection == section.id ? nil : section.id + } + } + ) + } + } + } +} + +// MARK: - Section Card + +struct SectionCard: View { + let section: HealthSection + let isExpanded: Bool + let onTap: () -> Void + + private var okCount: Int { section.checks.filter { $0.status == .ok }.count } + private var warnCount: Int { section.checks.filter { $0.status == .warning }.count } + private var errorCount: Int { section.checks.filter { $0.status == .error }.count } + + private var accentColor: Color { + if errorCount > 0 { return .red } + if warnCount > 0 { return .orange } + return .green + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button(action: onTap) { + HStack(spacing: 10) { + Image(systemName: section.icon) + .font(.title3) + .foregroundStyle(accentColor) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(section.title) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + HStack(spacing: 8) { + if okCount > 0 { + HStack(spacing: 2) { + Circle().fill(.green).frame(width: 5, height: 5) + Text("\(okCount)").font(.caption2).foregroundStyle(.secondary) + } + } + if warnCount > 0 { + HStack(spacing: 2) { + Circle().fill(.orange).frame(width: 5, height: 5) + Text("\(warnCount)").font(.caption2).foregroundStyle(.secondary) + } + } + if errorCount > 0 { + HStack(spacing: 2) { + Circle().fill(.red).frame(width: 5, height: 5) + Text("\(errorCount)").font(.caption2).foregroundStyle(.secondary) + } + } + } + } + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(12) + } + .buttonStyle(.plain) + + if isExpanded { + Divider() + .padding(.horizontal, 12) + VStack(alignment: .leading, spacing: 3) { + ForEach(section.checks) { check in + CheckRow(check: check) + } + } + .padding(12) + } + } + .background(.quaternary.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(accentColor.opacity(0.3), lineWidth: 1) + ) + } +} + +// MARK: - Check Row + +struct CheckRow: View { + let check: HealthCheck + + var body: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: statusIcon) + .foregroundStyle(statusColor) + .font(.system(size: 9)) + .frame(width: 12, alignment: .center) + .padding(.top, 2) + VStack(alignment: .leading, spacing: 0) { + Text(check.label) + .font(.caption) + if let detail = check.detail { + Text(detail) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + + private var statusIcon: String { + switch check.status { + case .ok: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .error: return "xmark.circle.fill" + } + } + + private var statusColor: Color { + switch check.status { + case .ok: return .green + case .warning: return .orange + case .error: return .red + } + } +} + +// MARK: - Mini Count + +struct MiniCount: View { + let count: Int + let color: Color + let icon: String + + var body: some View { + HStack(spacing: 3) { + Image(systemName: icon) + .foregroundStyle(color) + .font(.caption2) + Text("\(count)") + .font(.caption.monospaced().bold()) + } + } +} diff --git a/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift b/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift new file mode 100644 index 0000000..980f97c --- /dev/null +++ b/scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift @@ -0,0 +1,234 @@ +import Foundation + +enum InsightsPeriod: String, CaseIterable, Identifiable { + case week = "7 Days" + case month = "30 Days" + case quarter = "90 Days" + case all = "All Time" + + var id: String { rawValue } + + var sinceDate: Date { + let calendar = Calendar.current + switch self { + case .week: return calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date() + case .month: return calendar.date(byAdding: .day, value: -30, to: Date()) ?? Date() + case .quarter: return calendar.date(byAdding: .day, value: -90, to: Date()) ?? Date() + case .all: return Date(timeIntervalSince1970: 0) + } + } +} + +struct ModelUsage: Identifiable { + var id: String { model } + let model: String + let sessions: Int + let inputTokens: Int + let outputTokens: Int + let cacheReadTokens: Int + let cacheWriteTokens: Int + var totalTokens: Int { inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens } +} + +struct PlatformUsage: Identifiable { + var id: String { platform } + let platform: String + let sessions: Int + let messages: Int + let tokens: Int +} + +struct ToolUsage: Identifiable { + var id: String { name } + let name: String + let count: Int + let percentage: Double +} + +struct NotableSession: Identifiable { + var id: String { session.id } + let label: String + let value: String + let session: HermesSession + let preview: String +} + +@Observable +final class InsightsViewModel { + private let dataService = HermesDataService() + + var period: InsightsPeriod = .month + var isLoading = true + + var sessions: [HermesSession] = [] + var sessionPreviews: [String: String] = [:] + var userMessageCount = 0 + var totalMessages = 0 + var totalToolCalls = 0 + var totalInputTokens = 0 + var totalOutputTokens = 0 + var totalCacheReadTokens = 0 + var totalCacheWriteTokens = 0 + var totalTokens = 0 + var activeTime: TimeInterval = 0 + var avgSessionDuration: TimeInterval = 0 + + var modelUsage: [ModelUsage] = [] + var platformUsage: [PlatformUsage] = [] + var toolUsage: [ToolUsage] = [] + var hourlyActivity: [Int: Int] = [:] + var dailyActivity: [Int: Int] = [:] + var notableSessions: [NotableSession] = [] + + func load() async { + isLoading = true + let opened = await dataService.open() + guard opened else { + isLoading = false + return + } + + let since = period.sinceDate + sessions = await dataService.fetchSessionsInPeriod(since: since) + sessionPreviews = await dataService.fetchSessionPreviews(limit: 500) + userMessageCount = await dataService.fetchUserMessageCount(since: since) + let tools = await dataService.fetchToolUsage(since: since) + hourlyActivity = await dataService.fetchSessionStartHours(since: since) + dailyActivity = await dataService.fetchSessionDaysOfWeek(since: since) + + await dataService.close() + + computeAggregates() + computeModelBreakdown() + computePlatformBreakdown() + computeToolBreakdown(tools) + computeNotableSessions() + isLoading = false + } + + func previewFor(_ session: HermesSession) -> String { + if let title = session.title, !title.isEmpty { return title } + if let preview = sessionPreviews[session.id], !preview.isEmpty { return preview } + return session.id + } + + private func computeAggregates() { + totalMessages = sessions.reduce(0) { $0 + $1.messageCount } + totalToolCalls = sessions.reduce(0) { $0 + $1.toolCallCount } + totalInputTokens = sessions.reduce(0) { $0 + $1.inputTokens } + totalOutputTokens = sessions.reduce(0) { $0 + $1.outputTokens } + totalCacheReadTokens = sessions.reduce(0) { $0 + $1.cacheReadTokens } + totalCacheWriteTokens = sessions.reduce(0) { $0 + $1.cacheWriteTokens } + totalTokens = totalInputTokens + totalOutputTokens + totalCacheReadTokens + totalCacheWriteTokens + + var total: TimeInterval = 0 + var count = 0 + for session in sessions { + if let dur = session.duration, dur > 0 { + total += dur + count += 1 + } + } + activeTime = total + avgSessionDuration = count > 0 ? total / Double(count) : 0 + } + + private func computeModelBreakdown() { + var grouped: [String: (sessions: Int, input: Int, output: Int, cacheRead: Int, cacheWrite: Int)] = [:] + for s in sessions { + let model = s.model ?? "unknown" + var entry = grouped[model, default: (0, 0, 0, 0, 0)] + entry.sessions += 1 + entry.input += s.inputTokens + entry.output += s.outputTokens + entry.cacheRead += s.cacheReadTokens + entry.cacheWrite += s.cacheWriteTokens + grouped[model] = entry + } + modelUsage = grouped.map { key, val in + ModelUsage(model: key, sessions: val.sessions, inputTokens: val.input, + outputTokens: val.output, cacheReadTokens: val.cacheRead, + cacheWriteTokens: val.cacheWrite) + }.sorted { $0.totalTokens > $1.totalTokens } + } + + private func computePlatformBreakdown() { + var grouped: [String: (sessions: Int, messages: Int, tokens: Int)] = [:] + for s in sessions { + var entry = grouped[s.source, default: (0, 0, 0)] + entry.sessions += 1 + entry.messages += s.messageCount + entry.tokens += s.inputTokens + s.outputTokens + s.cacheReadTokens + s.cacheWriteTokens + grouped[s.source] = entry + } + platformUsage = grouped.map { key, val in + PlatformUsage(platform: key, sessions: val.sessions, messages: val.messages, tokens: val.tokens) + }.sorted { $0.sessions > $1.sessions } + } + + private func computeToolBreakdown(_ tools: [(name: String, count: Int)]) { + let total = tools.reduce(0) { $0 + $1.count } + toolUsage = tools.map { tool in + ToolUsage(name: tool.name, count: tool.count, + percentage: total > 0 ? Double(tool.count) / Double(total) * 100 : 0) + } + } + + private func computeNotableSessions() { + notableSessions = [] + + if let longest = sessions.filter({ $0.duration != nil }).max(by: { ($0.duration ?? 0) < ($1.duration ?? 0) }) { + notableSessions.append(NotableSession( + label: "Longest Session", + value: formatDuration(longest.duration ?? 0), + session: longest, + preview: previewFor(longest) + )) + } + + if let mostMsgs = sessions.max(by: { $0.messageCount < $1.messageCount }), mostMsgs.messageCount > 0 { + notableSessions.append(NotableSession( + label: "Most Messages", + value: "\(mostMsgs.messageCount) msgs", + session: mostMsgs, + preview: previewFor(mostMsgs) + )) + } + + if let mostTokens = sessions.max(by: { $0.totalTokens < $1.totalTokens }), mostTokens.totalTokens > 0 { + notableSessions.append(NotableSession( + label: "Most Tokens", + value: formatTokens(mostTokens.totalTokens), + session: mostTokens, + preview: previewFor(mostTokens) + )) + } + + if let mostTools = sessions.max(by: { $0.toolCallCount < $1.toolCallCount }), mostTools.toolCallCount > 0 { + notableSessions.append(NotableSession( + label: "Most Tool Calls", + value: "\(mostTools.toolCallCount) calls", + session: mostTools, + preview: previewFor(mostTools) + )) + } + } +} + +func formatDuration(_ interval: TimeInterval) -> String { + let hours = Int(interval) / 3600 + let minutes = (Int(interval) % 3600) / 60 + if hours > 0 { + return "\(hours)h \(minutes)m" + } + return "\(minutes)m" +} + +func formatTokens(_ count: Int) -> String { + if count >= 1_000_000 { + return String(format: "%.1fM", Double(count) / 1_000_000) + } else if count >= 1_000 { + return String(format: "%.1fK", Double(count) / 1_000) + } + return "\(count)" +} diff --git a/scarf/scarf/Features/Insights/Views/InsightsView.swift b/scarf/scarf/Features/Insights/Views/InsightsView.swift new file mode 100644 index 0000000..8615efd --- /dev/null +++ b/scarf/scarf/Features/Insights/Views/InsightsView.swift @@ -0,0 +1,317 @@ +import SwiftUI + +struct InsightsView: View { + @State private var viewModel = InsightsViewModel() + @Environment(AppCoordinator.self) private var coordinator + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + periodPicker + overviewSection + modelSection + platformSection + toolsSection + activitySection + notableSection + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .navigationTitle("Insights") + .task { await viewModel.load() } + .onChange(of: viewModel.period) { + Task { await viewModel.load() } + } + } + + private var periodPicker: some View { + Picker("Period", selection: $viewModel.period) { + ForEach(InsightsPeriod.allCases) { period in + Text(period.rawValue).tag(period) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 400) + } + + // MARK: - Overview + + private var overviewSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Overview") + .font(.headline) + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 12) { + InsightCard(label: "Sessions", value: "\(viewModel.sessions.count)") + InsightCard(label: "Messages", value: "\(viewModel.totalMessages)") + InsightCard(label: "User Messages", value: "\(viewModel.userMessageCount)") + InsightCard(label: "Tool Calls", value: "\(viewModel.totalToolCalls)") + InsightCard(label: "Input Tokens", value: formatTokens(viewModel.totalInputTokens)) + InsightCard(label: "Output Tokens", value: formatTokens(viewModel.totalOutputTokens)) + InsightCard(label: "Cache Read", value: formatTokens(viewModel.totalCacheReadTokens)) + InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens)) + InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens)) + InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime)) + InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration)) + InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count))) + } + } + } + + // MARK: - Models + + private var modelSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Models") + .font(.headline) + if viewModel.modelUsage.isEmpty { + Text("No data") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.modelUsage) { model in + HStack { + Image(systemName: "cpu") + .foregroundStyle(.blue) + .frame(width: 20) + Text(model.model) + .font(.system(.body, design: .monospaced)) + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text("\(model.sessions) sessions") + .font(.caption) + Text(formatTokens(model.totalTokens) + " tokens") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(10) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + + // MARK: - Platforms + + private var platformSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Platforms") + .font(.headline) + if viewModel.platformUsage.isEmpty { + Text("No data") + .foregroundStyle(.secondary) + } else { + HStack(spacing: 12) { + ForEach(viewModel.platformUsage) { platform in + VStack(spacing: 6) { + Image(systemName: platformIcon(platform.platform)) + .font(.title2) + .foregroundStyle(Color.accentColor) + Text(platform.platform) + .font(.caption.bold()) + Text("\(platform.sessions) sessions") + .font(.caption) + .foregroundStyle(.secondary) + Text("\(platform.messages) msgs") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(12) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + } + + // MARK: - Tools + + private var toolsSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Top Tools") + .font(.headline) + if viewModel.toolUsage.isEmpty { + Text("No data") + .foregroundStyle(.secondary) + } else { + let maxCount = viewModel.toolUsage.first?.count ?? 1 + ForEach(viewModel.toolUsage.prefix(15)) { tool in + HStack(spacing: 10) { + Text(tool.name) + .font(.system(.caption, design: .monospaced)) + .frame(width: 140, alignment: .trailing) + GeometryReader { geo in + RoundedRectangle(cornerRadius: 3) + .fill(barColor(for: tool.name)) + .frame(width: max(4, geo.size.width * Double(tool.count) / Double(maxCount))) + } + .frame(height: 16) + Text("\(tool.count)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .frame(width: 40, alignment: .trailing) + Text(String(format: "%.1f%%", tool.percentage)) + .font(.caption) + .foregroundStyle(.tertiary) + .frame(width: 50, alignment: .trailing) + } + .frame(height: 20) + } + } + } + } + + // MARK: - Activity Patterns + + private var activitySection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Activity Patterns") + .font(.headline) + HStack(alignment: .top, spacing: 24) { + dayOfWeekChart + hourlyChart + } + } + } + + private var dayOfWeekChart: some View { + VStack(alignment: .leading, spacing: 4) { + Text("By Day") + .font(.caption.bold()) + .foregroundStyle(.secondary) + let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + let maxVal = max(1, viewModel.dailyActivity.values.max() ?? 1) + ForEach(0..<7, id: \.self) { day in + let count = viewModel.dailyActivity[day] ?? 0 + HStack(spacing: 6) { + Text(dayNames[day]) + .font(.caption.monospaced()) + .frame(width: 30, alignment: .trailing) + RoundedRectangle(cornerRadius: 2) + .fill(Color.accentColor.opacity(0.7)) + .frame(width: max(0, CGFloat(count) / CGFloat(maxVal) * 120), height: 14) + if count > 0 { + Text("\(count)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + } + + private var hourlyChart: some View { + VStack(alignment: .leading, spacing: 4) { + Text("By Hour") + .font(.caption.bold()) + .foregroundStyle(.secondary) + let maxVal = max(1, viewModel.hourlyActivity.values.max() ?? 1) + HStack(alignment: .bottom, spacing: 2) { + ForEach(0..<24, id: \.self) { hour in + let count = viewModel.hourlyActivity[hour] ?? 0 + VStack(spacing: 2) { + RoundedRectangle(cornerRadius: 2) + .fill(count > 0 ? Color.accentColor.opacity(0.7) : Color.secondary.opacity(0.15)) + .frame(width: 12, height: max(4, CGFloat(count) / CGFloat(maxVal) * 80)) + if hour % 6 == 0 { + Text("\(hour)") + .font(.system(size: 8)) + .foregroundStyle(.secondary) + } else { + Text("") + .font(.system(size: 8)) + } + } + } + } + } + } + + // MARK: - Notable Sessions + + private var notableSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Notable Sessions") + .font(.headline) + if viewModel.notableSessions.isEmpty { + Text("No data") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.notableSessions) { notable in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(notable.label) + .font(.caption.bold()) + .foregroundStyle(.secondary) + Text(notable.preview) + .lineLimit(1) + } + Spacer() + Text(notable.value) + .font(.system(.body, design: .monospaced, weight: .semibold)) + Button { + coordinator.selectedSessionId = notable.session.id + coordinator.selectedSection = .sessions + } label: { + Image(systemName: "arrow.right.circle") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + .help("Open session") + } + .padding(10) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + + // MARK: - Helpers + + private func platformIcon(_ platform: String) -> String { + switch platform { + case "cli": return "terminal" + case "telegram": return "paperplane" + case "discord": return "bubble.left.and.bubble.right" + case "slack": return "number" + case "email": return "envelope" + default: return "bubble.left" + } + } + + private func barColor(for toolName: String) -> Color { + switch toolName { + case "terminal": return .orange + case "read_file", "search_files": return .green + case "write_file", "patch": return .blue + case "web_search", "web_extract": return .purple + case _ where toolName.hasPrefix("browser"): return .indigo + case "memory": return .pink + case "vision", "image_gen": return .mint + default: return Color.accentColor + } + } +} + +struct InsightCard: View { + let label: String + let value: String + + var body: some View { + VStack(spacing: 4) { + Text(value) + .font(.system(.title3, design: .monospaced, weight: .semibold)) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(10) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift index 72ab51b..6aad57c 100644 --- a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift +++ b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift @@ -1,4 +1,13 @@ import Foundation +import AppKit +import UniformTypeIdentifiers + +struct SessionStoreStats { + let totalSessions: Int + let totalMessages: Int + let databaseSize: String + let platformCounts: [(platform: String, count: Int)] +} @Observable final class SessionsViewModel { @@ -11,12 +20,20 @@ final class SessionsViewModel { var searchText = "" var searchResults: [HermesMessage] = [] var isSearching = false + var storeStats: SessionStoreStats? + + var renameSessionId: String? + var renameText = "" + var showRenameSheet = false + var showDeleteConfirmation = false + var deleteSessionId: String? func load() async { let opened = await dataService.open() guard opened else { return } sessions = await dataService.fetchSessions(limit: 500) sessionPreviews = await dataService.fetchSessionPreviews(limit: 500) + computeStats() } func previewFor(_ session: HermesSession) -> String { @@ -50,4 +67,132 @@ final class SessionsViewModel { func cleanup() async { await dataService.close() } + + // MARK: - Session Actions + + func beginRename(_ session: HermesSession) { + renameSessionId = session.id + renameText = previewFor(session) + showRenameSheet = true + } + + func confirmRename() { + guard let sessionId = renameSessionId else { return } + let title = renameText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { return } + let result = runHermes(["sessions", "rename", sessionId, title]) + if result.exitCode == 0 { + if let idx = sessions.firstIndex(where: { $0.id == sessionId }) { + let updated = HermesSession( + id: sessions[idx].id, source: sessions[idx].source, + userId: sessions[idx].userId, model: sessions[idx].model, + title: title, parentSessionId: sessions[idx].parentSessionId, + startedAt: sessions[idx].startedAt, endedAt: sessions[idx].endedAt, + endReason: sessions[idx].endReason, messageCount: sessions[idx].messageCount, + toolCallCount: sessions[idx].toolCallCount, inputTokens: sessions[idx].inputTokens, + outputTokens: sessions[idx].outputTokens, cacheReadTokens: sessions[idx].cacheReadTokens, + cacheWriteTokens: sessions[idx].cacheWriteTokens, + estimatedCostUSD: sessions[idx].estimatedCostUSD + ) + sessions[idx] = updated + if selectedSession?.id == sessionId { + selectedSession = updated + } + } + sessionPreviews[sessionId] = title + } + showRenameSheet = false + renameSessionId = nil + } + + func beginDelete(_ session: HermesSession) { + deleteSessionId = session.id + showDeleteConfirmation = true + } + + func confirmDelete() { + guard let sessionId = deleteSessionId else { return } + let result = runHermes(["sessions", "delete", "--yes", sessionId]) + if result.exitCode == 0 { + sessions.removeAll { $0.id == sessionId } + if selectedSession?.id == sessionId { + selectedSession = nil + messages = [] + } + computeStats() + } + showDeleteConfirmation = false + deleteSessionId = nil + } + + func exportSession(_ session: HermesSession) { + let panel = NSSavePanel() + panel.nameFieldStringValue = "\(session.id).jsonl" + panel.allowedContentTypes = [.json] + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let url = panel.url else { return } + runHermes(["sessions", "export", url.path, "--session-id", session.id]) + } + + func exportAll() { + let panel = NSSavePanel() + panel.nameFieldStringValue = "hermes-sessions.jsonl" + panel.allowedContentTypes = [.json] + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let url = panel.url else { return } + runHermes(["sessions", "export", url.path]) + } + + // MARK: - Stats + + private func computeStats() { + let totalMessages = sessions.reduce(0) { $0 + $1.messageCount } + + var platformCounts: [String: Int] = [:] + for s in sessions { + platformCounts[s.source, default: 0] += 1 + } + let sorted = platformCounts.sorted { $0.value > $1.value }.map { (platform: $0.key, count: $0.value) } + + let dbPath = HermesPaths.stateDB + let fileSize: String + if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath), + let size = attrs[.size] as? Int { + if size >= 1_048_576 { + fileSize = String(format: "%.1f MB", Double(size) / 1_048_576) + } else { + fileSize = String(format: "%.0f KB", Double(size) / 1_024) + } + } else { + fileSize = "unknown" + } + + storeStats = SessionStoreStats( + totalSessions: sessions.count, + totalMessages: totalMessages, + databaseSize: fileSize, + platformCounts: sorted + ) + } + + // MARK: - Hermes CLI + + @discardableResult + private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { + let process = Process() + process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + return (output, process.terminationStatus) + } catch { + return ("", -1) + } + } } diff --git a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift index 9454531..8459a34 100644 --- a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift +++ b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift @@ -4,6 +4,9 @@ struct SessionDetailView: View { let session: HermesSession let messages: [HermesMessage] var preview: String? + var onRename: (() -> Void)? + var onExport: (() -> Void)? + var onDelete: (() -> Void)? var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -16,22 +19,41 @@ struct SessionDetailView: View { private var sessionHeader: some View { VStack(alignment: .leading, spacing: 6) { - Text(preview ?? session.displayTitle) - .font(.title3.bold()) + HStack { + Text(preview ?? session.displayTitle) + .font(.title3.bold()) + Spacer() + if onRename != nil || onExport != nil || onDelete != nil { + Menu { + if let onRename { Button("Rename...") { onRename() } } + if let onExport { Button("Export...") { onExport() } } + if let onDelete { + Divider() + Button("Delete...", role: .destructive) { onDelete() } + } + } label: { + Image(systemName: "ellipsis.circle") + .foregroundStyle(.secondary) + } + .menuStyle(.borderlessButton) + .fixedSize() + } + } HStack(spacing: 16) { Label(session.source, systemImage: session.sourceIcon) Label(session.model ?? "unknown", systemImage: "cpu") Label("\(session.messageCount) msgs", systemImage: "bubble.left") Label("\(session.toolCallCount) tools", systemImage: "wrench") - if let cost = session.estimatedCostUSD { - Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle") - } if let date = session.startedAt { Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar") } } .font(.caption) .foregroundStyle(.secondary) + Text(session.id) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + .textSelection(.enabled) } .padding() } diff --git a/scarf/scarf/Features/Sessions/Views/SessionsView.swift b/scarf/scarf/Features/Sessions/Views/SessionsView.swift index 44f904f..0dae57b 100644 --- a/scarf/scarf/Features/Sessions/Views/SessionsView.swift +++ b/scarf/scarf/Features/Sessions/Views/SessionsView.swift @@ -5,11 +5,17 @@ struct SessionsView: View { @Environment(AppCoordinator.self) private var coordinator var body: some View { - HSplitView { - sessionList - .frame(minWidth: 280, idealWidth: 320) - sessionDetail - .frame(minWidth: 400) + VStack(spacing: 0) { + if let stats = viewModel.storeStats { + statsBar(stats) + Divider() + } + HSplitView { + sessionList + .frame(minWidth: 280, idealWidth: 320) + sessionDetail + .frame(minWidth: 400) + } } .navigationTitle("Sessions") .searchable(text: $viewModel.searchText, prompt: "Search messages...") @@ -28,6 +34,33 @@ struct SessionsView: View { } } .onDisappear { Task { await viewModel.cleanup() } } + .sheet(isPresented: $viewModel.showRenameSheet) { + renameSheet + } + .confirmationDialog("Delete Session?", isPresented: $viewModel.showDeleteConfirmation) { + Button("Delete", role: .destructive) { viewModel.confirmDelete() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will permanently delete the session and all its messages.") + } + } + + private func statsBar(_ stats: SessionStoreStats) -> some View { + HStack(spacing: 16) { + Label("\(stats.totalSessions) sessions", systemImage: "bubble.left.and.bubble.right") + Label("\(stats.totalMessages) messages", systemImage: "text.bubble") + Label(stats.databaseSize, systemImage: "internaldrive") + ForEach(stats.platformCounts, id: \.platform) { item in + Label("\(item.count) \(item.platform)", systemImage: platformIcon(item.platform)) + } + Spacer() + Button("Export All") { viewModel.exportAll() } + .controlSize(.small) + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.vertical, 6) } private var sessionList: some View { @@ -64,6 +97,12 @@ struct SessionsView: View { ForEach(viewModel.sessions) { session in SessionRow(session: session, preview: viewModel.previewFor(session)) .tag(session.id) + .contextMenu { + Button("Rename...") { viewModel.beginRename(session) } + Button("Export...") { viewModel.exportSession(session) } + Divider() + Button("Delete...", role: .destructive) { viewModel.beginDelete(session) } + } } } } @@ -73,11 +112,50 @@ struct SessionsView: View { @ViewBuilder private var sessionDetail: some View { if let session = viewModel.selectedSession { - SessionDetailView(session: session, messages: viewModel.messages, preview: viewModel.previewFor(session)) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + SessionDetailView( + session: session, + messages: viewModel.messages, + preview: viewModel.previewFor(session), + onRename: { viewModel.beginRename(session) }, + onExport: { viewModel.exportSession(session) }, + onDelete: { viewModel.beginDelete(session) } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } else { ContentUnavailableView("Select a Session", systemImage: "bubble.left.and.bubble.right", description: Text("Choose a session from the list")) .frame(maxWidth: .infinity, maxHeight: .infinity) } } + + private var renameSheet: some View { + VStack(spacing: 16) { + Text("Rename Session") + .font(.headline) + TextField("Session title", text: $viewModel.renameText) + .textFieldStyle(.roundedBorder) + .onSubmit { viewModel.confirmRename() } + HStack { + Button("Cancel") { viewModel.showRenameSheet = false } + .keyboardShortcut(.cancelAction) + Spacer() + Button("Rename") { viewModel.confirmRename() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(viewModel.renameText.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding() + .frame(width: 400) + } + + private func platformIcon(_ platform: String) -> String { + switch platform { + case "cli": return "terminal" + case "telegram": return "paperplane" + case "discord": return "bubble.left.and.bubble.right" + case "slack": return "number" + case "email": return "envelope" + default: return "bubble.left" + } + } } diff --git a/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift b/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift new file mode 100644 index 0000000..db31c22 --- /dev/null +++ b/scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift @@ -0,0 +1,134 @@ +import Foundation + +@Observable +final class ToolsViewModel { + var selectedPlatform: HermesToolPlatform = KnownPlatforms.all[0] + var toolsets: [HermesToolset] = [] + var mcpStatus: String = "" + var isLoading = false + var availablePlatforms: [HermesToolPlatform] = [] + + func load() { + loadPlatforms() + loadTools(for: selectedPlatform) + loadMCPStatus() + } + + func switchPlatform(_ platform: HermesToolPlatform) { + selectedPlatform = platform + loadTools(for: platform) + } + + func toggleTool(_ tool: HermesToolset) { + let action = tool.enabled ? "disable" : "enable" + let result = runHermes(["tools", action, tool.name, "--platform", selectedPlatform.name]) + if result.exitCode == 0 { + if let idx = toolsets.firstIndex(where: { $0.name == tool.name }) { + toolsets[idx].enabled.toggle() + } + } + } + + private func loadPlatforms() { + let config = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? "" + var platforms: [HermesToolPlatform] = [] + var inSection = false + for line in config.components(separatedBy: "\n") { + if line.hasPrefix("platform_toolsets:") { + inSection = true + continue + } + if inSection { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || (!line.hasPrefix(" ") && !line.hasPrefix("\t")) { + if !trimmed.isEmpty { break } + continue + } + if trimmed.hasSuffix(":") && !trimmed.hasPrefix("-") { + let name = String(trimmed.dropLast()).trimmingCharacters(in: .whitespaces) + if let known = KnownPlatforms.all.first(where: { $0.name == name }) { + platforms.append(known) + } else { + platforms.append(HermesToolPlatform(name: name, displayName: name.capitalized, icon: "bubble.left")) + } + } + } + } + availablePlatforms = platforms.isEmpty ? [KnownPlatforms.all[0]] : platforms + if !availablePlatforms.contains(where: { $0.name == selectedPlatform.name }) { + selectedPlatform = availablePlatforms[0] + } + } + + private func loadTools(for platform: HermesToolPlatform) { + isLoading = true + let result = runHermes(["tools", "list", "--platform", platform.name]) + toolsets = parseToolsList(result.output) + isLoading = false + } + + private func loadMCPStatus() { + let result = runHermes(["mcp", "list"]) + mcpStatus = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func parseToolsList(_ output: String) -> [HermesToolset] { + var tools: [HermesToolset] = [] + for line in output.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + let isEnabled: Bool + if trimmed.hasPrefix("✓ enabled") { + isEnabled = true + } else if trimmed.hasPrefix("✗ disabled") { + isEnabled = false + } else { + continue + } + let rest = trimmed + .replacingOccurrences(of: "✓ enabled", with: "") + .replacingOccurrences(of: "✗ disabled", with: "") + .trimmingCharacters(in: .whitespaces) + + let parts = rest.split(separator: " ", maxSplits: 1) + guard let namePart = parts.first else { continue } + let name = String(namePart) + let rawDesc = parts.count > 1 ? String(parts[1]) : name + + let icon = extractEmoji(from: rawDesc) + let description = rawDesc + .unicodeScalars.filter { !$0.properties.isEmoji || $0.isASCII } + .map { String($0) }.joined() + .trimmingCharacters(in: .whitespaces) + + tools.append(HermesToolset(name: name, description: description, icon: icon, enabled: isEnabled)) + } + return tools + } + + private func extractEmoji(from text: String) -> String { + for scalar in text.unicodeScalars { + if scalar.properties.isEmoji && !scalar.isASCII { + return String(scalar) + } + } + return "🔧" + } + + private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { + let process = Process() + process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + return (output, process.terminationStatus) + } catch { + return ("", -1) + } + } +} diff --git a/scarf/scarf/Features/Tools/Views/ToolsView.swift b/scarf/scarf/Features/Tools/Views/ToolsView.swift new file mode 100644 index 0000000..749ed69 --- /dev/null +++ b/scarf/scarf/Features/Tools/Views/ToolsView.swift @@ -0,0 +1,110 @@ +import SwiftUI + +struct ToolsView: View { + @State private var viewModel = ToolsViewModel() + + var body: some View { + VStack(spacing: 0) { + platformPicker + Divider() + toolsList + if !viewModel.mcpStatus.isEmpty { + Divider() + mcpSection + } + } + .navigationTitle("Tools") + .onAppear { viewModel.load() } + } + + private var platformPicker: some View { + HStack(spacing: 16) { + ForEach(viewModel.availablePlatforms) { platform in + Button { + viewModel.switchPlatform(platform) + } label: { + HStack(spacing: 4) { + Image(systemName: platform.icon) + Text(platform.displayName) + .font(.caption) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(viewModel.selectedPlatform.name == platform.name ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } + Spacer() + Text("\(viewModel.toolsets.filter(\.enabled).count) of \(viewModel.toolsets.count) enabled") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + + private var toolsList: some View { + ScrollView { + LazyVStack(spacing: 1) { + ForEach(viewModel.toolsets) { tool in + ToolRow(tool: tool) { + viewModel.toggleTool(tool) + } + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } + } + + private var mcpSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("MCP Servers") + .font(.caption.bold()) + .foregroundStyle(.secondary) + if viewModel.mcpStatus.contains("No MCP servers") { + Label("No MCP servers configured", systemImage: "server.rack") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(viewModel.mcpStatus) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct ToolRow: View { + let tool: HermesToolset + let onToggle: () -> Void + + var body: some View { + HStack(spacing: 12) { + Text(tool.icon) + .font(.title3) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(tool.name) + .font(.system(.body, design: .monospaced, weight: .medium)) + Text(tool.description) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Toggle("", isOn: Binding( + get: { tool.enabled }, + set: { _ in onToggle() } + )) + .toggleStyle(.switch) + .labelsHidden() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.quaternary.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/scarf/scarf/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift index 97724a7..d8e45d7 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -2,12 +2,16 @@ import Foundation enum SidebarSection: String, CaseIterable, Identifiable { case dashboard = "Dashboard" + case insights = "Insights" case sessions = "Sessions" case activity = "Activity" case chat = "Chat" case memory = "Memory" case skills = "Skills" + case tools = "Tools" + case gateway = "Gateway" case cron = "Cron" + case health = "Health" case logs = "Logs" case settings = "Settings" @@ -16,12 +20,16 @@ enum SidebarSection: String, CaseIterable, Identifiable { var icon: String { switch self { case .dashboard: return "gauge.with.dots.needle.33percent" + case .insights: return "chart.bar" case .sessions: return "bubble.left.and.bubble.right" case .activity: return "bolt.horizontal" case .chat: return "text.bubble" case .memory: return "brain" case .skills: return "lightbulb" + case .tools: return "wrench.and.screwdriver" + case .gateway: return "antenna.radiowaves.left.and.right" case .cron: return "clock.arrow.2.circlepath" + case .health: return "stethoscope" case .logs: return "doc.text" case .settings: return "gearshape" } diff --git a/scarf/scarf/Navigation/SidebarView.swift b/scarf/scarf/Navigation/SidebarView.swift index e72a305..3bfd15d 100644 --- a/scarf/scarf/Navigation/SidebarView.swift +++ b/scarf/scarf/Navigation/SidebarView.swift @@ -7,7 +7,7 @@ struct SidebarView: View { @Bindable var coordinator = coordinator List(selection: $coordinator.selectedSection) { Section("Monitor") { - ForEach([SidebarSection.dashboard, .sessions, .activity]) { section in + ForEach([SidebarSection.dashboard, .insights, .sessions, .activity]) { section in Label(section.rawValue, systemImage: section.icon) .tag(section) } @@ -19,7 +19,7 @@ struct SidebarView: View { } } Section("Manage") { - ForEach([SidebarSection.cron, .logs, .settings]) { section in + ForEach([SidebarSection.tools, .gateway, .cron, .health, .logs, .settings]) { section in Label(section.rawValue, systemImage: section.icon) .tag(section) } diff --git a/scarf/scarf/scarf.entitlements b/scarf/scarf/scarf.entitlements new file mode 100644 index 0000000..b572d9c --- /dev/null +++ b/scarf/scarf/scarf.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.device.audio-input + + +