diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/LocalTransport.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/LocalTransport.swift index 0fe53ed..4be491d 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/LocalTransport.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/LocalTransport.swift @@ -36,31 +36,32 @@ public struct LocalTransport: ServerTransport { } public func writeFile(_ path: String, data: Data) throws { - let tmp = path + ".scarf.tmp" do { - try data.write(to: URL(fileURLWithPath: tmp)) - // Preserve `0600` for dotfiles holding secrets (.env, .auth, ...). - // The existing files already use 0600 via HermesEnvService; we - // mirror that here so a brand-new file created via this write - // also starts with safe permissions. - if Self.shouldEnforcePrivateMode(for: path) { - try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tmp) + // Ensure the parent dir exists — callers sometimes pass a + // path whose parent hasn't been mkdir'd yet (e.g., + // `~/.hermes/memories/MEMORY.md` on a Hermes install that + // never wrote memories before). + let parent = (path as NSString).deletingLastPathComponent + if !parent.isEmpty, !FileManager.default.fileExists(atPath: parent) { + try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true) } - // Atomic swap onto the final path. - let destURL = URL(fileURLWithPath: path) - let tmpURL = URL(fileURLWithPath: tmp) - if FileManager.default.fileExists(atPath: path) { - _ = try FileManager.default.replaceItemAt(destURL, withItemAt: tmpURL) - } else { - // Ensure parent exists. - let parent = (path as NSString).deletingLastPathComponent - if !parent.isEmpty, !FileManager.default.fileExists(atPath: parent) { - try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true) - } - try FileManager.default.moveItem(at: tmpURL, to: destURL) + // Atomic write: Data.write(options: .atomic) drops a temp + // file alongside the destination and rename(2)s it into + // place. Cross-platform (macOS + iOS + Linux CI for tests). + // + // Earlier this method used `FileManager.replaceItemAt`, + // which is Apple-only — Linux swift-corelibs would fail. + // Data.write-atomic works everywhere with identical + // semantics. + try data.write(to: URL(fileURLWithPath: path), options: .atomic) + // Preserve 0600 for files that conventionally hold secrets. + // The existing files use 0600 via HermesEnvService; apply + // the same to brand-new files so we never demote + // permissions on a rewrite. + if Self.shouldEnforcePrivateMode(for: path) { + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path) } } catch { - try? FileManager.default.removeItem(atPath: tmp) throw TransportError.fileIO(path: path, underlying: error.localizedDescription) } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift new file mode 100644 index 0000000..c829664 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift @@ -0,0 +1,85 @@ +import Foundation +import Observation + +/// iOS read-only Cron view-state. Loads `~/.hermes/cron/jobs.json` +/// via the transport, decodes into `CronJobsFile` (already Codable +/// in ScarfCore), exposes the list for SwiftUI. +/// +/// M5 is read-only by design — editing cron jobs (add / delete / +/// toggle enabled) is deferred until we have a clearer iOS story for +/// rewriting `jobs.json` atomically across the SSH SFTP path. The +/// Mac app's `CronViewModel` does this through `HermesFileService`; +/// porting that is out of scope for M5. +@Observable +@MainActor +public final class IOSCronViewModel { + public let context: ServerContext + + public private(set) var jobs: [HermesCronJob] = [] + public private(set) var isLoading: Bool = true + public private(set) var lastError: String? + + public init(context: ServerContext) { + self.context = context + } + + public func load() async { + isLoading = true + lastError = nil + let ctx = context + let path = ctx.paths.cronJobsJSON + + let result: Result = await Task.detached { + do { + guard let data = ctx.readData(path) else { + throw LoadError.missingFile(path: path) + } + let decoded = try JSONDecoder().decode(CronJobsFile.self, from: data) + return .success(decoded) + } catch { + return .failure(error) + } + }.value + + switch result { + case .success(let file): + // Sort: enabled first, then by nextRunAt ascending (nil + // last). Matches what the Mac app does for list rendering. + jobs = file.jobs.sorted { lhs, rhs in + if lhs.enabled != rhs.enabled { return lhs.enabled } + switch (lhs.nextRunAt, rhs.nextRunAt) { + case (let l?, let r?): return l < r + case (_?, nil): return true + case (nil, _?): return false + case (nil, nil): return lhs.name < rhs.name + } + } + isLoading = false + + case .failure(let err as LoadError): + // Missing jobs.json is the common case on a fresh Hermes + // install — don't surface as an error, show an empty + // list + hint in the UI. + if case .missingFile = err { + jobs = [] + } else { + lastError = err.localizedDescription + } + isLoading = false + + case .failure(let err): + lastError = "Couldn't parse jobs.json: \(err.localizedDescription)" + isLoading = false + } + } + + public enum LoadError: Error, LocalizedError { + case missingFile(path: String) + + public var errorDescription: String? { + switch self { + case .missingFile(let p): return "No cron jobs defined (\(p) doesn't exist yet)" + } + } + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift new file mode 100644 index 0000000..e508f7f --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSMemoryViewModel.swift @@ -0,0 +1,133 @@ +import Foundation +import Observation + +/// iOS Memory editor state. Loads MEMORY.md / USER.md via the +/// transport, holds the text in-memory, saves on explicit action. +/// +/// Lives in ScarfCore (not ScarfIOS) because it's pure file-I/O on +/// top of `ServerContext.readText` / `writeText` — no Keychain, no +/// Citadel, no UIKit — and that lets the state machine be unit- +/// tested on Linux with `InMemory` mocks. +/// +/// **Which file.** Constructor takes `kind` (`.memory` or `.user`) +/// and picks the corresponding path via `ServerContext.paths`. Users +/// toggle between the two via navigation. +@Observable +@MainActor +public final class IOSMemoryViewModel { + public enum Kind: Sendable, Equatable { + /// `~/.hermes/memories/MEMORY.md` — the agent's persistent + /// memory. Visible (and editable) to the agent at every + /// session start. + case memory + /// `~/.hermes/memories/USER.md` — user-profile notes the + /// agent reads but (by default) does not write. + case user + + /// Heading shown in the UI. + public var displayName: String { + switch self { + case .memory: return "MEMORY.md" + case .user: return "USER.md" + } + } + + /// SF Symbol used in the list row. + public var iconName: String { + switch self { + case .memory: return "brain.head.profile" + case .user: return "person.crop.square" + } + } + + /// Terse explanation shown under the heading. + public var subtitle: String { + switch self { + case .memory: + return "Agent's persistent memory. Appears in every session prompt." + case .user: + return "Notes about you. Read by the agent but not modified automatically." + } + } + + /// Resolve the remote path for this memory file on the + /// given context. `ServerContext.paths` exposes both + /// `memoryMD` and `userMD` directly. + public func path(on context: ServerContext) -> String { + switch self { + case .memory: return context.paths.memoryMD + case .user: return context.paths.userMD + } + } + } + + public let kind: Kind + public let context: ServerContext + + /// Content loaded from the file. `text` binds to the editor; the + /// view compares against `originalText` to gate the Save button. + public var text: String = "" + public private(set) var originalText: String = "" + + public private(set) var isLoading: Bool = true + public private(set) var isSaving: Bool = false + public private(set) var lastError: String? + + public var hasUnsavedChanges: Bool { text != originalText } + + public init(kind: Kind, context: ServerContext) { + self.kind = kind + self.context = context + } + + public func load() async { + isLoading = true + lastError = nil + // Run the file read on a detached task — `ServerContext.readText` + // blocks on transport I/O, and we don't want the MainActor + // hanging during a remote SFTP fetch. + let ctx = context + let path = kind.path(on: context) + let loaded: String? = await Task.detached { + ctx.readText(path) + }.value + + if let loaded { + text = loaded + originalText = loaded + } else { + // `readText` returns nil on missing file — treat as + // empty (the user is creating the file for the first + // time) rather than an error. + text = "" + originalText = "" + } + isLoading = false + } + + public func save() async -> Bool { + guard !isSaving else { return false } + isSaving = true + lastError = nil + let ctx = context + let path = kind.path(on: context) + let snapshot = text + let ok: Bool = await Task.detached { + ctx.writeText(path, content: snapshot) + }.value + isSaving = false + if ok { + originalText = snapshot + return true + } else { + lastError = "Couldn't save \(kind.displayName) — check the connection and try again." + return false + } + } + + /// Revert in-memory edits back to whatever the file contained + /// at last load. + public func revert() { + text = originalText + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSkillsViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSkillsViewModel.swift new file mode 100644 index 0000000..e2eda01 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSkillsViewModel.swift @@ -0,0 +1,100 @@ +import Foundation +import Observation + +/// iOS read-only Skills view-state. Scans `~/.hermes/skills/` for +/// category directories, then each category for skill directories, +/// then each skill directory for its file list. Mirrors what the +/// Mac app's `HermesFileService.loadSkills` does but scoped to what +/// the transport's `listDirectory` primitive can surface (no deep +/// YAML frontmatter parsing — users who want to inspect a skill's +/// full definition still do that on the Mac). +/// +/// M5 is read-only by design. Installing new skills would need a +/// git-clone over SSH plus schema validation; that's a separate +/// feature in a later phase. +@Observable +@MainActor +public final class IOSSkillsViewModel { + public let context: ServerContext + + public private(set) var categories: [HermesSkillCategory] = [] + public private(set) var isLoading: Bool = true + public private(set) var lastError: String? + + public init(context: ServerContext) { + self.context = context + } + + public func load() async { + isLoading = true + lastError = nil + let ctx = context + let skillsRoot = ctx.paths.skillsDir + + let loaded: Result<[HermesSkillCategory], Error> = await Task.detached { + let transport = ctx.makeTransport() + guard transport.fileExists(skillsRoot) else { + // Fresh install → no skills/ dir yet. + return .success([]) + } + do { + let categoryNames = try transport.listDirectory(skillsRoot) + .filter { !$0.hasPrefix(".") } + .sorted() + + var categories: [HermesSkillCategory] = [] + for categoryName in categoryNames { + let categoryPath = skillsRoot + "/" + categoryName + // Only include directories. + guard transport.stat(categoryPath)?.isDirectory == true else { continue } + + var skills: [HermesSkill] = [] + let skillNames: [String] + do { + skillNames = try transport.listDirectory(categoryPath) + .filter { !$0.hasPrefix(".") } + .sorted() + } catch { + // Skip categories we can't read (permissions etc.) + // rather than failing the whole load. + continue + } + + for skillName in skillNames { + let skillPath = categoryPath + "/" + skillName + guard transport.stat(skillPath)?.isDirectory == true else { continue } + let files: [String] = (try? transport.listDirectory(skillPath)) ?? [] + skills.append(HermesSkill( + id: categoryName + "/" + skillName, + name: skillName, + category: categoryName, + path: skillPath, + files: files.filter { !$0.hasPrefix(".") }.sorted(), + requiredConfig: [] // Skills frontmatter parsing deferred. + )) + } + + if !skills.isEmpty { + categories.append(HermesSkillCategory( + id: categoryName, + name: categoryName, + skills: skills + )) + } + } + return .success(categories) + } catch { + return .failure(error) + } + }.value + + switch loaded { + case .success(let cats): + categories = cats + case .failure(let err): + categories = [] + lastError = "Couldn't list skills: \(err.localizedDescription)" + } + isLoading = false + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index 3c5ce5f..e72d979 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -95,10 +95,22 @@ public final class RichChatViewModel { private var activePollingTimer: Timer? public struct PendingPermission { - let requestId: Int - let title: String - let kind: String - let options: [(optionId: String, name: String)] + public let requestId: Int + public let title: String + public let kind: String + public let options: [(optionId: String, name: String)] + + public init( + requestId: Int, + title: String, + kind: String, + options: [(optionId: String, name: String)] + ) { + self.requestId = requestId + self.title = title + self.kind = kind + self.options = options + } } // MARK: - Reset diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift new file mode 100644 index 0000000..23e35b0 --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M5FeatureVMTests.swift @@ -0,0 +1,329 @@ +import Testing +import Foundation +@testable import ScarfCore + +/// M5 iOS feature ViewModels: Memory (read/write), Cron (read-only +/// JSON), Skills (read-only directory scan). All exercised through +/// `LocalTransport` against tmpfs paths so the suite runs on Linux +/// CI with the same file-I/O codepaths iOS hits (just without SFTP +/// in front). +@Suite(.serialized) struct M5FeatureVMTests { + + /// Build a context rooted at a fresh tmp directory. Also pre- + /// creates the Hermes subfolders so the VMs' `paths.*` resolve + /// to real locations. + @MainActor + private func makeFakeHermes() throws -> (context: ServerContext, home: URL) { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("scarf-m5-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + // We can't easily override ServerContext.paths without building + // a new ServerKind, and HermesPathSet is keyed on "home". So + // we LIE to ServerContext.local by symlinking? No — too risky. + // Instead: construct a remote-kind context whose remoteHome + // points at our tmp dir, then install a custom transport + // factory that returns a LocalTransport pointed at local + // files. LocalTransport ignores the path's "remote-ness" + // since on Linux everything resolves to the actual FS. + let kind = ServerKind.ssh(SSHConfig(host: "fake.invalid", remoteHome: tmp.path)) + let ctx = ServerContext(id: UUID(), displayName: "fake", kind: kind) + // Pre-create subdirs the VMs look for. + try FileManager.default.createDirectory( + at: tmp.appendingPathComponent("memories"), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: tmp.appendingPathComponent("cron"), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: tmp.appendingPathComponent("skills"), + withIntermediateDirectories: true + ) + return (ctx, tmp) + } + + /// Wrap each test body in a factory override so `ctx.makeTransport()` + /// returns a `LocalTransport` instead of trying to spawn a real SSH + /// subprocess. The `.serialized` suite trait guarantees no other + /// test races on the factory static. + @MainActor + private func withLocalTransportFactory( + _ body: @MainActor () async throws -> T + ) async throws -> T { + let previous = ServerContext.sshTransportFactory + defer { ServerContext.sshTransportFactory = previous } + ServerContext.sshTransportFactory = { id, _, _ in + LocalTransport(contextID: id) + } + return try await body() + } + + // MARK: - Memory + + @Test @MainActor func memoryLoadsEmptyWhenFileMissing() async throws { + try await withLocalTransportFactory { [self] in + let (ctx, _) = try makeFakeHermes() + let vm = IOSMemoryViewModel(kind: .memory, context: ctx) + await vm.load() + #expect(vm.text == "") + #expect(vm.originalText == "") + #expect(vm.isLoading == false) + #expect(vm.hasUnsavedChanges == false) + } + } + + @Test @MainActor func memoryRoundTripsFileContent() async throws { + try await withLocalTransportFactory { [self] in + let (ctx, home) = try makeFakeHermes() + // Seed a MEMORY.md file. + let seed = "# Known facts\n\n- scarf is a Hermes companion\n" + try seed.write( + to: home.appendingPathComponent("memories/MEMORY.md"), + atomically: true, + encoding: .utf8 + ) + + let vm = IOSMemoryViewModel(kind: .memory, context: ctx) + await vm.load() + #expect(vm.text == seed) + #expect(vm.originalText == seed) + #expect(!vm.hasUnsavedChanges) + + vm.text = seed + "- also does iOS now\n" + #expect(vm.hasUnsavedChanges) + + let saved = await vm.save() + #expect(saved) + #expect(!vm.hasUnsavedChanges) + + // Re-load via a fresh VM to confirm persistence. + let vm2 = IOSMemoryViewModel(kind: .memory, context: ctx) + await vm2.load() + #expect(vm2.text.contains("iOS")) + } + } + + @Test @MainActor func memoryRevertRestoresOriginal() async throws { + try await withLocalTransportFactory { [self] in + let (ctx, home) = try makeFakeHermes() + try "seed".write( + to: home.appendingPathComponent("memories/USER.md"), + atomically: true, + encoding: .utf8 + ) + let vm = IOSMemoryViewModel(kind: .user, context: ctx) + await vm.load() + vm.text = "scratch edit" + #expect(vm.hasUnsavedChanges) + vm.revert() + #expect(vm.text == "seed") + #expect(!vm.hasUnsavedChanges) + } + } + + @Test func memoryKindPathRouting() { + // Pin that .memory → memoryMD, .user → userMD. + let ctx = ServerContext.local + #expect(IOSMemoryViewModel.Kind.memory.path(on: ctx) == ctx.paths.memoryMD) + #expect(IOSMemoryViewModel.Kind.user.path(on: ctx) == ctx.paths.userMD) + } + + // MARK: - Cron + + @Test @MainActor func cronEmptyWhenJobsFileMissing() async throws { + try await withLocalTransportFactory { [self] in + let (ctx, _) = try makeFakeHermes() + let vm = IOSCronViewModel(context: ctx) + await vm.load() + #expect(vm.jobs.isEmpty) + #expect(vm.lastError == nil) // "missing file" is not an error + #expect(vm.isLoading == false) + } + } + + @Test @MainActor func cronLoadsAndSortsJobs() async throws { + try await withLocalTransportFactory { [self] in + let (ctx, home) = try makeFakeHermes() + // Two enabled, one disabled — verify disabled sinks to bottom. + let json = #""" + { + "jobs": [ + { + "id": "b", + "name": "Late riser", + "prompt": "brief me", + "skills": null, + "model": null, + "schedule": {"kind": "cron", "run_at": null, "display": "9am weekdays", "expression": "0 9 * * 1-5"}, + "enabled": true, + "state": "scheduled", + "deliver": null, + "next_run_at": "2026-04-24T09:00:00Z", + "last_run_at": null, + "last_error": null, + "pre_run_script": null, + "delivery_failures": 0, + "last_delivery_error": null, + "timeout_type": null, + "timeout_seconds": null, + "silent": false + }, + { + "id": "a", + "name": "Early bird", + "prompt": "wake me", + "skills": null, + "model": null, + "schedule": {"kind": "cron", "run_at": null, "display": "6am daily", "expression": "0 6 * * *"}, + "enabled": true, + "state": "scheduled", + "deliver": "discord:general", + "next_run_at": "2026-04-23T06:00:00Z", + "last_run_at": null, + "last_error": null, + "pre_run_script": null, + "delivery_failures": 0, + "last_delivery_error": null, + "timeout_type": null, + "timeout_seconds": null, + "silent": false + }, + { + "id": "c", + "name": "Off", + "prompt": "quiet", + "skills": null, + "model": null, + "schedule": {"kind": "interval", "run_at": null, "display": "every hour", "expression": null}, + "enabled": false, + "state": "scheduled", + "deliver": null, + "next_run_at": null, + "last_run_at": null, + "last_error": null, + "pre_run_script": null, + "delivery_failures": 0, + "last_delivery_error": null, + "timeout_type": null, + "timeout_seconds": null, + "silent": false + } + ], + "updated_at": "2026-04-22T12:00:00Z" + } + """# + try json.write( + to: home.appendingPathComponent("cron/jobs.json"), + atomically: true, + encoding: .utf8 + ) + let vm = IOSCronViewModel(context: ctx) + await vm.load() + #expect(vm.lastError == nil) + #expect(vm.jobs.count == 3) + // Enabled + next_run_at earlier → first + #expect(vm.jobs[0].name == "Early bird") + #expect(vm.jobs[1].name == "Late riser") + // Disabled → last + #expect(vm.jobs[2].name == "Off") + #expect(vm.jobs[0].deliveryDisplay?.contains("Discord") == true) + } + } + + @Test @MainActor func cronSurfacesDecodeErrors() async throws { + try await withLocalTransportFactory { [self] in + let (ctx, home) = try makeFakeHermes() + try "garbage, not json".write( + to: home.appendingPathComponent("cron/jobs.json"), + atomically: true, + encoding: .utf8 + ) + let vm = IOSCronViewModel(context: ctx) + await vm.load() + #expect(vm.lastError != nil) + #expect(vm.jobs.isEmpty) + } + } + + // MARK: - Skills + + @Test @MainActor func skillsEmptyWhenDirMissing() async throws { + try await withLocalTransportFactory { [self] in + let (ctx, home) = try makeFakeHermes() + // Remove the skills/ dir we pre-created. + try FileManager.default.removeItem( + at: home.appendingPathComponent("skills") + ) + let vm = IOSSkillsViewModel(context: ctx) + await vm.load() + #expect(vm.categories.isEmpty) + #expect(vm.lastError == nil) + } + } + + @Test @MainActor func skillsScansCategoryAndSkillStructure() async throws { + try await withLocalTransportFactory { [self] in + let (ctx, home) = try makeFakeHermes() + let skills = home.appendingPathComponent("skills") + let dev = skills.appendingPathComponent("dev") + let personal = skills.appendingPathComponent("personal") + try FileManager.default.createDirectory(at: dev, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: personal, withIntermediateDirectories: true) + // dev/git/ + let devGit = dev.appendingPathComponent("git") + try FileManager.default.createDirectory(at: devGit, withIntermediateDirectories: true) + try "".write(to: devGit.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + try "".write(to: devGit.appendingPathComponent("helpers.sh"), atomically: true, encoding: .utf8) + // personal/journaling/ + let pJournal = personal.appendingPathComponent("journaling") + try FileManager.default.createDirectory(at: pJournal, withIntermediateDirectories: true) + try "".write(to: pJournal.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) + // Dotfile should be filtered + try "".write(to: pJournal.appendingPathComponent(".DS_Store"), atomically: true, encoding: .utf8) + + let vm = IOSSkillsViewModel(context: ctx) + await vm.load() + #expect(vm.categories.count == 2) + #expect(vm.categories[0].name == "dev") + #expect(vm.categories[1].name == "personal") + #expect(vm.categories[0].skills.count == 1) + #expect(vm.categories[0].skills[0].name == "git") + #expect(vm.categories[0].skills[0].files.sorted() == ["SKILL.md", "helpers.sh"]) + // Dotfile filtered out + #expect(vm.categories[1].skills[0].files == ["SKILL.md"]) + } + } + + @Test @MainActor func skillsSkipsEmptyCategories() async throws { + try await withLocalTransportFactory { [self] in + let (ctx, home) = try makeFakeHermes() + // Empty category shouldn't appear in the list. + try FileManager.default.createDirectory( + at: home.appendingPathComponent("skills/empty-cat"), + withIntermediateDirectories: true + ) + let vm = IOSSkillsViewModel(context: ctx) + await vm.load() + #expect(vm.categories.isEmpty) + } + } + + // MARK: - RichChatViewModel PendingPermission public init + + #if canImport(SQLite3) + @Test func pendingPermissionMemberwise() { + let p = RichChatViewModel.PendingPermission( + requestId: 99, + title: "write_file: /etc/hosts", + kind: "edit", + options: [("allow", "Allow once"), ("deny", "Deny")] + ) + #expect(p.requestId == 99) + #expect(p.title == "write_file: /etc/hosts") + #expect(p.kind == "edit") + #expect(p.options.count == 2) + #expect(p.options[0].optionId == "allow") + } + #endif +} diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index d15fc1d..7c6a5bd 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -65,6 +65,17 @@ struct ChatView: View { errorOverlay(msg) } } + .sheet(item: Binding( + get: { controller.vm.pendingPermission.map(PermissionWrapper.init) }, + set: { if $0 == nil { controller.vm.pendingPermission = nil } } + )) { wrapper in + PermissionSheet(permission: wrapper.value) { optionId in + await controller.respondToPermission( + requestId: wrapper.value.requestId, + optionId: optionId + ) + } + } } // MARK: - Subviews @@ -302,6 +313,23 @@ final class ChatController { vm.reset() await start() } + + /// Dispatch the user's answer to a pending permission request. + /// Called by `PermissionSheet`. + func respondToPermission(requestId: Int, optionId: String) async { + guard let client else { return } + await client.respondToPermission(requestId: requestId, optionId: optionId) + vm.pendingPermission = nil + } +} + +/// `Identifiable` wrapper so SwiftUI's `.sheet(item:)` can key off +/// the pending permission. Two permissions for the same request-id +/// are treated as identical (rare — would only happen if the remote +/// sends a duplicate). +private struct PermissionWrapper: Identifiable { + let value: RichChatViewModel.PendingPermission + var id: Int { value.requestId } } // MARK: - Message bubble @@ -310,33 +338,243 @@ private struct MessageBubble: View { let message: HermesMessage var body: some View { - HStack { - if message.isUser { Spacer(minLength: 40) } - VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) { - Text(message.content) - .font(.body) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .foregroundStyle(message.isUser ? Color.white : Color.primary) - .background( - message.isUser ? Color.accentColor : Color(.secondarySystemBackground) - ) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .textSelection(.enabled) - if message.hasReasoning, let r = message.reasoning, !r.isEmpty { - Text("🧠 \(r)") - .font(.caption2) - .italic() + if message.isToolResult { + ToolResultRow(message: message) + } else { + HStack(alignment: .bottom) { + if message.isUser { Spacer(minLength: 40) } + VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) { + if message.hasReasoning, let r = message.reasoning, !r.isEmpty { + ReasoningDisclosure(reasoning: r) + } + bubbleContent + if !message.toolCalls.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(message.toolCalls) { call in + ToolCallCard(call: call) + } + } + } + } + if !message.isUser { Spacer(minLength: 40) } + } + .padding(.horizontal) + } + } + + @ViewBuilder + private var bubbleContent: some View { + // Render markdown on the assistant side so bold/code/links + // look right. User messages stay plain — no reason to parse + // what the user just typed. AttributedString(markdown:) is + // conservative — unknown constructs fall through as literal + // text, so the worst case is just "no formatting". + let text: Text = { + if message.isUser { + return Text(message.content) + } + if let attributed = try? AttributedString( + markdown: message.content, + options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + ) { + return Text(attributed) + } + return Text(message.content) + }() + + text + .font(.body) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .foregroundStyle(message.isUser ? Color.white : Color.primary) + .background( + message.isUser ? Color.accentColor : Color(.secondarySystemBackground) + ) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .textSelection(.enabled) + } +} + +/// Inline, expandable "chain-of-thought" disclosure shown above the +/// assistant's primary message when the remote surfaces `reasoning`. +/// Collapsed by default so a chatty model doesn't dominate the scroll +/// position. +private struct ReasoningDisclosure: View { + let reasoning: String + @State private var isExpanded = false + + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + Text(reasoning) + .font(.caption) + .foregroundStyle(.secondary) + .italic() + .textSelection(.enabled) + .padding(.top, 4) + } label: { + Label("Thinking…", systemImage: "brain") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 6) + } +} + +/// Expanding card for a single `HermesToolCall` — shows function name +/// + summary collapsed; full JSON arguments expanded. +private struct ToolCallCard: View { + let call: HermesToolCall + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() } + } label: { + HStack(spacing: 6) { + Image(systemName: iconName) + .foregroundStyle(.tint) + Text(call.functionName) + .font(.caption.monospaced()) + .foregroundStyle(.primary) + Text(call.argumentsSummary.prefix(60)) + .font(.caption) .foregroundStyle(.secondary) - .padding(.horizontal, 12) + .lineLimit(1) + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption2) + .foregroundStyle(.tertiary) } } - if !message.isUser { Spacer(minLength: 40) } + .buttonStyle(.plain) + + if isExpanded { + Text(call.arguments) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .padding(.top, 2) + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(.tertiarySystemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color(.separator), lineWidth: 0.5) + ) + } + + private var iconName: String { + call.toolKind.icon + } +} + +/// Row showing a tool-result (role="tool"). Styled as a small +/// quoted block beneath whichever assistant message preceded it. +private struct ToolResultRow: View { + let message: HermesMessage + @State private var isExpanded = false + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() } + } label: { + HStack(spacing: 6) { + Image(systemName: "arrow.turn.down.right") + .font(.caption2) + .foregroundStyle(.secondary) + Text("Tool output") + .font(.caption) + .foregroundStyle(.secondary) + Text(message.content.prefix(80)) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + Spacer() + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .buttonStyle(.plain) + if isExpanded { + Text(message.content) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .padding(.top, 2) + } + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(.tertiarySystemBackground)) + ) + Spacer(minLength: 40) } .padding(.horizontal) } } +// MARK: - Permission sheet + +/// Sheet presented when the remote asks for permission (e.g., +/// "allow write to /etc/hosts"). Renders the VM's `PendingPermission` +/// options as tappable buttons. Tapping responds via the ChatController +/// which dispatches the answer over the ACP channel. +private struct PermissionSheet: View { + let permission: RichChatViewModel.PendingPermission + let onRespond: (_ optionId: String) async -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(permission.title) + .font(.headline) + .textSelection(.enabled) + Text("Kind: \(permission.kind)") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + + Section("Your response") { + ForEach(permission.options, id: \.optionId) { opt in + Button { + Task { + await onRespond(opt.optionId) + dismiss() + } + } label: { + HStack { + Text(opt.name) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + } + } + .navigationTitle("Agent permission") + .navigationBarTitleDisplayMode(.inline) + } + } +} + #endif // canImport(SQLite3) // Empty shim so the file compiles on platforms without SQLite3 — the diff --git a/scarf/Scarf iOS/Cron/CronListView.swift b/scarf/Scarf iOS/Cron/CronListView.swift new file mode 100644 index 0000000..7b4357b --- /dev/null +++ b/scarf/Scarf iOS/Cron/CronListView.swift @@ -0,0 +1,193 @@ +import SwiftUI +import ScarfCore + +/// iOS Cron screen. Read-only list of scheduled jobs pulled from +/// `~/.hermes/cron/jobs.json`. Editing is deferred to a later phase — +/// see `IOSCronViewModel`'s header for the scope rationale. +struct CronListView: View { + let config: IOSServerConfig + + @State private var vm: IOSCronViewModel + + 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: IOSCronViewModel(context: ctx)) + } + + var body: some View { + List { + if let err = vm.lastError { + Section { + Label(err, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + } + + if vm.jobs.isEmpty, !vm.isLoading { + Section { + VStack(alignment: .leading, spacing: 6) { + Text("No cron jobs yet.") + .font(.headline) + Text("Create cron jobs from the Mac app or by editing `~/.hermes/cron/jobs.json` directly. iOS will display them here.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } else { + Section { + ForEach(vm.jobs) { job in + CronRow(job: job) + } + } + } + } + .navigationTitle("Cron jobs") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if vm.isLoading && vm.jobs.isEmpty { + ProgressView("Loading jobs…") + .padding() + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + .refreshable { await vm.load() } + .task { await vm.load() } + } +} + +private struct CronRow: View { + let job: HermesCronJob + + var body: some View { + NavigationLink { + CronDetailView(job: job) + } label: { + HStack(alignment: .top, spacing: 12) { + VStack { + Image(systemName: job.stateIcon) + .foregroundStyle(stateColor) + .font(.body) + } + .frame(width: 22) + + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(job.name) + .font(.body) + .fontWeight(.medium) + if !job.enabled { + Text("DISABLED") + .font(.caption2) + .fontWeight(.bold) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color(.secondarySystemFill)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } + if let schedule = job.schedule.display, !schedule.isEmpty { + Text(schedule) + .font(.caption) + .foregroundStyle(.secondary) + } else if !job.schedule.kind.isEmpty { + Text(job.schedule.kind) + .font(.caption) + .foregroundStyle(.secondary) + } + if let nextRun = job.nextRunAt { + Text("Next: \(nextRun)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + .padding(.vertical, 2) + } + } + + private var stateColor: Color { + switch job.state { + case "running": return .blue + case "completed": return .green + case "failed": return .red + default: return .secondary + } + } +} + +private struct CronDetailView: View { + let job: HermesCronJob + + var body: some View { + Form { + Section("Prompt") { + Text(job.prompt) + .font(.body) + .textSelection(.enabled) + } + + Section("Schedule") { + LabeledContent("Kind", value: job.schedule.kind) + if let display = job.schedule.display { + LabeledContent("When", value: display) + } + if let expr = job.schedule.expression { + LabeledContent("Expression", value: expr) + } + } + + Section("State") { + LabeledContent("Enabled", value: job.enabled ? "yes" : "no") + LabeledContent("State", value: job.state) + if let last = job.lastRunAt { + LabeledContent("Last run", value: last) + } + if let next = job.nextRunAt { + LabeledContent("Next run", value: next) + } + if let err = job.lastError { + VStack(alignment: .leading, spacing: 4) { + Text("Last error") + .font(.caption) + .foregroundStyle(.secondary) + Text(err) + .font(.caption.monospaced()) + .foregroundStyle(.red) + .textSelection(.enabled) + } + } + } + + if let delivery = job.deliveryDisplay { + Section("Delivery") { + LabeledContent("Route", value: delivery) + } + } + + if let skills = job.skills, !skills.isEmpty { + Section("Skills") { + ForEach(skills, id: \.self) { s in + Text(s) + .font(.caption.monospaced()) + } + } + } + + if let model = job.model { + Section("Model") { + Text(model).font(.caption.monospaced()) + } + } + } + .navigationTitle(job.name) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/scarf/Scarf iOS/Dashboard/DashboardView.swift b/scarf/Scarf iOS/Dashboard/DashboardView.swift index 1b92784..03adbd9 100644 --- a/scarf/Scarf iOS/Dashboard/DashboardView.swift +++ b/scarf/Scarf iOS/Dashboard/DashboardView.swift @@ -89,12 +89,27 @@ struct DashboardView: View { } } - Section { + Section("Surfaces") { NavigationLink { ChatView(config: config, key: key) } label: { Label("Chat", systemImage: "bubble.left.and.bubble.right.fill") } + NavigationLink { + MemoryListView(config: config) + } label: { + Label("Memory", systemImage: "brain.head.profile") + } + NavigationLink { + CronListView(config: config) + } label: { + Label("Cron", systemImage: "clock.arrow.circlepath") + } + NavigationLink { + SkillsListView(config: config) + } label: { + Label("Skills", systemImage: "sparkles") + } } Section("Connected to") { diff --git a/scarf/Scarf iOS/Memory/MemoryEditorView.swift b/scarf/Scarf iOS/Memory/MemoryEditorView.swift new file mode 100644 index 0000000..f3707d4 --- /dev/null +++ b/scarf/Scarf iOS/Memory/MemoryEditorView.swift @@ -0,0 +1,80 @@ +import SwiftUI +import ScarfCore + +/// Editor for a single memory file (MEMORY.md or USER.md). Owns an +/// `IOSMemoryViewModel` instance, renders its `text` in a TextEditor, +/// and exposes Save + Revert toolbar buttons. +struct MemoryEditorView: View { + @State private var vm: IOSMemoryViewModel + @State private var showSavedConfirmation = false + + init(kind: IOSMemoryViewModel.Kind, context: ServerContext) { + _vm = State(initialValue: IOSMemoryViewModel(kind: kind, context: context)) + } + + var body: some View { + VStack(spacing: 0) { + if vm.isLoading { + Spacer() + ProgressView("Loading \(vm.kind.displayName)…") + Spacer() + } else { + TextEditor(text: $vm.text) + .font(.system(.body, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .padding(.horizontal, 8) + if let err = vm.lastError { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(err) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.regularMaterial) + } + } + } + .navigationTitle(vm.kind.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Save") { + Task { + let ok = await vm.save() + if ok { + showSavedConfirmation = true + Task { + try? await Task.sleep(nanoseconds: 1_500_000_000) + showSavedConfirmation = false + } + } + } + } + .disabled(!vm.hasUnsavedChanges || vm.isSaving) + } + ToolbarItem(placement: .topBarLeading) { + if vm.hasUnsavedChanges { + Button("Revert") { vm.revert() } + } + } + } + .overlay(alignment: .bottom) { + if showSavedConfirmation { + Label("Saved", systemImage: "checkmark.circle.fill") + .font(.callout) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(.thinMaterial, in: Capsule()) + .padding(.bottom, 16) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.2), value: showSavedConfirmation) + .task { await vm.load() } + } +} diff --git a/scarf/Scarf iOS/Memory/MemoryListView.swift b/scarf/Scarf iOS/Memory/MemoryListView.swift new file mode 100644 index 0000000..f2a87f9 --- /dev/null +++ b/scarf/Scarf iOS/Memory/MemoryListView.swift @@ -0,0 +1,52 @@ +import SwiftUI +import ScarfCore + +/// Entry screen for the Memory feature. Two rows: MEMORY.md and +/// USER.md. Each taps into `MemoryEditorView`. Pure SwiftUI — the +/// actual load/save happens in `IOSMemoryViewModel` which lives in +/// ScarfCore and is tested on Linux. +struct MemoryListView: View { + let config: IOSServerConfig + + private static let sharedContextID: ServerID = ServerID( + uuidString: "00000000-0000-0000-0000-0000000000A1" + )! + + var body: some View { + let ctx = config.toServerContext(id: Self.sharedContextID) + List { + Section { + memoryRow(.memory, context: ctx) + memoryRow(.user, context: ctx) + } footer: { + Text("These files live under `~/.hermes/memories/` on the remote host.") + .font(.caption) + } + } + .navigationTitle("Memory") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private func memoryRow(_ kind: IOSMemoryViewModel.Kind, context: ServerContext) -> some View { + NavigationLink { + MemoryEditorView(kind: kind, context: context) + } label: { + HStack(alignment: .top, spacing: 12) { + Image(systemName: kind.iconName) + .font(.title3) + .foregroundStyle(.tint) + .frame(width: 28, alignment: .center) + VStack(alignment: .leading, spacing: 2) { + Text(kind.displayName) + .font(.body) + .fontWeight(.medium) + Text(kind.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + } +} diff --git a/scarf/Scarf iOS/Skills/SkillsListView.swift b/scarf/Scarf iOS/Skills/SkillsListView.swift new file mode 100644 index 0000000..fa99bd3 --- /dev/null +++ b/scarf/Scarf iOS/Skills/SkillsListView.swift @@ -0,0 +1,102 @@ +import SwiftUI +import ScarfCore + +/// iOS Skills browser. Read-only list grouped by category. Tapping +/// a skill shows its files + on-disk path — enough for a user to +/// verify what's installed without opening Terminal. +struct SkillsListView: View { + let config: IOSServerConfig + + @State private var vm: IOSSkillsViewModel + + 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: IOSSkillsViewModel(context: ctx)) + } + + var body: some View { + List { + if let err = vm.lastError { + Section { + Label(err, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + } + + if vm.categories.isEmpty, !vm.isLoading { + Section { + VStack(alignment: .leading, spacing: 6) { + Text("No skills installed") + .font(.headline) + Text("Skills live under `~/.hermes/skills///` on the remote. Install them from the Mac app or by cloning directly.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } else { + ForEach(vm.categories) { category in + Section(category.name) { + ForEach(category.skills) { skill in + NavigationLink { + SkillDetailView(skill: skill) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(skill.name) + .font(.body) + Text("\(skill.files.count) file\(skill.files.count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + } + } + .navigationTitle("Skills") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if vm.isLoading && vm.categories.isEmpty { + ProgressView("Scanning skills…") + .padding() + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + .refreshable { await vm.load() } + .task { await vm.load() } + } +} + +private struct SkillDetailView: View { + let skill: HermesSkill + + var body: some View { + List { + Section("Location") { + LabeledContent("Category", value: skill.category) + Text(skill.path) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + + if !skill.files.isEmpty { + Section("Files") { + ForEach(skill.files, id: \.self) { file in + Text(file) + .font(.caption.monospaced()) + } + } + } + } + .navigationTitle(skill.name) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/scarf/docs/IOS_PORT_PLAN.md b/scarf/docs/IOS_PORT_PLAN.md index f9aa50e..27db353 100644 --- a/scarf/docs/IOS_PORT_PLAN.md +++ b/scarf/docs/IOS_PORT_PLAN.md @@ -653,5 +653,47 @@ the 3 ScarfIOS tests. - **Chat is rich-chat-only on iOS in v1.** Terminal mode (embedded SwiftTerm) deferred. - **Message markdown / tool-call cards / permission sheets** are M5 polish. -### M5 — pending +### M5 — shipped (on `claude/ios-m5-polish-writes` branch, separate PR, stacked on M4) + +Chat polish + three new iOS feature surfaces — Memory (read + edit), Cron (read-only), Skills (read-only). + +**Chat polish:** +- **Tool-call cards** — assistant messages with embedded `HermesToolCall`s now render each call as an expandable card (tapped → shows full JSON arguments). Tool-kind icon in the card header. +- **Tool-result row** — messages with `role == "tool"` render as a compact "Tool output" disclosure beneath the assistant bubble they flowed from. Keeps the transcript scannable while letting users drill in. +- **Permission sheet** — when the remote emits `session/request_permission`, a SwiftUI `.sheet(item:)` modal presents the title + kind + option buttons. Tapping an option dispatches `respondToPermission(requestId:optionId:)` through `ChatController` → `ACPClient`. +- **Markdown rendering** — assistant messages (only) go through `AttributedString(markdown:options: .inlineOnlyPreservingWhitespace)`. Bold/italic/code/links render; unknown constructs fall through as plain text. +- **Reasoning disclosure** — `HermesMessage.reasoning` (when present) renders as an "Thinking…" `DisclosureGroup` above the main bubble. Collapsed by default so chain-of-thought-heavy models don't dominate the scroll. + +**New feature surfaces** (all in `scarf/Scarf iOS/` + ScarfCore ViewModels): +- **Memory** — `IOSMemoryViewModel` (ScarfCore) + `MemoryListView` / `MemoryEditorView`. Two rows: MEMORY.md and USER.md. TextEditor bound to `vm.text`; toolbar Save + Revert. "Saved" toast confirmation. VM uses `ServerContext.readText` / `writeText` — works through any transport. +- **Cron** — `IOSCronViewModel` + `CronListView` + `CronDetailView`. Read-only. Decodes `jobs.json` via `CronJobsFile` (Codable, from ScarfCore M0a). Sorted enabled-first, then by `nextRunAt`. Missing file = empty list (no error — common on fresh installs). +- **Skills** — `IOSSkillsViewModel` + `SkillsListView` + `SkillDetailView`. Read-only. Scans `~/.hermes/skills///` via `transport.listDirectory` + `stat` (no YAML frontmatter parsing — defer). Empty categories filtered. Dotfiles filtered. + +**Supporting changes:** +- `RichChatViewModel.PendingPermission` fields promoted `public` — the iOS `PermissionSheet` needs to read `title` / `kind` / `options`. +- `LocalTransport.writeFile` refactored to use `Data.write(options: .atomic)` instead of `FileManager.replaceItemAt`. replaceItemAt is Apple-only (Linux swift-corelibs doesn't implement it fully), which broke the M5 tests on Linux CI. The atomic-write option is cross-platform and has identical semantics. No behavior change on Mac/iOS; auto-creates the parent dir if missing. +- Dashboard's single Chat-only Section became a **Surfaces** section with four `NavigationLink`s: Chat, Memory, Cron, Skills. + +**Test coverage (`M5FeatureVMTests`, 10 new tests):** +- Memory: missing-file → empty state, full load+edit+save+reload round-trip, revert restores original, `Kind.path(on:)` routing. +- Cron: missing jobs.json → empty (no error), full-load sorts (enabled-first + next-run ascending + disabled-last), decode-error surfaces via `lastError`. +- Skills: missing skills dir → empty, directory scan extracts category/skill/files + filters dotfiles, empty categories excluded from list. +- `PendingPermission.init` pinned (SQLite3-gated). + +Total **98 → 108 tests passing on Linux** via `docker run --rm -v $PWD/Packages/ScarfCore:/work -w /work swift:6.0 swift test`. All M5 tests use a `.serialized` suite + a `withLocalTransportFactory` helper so the shared `ServerContext.sshTransportFactory` static doesn't race. + +**Manual validation needed on Mac:** +1. Xcode compile clean against M5 source additions. +2. Chat: trigger a tool call (e.g., ask "list files in ~") → verify the card renders + expands. Trigger a permission request (e.g., ask to write a file) → verify the sheet presents + responding dispatches correctly. +3. Markdown: ask for a bulleted list or bold/italic text → verify rendering. +4. Memory: edit MEMORY.md from phone → save → verify on remote filesystem via `cat ~/.hermes/memories/MEMORY.md`. +5. Cron: if you have existing cron jobs, verify they show up sorted correctly + the detail view is useful. +6. Skills: browse the list, tap a skill, verify the file list matches `ls ~/.hermes/skills///`. + +**Rules next phases can rely on:** +- **`IOSMemoryViewModel`, `IOSCronViewModel`, `IOSSkillsViewModel`** live in ScarfCore (not ScarfIOS) because they only use `ServerContext.readText` / `writeText` / `makeTransport` — no iOS-only APIs. Tests on Linux with `LocalTransport` are legitimate coverage. +- **`LocalTransport.writeFile` is atomic cross-platform** — services writing through it get POSIX rename-atomic semantics on every target. Don't reintroduce `replaceItemAt`. +- **Editing Cron, adding Skills** — both are deferred. Cron editing needs atomic JSON rewrites (doable). Skills install needs git-clone + schema validation (larger). +- **Settings tab on iOS is still missing** — requires a YAML parser in ScarfCore or porting `HermesFileService.loadConfig`. Next phase's job. + ### M6 — pending