Merge branch 'development'

This commit is contained in:
Alan Wizemann
2026-03-31 14:07:17 -04:00
25 changed files with 2215 additions and 29 deletions
+38 -13
View File
@@ -19,22 +19,36 @@
## Features ## Features
- **Dashboard** — System health, token usage, recent sessions at a glance - **Dashboard** — System health, token usage, recent sessions with live refresh
- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, and full-text search (FTS5) - **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)
- **Activity Feed** — Recent tool execution log with filtering by kind (read/edit/execute/fetch/browser) and detail inspector - **Sessions Browser** — Full conversation history with message rendering, tool call inspection, full-text search, rename, delete, and JSONL export
- **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
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live refresh - **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
- **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
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
- **Cron Manager** — View scheduled jobs, their status, prompts, and output - **Cron Manager** — View scheduled jobs, their status, prompts, and output
- **Log Viewer** — Real-time tailing of error and gateway logs with level filtering - **Log Viewer** — Real-time log tailing with level filtering and text search
- **Settings** — Read-only config display with raw YAML viewer and Finder path links - **Settings** — Configuration display with raw YAML viewer and Finder path links
- **Menu Bar** — Status icon showing Hermes running state with quick actions - **Menu Bar** — Status icon showing Hermes running state with quick actions
## Requirements ## Requirements
- macOS 26.2+ - macOS 26.2+
- Xcode 26.3+ - 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 ## Building
@@ -61,11 +75,14 @@ scarf/
Services/ Data access (SQLite reader, file I/O, log tailing, file watcher) Services/ Data access (SQLite reader, file I/O, log tailing, file watcher)
Features/ Self-contained feature modules Features/ Self-contained feature modules
Dashboard/ System overview and stats Dashboard/ System overview and stats
Sessions/ Conversation browser with detail view Insights/ Usage analytics and activity patterns
Sessions/ Conversation browser with rename, delete, export
Activity/ Tool execution feed with inspector Activity/ Tool execution feed with inspector
Chat/ Embedded terminal via SwiftTerm Chat/ Embedded terminal via SwiftTerm with voice controls
Memory/ Memory viewer and editor Memory/ Memory viewer and editor
Skills/ Skill browser by category Skills/ Skill browser by category
Tools/ Toolset management per platform
Gateway/ Messaging gateway control and pairing
Cron/ Scheduled job viewer Cron/ Scheduled job viewer
Logs/ Real-time log viewer Logs/ Real-time log viewer
Settings/ Configuration display Settings/ Configuration display
@@ -86,8 +103,12 @@ Scarf reads Hermes data directly from `~/.hermes/`:
| `gateway_state.json` | JSON | Read-only | | `gateway_state.json` | JSON | Read-only |
| `skills/` | Directory tree | Read-only | | `skills/` | Directory tree | Read-only |
| `hermes chat` | Terminal subprocess | Interactive | | `hermes chat` | Terminal subprocess | Interactive |
| `hermes tools` | CLI commands | Enable/Disable |
| `hermes sessions` | CLI commands | Rename/Delete/Export |
| `hermes gateway` | CLI commands | Start/Stop/Restart |
| `hermes pairing` | CLI commands | Approve/Revoke |
The app **never writes** to `state.db` — it opens in read-only mode to avoid WAL contention with Hermes. The app opens `state.db` in read-only mode to avoid WAL contention with Hermes. Management actions (tool toggles, session rename/delete/export) go through the Hermes CLI.
### Dependencies ### Dependencies
@@ -99,7 +120,11 @@ Everything else uses system frameworks: SQLite3 C API, Foundation JSON, Attribut
## How It Works ## 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. The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary.
+58
View File
@@ -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
+4
View File
@@ -404,6 +404,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
@@ -415,6 +416,7 @@
INFOPLIST_KEY_CFBundleDisplayName = Scarf; INFOPLIST_KEY_CFBundleDisplayName = Scarf;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
@@ -438,6 +440,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
@@ -449,6 +452,7 @@
INFOPLIST_KEY_CFBundleDisplayName = Scarf; INFOPLIST_KEY_CFBundleDisplayName = Scarf;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
+8
View File
@@ -16,6 +16,8 @@ struct ContentView: View {
switch coordinator.selectedSection { switch coordinator.selectedSection {
case .dashboard: case .dashboard:
DashboardView() DashboardView()
case .insights:
InsightsView()
case .sessions: case .sessions:
SessionsView() SessionsView()
case .activity: case .activity:
@@ -26,8 +28,14 @@ struct ContentView: View {
MemoryView() MemoryView()
case .skills: case .skills:
SkillsView() SkillsView()
case .tools:
ToolsView()
case .gateway:
GatewayView()
case .cron: case .cron:
CronView() CronView()
case .health:
HealthView()
case .logs: case .logs:
LogsView() LogsView()
case .settings: case .settings:
+3 -1
View File
@@ -13,6 +13,7 @@ struct HermesConfig: Sendable {
var streaming: Bool var streaming: Bool
var showReasoning: Bool var showReasoning: Bool
var verbose: Bool var verbose: Bool
var autoTTS: Bool
static let empty = HermesConfig( static let empty = HermesConfig(
model: "unknown", model: "unknown",
@@ -26,7 +27,8 @@ struct HermesConfig: Sendable {
nudgeInterval: 0, nudgeInterval: 0,
streaming: true, streaming: true,
showReasoning: false, showReasoning: false,
verbose: false verbose: false,
autoTTS: true
) )
} }
+28
View File
@@ -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"),
]
}
@@ -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? { func stateDBModificationDate() -> Date? {
let walPath = HermesPaths.stateDB + "-wal" let walPath = HermesPaths.stateDB + "-wal"
let dbPath = HermesPaths.stateDB let dbPath = HermesPaths.stateDB
@@ -42,7 +42,8 @@ struct HermesFileService: Sendable {
nudgeInterval: Int(values["memory.nudge_interval"] ?? "") ?? 0, nudgeInterval: Int(values["memory.nudge_interval"] ?? "") ?? 0,
streaming: values["display.streaming"] != "false", streaming: values["display.streaming"] != "false",
showReasoning: values["display.show_reasoning"] == "true", showReasoning: values["display.show_reasoning"] == "true",
verbose: values["agent.verbose"] == "true" verbose: values["agent.verbose"] == "true",
autoTTS: values["voice.auto_tts"] != "false"
) )
} }
@@ -5,11 +5,15 @@ import SwiftTerm
@Observable @Observable
final class ChatViewModel { final class ChatViewModel {
private let dataService = HermesDataService() private let dataService = HermesDataService()
private let fileService = HermesFileService()
var recentSessions: [HermesSession] = [] var recentSessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:] var sessionPreviews: [String: String] = [:]
var terminalView: LocalProcessTerminalView? var terminalView: LocalProcessTerminalView?
var hasActiveProcess = false var hasActiveProcess = false
var voiceEnabled = false
var ttsEnabled = false
var isRecording = false
private var coordinator: Coordinator? private var coordinator: Coordinator?
var hermesBinaryExists: Bool { var hermesBinaryExists: Bool {
@@ -17,14 +21,23 @@ final class ChatViewModel {
} }
func startNewSession() { func startNewSession() {
voiceEnabled = false
ttsEnabled = false
isRecording = false
launchTerminal(arguments: ["chat"]) launchTerminal(arguments: ["chat"])
} }
func resumeSession(_ sessionId: String) { func resumeSession(_ sessionId: String) {
voiceEnabled = false
ttsEnabled = false
isRecording = false
launchTerminal(arguments: ["chat", "--resume", sessionId]) launchTerminal(arguments: ["chat", "--resume", sessionId])
} }
func continueLastSession() { func continueLastSession() {
voiceEnabled = false
ttsEnabled = false
isRecording = false
launchTerminal(arguments: ["chat", "--continue"]) launchTerminal(arguments: ["chat", "--continue"])
} }
@@ -42,6 +55,38 @@ final class ChatViewModel {
return session.id return session.id
} }
func toggleVoice() {
guard let tv = terminalView else { return }
if voiceEnabled {
sendToTerminal(tv, text: "/voice off\r")
voiceEnabled = false
isRecording = false
} else {
sendToTerminal(tv, text: "/voice on\r")
voiceEnabled = true
ttsEnabled = fileService.loadConfig().autoTTS
}
}
func toggleTTS() {
guard let tv = terminalView, voiceEnabled else { return }
sendToTerminal(tv, text: "/voice tts\r")
ttsEnabled.toggle()
}
func pushToTalk() {
guard let tv = terminalView, voiceEnabled else { return }
// Ctrl+B = ASCII 0x02
let ctrlB: [UInt8] = [0x02]
tv.send(source: tv, data: ctrlB[0..<1])
isRecording.toggle()
}
private func sendToTerminal(_ tv: LocalProcessTerminalView, text: String) {
let bytes = Array(text.utf8)
tv.send(source: tv, data: bytes[0..<bytes.count])
}
private func launchTerminal(arguments: [String]) { private func launchTerminal(arguments: [String]) {
if let existing = terminalView { if let existing = terminalView {
existing.terminate() existing.terminate()
@@ -55,6 +100,8 @@ final class ChatViewModel {
let coord = Coordinator(onTerminated: { [weak self] in let coord = Coordinator(onTerminated: { [weak self] in
self?.hasActiveProcess = false self?.hasActiveProcess = false
self?.voiceEnabled = false
self?.isRecording = false
}) })
terminal.processDelegate = coord terminal.processDelegate = coord
self.coordinator = coord self.coordinator = coord
@@ -2,6 +2,7 @@ import SwiftUI
struct ChatView: View { struct ChatView: View {
@Environment(ChatViewModel.self) private var viewModel @Environment(ChatViewModel.self) private var viewModel
@Environment(HermesFileWatcher.self) private var fileWatcher
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -11,6 +12,9 @@ struct ChatView: View {
} }
.navigationTitle("Chat") .navigationTitle("Chat")
.task { await viewModel.loadRecentSessions() } .task { await viewModel.loadRecentSessions() }
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.loadRecentSessions() }
}
} }
private var toolbar: some View { private var toolbar: some View {
@@ -36,6 +40,10 @@ struct ChatView: View {
Spacer() Spacer()
if viewModel.hasActiveProcess {
voiceControls
}
if !viewModel.hermesBinaryExists { if !viewModel.hermesBinaryExists {
Label("Hermes binary not found", systemImage: "exclamationmark.triangle") Label("Hermes binary not found", systemImage: "exclamationmark.triangle")
.font(.caption) .font(.caption)
@@ -80,6 +88,55 @@ struct ChatView: View {
.padding(.vertical, 6) .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.toggleTTS()
} label: {
HStack(spacing: 4) {
Image(systemName: viewModel.ttsEnabled ? "speaker.wave.2.fill" : "speaker.slash")
.foregroundStyle(viewModel.ttsEnabled ? .green : .secondary)
Text(viewModel.ttsEnabled ? "TTS On" : "TTS Off")
.font(.caption)
.foregroundStyle(viewModel.ttsEnabled ? .primary : .secondary)
}
}
.buttonStyle(.plain)
.help("Toggle text-to-speech (/voice tts)")
Button {
viewModel.pushToTalk()
} label: {
HStack(spacing: 4) {
Image(systemName: viewModel.isRecording ? "waveform.circle.fill" : "waveform.circle")
.foregroundStyle(viewModel.isRecording ? .red : Color.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 @ViewBuilder
private var terminalArea: some View { private var terminalArea: some View {
if let terminal = viewModel.terminalView { if let terminal = viewModel.terminalView {
@@ -3,6 +3,7 @@ import SwiftUI
struct DashboardView: View { struct DashboardView: View {
@State private var viewModel = DashboardViewModel() @State private var viewModel = DashboardViewModel()
@Environment(AppCoordinator.self) private var coordinator @Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -16,6 +17,9 @@ struct DashboardView: View {
} }
.navigationTitle("Dashboard") .navigationTitle("Dashboard")
.task { await viewModel.load() } .task { await viewModel.load() }
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.load() }
}
} }
private var statusSection: some View { private var statusSection: some View {
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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..<parenStart].trimmingCharacters(in: .whitespaces)
let detail = String(text[parenStart...]).trimmingCharacters(in: CharacterSet(charactersIn: "()"))
return (label, detail)
}
return (text, nil)
}
private func computeCounts() {
let allChecks = (statusSections + doctorSections).flatMap(\.checks)
okCount = allChecks.filter { $0.status == .ok }.count
warningCount = allChecks.filter { $0.status == .warning }.count
issueCount = allChecks.filter { $0.status == .error }.count
}
private func iconForSection(_ title: String) -> 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)
}
}
}
@@ -0,0 +1,219 @@
import SwiftUI
struct HealthView: View {
@State private var viewModel = HealthViewModel()
@State private var expandedSection: UUID?
@State private var selectedTab = 0
var body: some View {
VStack(spacing: 0) {
headerBar
Divider()
Picker("", selection: $selectedTab) {
Text("Status").tag(0)
Text("Diagnostics").tag(1)
}
.pickerStyle(.segmented)
.frame(maxWidth: 300)
.padding(.vertical, 8)
Divider()
ScrollView {
sectionGrid(selectedTab == 0 ? viewModel.statusSections : viewModel.doctorSections)
.padding()
}
}
.navigationTitle("Health")
.onAppear { viewModel.load() }
}
// MARK: - Header
private var headerBar: some View {
HStack(spacing: 16) {
if !viewModel.version.isEmpty {
Text(viewModel.version)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
if viewModel.hasUpdate {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.caption2)
Text(viewModel.updateInfo)
.font(.caption)
}
.foregroundStyle(.orange)
}
Spacer()
HStack(spacing: 12) {
MiniCount(count: viewModel.okCount, color: .green, icon: "checkmark.circle.fill")
MiniCount(count: viewModel.warningCount, color: .orange, icon: "exclamationmark.triangle.fill")
MiniCount(count: viewModel.issueCount, color: .red, icon: "xmark.circle.fill")
}
Button("Refresh") { viewModel.load() }
.controlSize(.small)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
// MARK: - Grid
private func sectionGrid(_ sections: [HealthSection]) -> some View {
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
ForEach(sections) { section in
SectionCard(
section: section,
isExpanded: expandedSection == section.id,
onTap: {
withAnimation(.easeInOut(duration: 0.2)) {
expandedSection = expandedSection == section.id ? nil : section.id
}
}
)
}
}
}
}
// MARK: - Section Card
struct SectionCard: View {
let section: HealthSection
let isExpanded: Bool
let onTap: () -> Void
private var okCount: Int { section.checks.filter { $0.status == .ok }.count }
private var warnCount: Int { section.checks.filter { $0.status == .warning }.count }
private var errorCount: Int { section.checks.filter { $0.status == .error }.count }
private var accentColor: Color {
if errorCount > 0 { return .red }
if warnCount > 0 { return .orange }
return .green
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button(action: onTap) {
HStack(spacing: 10) {
Image(systemName: section.icon)
.font(.title3)
.foregroundStyle(accentColor)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(section.title)
.font(.subheadline.weight(.medium))
.foregroundStyle(.primary)
HStack(spacing: 8) {
if okCount > 0 {
HStack(spacing: 2) {
Circle().fill(.green).frame(width: 5, height: 5)
Text("\(okCount)").font(.caption2).foregroundStyle(.secondary)
}
}
if warnCount > 0 {
HStack(spacing: 2) {
Circle().fill(.orange).frame(width: 5, height: 5)
Text("\(warnCount)").font(.caption2).foregroundStyle(.secondary)
}
}
if errorCount > 0 {
HStack(spacing: 2) {
Circle().fill(.red).frame(width: 5, height: 5)
Text("\(errorCount)").font(.caption2).foregroundStyle(.secondary)
}
}
}
}
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(12)
}
.buttonStyle(.plain)
if isExpanded {
Divider()
.padding(.horizontal, 12)
VStack(alignment: .leading, spacing: 3) {
ForEach(section.checks) { check in
CheckRow(check: check)
}
}
.padding(12)
}
}
.background(.quaternary.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(accentColor.opacity(0.3), lineWidth: 1)
)
}
}
// MARK: - Check Row
struct CheckRow: View {
let check: HealthCheck
var body: some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: statusIcon)
.foregroundStyle(statusColor)
.font(.system(size: 9))
.frame(width: 12, alignment: .center)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 0) {
Text(check.label)
.font(.caption)
if let detail = check.detail {
Text(detail)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
private var statusIcon: String {
switch check.status {
case .ok: return "checkmark.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .error: return "xmark.circle.fill"
}
}
private var statusColor: Color {
switch check.status {
case .ok: return .green
case .warning: return .orange
case .error: return .red
}
}
}
// MARK: - Mini Count
struct MiniCount: View {
let count: Int
let color: Color
let icon: String
var body: some View {
HStack(spacing: 3) {
Image(systemName: icon)
.foregroundStyle(color)
.font(.caption2)
Text("\(count)")
.font(.caption.monospaced().bold())
}
}
}
@@ -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)"
}
@@ -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))
}
}
@@ -1,4 +1,13 @@
import Foundation import Foundation
import AppKit
import UniformTypeIdentifiers
struct SessionStoreStats {
let totalSessions: Int
let totalMessages: Int
let databaseSize: String
let platformCounts: [(platform: String, count: Int)]
}
@Observable @Observable
final class SessionsViewModel { final class SessionsViewModel {
@@ -11,12 +20,20 @@ final class SessionsViewModel {
var searchText = "" var searchText = ""
var searchResults: [HermesMessage] = [] var searchResults: [HermesMessage] = []
var isSearching = false var isSearching = false
var storeStats: SessionStoreStats?
var renameSessionId: String?
var renameText = ""
var showRenameSheet = false
var showDeleteConfirmation = false
var deleteSessionId: String?
func load() async { func load() async {
let opened = await dataService.open() let opened = await dataService.open()
guard opened else { return } guard opened else { return }
sessions = await dataService.fetchSessions(limit: 500) sessions = await dataService.fetchSessions(limit: 500)
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500) sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
computeStats()
} }
func previewFor(_ session: HermesSession) -> String { func previewFor(_ session: HermesSession) -> String {
@@ -50,4 +67,132 @@ final class SessionsViewModel {
func cleanup() async { func cleanup() async {
await dataService.close() await dataService.close()
} }
// MARK: - Session Actions
func beginRename(_ session: HermesSession) {
renameSessionId = session.id
renameText = previewFor(session)
showRenameSheet = true
}
func confirmRename() {
guard let sessionId = renameSessionId else { return }
let title = renameText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else { return }
let result = runHermes(["sessions", "rename", sessionId, title])
if result.exitCode == 0 {
if let idx = sessions.firstIndex(where: { $0.id == sessionId }) {
let updated = HermesSession(
id: sessions[idx].id, source: sessions[idx].source,
userId: sessions[idx].userId, model: sessions[idx].model,
title: title, parentSessionId: sessions[idx].parentSessionId,
startedAt: sessions[idx].startedAt, endedAt: sessions[idx].endedAt,
endReason: sessions[idx].endReason, messageCount: sessions[idx].messageCount,
toolCallCount: sessions[idx].toolCallCount, inputTokens: sessions[idx].inputTokens,
outputTokens: sessions[idx].outputTokens, cacheReadTokens: sessions[idx].cacheReadTokens,
cacheWriteTokens: sessions[idx].cacheWriteTokens,
estimatedCostUSD: sessions[idx].estimatedCostUSD
)
sessions[idx] = updated
if selectedSession?.id == sessionId {
selectedSession = updated
}
}
sessionPreviews[sessionId] = title
}
showRenameSheet = false
renameSessionId = nil
}
func beginDelete(_ session: HermesSession) {
deleteSessionId = session.id
showDeleteConfirmation = true
}
func confirmDelete() {
guard let sessionId = deleteSessionId else { return }
let result = runHermes(["sessions", "delete", "--yes", sessionId])
if result.exitCode == 0 {
sessions.removeAll { $0.id == sessionId }
if selectedSession?.id == sessionId {
selectedSession = nil
messages = []
}
computeStats()
}
showDeleteConfirmation = false
deleteSessionId = nil
}
func exportSession(_ session: HermesSession) {
let panel = NSSavePanel()
panel.nameFieldStringValue = "\(session.id).jsonl"
panel.allowedContentTypes = [.json]
panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
runHermes(["sessions", "export", url.path, "--session-id", session.id])
}
func exportAll() {
let panel = NSSavePanel()
panel.nameFieldStringValue = "hermes-sessions.jsonl"
panel.allowedContentTypes = [.json]
panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
runHermes(["sessions", "export", url.path])
}
// MARK: - Stats
private func computeStats() {
let totalMessages = sessions.reduce(0) { $0 + $1.messageCount }
var platformCounts: [String: Int] = [:]
for s in sessions {
platformCounts[s.source, default: 0] += 1
}
let sorted = platformCounts.sorted { $0.value > $1.value }.map { (platform: $0.key, count: $0.value) }
let dbPath = HermesPaths.stateDB
let fileSize: String
if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath),
let size = attrs[.size] as? Int {
if size >= 1_048_576 {
fileSize = String(format: "%.1f MB", Double(size) / 1_048_576)
} else {
fileSize = String(format: "%.0f KB", Double(size) / 1_024)
}
} else {
fileSize = "unknown"
}
storeStats = SessionStoreStats(
totalSessions: sessions.count,
totalMessages: totalMessages,
databaseSize: fileSize,
platformCounts: sorted
)
}
// MARK: - Hermes CLI
@discardableResult
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
let process = Process()
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
return (output, process.terminationStatus)
} catch {
return ("", -1)
}
}
} }
@@ -4,6 +4,9 @@ struct SessionDetailView: View {
let session: HermesSession let session: HermesSession
let messages: [HermesMessage] let messages: [HermesMessage]
var preview: String? var preview: String?
var onRename: (() -> Void)?
var onExport: (() -> Void)?
var onDelete: (() -> Void)?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -16,22 +19,41 @@ struct SessionDetailView: View {
private var sessionHeader: some View { private var sessionHeader: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack {
Text(preview ?? session.displayTitle) Text(preview ?? session.displayTitle)
.font(.title3.bold()) .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) { HStack(spacing: 16) {
Label(session.source, systemImage: session.sourceIcon) Label(session.source, systemImage: session.sourceIcon)
Label(session.model ?? "unknown", systemImage: "cpu") Label(session.model ?? "unknown", systemImage: "cpu")
Label("\(session.messageCount) msgs", systemImage: "bubble.left") Label("\(session.messageCount) msgs", systemImage: "bubble.left")
Label("\(session.toolCallCount) tools", systemImage: "wrench") Label("\(session.toolCallCount) tools", systemImage: "wrench")
if let cost = session.estimatedCostUSD {
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
}
if let date = session.startedAt { if let date = session.startedAt {
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar") Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
} }
} }
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text(session.id)
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
.textSelection(.enabled)
} }
.padding() .padding()
} }
@@ -5,12 +5,18 @@ struct SessionsView: View {
@Environment(AppCoordinator.self) private var coordinator @Environment(AppCoordinator.self) private var coordinator
var body: some View { var body: some View {
VStack(spacing: 0) {
if let stats = viewModel.storeStats {
statsBar(stats)
Divider()
}
HSplitView { HSplitView {
sessionList sessionList
.frame(minWidth: 280, idealWidth: 320) .frame(minWidth: 280, idealWidth: 320)
sessionDetail sessionDetail
.frame(minWidth: 400) .frame(minWidth: 400)
} }
}
.navigationTitle("Sessions") .navigationTitle("Sessions")
.searchable(text: $viewModel.searchText, prompt: "Search messages...") .searchable(text: $viewModel.searchText, prompt: "Search messages...")
.onSubmit(of: .search) { Task { await viewModel.search() } } .onSubmit(of: .search) { Task { await viewModel.search() } }
@@ -28,6 +34,33 @@ struct SessionsView: View {
} }
} }
.onDisappear { Task { await viewModel.cleanup() } } .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 { private var sessionList: some View {
@@ -64,6 +97,12 @@ struct SessionsView: View {
ForEach(viewModel.sessions) { session in ForEach(viewModel.sessions) { session in
SessionRow(session: session, preview: viewModel.previewFor(session)) SessionRow(session: session, preview: viewModel.previewFor(session))
.tag(session.id) .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 @ViewBuilder
private var sessionDetail: some View { private var sessionDetail: some View {
if let session = viewModel.selectedSession { if let session = viewModel.selectedSession {
SessionDetailView(session: session, messages: viewModel.messages, preview: viewModel.previewFor(session)) 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) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} else { } else {
ContentUnavailableView("Select a Session", systemImage: "bubble.left.and.bubble.right", description: Text("Choose a session from the list")) ContentUnavailableView("Select a Session", systemImage: "bubble.left.and.bubble.right", description: Text("Choose a session from the list"))
.frame(maxWidth: .infinity, maxHeight: .infinity) .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"
}
}
} }
@@ -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)
}
}
}
@@ -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))
}
}
@@ -2,12 +2,16 @@ import Foundation
enum SidebarSection: String, CaseIterable, Identifiable { enum SidebarSection: String, CaseIterable, Identifiable {
case dashboard = "Dashboard" case dashboard = "Dashboard"
case insights = "Insights"
case sessions = "Sessions" case sessions = "Sessions"
case activity = "Activity" case activity = "Activity"
case chat = "Chat" case chat = "Chat"
case memory = "Memory" case memory = "Memory"
case skills = "Skills" case skills = "Skills"
case tools = "Tools"
case gateway = "Gateway"
case cron = "Cron" case cron = "Cron"
case health = "Health"
case logs = "Logs" case logs = "Logs"
case settings = "Settings" case settings = "Settings"
@@ -16,12 +20,16 @@ enum SidebarSection: String, CaseIterable, Identifiable {
var icon: String { var icon: String {
switch self { switch self {
case .dashboard: return "gauge.with.dots.needle.33percent" case .dashboard: return "gauge.with.dots.needle.33percent"
case .insights: return "chart.bar"
case .sessions: return "bubble.left.and.bubble.right" case .sessions: return "bubble.left.and.bubble.right"
case .activity: return "bolt.horizontal" case .activity: return "bolt.horizontal"
case .chat: return "text.bubble" case .chat: return "text.bubble"
case .memory: return "brain" case .memory: return "brain"
case .skills: return "lightbulb" case .skills: return "lightbulb"
case .tools: return "wrench.and.screwdriver"
case .gateway: return "antenna.radiowaves.left.and.right"
case .cron: return "clock.arrow.2.circlepath" case .cron: return "clock.arrow.2.circlepath"
case .health: return "stethoscope"
case .logs: return "doc.text" case .logs: return "doc.text"
case .settings: return "gearshape" case .settings: return "gearshape"
} }
+2 -2
View File
@@ -7,7 +7,7 @@ struct SidebarView: View {
@Bindable var coordinator = coordinator @Bindable var coordinator = coordinator
List(selection: $coordinator.selectedSection) { List(selection: $coordinator.selectedSection) {
Section("Monitor") { Section("Monitor") {
ForEach([SidebarSection.dashboard, .sessions, .activity]) { section in ForEach([SidebarSection.dashboard, .insights, .sessions, .activity]) { section in
Label(section.rawValue, systemImage: section.icon) Label(section.rawValue, systemImage: section.icon)
.tag(section) .tag(section)
} }
@@ -19,7 +19,7 @@ struct SidebarView: View {
} }
} }
Section("Manage") { Section("Manage") {
ForEach([SidebarSection.cron, .logs, .settings]) { section in ForEach([SidebarSection.tools, .gateway, .cron, .health, .logs, .settings]) { section in
Label(section.rawValue, systemImage: section.icon) Label(section.rawValue, systemImage: section.icon)
.tag(section) .tag(section)
} }
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>