From f3cb1eb86bce81fcb0fd60621b8d71940eac3217 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 10:45:35 -0400 Subject: [PATCH 01/14] Add Insights Dashboard with usage analytics New sidebar section showing rich analytics from the sessions database: - Overview grid: sessions, messages, tokens (input/output/cache), active time, avg session duration, avg messages per session - Model breakdown: sessions and total tokens per model - Platform breakdown: CLI vs Telegram etc with session/message counts - Top tools bar chart: ranked by call count with percentages - Activity patterns: day-of-week bars and hourly heatmap - Notable sessions: longest, most messages, most tokens, most tool calls with clickable links to open in Sessions browser - Time period selector: 7/30/90 days or all time Also adds ROADMAP.md documenting the full feature expansion plan. Co-Authored-By: Claude Opus 4.6 (1M context) --- scarf/docs/ROADMAP.md | 58 ++++ scarf/scarf/ContentView.swift | 2 + .../Core/Services/HermesDataService.swift | 106 ++++++ .../ViewModels/InsightsViewModel.swift | 234 +++++++++++++ .../Insights/Views/InsightsView.swift | 317 ++++++++++++++++++ scarf/scarf/Navigation/AppCoordinator.swift | 2 + scarf/scarf/Navigation/SidebarView.swift | 2 +- 7 files changed, 720 insertions(+), 1 deletion(-) create mode 100644 scarf/docs/ROADMAP.md create mode 100644 scarf/scarf/Features/Insights/ViewModels/InsightsViewModel.swift create mode 100644 scarf/scarf/Features/Insights/Views/InsightsView.swift 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/ContentView.swift b/scarf/scarf/ContentView.swift index 895ab67..1f47e5b 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: 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/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/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift index 97724a7..b9b4995 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -2,6 +2,7 @@ import Foundation enum SidebarSection: String, CaseIterable, Identifiable { case dashboard = "Dashboard" + case insights = "Insights" case sessions = "Sessions" case activity = "Activity" case chat = "Chat" @@ -16,6 +17,7 @@ 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" diff --git a/scarf/scarf/Navigation/SidebarView.swift b/scarf/scarf/Navigation/SidebarView.swift index e72a305..372292f 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) } From cfbf3ea142697c6e0ecbb551bc03f2a715e32eba Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 11:31:50 -0400 Subject: [PATCH 02/14] Update README with Insights, voice controls, and activity filter Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f3be841..59530df 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,10 @@ ## Features - **Dashboard** — System health, token usage, recent sessions at a glance +- **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, 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) +- **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 refresh - **Skills Browser** — Browse all installed skills by category with file content viewer - **Cron Manager** — View scheduled jobs, their status, prompts, and output @@ -61,9 +62,10 @@ scarf/ Services/ Data access (SQLite reader, file I/O, log tailing, file watcher) Features/ Self-contained feature modules Dashboard/ System overview and stats + Insights/ Usage analytics and activity patterns Sessions/ Conversation browser with detail view 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 Cron/ Scheduled job viewer From 36757a8c9a0cf68f1b8ac1160bdfd429e94782cb Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 11:47:25 -0400 Subject: [PATCH 03/14] Add Tool Management panel with per-platform toggle switches New Tools section in the Manage group: - Platform tabs parsed from config.yaml (CLI, Telegram, Discord, etc.) - Lists all toolsets with emoji icon, name, description, and toggle - Toggle switches call hermes tools enable/disable under the hood - Shows enabled count vs total - MCP server status section at bottom - Optimistic UI update on toggle with CLI fallback Co-Authored-By: Claude Opus 4.6 (1M context) --- scarf/scarf/ContentView.swift | 2 + scarf/scarf/Core/Models/HermesTool.swift | 28 ++++ .../Tools/ViewModels/ToolsViewModel.swift | 134 ++++++++++++++++++ .../Features/Tools/Views/ToolsView.swift | 110 ++++++++++++++ scarf/scarf/Navigation/AppCoordinator.swift | 2 + scarf/scarf/Navigation/SidebarView.swift | 2 +- 6 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 scarf/scarf/Core/Models/HermesTool.swift create mode 100644 scarf/scarf/Features/Tools/ViewModels/ToolsViewModel.swift create mode 100644 scarf/scarf/Features/Tools/Views/ToolsView.swift diff --git a/scarf/scarf/ContentView.swift b/scarf/scarf/ContentView.swift index 1f47e5b..a9abad1 100644 --- a/scarf/scarf/ContentView.swift +++ b/scarf/scarf/ContentView.swift @@ -28,6 +28,8 @@ struct ContentView: View { MemoryView() case .skills: SkillsView() + case .tools: + ToolsView() case .cron: CronView() case .logs: 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/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 b9b4995..afae5c9 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -8,6 +8,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case chat = "Chat" case memory = "Memory" case skills = "Skills" + case tools = "Tools" case cron = "Cron" case logs = "Logs" case settings = "Settings" @@ -23,6 +24,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case .chat: return "text.bubble" case .memory: return "brain" case .skills: return "lightbulb" + case .tools: return "wrench.and.screwdriver" case .cron: return "clock.arrow.2.circlepath" case .logs: return "doc.text" case .settings: return "gearshape" diff --git a/scarf/scarf/Navigation/SidebarView.swift b/scarf/scarf/Navigation/SidebarView.swift index 372292f..59f6634 100644 --- a/scarf/scarf/Navigation/SidebarView.swift +++ b/scarf/scarf/Navigation/SidebarView.swift @@ -19,7 +19,7 @@ struct SidebarView: View { } } Section("Manage") { - ForEach([SidebarSection.cron, .logs, .settings]) { section in + ForEach([SidebarSection.tools, .cron, .logs, .settings]) { section in Label(section.rawValue, systemImage: section.icon) .tag(section) } From e4d5bb0364b681c571bf2c74fc74a1aba24b1a4c Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 11:55:35 -0400 Subject: [PATCH 04/14] Add session management: rename, delete, export, and stats bar Sessions browser enhancements: - Stats bar: total sessions, messages, DB size, per-platform counts - Right-click context menu on session rows: Rename, Export, Delete - Detail view actions menu (ellipsis button): same actions - Rename: sheet with text field, calls hermes sessions rename - Delete: confirmation dialog, calls hermes sessions delete --yes - Export single session: NSSavePanel, calls hermes sessions export - Export all: button in stats bar, exports everything to JSONL - Session ID shown in detail header for reference Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ViewModels/SessionsViewModel.swift | 140 ++++++++++++++++++ .../Sessions/Views/SessionDetailView.swift | 32 +++- .../Sessions/Views/SessionsView.swift | 92 +++++++++++- 3 files changed, 252 insertions(+), 12 deletions(-) diff --git a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift index 72ab51b..6809a94 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,127 @@ 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 }) { + sessions[idx] = 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 + ) + } + } + 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" + } + } } From a800a630a85719577b1e8145244b6ecafc1e4d6d Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 12:01:20 -0400 Subject: [PATCH 05/14] Fix session rename not updating across views After rename: - Update selectedSession so detail header refreshes immediately - Update sessionPreviews so previewFor() returns the new title - Dashboard now observes HermesFileWatcher and reloads on DB changes - Chat session menu reloads via file watcher (persists across nav) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../scarf/Features/Chat/Views/ChatView.swift | 43 +++++++++++++++++++ .../Dashboard/Views/DashboardView.swift | 4 ++ .../ViewModels/SessionsViewModel.swift | 7 ++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/scarf/scarf/Features/Chat/Views/ChatView.swift b/scarf/scarf/Features/Chat/Views/ChatView.swift index b28a8f5..b70e107 100644 --- a/scarf/scarf/Features/Chat/Views/ChatView.swift +++ b/scarf/scarf/Features/Chat/Views/ChatView.swift @@ -2,6 +2,7 @@ import SwiftUI struct ChatView: View { @Environment(ChatViewModel.self) private var viewModel + @Environment(HermesFileWatcher.self) private var fileWatcher var body: some View { VStack(spacing: 0) { @@ -11,6 +12,9 @@ struct ChatView: View { } .navigationTitle("Chat") .task { await viewModel.loadRecentSessions() } + .onChange(of: fileWatcher.lastChangeDate) { + Task { await viewModel.loadRecentSessions() } + } } private var toolbar: some View { @@ -36,6 +40,10 @@ struct ChatView: View { Spacer() + if viewModel.hasActiveProcess { + voiceControls + } + if !viewModel.hermesBinaryExists { Label("Hermes binary not found", systemImage: "exclamationmark.triangle") .font(.caption) @@ -80,6 +88,41 @@ struct ChatView: View { .padding(.vertical, 6) } + private var voiceControls: some View { + HStack(spacing: 8) { + Button { + viewModel.toggleVoice() + } label: { + HStack(spacing: 4) { + Image(systemName: viewModel.voiceEnabled ? "mic.fill" : "mic.slash") + .foregroundStyle(viewModel.voiceEnabled ? .green : .secondary) + Text(viewModel.voiceEnabled ? "Voice On" : "Voice Off") + .font(.caption) + .foregroundStyle(viewModel.voiceEnabled ? .primary : .secondary) + } + } + .buttonStyle(.plain) + .help("Toggle voice mode (/voice)") + + if viewModel.voiceEnabled { + Button { + viewModel.pushToTalk() + } label: { + HStack(spacing: 4) { + Image(systemName: viewModel.isRecording ? "waveform.circle.fill" : "waveform.circle") + .foregroundStyle(viewModel.isRecording ? .red : .accentColor) + .symbolEffect(.pulse, isActive: viewModel.isRecording) + Text(viewModel.isRecording ? "Recording..." : "Push to Talk") + .font(.caption) + } + } + .buttonStyle(.plain) + .help("Push to talk (Ctrl+B)") + .keyboardShortcut("b", modifiers: .control) + } + } + } + @ViewBuilder private var terminalArea: some View { if let terminal = viewModel.terminalView { diff --git a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift index a773b19..47fc502 100644 --- a/scarf/scarf/Features/Dashboard/Views/DashboardView.swift +++ b/scarf/scarf/Features/Dashboard/Views/DashboardView.swift @@ -3,6 +3,7 @@ import SwiftUI struct DashboardView: View { @State private var viewModel = DashboardViewModel() @Environment(AppCoordinator.self) private var coordinator + @Environment(HermesFileWatcher.self) private var fileWatcher var body: some View { ScrollView { @@ -16,6 +17,9 @@ struct DashboardView: View { } .navigationTitle("Dashboard") .task { await viewModel.load() } + .onChange(of: fileWatcher.lastChangeDate) { + Task { await viewModel.load() } + } } private var statusSection: some View { diff --git a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift index 6809a94..6aad57c 100644 --- a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift +++ b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift @@ -83,7 +83,7 @@ final class SessionsViewModel { let result = runHermes(["sessions", "rename", sessionId, title]) if result.exitCode == 0 { if let idx = sessions.firstIndex(where: { $0.id == sessionId }) { - sessions[idx] = HermesSession( + 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, @@ -94,7 +94,12 @@ final class SessionsViewModel { cacheWriteTokens: sessions[idx].cacheWriteTokens, estimatedCostUSD: sessions[idx].estimatedCostUSD ) + sessions[idx] = updated + if selectedSession?.id == sessionId { + selectedSession = updated + } } + sessionPreviews[sessionId] = title } showRenameSheet = false renameSessionId = nil From b79200e950951e1f6181cc2ca72ea7995b7dff51 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 12:02:29 -0400 Subject: [PATCH 06/14] Update README with Tools Manager, session management, and revised docs - Added Tools Manager to features list - Updated Sessions Browser with rename/delete/export - Updated Skills Browser with file switcher - Updated Dashboard with live refresh - Updated Log Viewer with text search - Added hermes tools and hermes sessions to data sources table - Revised How It Works section to cover management actions - Updated architecture tree with Tools feature Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 59530df..533eb3d 100644 --- a/README.md +++ b/README.md @@ -19,16 +19,17 @@ ## Features -- **Dashboard** — System health, token usage, recent sessions at a glance +- **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, and full-text search (FTS5) +- **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 refresh -- **Skills Browser** — Browse all installed skills by category with file content viewer +- **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 - **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 @@ -63,11 +64,12 @@ scarf/ Features/ Self-contained feature modules Dashboard/ System overview and stats Insights/ Usage analytics and activity patterns - Sessions/ Conversation browser with detail view + Sessions/ Conversation browser with rename, delete, export Activity/ Tool execution feed with inspector Chat/ Embedded terminal via SwiftTerm with voice controls Memory/ Memory viewer and editor Skills/ Skill browser by category + Tools/ Toolset management per platform Cron/ Scheduled job viewer Logs/ Real-time log viewer Settings/ Configuration display @@ -88,8 +90,10 @@ 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 | -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 @@ -101,7 +105,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. From c09f1677607cc5bdde60ebf0309cedefd5553863 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 12:32:29 -0400 Subject: [PATCH 07/14] Add Gateway Control Center with service control and pairing management New Gateway section in the Manage group: - Service controls: Start/Stop/Restart buttons calling hermes gateway CLI - Status display: state (running/stopped), PID, loaded indicator, stale service warning, exit reason, last update timestamp - Platform cards: each connected messaging platform with connection state (reads from gateway_state.json) - Pairing management: approved users list with revoke button, pending pairing codes with approve button - Auto-refreshes via HermesFileWatcher when gateway state changes Co-Authored-By: Claude Opus 4.6 (1M context) --- scarf/scarf/ContentView.swift | 2 + .../Gateway/ViewModels/GatewayViewModel.swift | 187 ++++++++++++++++ .../Features/Gateway/Views/GatewayView.swift | 205 ++++++++++++++++++ scarf/scarf/Navigation/AppCoordinator.swift | 2 + scarf/scarf/Navigation/SidebarView.swift | 2 +- 5 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift create mode 100644 scarf/scarf/Features/Gateway/Views/GatewayView.swift diff --git a/scarf/scarf/ContentView.swift b/scarf/scarf/ContentView.swift index a9abad1..d317d13 100644 --- a/scarf/scarf/ContentView.swift +++ b/scarf/scarf/ContentView.swift @@ -30,6 +30,8 @@ struct ContentView: View { SkillsView() case .tools: ToolsView() + case .gateway: + GatewayView() case .cron: CronView() case .logs: diff --git a/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift b/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift new file mode 100644 index 0000000..7eac5a2 --- /dev/null +++ b/scarf/scarf/Features/Gateway/ViewModels/GatewayViewModel.swift @@ -0,0 +1,187 @@ +import Foundation + +struct GatewayInfo { + let pid: Int? + let state: String + let exitReason: String? + let startTime: String? + let updatedAt: String? + let platforms: [PlatformInfo] + let isLoaded: Bool + let isStale: Bool +} + +struct PlatformInfo: Identifiable { + var id: String { name } + let name: String + let state: String + let updatedAt: String? + + var isConnected: Bool { state == "connected" } + + var icon: String { + switch name { + 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 PairedUser: Identifiable { + var id: String { platform + userId } + let platform: String + let userId: String + let name: String +} + +struct PendingPairing: Identifiable { + var id: String { platform + code } + let platform: String + let code: String +} + +@Observable +final class GatewayViewModel { + var gateway = GatewayInfo(pid: nil, state: "unknown", exitReason: nil, startTime: nil, updatedAt: nil, platforms: [], isLoaded: false, isStale: false) + var approvedUsers: [PairedUser] = [] + var pendingPairings: [PendingPairing] = [] + var isLoading = false + var actionMessage: String? + + func load() { + isLoading = true + loadGatewayStatus() + loadPairing() + isLoading = false + } + + func startGateway() { + runHermes(["gateway", "start"]) + actionMessage = "Gateway start requested" + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.loadGatewayStatus() + self?.actionMessage = nil + } + } + + func stopGateway() { + runHermes(["gateway", "stop"]) + actionMessage = "Gateway stop requested" + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.loadGatewayStatus() + self?.actionMessage = nil + } + } + + func restartGateway() { + runHermes(["gateway", "restart"]) + actionMessage = "Gateway restart requested" + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + self?.loadGatewayStatus() + self?.actionMessage = nil + } + } + + func approvePairing(platform: String, code: String) { + runHermes(["pairing", "approve", platform, code]) + loadPairing() + } + + func revokeUser(_ user: PairedUser) { + runHermes(["pairing", "revoke", user.platform, user.userId]) + approvedUsers.removeAll { $0.id == user.id } + } + + // MARK: - Private + + private func loadGatewayStatus() { + let stateJSON = FileManager.default.contents(atPath: HermesPaths.gatewayStateJSON) + var pid: Int? + var state = "unknown" + var exitReason: String? + var startTime: String? + var updatedAt: String? + var platforms: [PlatformInfo] = [] + + if let data = stateJSON, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + pid = json["pid"] as? Int + state = json["gateway_state"] as? String ?? "unknown" + exitReason = json["exit_reason"] as? String + startTime = json["start_time"] as? String + updatedAt = json["updated_at"] as? String + if let plats = json["platforms"] as? [String: Any] { + platforms = plats.compactMap { key, value in + guard let info = value as? [String: Any] else { return nil } + return PlatformInfo( + name: key, + state: info["state"] as? String ?? "unknown", + updatedAt: info["updated_at"] as? String + ) + }.sorted { $0.name < $1.name } + } + } + + let statusOutput = runHermes(["gateway", "status"]).output + let isLoaded = statusOutput.contains("service is loaded") + let isStale = statusOutput.contains("stale") + + gateway = GatewayInfo( + pid: pid, state: state, exitReason: exitReason, + startTime: startTime, updatedAt: updatedAt, + platforms: platforms, isLoaded: isLoaded, isStale: isStale + ) + } + + private func loadPairing() { + let output = runHermes(["pairing", "list"]).output + approvedUsers = [] + pendingPairings = [] + + var inApproved = false + var inPending = false + + for line in output.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.contains("Approved Users") { inApproved = true; inPending = false; continue } + if trimmed.contains("Pending") { inPending = true; inApproved = false; continue } + if trimmed.isEmpty || trimmed.hasPrefix("Platform") || trimmed.hasPrefix("--------") { continue } + + let parts = trimmed.split(separator: " ", omittingEmptySubsequences: true) + if inApproved && parts.count >= 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/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift index afae5c9..33b7ce1 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -9,6 +9,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case memory = "Memory" case skills = "Skills" case tools = "Tools" + case gateway = "Gateway" case cron = "Cron" case logs = "Logs" case settings = "Settings" @@ -25,6 +26,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { 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 .logs: return "doc.text" case .settings: return "gearshape" diff --git a/scarf/scarf/Navigation/SidebarView.swift b/scarf/scarf/Navigation/SidebarView.swift index 59f6634..647cbe0 100644 --- a/scarf/scarf/Navigation/SidebarView.swift +++ b/scarf/scarf/Navigation/SidebarView.swift @@ -19,7 +19,7 @@ struct SidebarView: View { } } Section("Manage") { - ForEach([SidebarSection.tools, .cron, .logs, .settings]) { section in + ForEach([SidebarSection.tools, .gateway, .cron, .logs, .settings]) { section in Label(section.rawValue, systemImage: section.icon) .tag(section) } From b4c93ac79c28496c5bece9b058fe81bbe574a3c7 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 12:33:16 -0400 Subject: [PATCH 08/14] Add Gateway Control to README features, architecture, and data sources Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 533eb3d..d7c48c0 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ - **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 log tailing with level filtering and text search - **Settings** — Configuration display with raw YAML viewer and Finder path links @@ -70,6 +71,7 @@ scarf/ 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 @@ -92,6 +94,8 @@ Scarf reads Hermes data directly from `~/.hermes/`: | `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 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. From c6f45ac22e3602af9329f0ad01e7646d6dc535c2 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 12:36:56 -0400 Subject: [PATCH 09/14] Add System Health view with status and diagnostics New Health section in the Manage group combining hermes status and hermes doctor output: - Version header with update available banner (e.g. "47 commits behind") - Summary badges: passing/warning/issue counts - Status sections: environment, API keys, auth providers, terminal backend, messaging platforms, gateway service, scheduled jobs - Diagnostics sections: Python environment, required/optional packages, config files, directory structure, external tools, API connectivity, submodules, tool availability, Skills Hub, Honcho memory - Each check shows green/orange/red icon with label and detail - Refresh button to re-run both commands Co-Authored-By: Claude Opus 4.6 (1M context) --- scarf/scarf/ContentView.swift | 2 + .../Health/ViewModels/HealthViewModel.swift | 179 ++++++++++++++++++ .../Features/Health/Views/HealthView.swift | 148 +++++++++++++++ scarf/scarf/Navigation/AppCoordinator.swift | 2 + scarf/scarf/Navigation/SidebarView.swift | 2 +- 5 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift create mode 100644 scarf/scarf/Features/Health/Views/HealthView.swift diff --git a/scarf/scarf/ContentView.swift b/scarf/scarf/ContentView.swift index d317d13..ac408a8 100644 --- a/scarf/scarf/ContentView.swift +++ b/scarf/scarf/ContentView.swift @@ -34,6 +34,8 @@ struct ContentView: View { GatewayView() case .cron: CronView() + case .health: + HealthView() case .logs: LogsView() case .settings: 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..a09089d --- /dev/null +++ b/scarf/scarf/Features/Health/Views/HealthView.swift @@ -0,0 +1,148 @@ +import SwiftUI + +struct HealthView: View { + @State private var viewModel = HealthViewModel() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + headerSection + if !viewModel.statusSections.isEmpty { + sectionGroup("Status", sections: viewModel.statusSections) + } + if !viewModel.doctorSections.isEmpty { + sectionGroup("Diagnostics", sections: viewModel.doctorSections) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .navigationTitle("Health") + .onAppear { viewModel.load() } + } + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 16) { + if !viewModel.version.isEmpty { + Text(viewModel.version) + .font(.system(.body, design: .monospaced)) + } + Spacer() + Button("Refresh") { viewModel.load() } + .controlSize(.small) + } + + if viewModel.hasUpdate { + HStack(spacing: 8) { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(.orange) + Text(viewModel.updateInfo) + .font(.caption) + Text("Run `hermes update` in terminal") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.orange.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + HStack(spacing: 16) { + CountBadge(count: viewModel.okCount, label: "Passing", color: .green, icon: "checkmark.circle.fill") + CountBadge(count: viewModel.warningCount, label: "Warnings", color: .orange, icon: "exclamationmark.triangle.fill") + CountBadge(count: viewModel.issueCount, label: "Issues", color: .red, icon: "xmark.circle.fill") + } + } + } + + private func sectionGroup(_ title: String, sections: [HealthSection]) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text(title) + .font(.title3.bold()) + ForEach(sections) { section in + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: section.icon) + .foregroundStyle(.secondary) + .frame(width: 16) + Text(section.title) + .font(.headline) + } + VStack(alignment: .leading, spacing: 2) { + ForEach(section.checks) { check in + CheckRow(check: check) + } + } + .padding(.leading, 22) + } + .padding(12) + .background(.quaternary.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } +} + +struct CheckRow: View { + let check: HealthCheck + + var body: some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: statusIcon) + .foregroundStyle(statusColor) + .font(.caption) + .frame(width: 14) + VStack(alignment: .leading, spacing: 1) { + Text(check.label) + .font(.caption) + if let detail = check.detail { + Text(detail) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 1) + } + + 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 + } + } +} + +struct CountBadge: View { + let count: Int + let label: String + let color: Color + let icon: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: icon) + .foregroundStyle(color) + Text("\(count)") + .font(.system(.title3, design: .monospaced, weight: .semibold)) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/scarf/scarf/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift index 33b7ce1..d8e45d7 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -11,6 +11,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { case tools = "Tools" case gateway = "Gateway" case cron = "Cron" + case health = "Health" case logs = "Logs" case settings = "Settings" @@ -28,6 +29,7 @@ enum SidebarSection: String, CaseIterable, Identifiable { 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 647cbe0..3bfd15d 100644 --- a/scarf/scarf/Navigation/SidebarView.swift +++ b/scarf/scarf/Navigation/SidebarView.swift @@ -19,7 +19,7 @@ struct SidebarView: View { } } Section("Manage") { - ForEach([SidebarSection.tools, .gateway, .cron, .logs, .settings]) { section in + ForEach([SidebarSection.tools, .gateway, .cron, .health, .logs, .settings]) { section in Label(section.rawValue, systemImage: section.icon) .tag(section) } From 3477fa733f22441e555d6e44a570e135294b5137 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 12:39:42 -0400 Subject: [PATCH 10/14] Redesign Health view with card grid and expandable sections Replaced the long flat list with a cleaner layout: - Compact header bar: version, update banner, pass/warn/error counts - Status/Diagnostics tab switcher (segmented control) - 2-column card grid: each section is a uniform card showing icon, title, and colored status dot counts (green/orange/red) - Cards have a colored border accent based on worst status - Click to expand: reveals individual check rows inline - Only one section expanded at a time for clean scanning Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Features/Health/Views/HealthView.swift | 209 ++++++++++++------ 1 file changed, 140 insertions(+), 69 deletions(-) diff --git a/scarf/scarf/Features/Health/Views/HealthView.swift b/scarf/scarf/Features/Health/Views/HealthView.swift index a09089d..e50aafa 100644 --- a/scarf/scarf/Features/Health/Views/HealthView.swift +++ b/scarf/scarf/Features/Health/Views/HealthView.swift @@ -2,89 +2,165 @@ import SwiftUI struct HealthView: View { @State private var viewModel = HealthViewModel() + @State private var expandedSection: UUID? + @State private var selectedTab = 0 var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - headerSection - if !viewModel.statusSections.isEmpty { - sectionGroup("Status", sections: viewModel.statusSections) - } - if !viewModel.doctorSections.isEmpty { - sectionGroup("Diagnostics", sections: viewModel.doctorSections) - } + 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() } - .padding() - .frame(maxWidth: .infinity, alignment: .topLeading) } .navigationTitle("Health") .onAppear { viewModel.load() } } - private var headerSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 16) { - if !viewModel.version.isEmpty { - Text(viewModel.version) - .font(.system(.body, design: .monospaced)) - } - Spacer() - Button("Refresh") { viewModel.load() } - .controlSize(.small) + // 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: 8) { + HStack(spacing: 4) { Image(systemName: "arrow.triangle.2.circlepath") - .foregroundStyle(.orange) + .font(.caption2) Text(viewModel.updateInfo) .font(.caption) - Text("Run `hermes update` in terminal") - .font(.caption) - .foregroundStyle(.secondary) } - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.orange.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 6)) + .foregroundStyle(.orange) } - HStack(spacing: 16) { - CountBadge(count: viewModel.okCount, label: "Passing", color: .green, icon: "checkmark.circle.fill") - CountBadge(count: viewModel.warningCount, label: "Warnings", color: .orange, icon: "exclamationmark.triangle.fill") - CountBadge(count: viewModel.issueCount, label: "Issues", color: .red, icon: "xmark.circle.fill") + 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) } - private func sectionGroup(_ title: String, sections: [HealthSection]) -> some View { - VStack(alignment: .leading, spacing: 16) { - Text(title) - .font(.title3.bold()) + // 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 - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Image(systemName: section.icon) - .foregroundStyle(.secondary) - .frame(width: 16) - Text(section.title) - .font(.headline) - } - VStack(alignment: .leading, spacing: 2) { - ForEach(section.checks) { check in - CheckRow(check: check) + SectionCard( + section: section, + isExpanded: expandedSection == section.id, + onTap: { + withAnimation(.easeInOut(duration: 0.2)) { + expandedSection = expandedSection == section.id ? nil : section.id } } - .padding(.leading, 22) - } - .padding(12) - .background(.quaternary.opacity(0.3)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + ) } } } } +// 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 @@ -92,9 +168,10 @@ struct CheckRow: View { HStack(alignment: .top, spacing: 6) { Image(systemName: statusIcon) .foregroundStyle(statusColor) - .font(.caption) - .frame(width: 14) - VStack(alignment: .leading, spacing: 1) { + .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 { @@ -104,7 +181,6 @@ struct CheckRow: View { } } } - .padding(.vertical, 1) } private var statusIcon: String { @@ -124,25 +200,20 @@ struct CheckRow: View { } } -struct CountBadge: View { +// MARK: - Mini Count + +struct MiniCount: View { let count: Int - let label: String let color: Color let icon: String var body: some View { - HStack(spacing: 6) { + HStack(spacing: 3) { Image(systemName: icon) .foregroundStyle(color) + .font(.caption2) Text("\(count)") - .font(.system(.title3, design: .monospaced, weight: .semibold)) - Text(label) - .font(.caption) - .foregroundStyle(.secondary) + .font(.caption.monospaced().bold()) } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(.quaternary.opacity(0.5)) - .clipShape(RoundedRectangle(cornerRadius: 8)) } } From 55ee99c839cb7c33cabea03688a54fd7442247c5 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 12:45:50 -0400 Subject: [PATCH 11/14] Add Hermes version compatibility section to README Documents tested versions and the interfaces Scarf depends on (SQLite schema v6, CLI output parsing). Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d7c48c0..39990d0 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,18 @@ - 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 From bc8f4b0c25329861cdd0f8566db15e20418d1a14 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 12:50:14 -0400 Subject: [PATCH 12/14] Add TTS toggle button to voice controls Voice toolbar now shows three controls when voice is enabled: - Mic toggle (voice on/off) - TTS toggle (speaker icon, sends /voice tts) - Push to Talk (waveform, sends Ctrl+B) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Chat/ViewModels/ChatViewModel.swift | 45 +++++++++++++++++++ .../scarf/Features/Chat/Views/ChatView.swift | 16 ++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 261088b..d96128a 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -10,6 +10,9 @@ final class ChatViewModel { 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 +20,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 +54,37 @@ 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 + } + } + + 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.. Date: Tue, 31 Mar 2026 12:56:49 -0400 Subject: [PATCH 13/14] Add microphone permission for voice chat Hermes voice mode needs mic access when running as a Scarf subprocess. - Added NSMicrophoneUsageDescription to Info.plist keys - Created entitlements file with com.apple.security.device.audio-input - Applied to both Debug and Release configurations macOS will prompt for mic permission on first push-to-talk use. Co-Authored-By: Claude Opus 4.6 (1M context) --- scarf/scarf.xcodeproj/project.pbxproj | 4 ++++ scarf/scarf/scarf.entitlements | 8 ++++++++ 2 files changed, 12 insertions(+) create mode 100644 scarf/scarf/scarf.entitlements 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/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 + + + From ab45f957908537eb9239df9066a7e2b8a2f9cc91 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 14:03:34 -0400 Subject: [PATCH 14/14] Fix TTS toggle state reversed on voice enable Hermes auto-enables TTS when voice mode turns on (auto_tts config). Our ttsEnabled started as false, so the UI showed off when TTS was actually on. Now reads auto_tts from config.yaml when voice enables and sets the initial state to match. Co-Authored-By: Claude Opus 4.6 (1M context) --- scarf/scarf/Core/Models/HermesConfig.swift | 4 +++- scarf/scarf/Core/Services/HermesFileService.swift | 3 ++- scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) 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/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 d96128a..7b70175 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -5,6 +5,7 @@ import SwiftTerm @Observable final class ChatViewModel { private let dataService = HermesDataService() + private let fileService = HermesFileService() var recentSessions: [HermesSession] = [] var sessionPreviews: [String: String] = [:] @@ -63,6 +64,7 @@ final class ChatViewModel { } else { sendToTerminal(tv, text: "/voice on\r") voiceEnabled = true + ttsEnabled = fileService.loadConfig().autoTTS } }