M9 #4.3: scoped Settings editor via hermes config set

Pass-1 feedback: "Settings loads, but no fields are editable." By-
design read-only in M6, but the on-the-go story is weaker without
at least the core model / approval-mode / display toggles editable.

Not a generic YAML round-trip editor — that was ruled out in the
original iOS plan because comment/order preservation requires
Hermes-side changes or a significant YAML library. Instead:

- Curated v1 list of 7 editable keys: model.default, model.provider,
  approvals.mode, agent.max_turns, display.show_cost / show_reasoning
  / streaming. Covers ~80% of actual "I want to change this right
  now while I'm away from my Mac" scenarios.
- IOSSettingsViewModel.saveValue(key:value:) shells out to
  `hermes config set <key> <value>` over the SSH transport's
  runProcess, reusing the same PATH-prefix trick we added in pass-1
  for hermes acp so the remote shell finds hermes even in non-
  interactive mode. Hermes owns the YAML round-trip; Scarf just
  picks the value.
- SettingEditorSheet renders the right control per key: Toggle
  (booleans), segmented Picker (approval mode), Stepper (max_turns),
  TextField (model / provider / timezone). One sheet, four kinds
  of input, driven by a `SettingSpec.Kind` enum.
- SettingsView gets a "Quick edits" section at the top that lists
  the 7 keys with their current parsed values + an edit affordance.
  The existing 10+ read-only sections stay unchanged — editing stays
  scoped to the keys we curated.
- On save, the VM calls `load()` again so the parsed config (and
  therefore the Quick-edits labels + the read-only sections below)
  reflects the new value immediately.
- Errors from `hermes config set` (non-zero exit) surface inline on
  the sheet via SettingsSaveError.commandFailed.errorDescription,
  carrying stderr/stdout combined so the user sees what the remote
  complained about. Sheet stays open on error for retry.

ScarfGo builds green. Mac Settings is unaffected — this feature is
iOS-only (Mac has its own richer editors via HermesFileService).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 14:10:30 +02:00
parent 226b6e26be
commit 9bfaaf20f0
3 changed files with 342 additions and 0 deletions
@@ -55,4 +55,76 @@ public final class IOSSettingsViewModel {
config = HermesConfig(yaml: text) config = HermesConfig(yaml: text)
isLoading = false 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
}
}
} }
@@ -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<Int>)
}
/// 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
),
]
}
@@ -11,6 +11,7 @@ struct SettingsView: View {
@State private var vm: IOSSettingsViewModel @State private var vm: IOSSettingsViewModel
@State private var showRawYAML = false @State private var showRawYAML = false
@State private var editingSpec: SettingSpec?
private static let sharedContextID: ServerID = ServerID( private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1" uuidString: "00000000-0000-0000-0000-0000000000A1"
@@ -32,6 +33,7 @@ struct SettingsView: View {
} }
if !vm.isLoading || vm.config.model != "unknown" { if !vm.isLoading || vm.config.model != "unknown" {
quickEditsSection
modelSection modelSection
agentSection agentSection
displaySection displaySection
@@ -45,6 +47,7 @@ struct SettingsView: View {
rawYAMLToggleSection rawYAMLToggleSection
} }
} }
.scarfGoListDensity()
.navigationTitle("Settings") .navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.refreshable { await vm.load() } .refreshable { await vm.load() }
@@ -57,6 +60,66 @@ struct SettingsView: View {
.clipShape(RoundedRectangle(cornerRadius: 10)) .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 // MARK: - Sections