diff --git a/CLAUDE.md b/CLAUDE.md index ae55c80..664b76b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,3 +38,7 @@ scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup ```bash xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build ``` + +## Hermes Version + +Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse. diff --git a/README.md b/README.md index 9c35eca..fb4f2d5 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,15 @@ - **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, 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, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export. Subagent sessions are filtered from the main list and accessible via parent session drill-down - **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments and tool output display -- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, and permission request dialogs; **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, and voice mode controls +- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, permission request dialogs, and a one-click `/compress` focus sheet (when Hermes advertises the command); **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, and voice mode controls - **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker - **Skills Browser** — Browse and edit installed skills by category with file content viewer, file switcher, and required config warnings for skills that need specific settings -- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Webhook, Matrix, Feishu, Mattermost) with toggle switches and segmented platform picker, MCP server status +- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, iMessage, Email, Home Assistant, Webhook, Matrix, Feishu, Mattermost) with toggle switches and segmented platform picker, 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 with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators -- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering and text search +- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering, component filter (Gateway / Agent / Tools / CLI / Cron), clickable session-ID pills that filter to a single session, and text search - **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically -- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Docker environment, command allowlist, credential management, and more +- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Fast Mode service tier, interim assistant messages, gateway notify interval, force IPv4, context engine, Honcho eager init, Docker environment, command allowlist, credential management, and one-click **Backup & Restore** via `hermes backup` / `hermes import` - **Hermes Process Control** — Start, stop, and restart the Hermes agent directly from Scarf - **Menu Bar** — Status icon showing Hermes running state with quick actions @@ -39,7 +39,7 @@ - macOS 14.6+ (Sonoma) - Xcode 16.0+ -- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.8.0 recommended for full feature support) +- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` (v0.9.0 recommended for full feature support) ### Compatibility @@ -49,7 +49,8 @@ Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`, |----------------|--------| | v0.6.0 (2026-03-30) | Verified | | v0.7.0 (2026-04-03) | Verified | -| v0.8.0 (2026-04-08, latest) | Verified | +| v0.8.0 (2026-04-08) | Verified | +| v0.9.0 (2026-04-13, 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. diff --git a/scarf/scarf/Core/Models/HermesConfig.swift b/scarf/scarf/Core/Models/HermesConfig.swift index 7101171..44adf51 100644 --- a/scarf/scarf/Core/Models/HermesConfig.swift +++ b/scarf/scarf/Core/Models/HermesConfig.swift @@ -23,6 +23,12 @@ struct HermesConfig: Sendable { var dockerEnv: [String: String] var commandAllowlist: [String] var memoryProfile: String + var serviceTier: String + var gatewayNotifyInterval: Int + var forceIPv4: Bool + var contextEngine: String + var interimAssistantMessages: Bool + var honchoInitOnSessionStart: Bool static let empty = HermesConfig( model: "unknown", @@ -46,7 +52,13 @@ struct HermesConfig: Sendable { memoryProvider: "", dockerEnv: [:], commandAllowlist: [], - memoryProfile: "" + memoryProfile: "", + serviceTier: "normal", + gatewayNotifyInterval: 600, + forceIPv4: false, + contextEngine: "compressor", + interimAssistantMessages: true, + honchoInitOnSessionStart: false ) } diff --git a/scarf/scarf/Core/Models/HermesCronJob.swift b/scarf/scarf/Core/Models/HermesCronJob.swift index 9fc3af9..968cbd5 100644 --- a/scarf/scarf/Core/Models/HermesCronJob.swift +++ b/scarf/scarf/Core/Models/HermesCronJob.swift @@ -41,6 +41,21 @@ struct HermesCronJob: Identifiable, Sendable, Codable { default: return "questionmark.circle" } } + + var deliveryDisplay: String? { + guard let deliver, !deliver.isEmpty else { return nil } + // v0.9.0 extends Discord routing to threads: `discord::`. + if deliver.hasPrefix("discord:") { + let parts = deliver.dropFirst("discord:".count).split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + if parts.count == 2 { + return "Discord thread \(parts[1]) in \(parts[0])" + } + if parts.count == 1 { + return "Discord \(parts[0])" + } + } + return deliver + } } struct CronSchedule: Sendable, Codable { diff --git a/scarf/scarf/Core/Models/HermesTool.swift b/scarf/scarf/Core/Models/HermesTool.swift index acd3b10..fa1bd67 100644 --- a/scarf/scarf/Core/Models/HermesTool.swift +++ b/scarf/scarf/Core/Models/HermesTool.swift @@ -30,6 +30,7 @@ enum KnownPlatforms { HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"), HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"), HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"), + HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"), ] static func icon(for platform: String) -> String { @@ -46,6 +47,7 @@ enum KnownPlatforms { case "matrix": return "lock.rectangle.stack" case "feishu": return "message.badge.circle" case "mattermost": return "bubble.left.and.exclamationmark.bubble.right" + case "imessage": return "message.fill" default: return "bubble.left" } } diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 8bf1f65..d064259 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -87,7 +87,13 @@ struct HermesFileService: Sendable { memoryProvider: values["memory.provider"] ?? "", dockerEnv: dockerEnv, commandAllowlist: commandAllowlist, - memoryProfile: values["memory.profile"] ?? "" + memoryProfile: values["memory.profile"] ?? "", + serviceTier: values["agent.service_tier"] ?? "normal", + gatewayNotifyInterval: Int(values["agent.gateway_notify_interval"] ?? "") ?? 600, + forceIPv4: values["network.force_ipv4"] == "true", + contextEngine: values["context.engine"] ?? "compressor", + interimAssistantMessages: values["display.interim_assistant_messages"] != "false", + honchoInitOnSessionStart: values["honcho.initOnSessionStart"] == "true" ) } @@ -268,10 +274,57 @@ struct HermesFileService: Sendable { @discardableResult func stopHermes() -> Bool { + // v0.9.0 fixed `hermes gateway stop` so it issues `launchctl bootout` and + // waits for exit. Use the CLI to avoid racing launchd's KeepAlive respawn. + if runHermesCLI(args: ["gateway", "stop"]).exitCode == 0 { + return true + } guard let pid = hermesPID() else { return false } return kill(pid, SIGTERM) == 0 } + nonisolated func hermesBinaryPath() -> String? { + let candidates = [ + ("\(NSHomeDirectory())/.local/bin/hermes"), + "/opt/homebrew/bin/hermes", + "/usr/local/bin/hermes" + ] + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } + + @discardableResult + nonisolated func runHermesCLI(args: [String], timeout: TimeInterval = 60) -> (exitCode: Int32, output: String) { + guard let binary = hermesBinaryPath() else { return (-1, "") } + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = Process() + process.executableURL = URL(fileURLWithPath: binary) + process.arguments = args + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + defer { + try? stdoutPipe.fileHandleForReading.close() + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForReading.close() + try? stderrPipe.fileHandleForWriting.close() + } + do { + try process.run() + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) + } + if process.isRunning { process.terminate() } + process.waitUntilExit() + let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let combined = (String(data: outData, encoding: .utf8) ?? "") + (String(data: errData, encoding: .utf8) ?? "") + return (process.terminationStatus, combined) + } catch { + return (-1, error.localizedDescription) + } + } + // MARK: - File I/O private func readFile(_ path: String) -> String? { diff --git a/scarf/scarf/Core/Services/HermesLogService.swift b/scarf/scarf/Core/Services/HermesLogService.swift index 59887dd..96ea619 100644 --- a/scarf/scarf/Core/Services/HermesLogService.swift +++ b/scarf/scarf/Core/Services/HermesLogService.swift @@ -4,6 +4,7 @@ struct LogEntry: Identifiable, Sendable { let id: Int let timestamp: String let level: LogLevel + let sessionId: String? let logger: String let message: String let raw: String @@ -72,23 +73,30 @@ actor HermesLogService { private func parseLine(_ line: String) -> LogEntry { entryCounter += 1 - // Format: YYYY-MM-DD HH:MM:SS,MMM LEVEL logger: message - let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s+(.*)$"# + // Format (v0.9.0+): YYYY-MM-DD HH:MM:SS,MMM LEVEL [session_id] logger: message + // Session tag is optional — earlier Hermes releases and out-of-session lines omit it. + let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(?:\[([^\]]+)\]\s+)?(\S+?):\s+(.*)$"# if let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) { let timestamp = String(line[Range(match.range(at: 1), in: line)!]) let levelStr = String(line[Range(match.range(at: 2), in: line)!]) - let logger = String(line[Range(match.range(at: 3), in: line)!]) - let message = String(line[Range(match.range(at: 4), in: line)!]) + let sessionId: String? = { + let range = match.range(at: 3) + guard range.location != NSNotFound, let r = Range(range, in: line) else { return nil } + return String(line[r]) + }() + let logger = String(line[Range(match.range(at: 4), in: line)!]) + let message = String(line[Range(match.range(at: 5), in: line)!]) return LogEntry( id: entryCounter, timestamp: timestamp, level: LogEntry.LogLevel(rawValue: levelStr) ?? .info, + sessionId: sessionId, logger: logger, message: message, raw: line ) } - return LogEntry(id: entryCounter, timestamp: "", level: .info, logger: "", message: line, raw: line) + return LogEntry(id: entryCounter, timestamp: "", level: .info, sessionId: nil, logger: "", message: line, raw: line) } } diff --git a/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift index 375db55..6def0f2 100644 --- a/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift @@ -41,6 +41,11 @@ final class RichChatViewModel { private(set) var acpThoughtTokens = 0 private(set) var acpCachedReadTokens = 0 + /// Slash commands advertised by the ACP server via `available_commands_update`. + private(set) var availableCommandNames: Set = [] + + var supportsCompress: Bool { availableCommandNames.contains("compress") } + var hasMessages: Bool { !messages.isEmpty } func requestScrollToBottom() { @@ -93,6 +98,7 @@ final class RichChatViewModel { acpOutputTokens = 0 acpThoughtTokens = 0 acpCachedReadTokens = 0 + availableCommandNames = [] pendingPermission = nil } @@ -167,7 +173,16 @@ final class RichChatViewModel { handlePromptComplete(response: response) case .connectionLost(let reason): handleConnectionLost(reason: reason) - case .availableCommands, .unknown: + case .availableCommands(_, let commands): + var names: Set = [] + for entry in commands { + if let name = entry["name"] as? String { + // Hermes sends names either as "compress" or "/compress" + names.insert(name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) + } + } + availableCommandNames = names + case .unknown: break } } diff --git a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift index efaa63d..5656376 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift @@ -3,12 +3,29 @@ import SwiftUI struct RichChatInputBar: View { let onSend: (String) -> Void let isEnabled: Bool + var supportsCompress: Bool = false @State private var text = "" + @State private var showCompressSheet = false + @State private var compressFocus = "" @FocusState private var isFocused: Bool var body: some View { HStack(alignment: .bottom, spacing: 8) { + if supportsCompress { + Button { + compressFocus = "" + showCompressSheet = true + } label: { + Image(systemName: "rectangle.compress.vertical") + .font(.title3) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .help("Compress conversation (/compress)") + } + TextEditor(text: $text) .font(.body) .scrollContentBackground(.hidden) @@ -50,6 +67,34 @@ struct RichChatInputBar: View { .padding(.horizontal, 12) .padding(.vertical, 8) .background(.bar) + .sheet(isPresented: $showCompressSheet) { + compressSheet + } + } + + private var compressSheet: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Compress Conversation") + .font(.headline) + Text("Optionally focus the summary on a specific topic. Leave blank to compress evenly.") + .font(.caption) + .foregroundStyle(.secondary) + TextField("Focus topic (optional)", text: $compressFocus) + .textFieldStyle(.roundedBorder) + HStack { + Spacer() + Button("Cancel") { showCompressSheet = false } + Button("Compress") { + let focus = compressFocus.trimmingCharacters(in: .whitespacesAndNewlines) + let command = focus.isEmpty ? "/compress" : "/compress \(focus)" + onSend(command) + showCompressSheet = false + } + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 360) } private var canSend: Bool { diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift index d8d8b27..5c61d0f 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatView.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -41,7 +41,8 @@ struct RichChatView: View { onSend: { text in onSend(text) }, - isEnabled: isEnabled + isEnabled: isEnabled, + supportsCompress: richChat.supportsCompress ) } // DB polling fallback for terminal mode only — never overwrite ACP messages diff --git a/scarf/scarf/Features/Cron/Views/CronView.swift b/scarf/scarf/Features/Cron/Views/CronView.swift index 51f9011..0328058 100644 --- a/scarf/scarf/Features/Cron/Views/CronView.swift +++ b/scarf/scarf/Features/Cron/Views/CronView.swift @@ -72,7 +72,7 @@ struct CronView: View { Label(job.state, systemImage: job.stateIcon) Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock") Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle") - if let deliver = job.deliver { + if let deliver = job.deliveryDisplay { Label("Deliver: \(deliver)", systemImage: "paperplane") } } diff --git a/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift b/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift index cc6d96c..f9158e7 100644 --- a/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift +++ b/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift @@ -7,6 +7,7 @@ final class LogsViewModel { var entries: [LogEntry] = [] var selectedLogFile: LogFile = .agent var filterLevel: LogEntry.LogLevel? + var selectedComponent: LogComponent = .all var searchText = "" private var pollTimer: Timer? @@ -26,11 +27,37 @@ final class LogsViewModel { } } + enum LogComponent: String, CaseIterable, Identifiable { + case all = "All" + case gateway = "Gateway" + case agent = "Agent" + case tools = "Tools" + case cli = "CLI" + case cron = "Cron" + + var id: String { rawValue } + + var loggerPrefix: String? { + switch self { + case .all: return nil + case .gateway: return "gateway" + case .agent: return "agent" + case .tools: return "tools" + case .cli: return "cli" + case .cron: return "cron" + } + } + } + var filteredEntries: [LogEntry] { entries.filter { entry in let levelOk = filterLevel == nil || entry.level == filterLevel let searchOk = searchText.isEmpty || entry.raw.localizedCaseInsensitiveContains(searchText) - return levelOk && searchOk + let componentOk: Bool = { + guard let prefix = selectedComponent.loggerPrefix else { return true } + return entry.logger.hasPrefix(prefix) + }() + return levelOk && searchOk && componentOk } } diff --git a/scarf/scarf/Features/Logs/Views/LogsView.swift b/scarf/scarf/Features/Logs/Views/LogsView.swift index 8bb1268..0842d5e 100644 --- a/scarf/scarf/Features/Logs/Views/LogsView.swift +++ b/scarf/scarf/Features/Logs/Views/LogsView.swift @@ -28,6 +28,13 @@ struct LogsView: View { .pickerStyle(.segmented) .frame(maxWidth: 300) + Picker("Component", selection: $viewModel.selectedComponent) { + ForEach(LogsViewModel.LogComponent.allCases) { component in + Text(component.rawValue).tag(component) + } + } + .frame(maxWidth: 140) + Spacer() Picker("Level", selection: $viewModel.filterLevel) { @@ -58,6 +65,27 @@ struct LogsView: View { .font(.caption.monospaced().bold()) .foregroundStyle(colorForLevel(entry.level)) .frame(width: 60, alignment: .leading) + if let sessionId = entry.sessionId { + Button { + viewModel.searchText = sessionId + } label: { + Text(sessionId) + .font(.system(.caption2, design: .monospaced)) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.accentColor.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 3)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Filter to session \(sessionId)") + } + Text(entry.logger) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 140, alignment: .leading) Text(entry.message) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) diff --git a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift index 8c578c1..526eb79 100644 --- a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift @@ -1,5 +1,6 @@ import Foundation import AppKit +import UniformTypeIdentifiers @Observable final class SettingsViewModel { @@ -58,6 +59,79 @@ final class SettingsViewModel { func setShowCost(_ value: Bool) { setSetting("display.show_cost", value: value ? "true" : "false") } func setApprovalMode(_ value: String) { setSetting("approvals.mode", value: value) } func setBrowserBackend(_ value: String) { setSetting("browser.backend", value: value) } + func setServiceTier(_ value: String) { setSetting("agent.service_tier", value: value) } + func setGatewayNotifyInterval(_ value: Int) { setSetting("agent.gateway_notify_interval", value: String(value)) } + func setForceIPv4(_ value: Bool) { setSetting("network.force_ipv4", value: value ? "true" : "false") } + func setInterimAssistantMessages(_ value: Bool) { setSetting("display.interim_assistant_messages", value: value ? "true" : "false") } + // Hermes v0.9.0 PR #6995: the key is camelCase in config.yaml (not snake_case like the rest of Hermes). + func setHonchoInitOnSessionStart(_ value: Bool) { setSetting("honcho.initOnSessionStart", value: value ? "true" : "false") } + + // MARK: - Backup & Restore (v0.9.0) + + var backupInProgress = false + + func runBackup() { + backupInProgress = true + Task.detached { [fileService] in + let result = fileService.runHermesCLI(args: ["backup"], timeout: 300) + let zipPath = Self.extractZipPath(from: result.output) + await MainActor.run { + self.backupInProgress = false + if result.exitCode == 0 { + if let zipPath { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: zipPath)]) + self.saveMessage = "Backup saved" + } else { + self.saveMessage = "Backup complete" + } + } else { + self.saveMessage = "Backup failed" + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.saveMessage = nil + } + } + } + } + + func runRestore(from url: URL) { + backupInProgress = true + Task.detached { [fileService] in + let result = fileService.runHermesCLI(args: ["import", url.path], timeout: 300) + await MainActor.run { + self.backupInProgress = false + self.saveMessage = result.exitCode == 0 ? "Restore complete — restart Scarf" : "Restore failed" + if result.exitCode == 0 { + self.load() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + self?.saveMessage = nil + } + } + } + } + + /// Pull the first absolute `.zip` path out of `hermes backup` stdout. + /// Hermes prints a line like "Backup saved to /Users/foo/.hermes-backups/hermes-2026-04-14.zip (5.4 MB)". + nonisolated static func extractZipPath(from output: String) -> String? { + let pattern = #"(/[^\s]+\.zip)"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(output.startIndex..., in: output) + guard let match = regex.firstMatch(in: output, range: range), + let r = Range(match.range(at: 1), in: output) else { return nil } + return String(output[r]) + } + + func presentRestorePicker() -> URL? { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.zip] + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.message = "Choose a Hermes backup archive to restore" + guard panel.runModal() == .OK, let url = panel.url else { return nil } + return url + } func removeAuth() { let result = runHermes(["auth", "remove"]) diff --git a/scarf/scarf/Features/Settings/Views/SettingsView.swift b/scarf/scarf/Features/Settings/Views/SettingsView.swift index bcf5e9b..a83d5c5 100644 --- a/scarf/scarf/Features/Settings/Views/SettingsView.swift +++ b/scarf/scarf/Features/Settings/Views/SettingsView.swift @@ -19,6 +19,10 @@ struct SettingsView: View { } voiceSection memorySection + performanceSection + networkSection + advancedSection + backupSection pathsSection rawConfigSection } @@ -85,6 +89,7 @@ struct SettingsView: View { ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) } ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) } ToggleRow(label: "Show Cost", isOn: viewModel.config.showCost) { viewModel.setShowCost($0) } + ToggleRow(label: "Interim Messages", isOn: viewModel.config.interimAssistantMessages) { viewModel.setInterimAssistantMessages($0) } ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) } } } @@ -139,6 +144,87 @@ struct SettingsView: View { StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) } StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) } StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) } + if viewModel.config.memoryProvider == "honcho" { + ToggleRow(label: "Honcho Eager Init", isOn: viewModel.config.honchoInitOnSessionStart) { viewModel.setHonchoInitOnSessionStart($0) } + } + } + } + + // MARK: - Performance (v0.9.0) + + private var performanceSection: some View { + SettingsSection(title: "Performance", icon: "bolt") { + ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in + viewModel.setServiceTier(on ? "fast" : "normal") + } + StepperRow(label: "Notify Interval (s)", value: viewModel.config.gatewayNotifyInterval, range: 0...3600) { viewModel.setGatewayNotifyInterval($0) } + } + } + + // MARK: - Network (v0.9.0) + + private var networkSection: some View { + SettingsSection(title: "Network", icon: "network") { + ToggleRow(label: "Force IPv4", isOn: viewModel.config.forceIPv4) { viewModel.setForceIPv4($0) } + } + } + + // MARK: - Advanced (v0.9.0) + + private var advancedSection: some View { + SettingsSection(title: "Advanced", icon: "slider.horizontal.3") { + ReadOnlyRow(label: "Context Engine", value: viewModel.config.contextEngine) + } + } + + // MARK: - Backup & Restore (v0.9.0) + + @State private var showRestoreConfirm = false + @State private var pendingRestoreURL: URL? + + private var backupSection: some View { + SettingsSection(title: "Backup & Restore", icon: "externaldrive") { + HStack { + Text("Archive") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 130, alignment: .trailing) + Button { + viewModel.runBackup() + } label: { + Label("Backup Now", systemImage: "arrow.down.doc") + } + .controlSize(.small) + .disabled(viewModel.backupInProgress) + Button { + if let url = viewModel.presentRestorePicker() { + pendingRestoreURL = url + showRestoreConfirm = true + } + } label: { + Label("Restore…", systemImage: "arrow.up.doc") + } + .controlSize(.small) + .disabled(viewModel.backupInProgress) + if viewModel.backupInProgress { + ProgressView().controlSize(.small) + } + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.3)) + } + .confirmationDialog("Restore from backup?", isPresented: $showRestoreConfirm) { + Button("Restore", role: .destructive) { + if let url = pendingRestoreURL { + viewModel.runRestore(from: url) + } + pendingRestoreURL = nil + } + Button("Cancel", role: .cancel) { pendingRestoreURL = nil } + } message: { + Text("This will overwrite files under ~/.hermes/ with the archive contents.") } }