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 {
|
public func writeFile(_ path: String, data: Data) throws {
|
||||||
let tmp = path + ".scarf.tmp"
|
|
||||||
do {
|
do {
|
||||||
try data.write(to: URL(fileURLWithPath: tmp))
|
// Ensure the parent dir exists — callers sometimes pass a
|
||||||
// Preserve `0600` for dotfiles holding secrets (.env, .auth, ...).
|
// path whose parent hasn't been mkdir'd yet (e.g.,
|
||||||
// The existing files already use 0600 via HermesEnvService; we
|
// `~/.hermes/memories/MEMORY.md` on a Hermes install that
|
||||||
// mirror that here so a brand-new file created via this write
|
// never wrote memories before).
|
||||||
// also starts with safe permissions.
|
let parent = (path as NSString).deletingLastPathComponent
|
||||||
if Self.shouldEnforcePrivateMode(for: path) {
|
if !parent.isEmpty, !FileManager.default.fileExists(atPath: parent) {
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tmp)
|
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
// Atomic swap onto the final path.
|
// Atomic write: Data.write(options: .atomic) drops a temp
|
||||||
let destURL = URL(fileURLWithPath: path)
|
// file alongside the destination and rename(2)s it into
|
||||||
let tmpURL = URL(fileURLWithPath: tmp)
|
// place. Cross-platform (macOS + iOS + Linux CI for tests).
|
||||||
if FileManager.default.fileExists(atPath: path) {
|
//
|
||||||
_ = try FileManager.default.replaceItemAt(destURL, withItemAt: tmpURL)
|
// Earlier this method used `FileManager.replaceItemAt`,
|
||||||
} else {
|
// which is Apple-only — Linux swift-corelibs would fail.
|
||||||
// Ensure parent exists.
|
// Data.write-atomic works everywhere with identical
|
||||||
let parent = (path as NSString).deletingLastPathComponent
|
// semantics.
|
||||||
if !parent.isEmpty, !FileManager.default.fileExists(atPath: parent) {
|
try data.write(to: URL(fileURLWithPath: path), options: .atomic)
|
||||||
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
|
// Preserve 0600 for files that conventionally hold secrets.
|
||||||
}
|
// The existing files use 0600 via HermesEnvService; apply
|
||||||
try FileManager.default.moveItem(at: tmpURL, to: destURL)
|
// 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 {
|
} catch {
|
||||||
try? FileManager.default.removeItem(atPath: tmp)
|
|
||||||
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
|
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?
|
private var activePollingTimer: Timer?
|
||||||
|
|
||||||
public struct PendingPermission {
|
public struct PendingPermission {
|
||||||
let requestId: Int
|
public let requestId: Int
|
||||||
let title: String
|
public let title: String
|
||||||
let kind: String
|
public let kind: String
|
||||||
let options: [(optionId: String, name: 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
|
// 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)
|
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
|
// MARK: - Subviews
|
||||||
@@ -302,6 +313,23 @@ final class ChatController {
|
|||||||
vm.reset()
|
vm.reset()
|
||||||
await start()
|
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
|
// MARK: - Message bubble
|
||||||
@@ -310,33 +338,243 @@ private struct MessageBubble: View {
|
|||||||
let message: HermesMessage
|
let message: HermesMessage
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
if message.isToolResult {
|
||||||
if message.isUser { Spacer(minLength: 40) }
|
ToolResultRow(message: message)
|
||||||
VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) {
|
} else {
|
||||||
Text(message.content)
|
HStack(alignment: .bottom) {
|
||||||
.font(.body)
|
if message.isUser { Spacer(minLength: 40) }
|
||||||
.padding(.horizontal, 12)
|
VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) {
|
||||||
.padding(.vertical, 8)
|
if message.hasReasoning, let r = message.reasoning, !r.isEmpty {
|
||||||
.foregroundStyle(message.isUser ? Color.white : Color.primary)
|
ReasoningDisclosure(reasoning: r)
|
||||||
.background(
|
}
|
||||||
message.isUser ? Color.accentColor : Color(.secondarySystemBackground)
|
bubbleContent
|
||||||
)
|
if !message.toolCalls.isEmpty {
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
.textSelection(.enabled)
|
ForEach(message.toolCalls) { call in
|
||||||
if message.hasReasoning, let r = message.reasoning, !r.isEmpty {
|
ToolCallCard(call: call)
|
||||||
Text("🧠 \(r)")
|
}
|
||||||
.font(.caption2)
|
}
|
||||||
.italic()
|
}
|
||||||
|
}
|
||||||
|
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)
|
.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)
|
.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)
|
#endif // canImport(SQLite3)
|
||||||
|
|
||||||
// Empty shim so the file compiles on platforms without SQLite3 — the
|
// 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 {
|
NavigationLink {
|
||||||
ChatView(config: config, key: key)
|
ChatView(config: config, key: key)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Chat", systemImage: "bubble.left.and.bubble.right.fill")
|
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") {
|
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.
|
- **Chat is rich-chat-only on iOS in v1.** Terminal mode (embedded SwiftTerm) deferred.
|
||||||
- **Message markdown / tool-call cards / permission sheets** are M5 polish.
|
- **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
|
### M6 — pending
|
||||||
|
|||||||
Reference in New Issue
Block a user