diff --git a/scarf/scarf/Core/Models/HermesConfig.swift b/scarf/scarf/Core/Models/HermesConfig.swift index bd5d91a..ca1b964 100644 --- a/scarf/scarf/Core/Models/HermesConfig.swift +++ b/scarf/scarf/Core/Models/HermesConfig.swift @@ -14,6 +14,7 @@ struct HermesConfig: Sendable { var showReasoning: Bool var verbose: Bool var autoTTS: Bool + var silenceThreshold: Int static let empty = HermesConfig( model: "unknown", @@ -28,7 +29,8 @@ struct HermesConfig: Sendable { streaming: true, showReasoning: false, verbose: false, - autoTTS: true + autoTTS: true, + silenceThreshold: 200 ) } diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index e94d785..0f1d2ef 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -43,7 +43,8 @@ struct HermesFileService: Sendable { streaming: values["display.streaming"] != "false", showReasoning: values["display.show_reasoning"] == "true", verbose: values["agent.verbose"] == "true", - autoTTS: values["voice.auto_tts"] != "false" + autoTTS: values["voice.auto_tts"] != "false", + silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? 200 ) } diff --git a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift index b75d31f..83f71b1 100644 --- a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import AppKit @Observable final class SettingsViewModel { @@ -8,11 +9,89 @@ final class SettingsViewModel { var gatewayState: GatewayState? var hermesRunning = false var rawConfigYAML = "" + var personalities: [String] = [] + var providers = ["anthropic", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax"] + var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"] + var saveMessage: String? func load() { config = fileService.loadConfig() gatewayState = fileService.loadGatewayState() hermesRunning = fileService.isHermesRunning() rawConfigYAML = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? "" + personalities = parsePersonalities() + } + + func setSetting(_ key: String, value: String) { + let result = runHermes(["config", "set", key, value]) + if result.exitCode == 0 { + saveMessage = "Saved \(key)" + config = fileService.loadConfig() + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.saveMessage = nil + } + } + } + + func setModel(_ value: String) { setSetting("model.default", value: value) } + func setProvider(_ value: String) { setSetting("model.provider", value: value) } + func setPersonality(_ value: String) { setSetting("display.personality", value: value) } + func setTerminalBackend(_ value: String) { setSetting("terminal.backend", value: value) } + func setMaxTurns(_ value: Int) { setSetting("agent.max_turns", value: String(value)) } + func setMemoryEnabled(_ value: Bool) { setSetting("memory.memory_enabled", value: value ? "true" : "false") } + func setMemoryCharLimit(_ value: Int) { setSetting("memory.memory_char_limit", value: String(value)) } + func setUserCharLimit(_ value: Int) { setSetting("memory.user_char_limit", value: String(value)) } + func setNudgeInterval(_ value: Int) { setSetting("memory.nudge_interval", value: String(value)) } + func setStreaming(_ value: Bool) { setSetting("display.streaming", value: value ? "true" : "false") } + func setShowReasoning(_ value: Bool) { setSetting("display.show_reasoning", value: value ? "true" : "false") } + func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") } + func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") } + func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) } + + func openConfigInEditor() { + NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML)) + } + + private func parsePersonalities() -> [String] { + var names: [String] = [] + var inPersonalities = false + for line in rawConfigYAML.components(separatedBy: "\n") { + if line.trimmingCharacters(in: .whitespaces) == "personalities:" && line.hasPrefix(" ") { + inPersonalities = true + continue + } + if inPersonalities { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { continue } + let indent = line.prefix(while: { $0 == " " }).count + if indent <= 2 && !trimmed.isEmpty { + inPersonalities = false + continue + } + if indent == 4 && trimmed.contains(":") { + let name = String(trimmed.split(separator: ":")[0]) + names.append(name) + } + } + } + return names + } + + @discardableResult + private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { + let process = Process() + process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus) + } catch { + return ("", -1) + } } } diff --git a/scarf/scarf/Features/Settings/Views/SettingsView.swift b/scarf/scarf/Features/Settings/Views/SettingsView.swift index 020b97c..7369e5b 100644 --- a/scarf/scarf/Features/Settings/Views/SettingsView.swift +++ b/scarf/scarf/Features/Settings/Views/SettingsView.swift @@ -6,9 +6,13 @@ struct SettingsView: View { var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 20) { - configSection - gatewaySection + VStack(alignment: .leading, spacing: 24) { + headerBar + modelSection + displaySection + terminalSection + voiceSection + memorySection pathsSection rawConfigSection } @@ -19,62 +23,90 @@ struct SettingsView: View { .onAppear { viewModel.load() } } - private var configSection: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Configuration") - .font(.headline) - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { - SettingRow(label: "Model", value: viewModel.config.model) - SettingRow(label: "Provider", value: viewModel.config.provider) - SettingRow(label: "Personality", value: viewModel.config.personality) - SettingRow(label: "Max Turns", value: "\(viewModel.config.maxTurns)") - SettingRow(label: "Terminal Backend", value: viewModel.config.terminalBackend) - SettingRow(label: "Memory Enabled", value: viewModel.config.memoryEnabled ? "Yes" : "No") - SettingRow(label: "Memory Char Limit", value: "\(viewModel.config.memoryCharLimit)") - SettingRow(label: "User Char Limit", value: "\(viewModel.config.userCharLimit)") - SettingRow(label: "Nudge Interval", value: "\(viewModel.config.nudgeInterval) turns") - SettingRow(label: "Streaming", value: viewModel.config.streaming ? "Yes" : "No") - SettingRow(label: "Show Reasoning", value: viewModel.config.showReasoning ? "Yes" : "No") - SettingRow(label: "Verbose", value: viewModel.config.verbose ? "Yes" : "No") + private var headerBar: some View { + HStack { + if let msg = viewModel.saveMessage { + Label(msg, systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) } + Spacer() + Button("Open in Editor") { viewModel.openConfigInEditor() } + .controlSize(.small) + Button("Reload") { viewModel.load() } + .controlSize(.small) } } - private var gatewaySection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Gateway") - .font(.headline) - HStack(spacing: 16) { - Label( - viewModel.gatewayState?.statusText ?? "unknown", - systemImage: viewModel.gatewayState?.isRunning == true ? "circle.fill" : "circle" - ) - .foregroundStyle(viewModel.gatewayState?.isRunning == true ? .green : .secondary) - if let reason = viewModel.gatewayState?.exitReason { - Text(reason) - .font(.caption) - .foregroundStyle(.secondary) - } - } + // MARK: - Model & Provider + + private var modelSection: some View { + SettingsSection(title: "Model", icon: "cpu") { + EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($0) } + PickerRow(label: "Provider", selection: viewModel.config.provider, options: viewModel.providers) { viewModel.setProvider($0) } } } + // MARK: - Display + + private var displaySection: some View { + SettingsSection(title: "Display", icon: "paintbrush") { + if !viewModel.personalities.isEmpty { + PickerRow(label: "Personality", selection: viewModel.config.personality, options: viewModel.personalities) { viewModel.setPersonality($0) } + } else { + EditableTextField(label: "Personality", value: viewModel.config.personality) { viewModel.setPersonality($0) } + } + ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) } + ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) } + ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) } + } + } + + // MARK: - Terminal + + private var terminalSection: some View { + SettingsSection(title: "Terminal", icon: "terminal") { + PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) } + StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) } + } + } + + // MARK: - Voice + + private var voiceSection: some View { + SettingsSection(title: "Voice", icon: "mic") { + ToggleRow(label: "Auto TTS", isOn: viewModel.config.autoTTS) { viewModel.setAutoTTS($0) } + StepperRow(label: "Silence Threshold", value: viewModel.config.silenceThreshold, range: 50...500) { viewModel.setSilenceThreshold($0) } + } + } + + // MARK: - Memory + + private var memorySection: some View { + SettingsSection(title: "Memory", icon: "brain") { + ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) } + 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) } + } + } + + // MARK: - Paths + private var pathsSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Paths") - .font(.headline) - VStack(alignment: .leading, spacing: 4) { - PathRow(label: "Hermes Home", path: HermesPaths.home) - PathRow(label: "State DB", path: HermesPaths.stateDB) - PathRow(label: "Config", path: HermesPaths.configYAML) - PathRow(label: "Memory", path: HermesPaths.memoriesDir) - PathRow(label: "Sessions", path: HermesPaths.sessionsDir) - PathRow(label: "Skills", path: HermesPaths.skillsDir) - PathRow(label: "Logs", path: HermesPaths.errorsLog) - } + SettingsSection(title: "Paths", icon: "folder") { + PathRow(label: "Hermes Home", path: HermesPaths.home) + PathRow(label: "State DB", path: HermesPaths.stateDB) + PathRow(label: "Config", path: HermesPaths.configYAML) + PathRow(label: "Memory", path: HermesPaths.memoriesDir) + PathRow(label: "Sessions", path: HermesPaths.sessionsDir) + PathRow(label: "Skills", path: HermesPaths.skillsDir) + PathRow(label: "Logs", path: HermesPaths.errorsLog) } } + // MARK: - Raw Config + private var rawConfigSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { @@ -98,19 +130,143 @@ struct SettingsView: View { } } -struct SettingRow: View { +// MARK: - Reusable Components + +struct SettingsSection: View { + let title: String + let icon: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Label(title, systemImage: icon) + .font(.headline) + VStack(spacing: 1) { + content + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } +} + +struct EditableTextField: View { let label: String let value: String + let onCommit: (String) -> Void + @State private var text: String = "" + @State private var isEditing = false var body: some View { HStack { Text(label) .font(.caption) .foregroundStyle(.secondary) - .frame(width: 120, alignment: .trailing) - Text(value) + .frame(width: 130, alignment: .trailing) + if isEditing { + TextField(label, text: $text, onCommit: { + if text != value { onCommit(text) } + isEditing = false + }) + .textFieldStyle(.roundedBorder) .font(.system(.caption, design: .monospaced)) + Button("Cancel") { isEditing = false } + .controlSize(.mini) + } else { + Text(value) + .font(.system(.caption, design: .monospaced)) + Spacer() + Button("Edit") { + text = value + isEditing = true + } + .controlSize(.mini) + } } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.3)) + } +} + +struct PickerRow: View { + let label: String + let selection: String + let options: [String] + let onChange: (String) -> Void + + var body: some View { + HStack { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 130, alignment: .trailing) + Picker("", selection: Binding( + get: { selection }, + set: { onChange($0) } + )) { + ForEach(options, id: \.self) { option in + Text(option).tag(option) + } + } + .frame(maxWidth: 250) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.3)) + } +} + +struct ToggleRow: View { + let label: String + let isOn: Bool + let onChange: (Bool) -> Void + + var body: some View { + HStack { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 130, alignment: .trailing) + Toggle("", isOn: Binding( + get: { isOn }, + set: { onChange($0) } + )) + .toggleStyle(.switch) + .labelsHidden() + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.3)) + } +} + +struct StepperRow: View { + let label: String + let value: Int + let range: ClosedRange + let onChange: (Int) -> Void + + var body: some View { + HStack { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 130, alignment: .trailing) + Text("\(value)") + .font(.system(.caption, design: .monospaced)) + .frame(width: 50) + Stepper("", value: Binding( + get: { value }, + set: { onChange($0) } + ), in: range) + .labelsHidden() + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.3)) } } @@ -123,10 +279,11 @@ struct PathRow: View { Text(label) .font(.caption) .foregroundStyle(.secondary) - .frame(width: 100, alignment: .trailing) + .frame(width: 130, alignment: .trailing) Text(path) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) + Spacer() Button { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path) } label: { @@ -135,5 +292,8 @@ struct PathRow: View { } .buttonStyle(.plain) } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.3)) } }