diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift index e9e1326..8a855ff 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift @@ -55,4 +55,76 @@ public final class IOSSettingsViewModel { config = HermesConfig(yaml: text) isLoading = false } + + /// Set a dotted config key on the remote via `hermes config set`. + /// Hermes owns the YAML round-trip (preserves comments, key + /// order, formatting); Scarf just picks the value. Reloads the + /// parsed config on success so the UI reflects the change + /// immediately. + /// + /// Pass-1 M9 #4.3 — lets on-the-go users flip `model.default`, + /// `agent.approval_mode`, `display.show_cost` etc. without going + /// back to the Mac app. Scope intentionally narrow: a curated + /// list of keys in the editor sheet, not a generic YAML writer. + /// + /// Throws on non-zero exit or connection failure. Callers should + /// surface the error to the user (usually a banner on the editor + /// sheet) and leave the sheet open for retry. + public func saveValue(key: String, value: String) async throws { + isSaving = true + defer { isSaving = false } + + let ctx = context + let hermes = ctx.paths.hermesBinary + // Pass through the same PATH-prefix trick ACPClient+iOS uses + // (pass-1 M7 #5) so remote non-interactive shells find hermes + // even when it's in ~/.local/bin or /opt/homebrew/bin. + let script = "PATH=\"$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/.hermes/bin:$PATH\" \(hermes) config set \(shellEscape(key)) \(shellEscape(value))" + + let result: ProcessResult = try await Task.detached { + try ctx.makeTransport().runProcess( + executable: "/bin/sh", + args: ["-c", script], + stdin: nil, + timeout: 15 + ) + }.value + + if result.exitCode != 0 { + let stderr = result.stderrString.trimmingCharacters(in: .whitespacesAndNewlines) + let stdout = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + let combined = [stderr, stdout].filter { !$0.isEmpty }.joined(separator: "\n") + throw SettingsSaveError.commandFailed( + exitCode: result.exitCode, + message: combined.isEmpty ? "hermes config set exited with code \(result.exitCode)" : combined + ) + } + + // Reload so the UI reflects the just-written value. + await load() + } + + /// True while a `saveValue(...)` call is in flight. Sheet uses + /// this to disable the Save button + show a ProgressView. + public private(set) var isSaving: Bool = false + + /// Single-quote-escape a shell argument. Handles embedded single + /// quotes via the standard `'"'"'` trick. Used to quote both the + /// key and the value on the remote command line. + private func shellEscape(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } +} + +/// Errors surfaced by `IOSSettingsViewModel.saveValue`. Kept public +/// so SettingEditorSheet (ScarfGo) can narrow on commandFailed to +/// show the stderr payload inline instead of just the generic text. +public enum SettingsSaveError: Error, LocalizedError { + case commandFailed(exitCode: Int32, message: String) + + public var errorDescription: String? { + switch self { + case .commandFailed(_, let message): return message + } + } } diff --git a/scarf/Scarf iOS/Settings/SettingEditorSheet.swift b/scarf/Scarf iOS/Settings/SettingEditorSheet.swift new file mode 100644 index 0000000..af2de18 --- /dev/null +++ b/scarf/Scarf iOS/Settings/SettingEditorSheet.swift @@ -0,0 +1,207 @@ +import SwiftUI +import ScarfCore + +/// Sheet for editing a single Hermes config value. Renders the +/// appropriate control for each supported key: +/// - `.toggle` → SwiftUI Toggle (display.show_cost, show_reasoning, +/// streaming, agent.verbose). +/// - `.enumPicker(options)` → SwiftUI Picker (agent.approval_mode). +/// - `.number` → Stepper (agent.max_turns). +/// - `.text` → TextField (model.default, model.provider, timezone). +/// +/// The save path calls `IOSSettingsViewModel.saveValue(key:value:)` +/// which shells out to `hermes config set` remotely. Hermes owns the +/// YAML round-trip (preserves comments, key order). Scarf just picks +/// the value. +struct SettingEditorSheet: View { + let spec: SettingSpec + let currentValue: String + let vm: IOSSettingsViewModel + let onDismiss: () -> Void + + @State private var textValue: String = "" + @State private var boolValue: Bool = false + @State private var numberValue: Int = 0 + @State private var enumValue: String = "" + @State private var saveError: String? + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section { + control + } header: { + Text(spec.displayName) + } footer: { + Text(spec.helpText) + .font(.caption) + } + + if let err = saveError { + Section { + Label(err, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.caption) + .textSelection(.enabled) + } + } + } + .navigationTitle("Edit \(spec.displayName)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + .disabled(vm.isSaving) + } + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await save() } + } label: { + if vm.isSaving { + ProgressView() + } else { + Text("Save").bold() + } + } + .disabled(vm.isSaving || !hasValidValue) + } + } + .task { primeFromCurrent() } + } + .presentationDetents([.height(260), .large]) + .presentationDragIndicator(.visible) + } + + @ViewBuilder + private var control: some View { + switch spec.kind { + case .toggle: + Toggle(spec.displayName, isOn: $boolValue) + case .enumPicker(let options): + Picker(spec.displayName, selection: $enumValue) { + ForEach(options, id: \.self) { opt in + Text(opt).tag(opt) + } + } + .pickerStyle(.segmented) + case .number(let range): + Stepper(value: $numberValue, in: range, step: 1) { + Text("\(numberValue)") + .monospacedDigit() + } + case .text: + TextField(spec.displayName, text: $textValue) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + } + + private var hasValidValue: Bool { + switch spec.kind { + case .toggle, .enumPicker, .number: return true + case .text: return !textValue.trimmingCharacters(in: .whitespaces).isEmpty + } + } + + private var stringValue: String { + switch spec.kind { + case .toggle: return boolValue ? "true" : "false" + case .enumPicker: return enumValue + case .number: return String(numberValue) + case .text: return textValue.trimmingCharacters(in: .whitespaces) + } + } + + private func primeFromCurrent() { + switch spec.kind { + case .toggle: + boolValue = (currentValue.lowercased() == "true" || currentValue.lowercased() == "yes") + case .enumPicker(let options): + enumValue = options.contains(currentValue) ? currentValue : (options.first ?? "") + case .number: + numberValue = Int(currentValue) ?? 0 + case .text: + textValue = currentValue + } + } + + @MainActor + private func save() async { + saveError = nil + do { + try await vm.saveValue(key: spec.key, value: stringValue) + onDismiss() + dismiss() + } catch { + saveError = error.localizedDescription + } + } +} + +/// Describes a single editable Hermes config key. Centralized so the +/// SettingsView can iterate a curated list rather than hard-coding +/// one row per field. Add new entries here when a field graduates to +/// on-the-go-editable. +struct SettingSpec: Identifiable, Hashable { + let key: String // "model.default", "agent.approval_mode", ... + let displayName: String // "Default model", "Approval mode", ... + let helpText: String // Sentence for the sheet footer. + let kind: Kind + + var id: String { key } + + enum Kind: Hashable { + case text + case toggle + case enumPicker(options: [String]) + case number(range: ClosedRange) + } + + /// Curated v1 list. Ordered as it should appear in Settings. + static let v1Editable: [SettingSpec] = [ + SettingSpec( + key: "model.default", + displayName: "Default model", + helpText: "Used by every new chat unless overridden. Needs to be a model the selected provider actually serves.", + kind: .text + ), + SettingSpec( + key: "model.provider", + displayName: "Provider", + helpText: "Which backend Hermes routes prompts to. Switch to a provider you're authenticated against.", + kind: .text + ), + SettingSpec( + key: "approvals.mode", + displayName: "Approval mode", + helpText: "How agents handle risky tool calls. Manual prompts you; auto approves reads; yolo approves writes too.", + kind: .enumPicker(options: ["manual", "auto", "yolo"]) + ), + SettingSpec( + key: "agent.max_turns", + displayName: "Max turns", + helpText: "Ceiling on assistant replies per prompt. Higher = agent can chain more tool calls before stopping.", + kind: .number(range: 1...500) + ), + SettingSpec( + key: "display.show_cost", + displayName: "Show cost", + helpText: "Render per-prompt cost totals in the chat window.", + kind: .toggle + ), + SettingSpec( + key: "display.show_reasoning", + displayName: "Show reasoning", + helpText: "Expand the thinking-block above each assistant reply.", + kind: .toggle + ), + SettingSpec( + key: "display.streaming", + displayName: "Stream replies", + helpText: "Show the assistant's reply token-by-token as it comes in.", + kind: .toggle + ), + ] +} diff --git a/scarf/Scarf iOS/Settings/SettingsView.swift b/scarf/Scarf iOS/Settings/SettingsView.swift index 2477714..96bbf7b 100644 --- a/scarf/Scarf iOS/Settings/SettingsView.swift +++ b/scarf/Scarf iOS/Settings/SettingsView.swift @@ -11,6 +11,7 @@ struct SettingsView: View { @State private var vm: IOSSettingsViewModel @State private var showRawYAML = false + @State private var editingSpec: SettingSpec? private static let sharedContextID: ServerID = ServerID( uuidString: "00000000-0000-0000-0000-0000000000A1" @@ -32,6 +33,7 @@ struct SettingsView: View { } if !vm.isLoading || vm.config.model != "unknown" { + quickEditsSection modelSection agentSection displaySection @@ -45,6 +47,7 @@ struct SettingsView: View { rawYAMLToggleSection } } + .scarfGoListDensity() .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) .refreshable { await vm.load() } @@ -57,6 +60,66 @@ struct SettingsView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) } } + .sheet(item: $editingSpec) { spec in + SettingEditorSheet( + spec: spec, + currentValue: currentValue(for: spec.key), + vm: vm, + onDismiss: {} + ) + } + } + + @ViewBuilder + private var quickEditsSection: some View { + Section { + ForEach(SettingSpec.v1Editable) { spec in + Button { + editingSpec = spec + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(spec.displayName) + .font(.body) + .foregroundStyle(.primary) + Text(currentValue(for: spec.key)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + Image(systemName: "square.and.pencil") + .font(.caption) + .foregroundStyle(.tint) + } + } + .buttonStyle(.plain) + .scarfGoCompactListRow() + } + } header: { + Text("Quick edits") + } footer: { + Text("These flip common config.yaml values via `hermes config set` on the remote. Other fields below are read-only; edit them from the Mac app.") + .font(.caption) + } + } + + /// Map a config-set key to the current value from the parsed + /// HermesConfig. String-based so the Picker / Stepper / Toggle in + /// the editor sheet can pre-fill correctly. Unknown keys return + /// empty string (the sheet falls back to defaults). + private func currentValue(for key: String) -> String { + switch key { + case "model.default": return vm.config.model + case "model.provider": return vm.config.provider + case "approvals.mode": return vm.config.approvalMode + case "agent.max_turns": return String(vm.config.maxTurns) + case "display.show_cost": return vm.config.showCost ? "true" : "false" + case "display.show_reasoning": return vm.config.showReasoning ? "true" : "false" + case "display.streaming": return vm.config.streaming ? "true" : "false" + default: return "" + } } // MARK: - Sections