mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
iOS port M5: Chat polish + Memory + Cron + Skills features
Fleshes out the iOS app from "Chat + placeholder Dashboard" into a
real on-the-go Hermes companion: Chat now renders tool calls + tool
results + permission sheets + markdown + chain-of-thought, and the
Dashboard gains three new feature surfaces.
## Chat polish
scarf/Scarf iOS/Chat/ChatView.swift — several new small SwiftUI
view types:
- ToolCallCard: expandable card for each HermesToolCall on an
assistant message. Tool-kind icon in the header (from
HermesToolCall.toolKind.icon), arguments summary collapsed,
full JSON on tap.
- ToolResultRow: compact "Tool output" disclosure for messages
with role == "tool", shown indented beneath the preceding
assistant bubble.
- PermissionSheet: SwiftUI .sheet(item:) presentation of
RichChatViewModel.pendingPermission. Tapping an option
dispatches ChatController.respondToPermission → ACPClient.
- ReasoningDisclosure: DisclosureGroup for HermesMessage.reasoning,
collapsed by default so chatty thinkers don't dominate scroll.
MessageBubble now renders assistant content through
AttributedString(markdown: options: .inlineOnlyPreservingWhitespace).
User messages stay plain Text (no reason to parse what the user
just typed). Unknown markdown falls through as literal text — worst
case, no formatting.
ChatController gains respondToPermission(requestId:optionId:) that
forwards to ACPClient and clears vm.pendingPermission on the
MainActor.
## New feature surfaces
### Memory (read + edit)
ScarfCore/ViewModels/IOSMemoryViewModel.swift:
- Kind enum (.memory / .user) → maps to paths.memoryMD / .userMD
- text (mutable) + originalText (pristine) + hasUnsavedChanges
- load() / save() / revert()
- async file I/O via ServerContext.readText / writeText — run on
a detached task so the MainActor doesn't hang on remote SFTP
scarf/Scarf iOS/Memory/:
- MemoryListView: two-row NavigationLink (MEMORY.md, USER.md)
- MemoryEditorView: TextEditor bound to vm.text, toolbar Save +
Revert, "Saved" bottom toast on success.
### Cron (read-only)
ScarfCore/ViewModels/IOSCronViewModel.swift:
- Loads ~/.hermes/cron/jobs.json via transport.readFile + decodes
into CronJobsFile (Codable, shipped in M0a)
- Missing file = empty list (no error — common on fresh installs)
- Sort: enabled-first, then nextRunAt ascending, disabled last
- Surfaces decode errors via lastError
scarf/Scarf iOS/Cron/CronListView.swift:
- Row: state-icon + name + schedule.display + next-run-at.
- Detail: prompt, schedule, state, delivery route (via
job.deliveryDisplay), skills, model.
Editing is deferred — needs atomic jobs.json rewrites. Shipped the
read path so users can at least audit their cron config on the go.
### Skills (read-only)
ScarfCore/ViewModels/IOSSkillsViewModel.swift:
- Scans ~/.hermes/skills/<category>/<name>/ via transport.listDirectory
+ transport.stat for directory-ness
- Filters dotfiles. Skips empty categories. Swallows per-category
listing errors (permissions etc.) rather than failing the whole
load.
- requiredConfig stays empty — YAML frontmatter parsing deferred
(would need a parser in ScarfCore; see M5 plan note).
scarf/Scarf iOS/Skills/SkillsListView.swift:
- Grouped by category, tap → SkillDetailView (path + file list).
## Supporting tweaks
- RichChatViewModel.PendingPermission: fields + public init promoted
from `let`/internal to `public let` / `public init(...)` so
PermissionSheet can read title/kind/options and tests can construct
one directly.
- LocalTransport.writeFile refactored to use Data.write(options: .atomic)
instead of FileManager.replaceItemAt. replaceItemAt is Apple-only;
Linux swift-corelibs doesn't fully implement it, which was breaking
the M5 save-path tests on Linux CI. Data.write(atomic) is cross-
platform and has identical semantics (temp-file + rename). Also
auto-creates the parent directory if missing, folding in the one
bit of the old logic that wasn't atomicity-related.
- DashboardView: single Chat Section → "Surfaces" Section with four
NavigationLinks (Chat / Memory / Cron / Skills).
## Tests (ScarfCoreTests/M5FeatureVMTests, 10 new)
.serialized suite — tests install a `withLocalTransportFactory`
helper that swaps ServerContext.sshTransportFactory to produce a
LocalTransport against real tmp files (so .ssh contexts in the
test resolve to local FS paths). Restored in defer. Serialized
because the factory is a static.
- memoryLoadsEmptyWhenFileMissing
- memoryRoundTripsFileContent — seed file → load → edit → save
→ reload via fresh VM → confirm persistence
- memoryRevertRestoresOriginal
- memoryKindPathRouting — pin .memory → memoryMD etc.
- cronEmptyWhenJobsFileMissing — missing file is not an error
- cronLoadsAndSortsJobs — 3-job fixture, verify sort:
enabled-before-disabled and
nextRunAt-ascending within
- cronSurfacesDecodeErrors — garbage jobs.json
- skillsEmptyWhenDirMissing
- skillsScansCategoryAndSkillStructure — 2 categories, dotfile
filter check
- skillsSkipsEmptyCategories
- pendingPermissionMemberwise — SQLite3-gated (RichChatViewModel
is gated)
**108 / 108 passing on Linux** (98 → 108).
## Manual validation needed on Mac
1. Xcode compile clean against M5 source additions.
2. Chat: trigger a tool call + a permission request. Verify cards
render, options dispatch, markdown looks right.
3. Memory: edit MEMORY.md on phone → save → confirm via `cat` on
the remote.
4. Cron: existing jobs show sorted + detail view useful.
5. Skills: browse matches `ls ~/.hermes/skills/<cat>/<name>/`.
Updated scarf/docs/IOS_PORT_PLAN.md with M5's scope, rationale
for the LocalTransport.writeFile refactor (Linux CI), and the M6
Settings-blocker (needs YAML parser port).
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
@@ -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)
|
||||
}
|
||||
// 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.
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CronJobsFile, Error> = 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<T>(
|
||||
_ 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
|
||||
}
|
||||
@@ -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,10 +338,53 @@ private struct MessageBubble: View {
|
||||
let message: HermesMessage
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if message.isToolResult {
|
||||
ToolResultRow(message: message)
|
||||
} else {
|
||||
HStack(alignment: .bottom) {
|
||||
if message.isUser { Spacer(minLength: 40) }
|
||||
VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) {
|
||||
Text(message.content)
|
||||
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)
|
||||
@@ -323,20 +394,187 @@ private struct MessageBubble: View {
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.textSelection(.enabled)
|
||||
if message.hasReasoning, let r = message.reasoning, !r.isEmpty {
|
||||
Text("🧠 \(r)")
|
||||
.font(.caption2)
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
.padding(.horizontal, 12)
|
||||
.italic()
|
||||
.textSelection(.enabled)
|
||||
.padding(.top, 4)
|
||||
} label: {
|
||||
Label("Thinking…", systemImage: "brain")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
}
|
||||
}
|
||||
if !message.isUser { Spacer(minLength: 40) }
|
||||
|
||||
/// 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)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/<category>/<name>/` 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)
|
||||
}
|
||||
}
|
||||
@@ -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/<category>/<name>/` 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/<cat>/<name>/`.
|
||||
|
||||
**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
|
||||
|
||||
Reference in New Issue
Block a user