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)
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
}
}
}