mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user