mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
44d2d6d6c6
Ports the Mac app's YAML parser into ScarfCore, unlocking iOS
Settings. Adds Cron editing (add / delete / toggle / edit). Settings
stays read-only this phase (writes need a round-trip-preserving YAML
writer — out of scope). App Store submission deferred to a later
task per the brief.
## ScarfCore — YAML infrastructure
Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesYAML.swift:
- ParsedYAML struct (values / lists / maps)
- HermesYAML.parseNestedYAML(_:) — indent-based block parser
- HermesYAML.stripYAMLQuotes(_:) — single-layer quote stripping
Lifted verbatim from HermesFileService.parseNestedYAML/stripYAMLQuotes
and hoisted into a standalone namespace. Scope unchanged: the subset
Hermes's config.yaml actually uses (block nesting, scalars, bullet
lists, nested maps). NOT full YAML-spec compliance.
Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift:
- HermesConfig.init(yaml:) — ports HermesFileService.parseConfig
one-for-one. Every default, every key, every legacy fallback
(platforms.slack.* vs slack.*, command_allowlist vs permanent_
allowlist, etc.) matches the Mac implementation.
- Forgiving: malformed YAML produces partial state + defaults
rather than throwing. Callers surface the raw text so users can
diagnose parse failures on their own.
## ScarfCore — Cron editing (write paths)
Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift:
- toggleEnabled(id:)
- delete(id:)
- upsert(_:)
All funnel through private saveJobs(_:) which encodes the full
CronJobsFile (.prettyPrinted + .sortedKeys), writes atomically via
transport.writeFile (Data.write-atomic from M5). Creates the cron/
directory on fresh installs.
Models/HermesCronJob.swift — both HermesCronJob and CronJobsFile
gained real public memberwise inits (Swift's synthesis was
suppressed by the hand-written Codable; first draft hacked around
this with JSON round-trips). Also HermesCronJob.withEnabled(_:)
does clean field passthrough instead of encode→mutate→decode.
## ScarfCore — iOS Settings VM
Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift:
- Reads ~/.hermes/config.yaml via ServerContext.readText
- Parses with HermesConfig(yaml:)
- Surfaces both parsed config and rawYAML
- M6 read-only by design — config.yaml needs round-trip-preserving
YAML serialization (comments, key order, whitespace) for safe
edits; option (a) hand-write one, (b) YAML library dep, (c)
delegate to `hermes config set` via ACP. Defer.
## iOS app
Scarf iOS/Settings/SettingsView.swift:
- Read-only browser grouped into 10 sections matching the Mac
app's tabs. DisclosureGroup at the bottom reveals raw YAML
source for diagnostics.
Scarf iOS/Cron/CronListView.swift rewritten:
- Toggle-enabled circle (tap to flip, saves atomically)
- Swipe-to-delete
- "+" toolbar for new job → editor sheet
- Row-tap opens editor with existing fields populated
New CronEditorView form:
- Name, Prompt, Enabled toggle
- Schedule: kind picker (cron/interval/once), display, expression
(for cron), run_at (for once)
- Optional model + comma-separated skills + delivery route
- Preserves runtime fields (nextRunAt, lastRunAt,
deliveryFailures, etc.) when editing existing jobs — no reset
Dashboard's Surfaces section gains a 5th row: Settings.
## Test-suite reorganization (real bug caught)
swift-testing's `.serialized` trait serializes WITHIN one @Suite, not
across suites. Shipping M6 revealed a 3-way race on
`ServerContext.sshTransportFactory`:
- M5's `.serialized` suite sets factory, runs, restores.
- M6's `.serialized` suite did the same in parallel — clobbered.
- M0b's non-serialized `serverContextMakeTransportDispatches`
asserted the DEFAULT factory (nil) returned SSHTransport —
saw whichever factory was temporarily installed.
Fix: one serialization domain for everything that touches the
factory. Move cron-editing + settings-load M6 tests into M5's
serialized suite. M0b's factory-dependent assertion (SSHTransport
fallback) also moves to the M5 serialized suite with an explicit
`factory = nil` reset for race-freedom. Pure YAML/config/memberwise
tests stay in the new plain (non-serialized) M6ConfigCronTests
suite — they never touch globals.
## Test results: 108 → 134 passing on Linux
19 new in M6ConfigCronTests:
- YAML parser: scalars, bullets, nested maps, comments, quotes,
inline {} / []
- HermesConfig.init(yaml:): empty → defaults, model + agent,
display, security + blocklist domains, slack legacy fallback,
auxiliary (3 populated + 2 defaulted), permanent_allowlist vs
command_allowlist, quoted strings
- Memberwise inits for HermesCronJob, withEnabled(_:),
CronJobsFile, CronSchedule
7 new in M5FeatureVMTests (.serialized):
- defaultFactoryProducesSSHTransportForRemoteContext (moved +
hardened with explicit factory reset)
- cronUpsertCreatesFileFromScratch, cronToggleEnabledPersists,
cronDeleteRemovesJob, cronUpsertReplacesMatchingId,
cronPreservesRuntimeFieldsAcrossReloads
- settingsLoadsFromConfigYAML, settingsSurfacesMissingFile
## Manual validation needed on Mac
1. Xcode compile clean.
2. Settings: confirm every section populates from your real
~/.hermes/config.yaml. Tap "View source" disclosure, verify raw
text matches the remote file.
3. Cron: toggle-enabled survives refresh + relaunch. Swipe-delete
works. "+" creates jobs; round-trip name/prompt/schedule/skills.
Edit preserves runtime state.
4. Skills: unchanged from M5 (still browse-only, deferred).
Updated scarf/docs/IOS_PORT_PLAN.md with M6's shipped state, the
YAML-parser scope ceiling, the Settings-edit deferral rationale, and
the cross-suite serialization rule for future test authors.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
226 lines
8.4 KiB
Swift
226 lines
8.4 KiB
Swift
import SwiftUI
|
|
import ScarfCore
|
|
|
|
/// iOS Settings screen. Read-only browser of `~/.hermes/config.yaml`
|
|
/// as it currently stands on the remote, grouped into sections that
|
|
/// mirror the Mac app's tabs. Source-of-truth toggle at the bottom
|
|
/// reveals the raw YAML for users who want to see what the parser
|
|
/// consumed.
|
|
struct SettingsView: View {
|
|
let config: IOSServerConfig
|
|
|
|
@State private var vm: IOSSettingsViewModel
|
|
@State private var showRawYAML = false
|
|
|
|
private static let sharedContextID: ServerID = ServerID(
|
|
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
|
)!
|
|
|
|
init(config: IOSServerConfig) {
|
|
self.config = config
|
|
let ctx = config.toServerContext(id: Self.sharedContextID)
|
|
_vm = State(initialValue: IOSSettingsViewModel(context: ctx))
|
|
}
|
|
|
|
var body: some View {
|
|
List {
|
|
if let err = vm.lastError {
|
|
Section {
|
|
Label(err, systemImage: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
|
|
if !vm.isLoading || vm.config.model != "unknown" {
|
|
modelSection
|
|
agentSection
|
|
displaySection
|
|
terminalSection
|
|
memorySection
|
|
voiceSection
|
|
securitySection
|
|
compressionSection
|
|
loggingSection
|
|
platformsSection
|
|
rawYAMLToggleSection
|
|
}
|
|
}
|
|
.navigationTitle("Settings")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.refreshable { await vm.load() }
|
|
.task { await vm.load() }
|
|
.overlay {
|
|
if vm.isLoading && vm.config.model == "unknown" {
|
|
ProgressView("Loading config.yaml…")
|
|
.padding()
|
|
.background(.regularMaterial)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sections
|
|
|
|
@ViewBuilder
|
|
private var modelSection: some View {
|
|
Section("Model") {
|
|
LabeledContent("Default", value: vm.config.model)
|
|
if !vm.config.provider.isEmpty, vm.config.provider != "unknown" {
|
|
LabeledContent("Provider", value: vm.config.provider)
|
|
}
|
|
LabeledContent("Reasoning effort", value: vm.config.reasoningEffort)
|
|
if !vm.config.timezone.isEmpty {
|
|
LabeledContent("Timezone", value: vm.config.timezone)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var agentSection: some View {
|
|
Section("Agent") {
|
|
LabeledContent("Approval mode", value: vm.config.approvalMode)
|
|
LabeledContent("Max turns", value: "\(vm.config.maxTurns)")
|
|
LabeledContent("Service tier", value: vm.config.serviceTier)
|
|
yesNoRow("Verbose logging", vm.config.verbose)
|
|
LabeledContent("Tool use enforcement", value: vm.config.toolUseEnforcement)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var displaySection: some View {
|
|
Section("Display") {
|
|
yesNoRow("Streaming", vm.config.streaming)
|
|
yesNoRow("Show reasoning", vm.config.showReasoning)
|
|
yesNoRow("Show cost", vm.config.showCost)
|
|
LabeledContent("Skin", value: vm.config.display.skin)
|
|
yesNoRow("Compact", vm.config.display.compact)
|
|
yesNoRow("Inline diffs", vm.config.display.inlineDiffs)
|
|
LabeledContent("Personality", value: vm.config.personality)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var terminalSection: some View {
|
|
Section("Terminal") {
|
|
LabeledContent("Backend", value: vm.config.terminalBackend)
|
|
LabeledContent("Cwd", value: vm.config.terminal.cwd)
|
|
LabeledContent("Timeout", value: "\(vm.config.terminal.timeout)s")
|
|
yesNoRow("Persistent shell", vm.config.terminal.persistentShell)
|
|
if !vm.config.terminal.dockerImage.isEmpty {
|
|
LabeledContent("Docker image", value: vm.config.terminal.dockerImage)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var memorySection: some View {
|
|
Section("Memory") {
|
|
yesNoRow("Memory enabled", vm.config.memoryEnabled)
|
|
yesNoRow("User profile enabled", vm.config.userProfileEnabled)
|
|
if vm.config.memoryCharLimit > 0 {
|
|
LabeledContent("Char limit", value: "\(vm.config.memoryCharLimit)")
|
|
}
|
|
if !vm.config.memoryProfile.isEmpty {
|
|
LabeledContent("Profile", value: vm.config.memoryProfile)
|
|
}
|
|
if !vm.config.memoryProvider.isEmpty {
|
|
LabeledContent("Provider", value: vm.config.memoryProvider)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var voiceSection: some View {
|
|
Section("Voice") {
|
|
yesNoRow("Auto TTS", vm.config.autoTTS)
|
|
LabeledContent("TTS provider", value: vm.config.voice.ttsProvider)
|
|
yesNoRow("STT enabled", vm.config.voice.sttEnabled)
|
|
LabeledContent("STT provider", value: vm.config.voice.sttProvider)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var securitySection: some View {
|
|
Section("Security") {
|
|
yesNoRow("Redact secrets", vm.config.security.redactSecrets)
|
|
yesNoRow("Redact PII", vm.config.security.redactPII)
|
|
yesNoRow("Tirith enabled", vm.config.security.tirithEnabled)
|
|
yesNoRow("Website blocklist", vm.config.security.blocklistEnabled)
|
|
if !vm.config.security.blocklistDomains.isEmpty {
|
|
ForEach(vm.config.security.blocklistDomains.prefix(5), id: \.self) { domain in
|
|
Text(domain)
|
|
.font(.caption.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if vm.config.security.blocklistDomains.count > 5 {
|
|
Text("+ \(vm.config.security.blocklistDomains.count - 5) more")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var compressionSection: some View {
|
|
Section("Compression") {
|
|
yesNoRow("Enabled", vm.config.compression.enabled)
|
|
LabeledContent("Threshold", value: String(format: "%.2f", vm.config.compression.threshold))
|
|
LabeledContent("Target ratio", value: String(format: "%.2f", vm.config.compression.targetRatio))
|
|
LabeledContent("Protect last N", value: "\(vm.config.compression.protectLastN)")
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var loggingSection: some View {
|
|
Section("Logging") {
|
|
LabeledContent("Level", value: vm.config.logging.level)
|
|
LabeledContent("Max size", value: "\(vm.config.logging.maxSizeMB) MB")
|
|
LabeledContent("Backup count", value: "\(vm.config.logging.backupCount)")
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var platformsSection: some View {
|
|
Section("Platforms") {
|
|
yesNoRow("Discord: require mention", vm.config.discord.requireMention)
|
|
yesNoRow("Discord: auto-thread", vm.config.discord.autoThread)
|
|
yesNoRow("Telegram: require mention", vm.config.telegram.requireMention)
|
|
LabeledContent("Slack: reply mode", value: vm.config.slack.replyToMode)
|
|
yesNoRow("Matrix: require mention", vm.config.matrix.requireMention)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var rawYAMLToggleSection: some View {
|
|
Section {
|
|
DisclosureGroup("View source (config.yaml)", isExpanded: $showRawYAML) {
|
|
if vm.rawYAML.isEmpty {
|
|
Text("(empty)")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
} else {
|
|
Text(vm.rawYAML)
|
|
.font(.caption2.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
} footer: {
|
|
Text("M6 is read-only. Edit config.yaml on the Mac app or via a shell; iOS reflects the current remote state.")
|
|
.font(.caption)
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
@ViewBuilder
|
|
private func yesNoRow(_ label: String, _ value: Bool) -> some View {
|
|
LabeledContent(label) {
|
|
Text(value ? "yes" : "no")
|
|
.foregroundStyle(value ? .primary : .secondary)
|
|
}
|
|
}
|
|
}
|