feat(ios): 5-tab nav + Projects/Skills feature parity with Mac

Major iOS UI refactor that brings ScarfGo to feature parity with the
Mac app for Projects + Skills, on top of a ScarfCore consolidation
that unifies the view-model + scanner/parser layer between platforms.

Layout (ScarfGoTabRoot):
- Old: Chat / Dashboard / Memory / More (4 tabs).
- New: Dashboard / Projects / Chat / Skills / System (5 tabs, Chat
  centered). Memory + Cron + Settings consolidate under System.

Projects (NEW iOS feature):
- ProjectsListView, ProjectDetailView, ProjectSessionsView_iOS,
  ProjectSiteView.
- Widgets/ subdir: 7 widget views (Chart, List, Progress, Stat,
  Table, Text, Webview) + WidgetHelpers + DashboardWidgetsView.
- Tied to chat via ScarfGoCoordinator.startChatInProject() which
  sets pendingProjectChat + flips selectedTab to .chat.

Skills (NEW iOS surface):
- SkillsView is a 3-sub-tab switcher (Installed / Browse Hub / Updates).
- Installed/: InstalledSkillsListView, SkillDetailView,
  SkillEditorSheet.
- Hub/HubBrowseView for the skills hub catalog.
- Updates/UpdatesView for hermes skills check / update.

ScarfCore consolidation:
- SkillsViewModel and ProjectSessionsViewModel lift from Mac target
  into ScarfCore so iOS and Mac share one type.
- New SkillsScanner walks ~/.hermes/skills/ once for both platforms
  via the supplied transport.
- New SkillFrontmatterParser handles required_config: parsing.
- New HermesSkillsHubParser for the hub catalog format.
- Tests for both new parsers.

Mac touchpoints:
- Features/Skills/Views/SkillsView.swift: .onAppear wraps the now-
  async load() in a Task.
- Old Mac-target SkillsViewModel and ProjectSessionsViewModel
  deleted (replaced by ScarfCore).

Coordinator + chat:
- ScarfGoCoordinator gains pendingProjectChat: String?
  + startChatInProject(path:) helper.
- iOS ChatView consumes pendingProjectChat (mirrors the existing
  pendingResumeSessionID pattern); resolves path → ProjectEntry via
  registry, falls back to a synthesized entry on miss.

Tests:
- M5FeatureVMTests renames 3 IOSSkillsViewModel references to the
  shared SkillsViewModel.
- New SkillFrontmatterParserTests + SkillsHubParserTests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 09:52:16 +02:00
parent 6808adfa98
commit a73025aba0
34 changed files with 2624 additions and 622 deletions
@@ -0,0 +1,157 @@
import Foundation
/// Pure-Swift parsers for `hermes skills` CLI output. Extracted from
/// the Mac `SkillsViewModel` in v2.5 so iOS can share the same parse
/// logic both targets call `transport.runProcess(executable: hermes)`
/// and feed the captured stdout/stderr through these parsers.
///
/// Marked `Sendable` so they can run inside `Task.detached` blocks
/// without isolation gymnastics. All members are `nonisolated`.
public enum HermesSkillsHubParser: Sendable {
/// Parse `hermes skills browse|search` output.
///
/// Hermes emits a Rich box-drawn table with vertical bars as column
/// separators:
///
/// # Name Description Source Trust
///
/// 1 1password Set up and use 1Pass official official
///
/// Description cells can wrap across multiple rows the
/// continuation rows have an empty `#` column. We join consecutive
/// rows with the same skill by checking whether the first column
/// (after ``) is whitespace-only.
public static func parseHubList(_ output: String) -> [HermesHubSkill] {
var results: [HermesHubSkill] = []
for raw in output.components(separatedBy: "\n") {
let line = raw
// Skip everything that isn't a data row. Data rows start
// with `` and contain multiple `` separators. Border
// rows (``, ``, ``, ``, etc.) are drawn with `` or
// `` and should be skipped.
guard line.contains("") else { continue }
let cells = line
.split(separator: "", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespaces) }
// Expect at least: leading empty, #, Name, Description,
// Source, Trust, trailing empty
guard cells.count >= 6 else { continue }
let numCell = cells[1]
let nameCell = cells[2]
let descCell = cells[3]
let sourceCell = cells[4]
// Trust column (index 5) is informational only we ignore
// it in the UI.
// Continuation row: `#` column is empty. Merge its
// description into the last-added entry if present.
if numCell.isEmpty {
guard !results.isEmpty else { continue }
let last = results.removeLast()
let merged = [last.description, descCell]
.filter { !$0.isEmpty }
.joined(separator: " ")
results.append(HermesHubSkill(
identifier: last.identifier,
name: last.name,
description: merged,
source: last.source
))
continue
}
// Header row first data-looking row whose number cell
// isn't a digit.
if Int(numCell) == nil { continue }
// Empty name cell shouldn't happen but guard anyway.
guard !nameCell.isEmpty else { continue }
// Identifier: `hermes skills browse` shows the short name
// in the Name column. For install we need the full
// identifier like `<source>/<name>`. The CLI accepts just
// the name for official hub, so we use that as the install
// target.
let source = sourceCell
.replacingOccurrences(of: "", with: "")
.trimmingCharacters(in: .whitespaces)
results.append(HermesHubSkill(
identifier: nameCell,
name: nameCell,
description: descCell,
source: source
))
}
return results
}
/// Parse `hermes skills check` output for available updates. Format
/// is undocumented; we look for `` (U+2192) or `->` arrow markers
/// between version strings.
public static func parseUpdateList(_ output: String) -> [HermesSkillUpdate] {
var results: [HermesSkillUpdate] = []
for raw in output.components(separatedBy: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.contains("") || line.contains("->") else { continue }
let marker = line.contains("") ? "" : "->"
let parts = line.components(separatedBy: marker)
guard parts.count == 2 else { continue }
let left = parts[0].trimmingCharacters(in: .whitespaces)
let available = parts[1].trimmingCharacters(in: .whitespaces)
let leftTokens = left
.split(separator: " ", omittingEmptySubsequences: true)
.map(String.init)
guard leftTokens.count >= 2 else { continue }
let identifier = leftTokens[0]
let current = leftTokens[leftTokens.count - 1]
results.append(HermesSkillUpdate(
identifier: identifier,
currentVersion: current,
availableVersion: available
))
}
return results
}
}
// MARK: - Public model types
/// A single search/browse result from a skill registry. Mirrors the
/// shape `SkillsViewModel` had on Mac before the v2.5 ScarfCore promotion.
public struct HermesHubSkill: Identifiable, Sendable, Equatable {
public var id: String { identifier }
public let identifier: String // e.g. "openai/skills/skill-creator"
public let name: String
public let description: String
public let source: String // "official" | "skills-sh" | etc.
public init(
identifier: String,
name: String,
description: String,
source: String
) {
self.identifier = identifier
self.name = name
self.description = description
self.source = source
}
}
/// A local skill that has an upstream version available.
public struct HermesSkillUpdate: Identifiable, Sendable, Equatable {
public var id: String { identifier }
public let identifier: String
public let currentVersion: String
public let availableVersion: String
public init(
identifier: String,
currentVersion: String,
availableVersion: String
) {
self.identifier = identifier
self.currentVersion = currentVersion
self.availableVersion = availableVersion
}
}
@@ -0,0 +1,39 @@
import Foundation
/// Pure-Swift YAML-frontmatter parser for skill manifests' `required_config:`
/// list. Extracted from `HermesFileService.parseSkillRequiredConfig` in
/// v2.5 so iOS can flag missing config keys without depending on the
/// Mac target.
///
/// Intentionally not a full YAML parser Hermes skill manifests use a
/// very narrow subset of YAML for this list. We look for a top-level
/// `required_config:` key followed by `- key` entries with at least one
/// space of indent. Lines outside that section are ignored.
public enum SkillFrontmatterParser: Sendable {
/// Parse the `required_config:` list from a skill.yaml's text. Empty
/// result on any kind of malformation callers treat it as "no
/// required config, proceed".
public static func parseRequiredConfig(_ content: String) -> [String] {
var result: [String] = []
var inRequiredConfig = false
for line in content.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = line.prefix(while: { $0 == " " }).count
if trimmed == "required_config:" || trimmed.hasPrefix("required_config:") {
inRequiredConfig = true
continue
}
if inRequiredConfig {
if indent < 2 && !trimmed.isEmpty {
break
}
if trimmed.hasPrefix("- ") {
result.append(String(trimmed.dropFirst(2)))
}
}
}
return result
}
}
@@ -0,0 +1,65 @@
import Foundation
import os
/// Walks `~/.hermes/skills/<category>/<name>/` and returns a populated
/// list of `HermesSkillCategory`. Body ported from
/// `HermesFileService.loadSkills` in v2.5 so iOS and Mac share the same
/// scan logic only difference vs the Mac function is that this one
/// reads through the supplied transport rather than holding its own.
///
/// Synchronous + transport-backed: callers running on the MainActor
/// should wrap in `Task.detached` (the iOS pattern) since SFTP `stat` /
/// `listDirectory` calls block.
public enum SkillsScanner: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "SkillsScanner")
public static func scan(context: ServerContext, transport: any ServerTransport) -> [HermesSkillCategory] {
let dir = context.paths.skillsDir
// Fresh install: skills/ may not exist yet return [] without
// logging an error.
guard transport.fileExists(dir) else { return [] }
guard let categories = try? transport.listDirectory(dir) else { return [] }
return categories
.filter { !$0.hasPrefix(".") }
.sorted()
.compactMap { categoryName -> HermesSkillCategory? in
let categoryPath = dir + "/" + categoryName
guard transport.stat(categoryPath)?.isDirectory == true else { return nil }
guard let skillNames = try? transport.listDirectory(categoryPath) else { return nil }
let skills = skillNames
.filter { !$0.hasPrefix(".") }
.sorted()
.compactMap { skillName -> HermesSkill? in
let skillPath = categoryPath + "/" + skillName
guard transport.stat(skillPath)?.isDirectory == true else { return nil }
let files = ((try? transport.listDirectory(skillPath)) ?? [])
.filter { !$0.hasPrefix(".") }
.sorted()
let requiredConfig = readRequiredConfig(
yamlPath: skillPath + "/skill.yaml",
transport: transport
)
return HermesSkill(
id: categoryName + "/" + skillName,
name: skillName,
category: categoryName,
path: skillPath,
files: files,
requiredConfig: requiredConfig
)
}
guard !skills.isEmpty else { return nil }
return HermesSkillCategory(id: categoryName, name: categoryName, skills: skills)
}
}
private static func readRequiredConfig(yamlPath: String, transport: any ServerTransport) -> [String] {
guard let data = try? transport.readFile(yamlPath),
let content = String(data: data, encoding: .utf8)
else { return [] }
return SkillFrontmatterParser.parseRequiredConfig(content)
}
}
@@ -1,100 +0,0 @@
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
}
}
@@ -1,23 +1,26 @@
import Foundation
import os
import ScarfCore
/// Drives the per-project Sessions tab introduced in v2.3. Pulls the
/// global session list from `HermesDataService`, filters by the
/// attribution sidecar, and exposes a minimal surface for the view:
/// the filtered sessions array, loading state, and a refresh entry
/// point that the view can call on appearance + on file-watcher
/// change.
/// Drives the per-project Sessions tab introduced in v2.3 and reused
/// by the iOS Project Detail view in v2.5. Pulls the global session
/// list from `HermesDataService`, filters by the attribution sidecar,
/// and exposes a minimal surface for the view: the filtered sessions
/// array, loading state, and a refresh entry point that the view can
/// call on appearance + on file-watcher change.
///
/// Promoted from the Mac target into ScarfCore in v2.5 so iOS and Mac
/// share the exact same filtering + attribution semantics there's
/// nothing AppKit-specific here, just transport-backed I/O.
@Observable
@MainActor
final class ProjectSessionsViewModel {
public final class ProjectSessionsViewModel {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectSessionsViewModel")
private let dataService: HermesDataService
private let attribution: SessionAttributionService
private let project: ProjectEntry
init(context: ServerContext, project: ProjectEntry) {
public init(context: ServerContext, project: ProjectEntry) {
self.dataService = HermesDataService(context: context)
self.attribution = SessionAttributionService(context: context)
self.project = project
@@ -25,23 +28,23 @@ final class ProjectSessionsViewModel {
/// Sessions attributed to the owning project, in the order
/// `HermesDataService.fetchSessions` returns them (newest first).
var sessions: [HermesSession] = []
public var sessions: [HermesSession] = []
/// True from `load()` start to its completion. The view renders
/// a ProgressView during the first fetch; afterwards, re-fetches
/// triggered by file-watcher changes happen silently.
var isLoading: Bool = false
public var isLoading: Bool = false
/// Short diagnostic string for an empty list nil when sessions
/// are loaded and populated, otherwise explains the empty state
/// (no sessions ever created in this project, vs. no sessions
/// matched the project's attribution map).
var emptyStateHint: String?
public var emptyStateHint: String?
/// Refresh the session list. Safe to call repeatedly; the data
/// service reconnects to state.db on demand and the attribution
/// service reads the sidecar afresh each call.
func load() async {
public func load() async {
isLoading = true
defer { isLoading = false }
@@ -87,11 +90,10 @@ final class ProjectSessionsViewModel {
}
/// Release the underlying DB handle. Safe to call repeatedly; the
/// service re-opens on the next `load()`. Mirrors the pattern in
/// ActivityViewModel.swift:80 view calls this on `.onDisappear`
/// so file descriptors and the SQLite cache don't dangle once
/// the tab isn't visible.
func close() async {
/// service re-opens on the next `load()`. View calls this on
/// `.onDisappear` so file descriptors and the SQLite cache don't
/// dangle once the tab isn't visible.
public func close() async {
await dataService.close()
}
}
@@ -0,0 +1,347 @@
import Foundation
import os
/// Unified Skills viewmodel. Promoted from the Mac target into ScarfCore
/// in v2.5 so iOS and Mac share the exact same Installed / Hub / Updates
/// state machine. Replaces the old Mac `SkillsViewModel` and the
/// minimal iOS `IOSSkillsViewModel`.
///
/// Transport-backed throughout: skill scanning goes through
/// `SkillsScanner.scan(context:transport:)`, file I/O goes through
/// `transport.readFile / writeFile`, and CLI invocations go through
/// `transport.runProcess(executable:args:stdin:timeout:)`. iOS gets the
/// same hub features as Mac without a target-specific code path.
@Observable
public final class SkillsViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "SkillsViewModel")
public let context: ServerContext
private let transport: any ServerTransport
public init(context: ServerContext = .local) {
self.context = context
self.transport = context.makeTransport()
}
// MARK: - Installed skills
public var categories: [HermesSkillCategory] = []
public var selectedSkill: HermesSkill?
public var skillContent = ""
public var selectedFileName: String?
public var searchText = ""
public var missingConfig: [String] = []
public var isEditing = false
public var editText = ""
/// True while the installed-skills scan is in flight. Renders a
/// progress indicator on iOS; Mac historically didn't surface this
/// from VM state but adding it doesn't break the existing UI.
public var isLoading: Bool = false
/// Diagnostic for a failed scan. Nil on success or when the dir
/// is simply missing (fresh install).
public var lastError: String?
// MARK: - Hub integration
public var hubQuery = ""
public var hubResults: [HermesHubSkill] = []
public var updates: [HermesSkillUpdate] = []
public var isHubLoading = false
public var hubMessage: String?
public var hubSource: String = "all"
public let hubSources = ["all", "official", "skills-sh", "well-known", "github", "clawhub", "lobehub"]
public var filteredCategories: [HermesSkillCategory] {
guard !searchText.isEmpty else { return categories }
return categories.compactMap { category in
let filtered = category.skills.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
$0.category.localizedCaseInsensitiveContains(searchText)
}
guard !filtered.isEmpty else { return nil }
return HermesSkillCategory(id: category.id, name: category.name, skills: filtered)
}
}
public var totalSkillCount: Int {
categories.reduce(0) { $0 + $1.skills.count }
}
/// Awaitable scan. iOS's `.task { await vm.load() }` and the
/// ScarfCore unit tests use this directly; Mac call sites wrap in
/// `Task { await ... }` from `onAppear`.
@MainActor
public func load() async {
isLoading = true
lastError = nil
let ctx = context
let xport = transport
let cats: [HermesSkillCategory] = await Task.detached {
SkillsScanner.scan(context: ctx, transport: xport)
}.value
categories = cats
isLoading = false
}
public func selectSkill(_ skill: HermesSkill) {
selectedSkill = skill
let mainFile = skill.files.first(where: { $0.hasSuffix(".md") }) ?? skill.files.first
if let file = mainFile {
selectedFileName = file
skillContent = loadSkillContent(path: skill.path + "/" + file)
} else {
selectedFileName = nil
skillContent = ""
}
missingConfig = computeMissingConfig(for: skill)
}
private func computeMissingConfig(for skill: HermesSkill) -> [String] {
guard !skill.requiredConfig.isEmpty else { return [] }
guard let yaml = context.readText(context.paths.configYAML) else {
return skill.requiredConfig
}
return skill.requiredConfig.filter { key in
!yaml.contains(key)
}
}
public func selectFile(_ file: String) {
guard let skill = selectedSkill else { return }
selectedFileName = file
skillContent = loadSkillContent(path: skill.path + "/" + file)
}
public var isMarkdownFile: Bool {
selectedFileName?.hasSuffix(".md") == true
}
private var currentFilePath: String? {
guard let skill = selectedSkill, let file = selectedFileName else { return nil }
return skill.path + "/" + file
}
public func startEditing() {
editText = skillContent
isEditing = true
}
public func saveEdit() {
guard let path = currentFilePath else { return }
saveSkillContent(path: path, content: editText)
skillContent = editText
isEditing = false
}
public func cancelEditing() {
isEditing = false
}
// MARK: - Hub browse / search / install / update
public func browseHub() {
isHubLoading = true
let bin = context.paths.hermesBinary
let xport = transport
let source = hubSource
Task.detached { [weak self] in
var args = ["skills", "browse", "--size", "40"]
if source != "all" { args += ["--source", source] }
let result = Self.runHermes(executable: bin, args: args, transport: xport, timeout: 30)
let parsed = HermesSkillsHubParser.parseHubList(result.output)
await self?.finishBrowse(results: parsed, exitCode: result.exitCode, isSearch: false)
}
}
public func searchHub() {
guard !hubQuery.isEmpty else {
browseHub()
return
}
isHubLoading = true
let bin = context.paths.hermesBinary
let xport = transport
let source = hubSource
let query = hubQuery
Task.detached { [weak self] in
var args = ["skills", "search", query, "--limit", "40"]
if source != "all" { args += ["--source", source] }
let result = Self.runHermes(executable: bin, args: args, transport: xport, timeout: 30)
let parsed = HermesSkillsHubParser.parseHubList(result.output)
await self?.finishBrowse(results: parsed, exitCode: result.exitCode, isSearch: true)
}
}
public func installHubSkill(_ skill: HermesHubSkill) {
isHubLoading = true
hubMessage = "Installing \(skill.identifier)"
let bin = context.paths.hermesBinary
let xport = transport
let identifier = skill.identifier
Task.detached { [weak self] in
// --yes skips confirmation since we're running non-interactively.
let result = Self.runHermes(
executable: bin,
args: ["skills", "install", identifier, "--yes"],
transport: xport,
timeout: 120
)
await self?.finishInstall(identifier: identifier, exitCode: result.exitCode)
}
}
public func uninstallHubSkill(_ identifier: String) {
let bin = context.paths.hermesBinary
let xport = transport
Task.detached { [weak self] in
let result = Self.runHermes(
executable: bin,
args: ["skills", "uninstall", identifier, "--yes"],
transport: xport,
timeout: 60
)
await self?.finishUninstall(exitCode: result.exitCode)
}
}
public func checkForUpdates() {
isHubLoading = true
let bin = context.paths.hermesBinary
let xport = transport
Task.detached { [weak self] in
let result = Self.runHermes(
executable: bin,
args: ["skills", "check"],
transport: xport,
timeout: 60
)
let parsed = HermesSkillsHubParser.parseUpdateList(result.output)
await self?.finishCheckForUpdates(updates: parsed)
}
}
public func updateAll() {
let bin = context.paths.hermesBinary
let xport = transport
Task.detached { [weak self] in
let result = Self.runHermes(
executable: bin,
args: ["skills", "update", "--yes"],
transport: xport,
timeout: 300
)
await self?.finishUpdateAll(exitCode: result.exitCode)
}
}
// MARK: - Hub action finishers
//
// Each detached task above bounces through exactly one of these
// MainActor-isolated finishers. Keeping the post-CLI sequencing
// (load + sleep + clear status) here means the detached closure
// crosses the `self?` weak boundary only once required for clean
// builds under Swift 6 strict concurrency, and clearer to reason
// about than the prior interleaved `MainActor.run` chains.
@MainActor
private func finishBrowse(results: [HermesHubSkill], exitCode: Int32, isSearch: Bool) async {
isHubLoading = false
hubResults = results
if results.isEmpty {
hubMessage = isSearch
? "No matches"
: (exitCode == 0 ? "No results" : "Browse failed")
} else {
hubMessage = nil
}
}
@MainActor
private func finishInstall(identifier: String, exitCode: Int32) async {
isHubLoading = false
hubMessage = exitCode == 0 ? "Installed \(identifier)" : "Install failed"
await load()
try? await Task.sleep(nanoseconds: 3_000_000_000)
hubMessage = nil
}
@MainActor
private func finishUninstall(exitCode: Int32) async {
hubMessage = exitCode == 0 ? "Uninstalled" : "Uninstall failed"
await load()
try? await Task.sleep(nanoseconds: 2_000_000_000)
hubMessage = nil
}
@MainActor
private func finishCheckForUpdates(updates: [HermesSkillUpdate]) async {
isHubLoading = false
self.updates = updates
hubMessage = updates.isEmpty ? "No updates available" : "\(updates.count) update(s)"
try? await Task.sleep(nanoseconds: 3_000_000_000)
hubMessage = nil
}
@MainActor
private func finishUpdateAll(exitCode: Int32) async {
hubMessage = exitCode == 0 ? "Updated" : "Update failed"
await load()
checkForUpdates()
try? await Task.sleep(nanoseconds: 3_000_000_000)
hubMessage = nil
}
// MARK: - Transport helpers
/// Combined stdout+stderr CLI runner. Mirrors the legacy
/// `HermesFileService.runHermesCLI` shape so callers grepping
/// through `output` keep working.
nonisolated private static func runHermes(
executable: String,
args: [String],
transport: any ServerTransport,
timeout: TimeInterval
) -> (exitCode: Int32, output: String) {
do {
let result = try transport.runProcess(
executable: executable,
args: args,
stdin: nil,
timeout: timeout
)
return (result.exitCode, result.stdoutString + result.stderrString)
} catch let error as TransportError {
return (-1, error.diagnosticStderr.isEmpty
? (error.errorDescription ?? "transport error")
: error.diagnosticStderr)
} catch {
return (-1, error.localizedDescription)
}
}
private func loadSkillContent(path: String) -> String {
guard isValidSkillPath(path) else { return "" }
guard let data = try? transport.readFile(path),
let s = String(data: data, encoding: .utf8)
else { return "" }
return s
}
private func saveSkillContent(path: String, content: String) {
guard isValidSkillPath(path) else { return }
guard let data = content.data(using: .utf8) else { return }
do {
try transport.writeFile(path, data: data)
} catch {
logger.error("saveSkillContent(\(path, privacy: .public)) failed: \(error.localizedDescription, privacy: .public)")
}
}
private func isValidSkillPath(_ path: String) -> Bool {
guard !path.contains(".."), path.hasPrefix(context.paths.skillsDir) else {
logger.warning("Rejected skill path outside skills dir: \(path, privacy: .public)")
return false
}
return true
}
}
@@ -257,7 +257,7 @@ import Foundation
try FileManager.default.removeItem(
at: home.appendingPathComponent("skills")
)
let vm = IOSSkillsViewModel(context: ctx)
let vm = SkillsViewModel(context: ctx)
await vm.load()
#expect(vm.categories.isEmpty)
#expect(vm.lastError == nil)
@@ -284,7 +284,7 @@ import Foundation
// Dotfile should be filtered
try "".write(to: pJournal.appendingPathComponent(".DS_Store"), atomically: true, encoding: .utf8)
let vm = IOSSkillsViewModel(context: ctx)
let vm = SkillsViewModel(context: ctx)
await vm.load()
#expect(vm.categories.count == 2)
#expect(vm.categories[0].name == "dev")
@@ -305,7 +305,7 @@ import Foundation
at: home.appendingPathComponent("skills/empty-cat"),
withIntermediateDirectories: true
)
let vm = IOSSkillsViewModel(context: ctx)
let vm = SkillsViewModel(context: ctx)
await vm.load()
#expect(vm.categories.isEmpty)
}
@@ -0,0 +1,61 @@
import Testing
@testable import ScarfCore
/// Coverage for `SkillFrontmatterParser` narrow YAML reader for the
/// `required_config:` list in a skill's `skill.yaml`. The parser was
/// extracted from the Mac `HermesFileService` in v2.5 so iOS can flag
/// missing config keys with the same semantics.
@Suite("SkillFrontmatterParser")
struct SkillFrontmatterParserTests {
@Test func parsesSimpleRequiredConfigList() {
let yaml = """
name: example
required_config:
- api_key
- api_secret
version: 1.0.0
"""
let keys = SkillFrontmatterParser.parseRequiredConfig(yaml)
#expect(keys == ["api_key", "api_secret"])
}
@Test func returnsEmptyWhenSectionMissing() {
let yaml = """
name: example
version: 1.0.0
"""
#expect(SkillFrontmatterParser.parseRequiredConfig(yaml).isEmpty)
}
@Test func skipsCommentsAndEmptyLines() {
let yaml = """
# top comment
required_config:
# in-section comment
- first
- second
"""
let keys = SkillFrontmatterParser.parseRequiredConfig(yaml)
#expect(keys == ["first", "second"])
}
@Test func breaksOnNextTopLevelKey() {
let yaml = """
required_config:
- one
- two
next_key: hello
- three
"""
let keys = SkillFrontmatterParser.parseRequiredConfig(yaml)
// `next_key:` is at indent 0, terminating the list `three`
// is no longer in scope and shouldn't be picked up.
#expect(keys == ["one", "two"])
}
@Test func handlesEmptyInput() {
#expect(SkillFrontmatterParser.parseRequiredConfig("").isEmpty)
}
}
@@ -0,0 +1,116 @@
import Testing
@testable import ScarfCore
/// Coverage for `HermesSkillsHubParser` the Rich-table parser that
/// translates `hermes skills browse|search|check` stdout into typed
/// `HermesHubSkill` / `HermesSkillUpdate` arrays. The parser is shared
/// by Mac + iOS in v2.5; this suite locks in the canonical fixtures
/// so regressions on either platform fail here first.
@Suite("HermesSkillsHubParser")
struct SkillsHubParserTests {
// MARK: - parseHubList
@Test func parsesSingleRowFromBrowseOutput() {
let output = """
┏━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
┃ # ┃ Name ┃ Description ┃ Source ┃ Trust ┃
┡━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
│ 1 │ 1password │ Set up and use 1Password integration. │ official │ ★ official │
└──────┴────────────────┴────────────────────────────────────────────────────────┴──────────────┴────────────┘
"""
let result = HermesSkillsHubParser.parseHubList(output)
#expect(result.count == 1)
#expect(result[0].identifier == "1password")
#expect(result[0].name == "1password")
#expect(result[0].description == "Set up and use 1Password integration.")
#expect(result[0].source == "official")
}
@Test func mergesContinuationRowsIntoDescription() {
// Continuation rows have an empty `#` cell the parser should
// append their description to the previous skill rather than
// emit a blank entry.
let output = """
│ 1 │ skill-creator │ Create new skills, modify and improve existing skills, │ official │ ★ official │
│ │ │ and measure skill performance. │ │ │
"""
let result = HermesSkillsHubParser.parseHubList(output)
#expect(result.count == 1)
#expect(result[0].identifier == "skill-creator")
#expect(result[0].description.contains("Create new skills"))
#expect(result[0].description.contains("measure skill performance"))
}
@Test func skipsHeaderAndBorderRows() {
let output = """
┏━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━┓
┃ # ┃ Name ┃ Description ┃ Source ┃ Trust ┃
┡━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━┩
│ 1 │ alpha │ alpha skill │ official │ ★ official │
│ 2 │ beta │ beta skill │ skills-sh │ │
└──────┴────────┴───────────────┴───────────┴────────────┘
"""
let result = HermesSkillsHubParser.parseHubList(output)
#expect(result.count == 2)
#expect(result[0].name == "alpha")
#expect(result[1].name == "beta")
}
@Test func stripsStarFromSourceCell() {
// The Trust column shows ` official` for trusted sources;
// the Source column itself doesn't, but if the layout shifts
// and we end up with the star in our captured cell we should
// strip it.
let output = """
│ 1 │ widget │ a widget │ ★ official │ official │
"""
let result = HermesSkillsHubParser.parseHubList(output)
#expect(result.count == 1)
#expect(result[0].source == "official")
}
@Test func returnsEmptyOnNoTable() {
let result = HermesSkillsHubParser.parseHubList("Just plain text\n no table here")
#expect(result.isEmpty)
}
// MARK: - parseUpdateList
@Test func parsesArrowVersionMarker() {
let output = """
Checking for updates…
skill-creator 1.0.0 → 1.1.0
another-skill 2.3.4 → 2.3.5
"""
let result = HermesSkillsHubParser.parseUpdateList(output)
#expect(result.count == 2)
#expect(result[0].identifier == "skill-creator")
#expect(result[0].currentVersion == "1.0.0")
#expect(result[0].availableVersion == "1.1.0")
#expect(result[1].identifier == "another-skill")
#expect(result[1].currentVersion == "2.3.4")
#expect(result[1].availableVersion == "2.3.5")
}
@Test func parsesAsciiArrowMarker() {
// Some terminals or older Hermes versions emit `->` instead of
// the unicode ``. Both should parse identically.
let output = "skill-creator 1.0.0 -> 1.1.0"
let result = HermesSkillsHubParser.parseUpdateList(output)
#expect(result.count == 1)
#expect(result[0].identifier == "skill-creator")
#expect(result[0].availableVersion == "1.1.0")
}
@Test func updateListIgnoresLinesWithoutArrow() {
let output = """
Checking for updates…
Skill named foo is up to date.
skill-creator 1.0.0 → 1.1.0
"""
let result = HermesSkillsHubParser.parseUpdateList(output)
#expect(result.count == 1)
#expect(result[0].identifier == "skill-creator")
}
}
+23 -5
View File
@@ -5,10 +5,11 @@ import ScarfCore
/// `AppCoordinator` pattern: an `@Observable` carrier injected via
/// `.environment(_:)` that any view in the tab tree can reach.
///
/// Single responsibility in M9 scope: route "user tapped a recent
/// session in Dashboard" "open the Chat tab with a resume request."
/// Future uses (project-scoped chat handoff, notification deep-link
/// specific session) compose naturally on the same primitive.
/// v2.5 expands the surface to include project handoff: tapping
/// "New Chat" inside a Project Detail view sets `pendingProjectChat`
/// and routes to the Chat tab, where ChatController consumes it and
/// dispatches `resetAndStartInProject(_:)` (same wiring the in-Chat
/// project picker sheet already uses).
@Observable
@MainActor
final class ScarfGoCoordinator {
@@ -23,8 +24,15 @@ final class ScarfGoCoordinator {
/// ChatController after it honours the request.
var pendingResumeSessionID: String?
/// If non-nil, the Chat tab should start an in-project session at
/// this absolute remote path on next appear instead of a quick
/// chat. Consumed (cleared) by ChatController after it kicks off
/// `resetAndStartInProject(_:)`. Mirrors Mac's
/// `AppCoordinator.pendingProjectChat`.
var pendingProjectChat: String?
enum Tab: Hashable {
case chat, dashboard, memory, more
case dashboard, projects, chat, skills, system
}
/// Convenience: route to Chat and queue a resume. Dashboard rows
@@ -35,6 +43,16 @@ final class ScarfGoCoordinator {
pendingResumeSessionID = id
selectedTab = .chat
}
/// Convenience: route to Chat and queue a project-scoped session
/// start at `path`. Project Detail's "New Chat" toolbar button
/// calls this. Clearing `pendingProjectChat` is the consumer's
/// responsibility (ChatController) once `resetAndStartInProject`
/// has been dispatched.
func startChatInProject(path: String) {
pendingProjectChat = path
selectedTab = .chat
}
}
/// Environment key so subviews can pull the coordinator without
+70 -49
View File
@@ -2,22 +2,26 @@ import SwiftUI
import ScarfCore
import ScarfIOS
/// ScarfGo's primary navigation surface. Replaces the pre-M8
/// "Dashboard is the hub" pattern where Chat/Memory/Cron/Skills/
/// Settings lived as NavigationLink rows three-quarters of the way
/// down a scrolling List pass-1 user-visible complaint:
/// ScarfGo's primary navigation surface. v2.5 expands the original
/// 4-tab layout (Chat | Dashboard | Memory | More) to 5 primary tabs
/// with Chat in the mathematical center:
///
/// > "We should have the actions for the user in a permanent footer?
/// > I don't see any navigation."
/// Dashboard | Projects | Chat | Skills | System
///
/// 4 primary tabs + a "More" bucket for the read-heavy / seldom-used
/// features. Uses iOS 18's `.sidebarAdaptable` tab style so the same
/// tree degrades to a bottom tab bar on iPhone and gets a native
/// sidebar on iPadOS / macCatalyst if we ever add those targets.
/// "Chat in the middle" is the v2.5 product ask chat is the action
/// users come back for, so it's the most thumb-reachable slot on a
/// phone-sized device. We stay on Apple's native `TabView` instead of
/// drawing a custom raised center button: 5 tabs is exactly the iPhone
/// system maximum (no auto-collapse to "More"), and `.sidebarAdaptable`
/// continues to give us a real sidebar on iPad / macCatalyst for free.
/// Memory drops out of primary slots and lives inside the renamed
/// "System" tab (was "More"). Skills graduates from a System sub-row
/// into its own primary tab to match v2.5's full Mac parity for skills
/// (Installed / Browse Hub / Updates).
///
/// Each tab wraps its feature view in its own `NavigationStack` so
/// push navigation (Cron editor, Memory detail, etc.) stays scoped
/// to the tab instead of bleeding across.
/// Each tab wraps its feature view in its own `NavigationStack` so push
/// navigation (Cron editor, Memory detail, Project detail, etc.) stays
/// scoped to the tab instead of bleeding across.
struct ScarfGoTabRoot: View {
let serverID: ServerID
let config: IOSServerConfig
@@ -26,8 +30,9 @@ struct ScarfGoTabRoot: View {
let onForget: @MainActor () async -> Void
/// One coordinator per server-connected session. Cross-tab
/// signalling (Dashboard row Chat tab resume, eventually
/// notification deep-link Chat) flows through here.
/// signalling (Dashboard row Chat tab resume, Project Detail
/// in-project chat handoff, notification deep-link Chat) flows
/// through here.
@State private var coordinator = ScarfGoCoordinator()
var body: some View {
@@ -39,18 +44,7 @@ struct ScarfGoTabRoot: View {
// SSH channel contention.
let ctx = config.toServerContext(id: serverID)
TabView(selection: $coordinator.selectedTab) {
// 1 Chat: the reason the app is on your phone. Primary
// tab; opens straight into the chat surface.
NavigationStack {
ChatView(config: config, key: key)
}
.tabItem {
Label("Chat", systemImage: "bubble.left.and.bubble.right.fill")
}
.tag(ScarfGoCoordinator.Tab.chat)
// 2 Dashboard: stats + recent sessions (no surfaces list
// anymore those live in More).
// 1 Dashboard: stats + recent sessions.
NavigationStack {
DashboardView(config: config, key: key)
}
@@ -58,33 +52,59 @@ struct ScarfGoTabRoot: View {
Label("Dashboard", systemImage: "gauge.with.needle")
}
.tag(ScarfGoCoordinator.Tab.dashboard)
.accessibilityLabel("Dashboard tab")
// 3 Memory: MEMORY.md + USER.md + SOUL.md.
// 2 Projects: registered projects per-project dashboard,
// site, and sessions. Read-only registry on iOS add /
// rename / archive happens in the Mac app.
NavigationStack {
MemoryListView(config: config)
ProjectsListView(config: config)
}
.tabItem {
Label("Memory", systemImage: "brain.head.profile")
Label("Projects", systemImage: "square.grid.2x2")
}
.tag(ScarfGoCoordinator.Tab.memory)
.tag(ScarfGoCoordinator.Tab.projects)
.accessibilityLabel("Projects tab")
// 4 More: Cron, Skills, Settings, plus the destructive
// "Forget this server" action. Named "More" because on
// iOS 18 with .sidebarAdaptable the system collapses
// leftover tabs into a disclosure group with that exact
// label automatically; choosing the same word keeps our
// More tab visually consistent with the system default.
// 3 Chat: the reason the app is on your phone. Centered
// among the 5 tabs for thumb reach + visual prominence.
NavigationStack {
MoreTab(
ChatView(config: config, key: key)
}
.tabItem {
Label("Chat", systemImage: "bubble.left.and.bubble.right.fill")
}
.tag(ScarfGoCoordinator.Tab.chat)
.accessibilityLabel("Chat tab")
// 4 Skills: Installed | Browse Hub | Updates, mirroring
// the Mac app's 3-tab skills surface.
NavigationStack {
SkillsView(config: config)
}
.tabItem {
Label("Skills", systemImage: "lightbulb")
}
.tag(ScarfGoCoordinator.Tab.skills)
.accessibilityLabel("Skills tab")
// 5 System: server identity, Memory, Cron, Settings, plus
// the destructive disconnect / forget actions. Renamed from
// "More" to match the user-facing v2.5 vocabulary; the
// .sidebarAdaptable system fallback label happens not to
// matter here because we never overflow.
NavigationStack {
SystemTab(
config: config,
onSoftDisconnect: onSoftDisconnect,
onForget: onForget
)
}
.tabItem {
Label("More", systemImage: "ellipsis.circle")
Label("System", systemImage: "gearshape.fill")
}
.tag(ScarfGoCoordinator.Tab.more)
.tag(ScarfGoCoordinator.Tab.system)
.accessibilityLabel("System tab")
}
// Pulls the sidebar-on-iPad affordance into the same code path
// as the bottom-bar-on-iPhone one. No-op on iPhone today.
@@ -101,14 +121,15 @@ struct ScarfGoTabRoot: View {
}
}
/// Groups the features that don't deserve a primary tab on a phone:
/// Cron (infrequent edits), Skills (read-only), Settings (read-only
/// until M9 scoped editor), plus the destructive server-forget action.
/// Server identity + Memory + Cron + Settings + destructive actions.
/// "System" reads as configuration / server-meta; the reorganization
/// in v2.5 promotes Skills out of here into its own primary tab and
/// pulls Memory in from a primary tab into a NavigationLink row.
///
/// Kept private to this file because we don't expect it to be reused
/// elsewhere if a feature graduates to a primary tab, that's a
/// deliberate design decision.
private struct MoreTab: View {
private struct SystemTab: View {
let config: IOSServerConfig
let onSoftDisconnect: @MainActor () async -> Void
let onForget: @MainActor () async -> Void
@@ -131,15 +152,15 @@ private struct MoreTab: View {
Section("Features") {
NavigationLink {
CronListView(config: config)
MemoryListView(config: config)
} label: {
Label("Cron jobs", systemImage: "clock.arrow.circlepath")
Label("Memory", systemImage: "brain.head.profile")
}
.scarfGoCompactListRow()
NavigationLink {
SkillsListView(config: config)
CronListView(config: config)
} label: {
Label("Skills", systemImage: "sparkles")
Label("Cron jobs", systemImage: "clock.arrow.circlepath")
}
.scarfGoCompactListRow()
NavigationLink {
@@ -194,7 +215,7 @@ private struct MoreTab: View {
}
}
.scarfGoListDensity()
.navigationTitle("More")
.navigationTitle("System")
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog(
"Forget this server?",
+40 -11
View File
@@ -75,29 +75,37 @@ struct ChatView: View {
)
}
.task {
// Dashboard row taps set `pendingResumeSessionID` on the
// coordinator before switching to the Chat tab. Honor
// that if present, else open a fresh session. Clearing
// the coordinator value is the consumer's responsibility
// (us) otherwise a later plain tap on the Chat tab
// would accidentally re-resume the old session.
// Dashboard row taps set `pendingResumeSessionID`, Project
// Detail's "New Chat" sets `pendingProjectChat`. Both fire
// a tab switch to .chat alongside the value set; we
// consume + clear here on first appear. Resume wins over
// project-chat if both somehow get set in a single hop
// but in practice the coordinator never sets both at once.
if let sessionID = coordinator?.pendingResumeSessionID {
coordinator?.pendingResumeSessionID = nil
await controller.startResuming(sessionID: sessionID)
} else if let projectPath = coordinator?.pendingProjectChat {
coordinator?.pendingProjectChat = nil
await consumePendingProjectChat(projectPath)
} else {
await controller.start()
}
}
// Also react to a coordinator change that happens while Chat
// is already mounted (e.g., user is in Chat, switches to
// Dashboard, taps a session row coordinator flips the tab
// AND sets pendingResumeSessionID. The `.task` above only
// fires on first appear; this is the mid-session hook.)
// React to coordinator changes that happen while Chat is
// already mounted (e.g., user is in Chat, taps Projects, opens
// a project detail, taps "New Chat" coordinator flips the
// tab AND sets pendingProjectChat. The `.task` above only
// fires on first appear; these are the mid-session hooks.)
.onChange(of: coordinator?.pendingResumeSessionID) { _, new in
guard let sessionID = new else { return }
coordinator?.pendingResumeSessionID = nil
Task { await controller.startResuming(sessionID: sessionID) }
}
.onChange(of: coordinator?.pendingProjectChat) { _, new in
guard let projectPath = new else { return }
coordinator?.pendingProjectChat = nil
Task { await consumePendingProjectChat(projectPath) }
}
// Deliberately NOT tearing down the ACP session on .onDisappear.
// `TabView` unmounts tab content when the user switches tabs
// (disappear fires), but `@State var controller` keeps the
@@ -140,6 +148,27 @@ struct ChatView: View {
}
}
/// Resolve a project absolute path to a `ProjectEntry` via the
/// transport-backed registry, then dispatch `resetAndStartInProject`.
/// If the path isn't registered (race with a Mac-app removal, or
/// SFTP read failure), fall back to a synthesized entry whose name
/// is the path's last component chat still starts and the user
/// sees a usable project chip.
private func consumePendingProjectChat(_ path: String) async {
let ctx = config.toServerContext(id: Self.sharedContextID)
let entry: ProjectEntry = await Task.detached {
let registry = ProjectDashboardService(context: ctx).loadRegistry()
if let match = registry.projects.first(where: { $0.path == path }) {
return match
}
return ProjectEntry(
name: (path as NSString).lastPathComponent.isEmpty ? path : (path as NSString).lastPathComponent,
path: path
)
}.value
await controller.resetAndStartInProject(entry)
}
// MARK: - Subviews
@ViewBuilder
@@ -0,0 +1,215 @@
import SwiftUI
import ScarfCore
/// Per-project detail view, presented when a row in `ProjectsListView`
/// is tapped. Mirrors the Mac three-tab layout (Dashboard | Site |
/// Sessions) using a segmented `Picker`. The Site segment is gated on
/// the dashboard containing a `webview` widget empty dashboards or
/// dashboards without a site URL hide the segment to match Mac's
/// `visibleTabs` logic in `ProjectsView.swift`.
///
/// "New Chat" toolbar button calls `ScarfGoCoordinator.startChatInProject`
/// which sets `pendingProjectChat` and routes to the Chat tab.
/// `ChatController` consumes `pendingProjectChat` on next appear and
/// dispatches `resetAndStartInProject(_:)` same wiring the existing
/// in-Chat picker sheet uses.
struct ProjectDetailView: View {
let project: ProjectEntry
let config: IOSServerConfig
@Environment(\.scarfGoCoordinator) private var coordinator
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A2"
)!
@State private var dashboard: ProjectDashboard?
@State private var dashboardError: String?
@State private var isLoading: Bool = true
@State private var selectedTab: DetailTab = .dashboard
/// Last-seen mtime on `<project>/.scarf/dashboard.json`. The
/// foreground poll task compares this against a fresh stat to
/// decide whether to re-parse cheap when the file is unchanged,
/// and the poll only runs while the view is visible.
@State private var lastDashboardMtime: Date?
enum DetailTab: Hashable {
case dashboard, site, sessions
}
private var serverContext: ServerContext {
config.toServerContext(id: Self.sharedContextID)
}
/// First webview widget across all sections, if any. Nil Site
/// segment hidden. Mirrors Mac `siteWidget`.
private var siteWidget: DashboardWidget? {
dashboard?
.sections
.flatMap(\.widgets)
.first { $0.type == "webview" }
}
private var visibleTabs: [DetailTab] {
var tabs: [DetailTab] = [.dashboard]
if siteWidget != nil { tabs.append(.site) }
tabs.append(.sessions)
return tabs
}
var body: some View {
VStack(spacing: 0) {
tabPicker
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 6)
Divider()
tabContent
}
.navigationTitle(project.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
coordinator?.startChatInProject(path: project.path)
} label: {
Label("New Chat", systemImage: "message.badge.filled.fill")
}
.accessibilityLabel("Start new chat in \(project.name)")
.accessibilityHint("Opens the Chat tab and begins a session scoped to this project")
}
}
.task(id: project.id) { await loadDashboard() }
.task(id: project.id) { await pollDashboardMtime() }
.refreshable { await loadDashboard() }
.onChange(of: visibleTabs) { _, newTabs in
// If the user was on Site and a refresh removed the
// webview widget, fall back to Dashboard so the segmented
// picker doesn't end up out-of-sync with its segments.
if !newTabs.contains(selectedTab) {
selectedTab = .dashboard
}
}
}
// MARK: - Tab picker
@ViewBuilder
private var tabPicker: some View {
Picker("Section", selection: $selectedTab) {
ForEach(visibleTabs, id: \.self) { tab in
Text(label(for: tab)).tag(tab)
}
}
.pickerStyle(.segmented)
}
private func label(for tab: DetailTab) -> String {
switch tab {
case .dashboard: return "Dashboard"
case .site: return "Site"
case .sessions: return "Sessions"
}
}
// MARK: - Tab content
@ViewBuilder
private var tabContent: some View {
switch selectedTab {
case .dashboard:
dashboardTab
case .site:
if let widget = siteWidget {
ProjectSiteView(widget: widget)
} else {
emptyDashboard
}
case .sessions:
ProjectSessionsView_iOS(project: project)
}
}
@ViewBuilder
private var dashboardTab: some View {
if isLoading && dashboard == nil {
ProgressView("Loading dashboard…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let dash = dashboard {
DashboardWidgetsView(dashboard: dash)
} else {
emptyDashboard
}
}
private var emptyDashboard: some View {
ContentUnavailableView {
Label("No Dashboard", systemImage: "rectangle.dashed")
} description: {
Text(dashboardError ?? "This project doesn't have a dashboard at \(project.dashboardPath) yet.")
.font(.caption)
} actions: {
Button("Try Again") {
Task { await loadDashboard() }
}
}
}
// MARK: - Loading
/// Load the project's dashboard via `ProjectDashboardService` on a
/// background task same `Task.detached` pattern the registry
/// loader uses to keep the SFTP read off MainActor.
private func loadDashboard() async {
isLoading = true
defer { isLoading = false }
let ctx = serverContext
let proj = project
let result: (ProjectDashboard?, String?, Date?) = await Task.detached {
let service = ProjectDashboardService(context: ctx)
if !service.dashboardExists(for: proj) {
return (nil, "No dashboard found at \(proj.dashboardPath)", nil)
}
let mtime = service.dashboardModificationDate(for: proj)
if let loaded = service.loadDashboard(for: proj) {
return (loaded, nil, mtime)
}
return (nil, "Failed to parse dashboard JSON", mtime)
}.value
dashboard = result.0
dashboardError = result.1
lastDashboardMtime = result.2
}
/// Poll the dashboard file's mtime every 4 seconds while the view
/// is foregrounded; reload on any change. iOS doesn't have an
/// inotify-style watcher over SFTP, but a per-view poll is cheap
/// (one stat call per tick) and stops the moment the user
/// navigates away the `.task` modifier cancels the loop on view
/// disappear automatically.
private func pollDashboardMtime() async {
let ctx = serverContext
let proj = project
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 4_000_000_000)
if Task.isCancelled { break }
let fresh: Date? = await Task.detached {
ProjectDashboardService(context: ctx)
.dashboardModificationDate(for: proj)
}.value
// First tick after a missing-dashboard error: nil nil is
// a no-op; nil Date triggers a reload (file just appeared).
// Date newer Date triggers a reload. Same Date is a no-op.
switch (lastDashboardMtime, fresh) {
case (nil, nil), (_, nil):
continue
case (nil, _):
await loadDashboard()
case (let prev?, let now?) where now > prev:
await loadDashboard()
default:
continue
}
}
}
}
@@ -0,0 +1,185 @@
import SwiftUI
import ScarfCore
/// iOS twin of the Mac per-project Sessions tab. Reuses the
/// ScarfCore-side `ProjectSessionsViewModel` (promoted from the Mac
/// target in v2.5) so attribution + filtering semantics stay
/// identical. The "New Chat" button routes into the Chat tab via
/// `ScarfGoCoordinator.startChatInProject(path:)`; row taps route via
/// `coordinator.resumeSession(_:)`, the same primitive
/// `DashboardView` already uses.
struct ProjectSessionsView_iOS: View {
let project: ProjectEntry
@Environment(\.scarfGoCoordinator) private var coordinator
@Environment(\.serverContext) private var serverContext
@State private var viewModel: ProjectSessionsViewModel?
var body: some View {
VStack(spacing: 0) {
header
Divider()
content
}
.task(id: project.id) {
// Rebuild the VM when the project changes so stale state
// from a previously-selected project doesn't bleed
// through.
viewModel = ProjectSessionsViewModel(
context: serverContext,
project: project
)
await viewModel?.load()
}
.onDisappear {
// Release the SQLite handle so it doesn't dangle once
// the user leaves this tab. `load()` will re-open next
// time. Mirrors ActivityView's disappear cleanup.
Task { await viewModel?.close() }
}
}
// MARK: - Header
private var header: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text("Sessions in this project")
.font(.subheadline)
.fontWeight(.semibold)
Text("Chats you start here are attributed automatically.")
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Button {
coordinator?.startChatInProject(path: project.path)
} label: {
Label("New Chat", systemImage: "message.badge.filled.fill")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.accessibilityLabel("Start new chat in \(project.name)")
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
// MARK: - Content
@ViewBuilder
private var content: some View {
if let vm = viewModel {
if vm.isLoading && vm.sessions.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else if vm.sessions.isEmpty {
emptyState(hint: vm.emptyStateHint)
} else {
sessionList(vm.sessions)
}
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
private func emptyState(hint: String?) -> some View {
VStack(spacing: 10) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.system(size: 36))
.foregroundStyle(.tertiary)
Text(hint ?? "No sessions yet.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 32)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 40)
}
private func sessionList(_ sessions: [HermesSession]) -> some View {
List {
ForEach(sessions) { session in
Button {
coordinator?.resumeSession(session.id)
} label: {
ProjectSessionRow_iOS(session: session)
}
.buttonStyle(.plain)
.scarfGoCompactListRow()
}
}
.scarfGoListDensity()
}
}
/// Single row in the per-project Sessions list. Mirrors the Mac
/// `ProjectSessionRow` content but uses iOS-friendly text sizing.
private struct ProjectSessionRow_iOS: View {
let session: HermesSession
var body: some View {
HStack(spacing: 10) {
Image(systemName: iconForSource(session.source))
.foregroundStyle(.secondary)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(displayTitle)
.font(.callout)
.lineLimit(1)
HStack(spacing: 6) {
Text(session.id.prefix(12))
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
if let started = formattedStart {
Text("·")
.foregroundStyle(.tertiary)
Text(started)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
Spacer(minLength: 12)
VStack(alignment: .trailing, spacing: 2) {
Text("\(session.messageCount)")
.font(.caption.monospaced())
Text("msgs")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
.contentShape(Rectangle())
}
private var displayTitle: String {
if let t = session.title, !t.isEmpty { return t }
return "Untitled session"
}
private var formattedStart: String? {
guard let date = session.startedAt else { return nil }
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter.string(from: date)
}
private func iconForSource(_ source: String) -> String {
switch source.lowercased() {
case "cli", "acp": return "terminal"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
default: return "message"
}
}
}
@@ -0,0 +1,16 @@
import SwiftUI
import ScarfCore
/// Full-canvas webview wrapper for the Site sub-tab. Reuses the
/// `WebviewWidgetView` representable in its `fullCanvas: true` mode so
/// rendering, error handling, and the non-persistent data store all
/// stay in one place.
struct ProjectSiteView: View {
let widget: DashboardWidget
var body: some View {
WebviewWidgetView(widget: widget, fullCanvas: true)
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
}
@@ -0,0 +1,144 @@
import SwiftUI
import ScarfCore
/// Top-level Projects tab. Lists registered Scarf projects from
/// `~/.hermes/scarf/projects.json`. Folder groupings + archive flags
/// from the v2.3 registry schema are honored archived projects are
/// hidden, top-level projects render flat, and any non-empty folder
/// labels become a `Section` per folder.
///
/// Read-only on iOS for v2.5 add / rename / move / archive happens
/// in the Mac app, where the template installer + ConfigEditor live.
/// The empty state copy directs users there.
struct ProjectsListView: View {
let config: IOSServerConfig
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A2"
)!
@State private var projects: [ProjectEntry] = []
@State private var isLoading: Bool = true
@State private var loadError: String?
private var serverContext: ServerContext {
config.toServerContext(id: Self.sharedContextID)
}
var body: some View {
Group {
if isLoading && projects.isEmpty {
ProgressView("Loading projects…")
} else if let err = loadError, projects.isEmpty {
ContentUnavailableView {
Label("Couldn't load projects", systemImage: "exclamationmark.triangle.fill")
} description: {
Text(err)
}
} else if visibleProjects.isEmpty {
ContentUnavailableView {
Label("No projects yet", systemImage: "square.grid.2x2")
} description: {
Text("Use the Mac app to add and configure projects — they'll appear here automatically.")
}
} else {
projectList
}
}
.navigationTitle("Projects")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: ProjectEntry.self) { project in
ProjectDetailView(project: project, config: config)
}
.refreshable { await load() }
.task { await load() }
}
@ViewBuilder
private var projectList: some View {
let folders = folderLabels
List {
// Top-level (no folder) projects first, then folder
// disclosure sections same shape as Mac
// ProjectsSidebar.swift renders.
let topLevel = visibleProjects.filter { ($0.folder ?? "").isEmpty }
if !topLevel.isEmpty {
Section {
ForEach(topLevel) { project in
projectRow(project)
}
}
}
ForEach(folders, id: \.self) { folder in
Section(folder) {
ForEach(visibleProjects.filter { $0.folder == folder }) { project in
projectRow(project)
}
}
}
}
.scarfGoListDensity()
}
private func projectRow(_ project: ProjectEntry) -> some View {
NavigationLink(value: project) {
HStack(alignment: .center, spacing: 12) {
Image(systemName: "folder.fill")
.font(.title3)
.foregroundStyle(.tint)
.frame(width: 28)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(project.name)
.font(.body)
.foregroundStyle(.primary)
Text(project.path)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
.scarfGoCompactListRow()
.accessibilityElement(children: .combine)
.accessibilityLabel("\(project.name), at \(project.path)")
.accessibilityHint("Opens project dashboard, site, and sessions")
}
/// Visible projects = registry minus archived, sorted alphabetically.
/// Mirrors Mac sidebar's default filter.
private var visibleProjects: [ProjectEntry] {
projects
.filter { !$0.archived }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
/// Distinct, sorted folder labels across the visible set. Empty
/// strings are treated as top-level (filtered out here so they
/// don't render as a "" section title).
private var folderLabels: [String] {
let set = Set(visibleProjects.compactMap(\.folder).filter { !$0.isEmpty })
return set.sorted()
}
/// Load the project registry over the active transport. Same
/// pattern as `ProjectPickerSheet.loadProjects` wrap the
/// synchronous `ProjectDashboardService` calls in `Task.detached`
/// so the SFTP read doesn't run on the MainActor.
private func load() async {
isLoading = true
defer { isLoading = false }
let ctx = serverContext
do {
let loaded: [ProjectEntry] = try await Task.detached {
let service = ProjectDashboardService(context: ctx)
return service.loadRegistry().projects
}.value
projects = loaded
loadError = nil
} catch {
loadError = error.localizedDescription
}
}
}
@@ -0,0 +1,83 @@
import SwiftUI
import ScarfCore
import Charts
// Flattened data point for Charts to avoid complex nested generic inference
private struct PlottablePoint: Identifiable {
let id = UUID()
let seriesName: String
let x: String
let y: Double
let color: Color
}
struct ChartWidgetView: View {
let widget: DashboardWidget
private var points: [PlottablePoint] {
guard let series = widget.series else { return [] }
return series.flatMap { s in
let color = parseColor(s.color)
return s.data.map { d in
PlottablePoint(seriesName: s.name, x: d.x, y: d.y, color: color)
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
chartContent
.frame(height: 150)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
@ViewBuilder
private var chartContent: some View {
switch widget.chartType {
case "pie":
pieChart
case "bar":
barChart
default:
lineChart
}
}
private var lineChart: some View {
Chart(points) { point in
LineMark(
x: .value("X", point.x),
y: .value("Y", point.y)
)
.foregroundStyle(point.color)
.symbol(by: .value("Series", point.seriesName))
}
}
private var barChart: some View {
Chart(points) { point in
BarMark(
x: .value("X", point.x),
y: .value("Y", point.y)
)
.foregroundStyle(point.color)
}
}
private var pieChart: some View {
Chart(points) { point in
SectorMark(
angle: .value(point.x, point.y),
innerRadius: .ratio(0.5)
)
.foregroundStyle(point.color)
}
}
}
@@ -0,0 +1,117 @@
import SwiftUI
import ScarfCore
/// iOS dashboard layout. ScrollView of sections; each section is a
/// `LazyVGrid` whose column count is clamped to the device's
/// `horizontalSizeClass`. iPhone (compact) 1 column. iPad / split-
/// view (regular) 2 columns max, even when the dashboard JSON asks
/// for 3 (3-column on a 13" iPad portrait still cramps individual
/// widgets).
///
/// Webview widgets in card mode render inline like any other widget.
/// The full-canvas Site tab is rendered separately by `ProjectSiteView`
/// and excluded from this grid by `ProjectDetailView` before passing
/// the dashboard down so we don't filter here.
struct DashboardWidgetsView: View {
let dashboard: ProjectDashboard
@Environment(\.horizontalSizeClass) private var hSizeClass
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if let description = dashboard.description, !description.isEmpty {
Text(description)
.font(.callout)
.foregroundStyle(.secondary)
.padding(.horizontal)
}
ForEach(dashboard.sections) { section in
sectionView(section)
}
}
.padding(.vertical, 12)
}
}
@ViewBuilder
private func sectionView(_ section: DashboardSection) -> some View {
// Filter out webview widgets those are rendered full-screen
// in the Site tab instead. Matches Mac DashboardSectionView.
let displayWidgets = section.widgets.filter { $0.type != "webview" }
if !displayWidgets.isEmpty {
let cols = columnCount(for: section)
VStack(alignment: .leading, spacing: 8) {
if !section.title.isEmpty {
Text(section.title)
.font(.headline)
.padding(.horizontal)
}
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: cols),
spacing: 10
) {
ForEach(displayWidgets) { widget in
WidgetView(widget: widget)
}
}
.padding(.horizontal)
}
}
}
/// Cap the requested column count by available width. Compact
/// (iPhone) is always 1; regular (iPad / large split-view) caps at
/// 2 to avoid a 3-up layout that crowds chart + table widgets.
private func columnCount(for section: DashboardSection) -> Int {
switch hSizeClass {
case .compact: return 1
case .regular: return min(section.columnCount, 2)
default: return 1
}
}
}
/// Widget-type dispatcher. Mirrors Mac's `WidgetView` switch in
/// `scarf/Features/Projects/Views/ProjectsView.swift`. Unknown types
/// fall through to a small placeholder so a manifest from a future
/// schema version doesn't crash the UI.
struct WidgetView: View {
let widget: DashboardWidget
var body: some View {
switch widget.type {
case "stat":
StatWidgetView(widget: widget)
case "progress":
ProgressWidgetView(widget: widget)
case "text":
TextWidgetView(widget: widget)
case "table":
TableWidgetView(widget: widget)
case "chart":
ChartWidgetView(widget: widget)
case "list":
ListWidgetView(widget: widget)
case "webview":
WebviewWidgetView(widget: widget)
default:
unsupportedView
}
}
private var unsupportedView: some View {
VStack(alignment: .leading, spacing: 4) {
Label(widget.title, systemImage: "questionmark.app.dashed")
.font(.caption)
.foregroundStyle(.secondary)
Text("Widget type \"\(widget.type)\" isn't supported in this version of Scarf yet.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,55 @@
import SwiftUI
import ScarfCore
struct ListWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(.secondary)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
}
if let items = widget.items {
ForEach(items) { item in
HStack(spacing: 6) {
Image(systemName: statusIcon(item.status))
.font(.caption2)
.foregroundStyle(statusColor(item.status))
Text(item.text)
.font(.callout)
.strikethrough(item.status == "done")
.foregroundStyle(item.status == "done" ? .secondary : .primary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private func statusIcon(_ status: String?) -> String {
switch status {
case "done": return "checkmark.circle.fill"
case "active": return "circle.inset.filled"
case "pending": return "circle"
default: return "circle"
}
}
private func statusColor(_ status: String?) -> Color {
switch status {
case "done": return .green
case "active": return .blue
default: return .secondary
}
}
}
@@ -0,0 +1,33 @@
import SwiftUI
import ScarfCore
struct ProgressWidgetView: View {
let widget: DashboardWidget
private var progressValue: Double {
switch widget.value {
case .number(let n): return n
default: return 0
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
ProgressView(value: progressValue) {
if let label = widget.label {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.tint(parseColor(widget.color))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,38 @@
import SwiftUI
import ScarfCore
struct StatWidgetView: View {
let widget: DashboardWidget
private var widgetColor: Color {
parseColor(widget.color)
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(widgetColor)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
}
if let value = widget.value {
Text(value.displayString)
.font(.system(.title2, design: .monospaced, weight: .semibold))
}
if let subtitle = widget.subtitle {
Text(subtitle)
.font(.caption2)
.foregroundStyle(widgetColor)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,40 @@
import SwiftUI
import ScarfCore
struct TableWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
if let columns = widget.columns, let rows = widget.rows {
ScrollView(.horizontal, showsIndicators: false) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 4) {
GridRow {
ForEach(columns, id: \.self) { col in
Text(col)
.font(.caption.bold())
.foregroundStyle(.secondary)
}
}
Divider()
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
GridRow {
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
Text(cell)
.font(.callout)
}
}
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -0,0 +1,35 @@
import SwiftUI
import ScarfCore
struct TextWidgetView: View {
let widget: DashboardWidget
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
if let content = widget.content {
if widget.format == "markdown" {
// SwiftUI's built-in inline markdown via AttributedString.
// Doesn't support block elements (lists, tables) the way
// Mac's MarkdownContentView does, but covers the common
// dashboard cases (bold, italic, links, inline code).
Text(attributed(content))
.font(.callout)
} else {
Text(content)
.font(.callout)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private func attributed(_ markdown: String) -> AttributedString {
(try? AttributedString(markdown: markdown)) ?? AttributedString(markdown)
}
}
@@ -0,0 +1,123 @@
import SwiftUI
import ScarfCore
import WebKit
/// iOS twin of Mac's `WebviewWidgetView`. Same two modes (inline card
/// + full-canvas Site tab); the only platform-specific bit is the
/// `UIViewRepresentable` wrapper around `WKWebView` (Mac uses
/// `NSViewRepresentable`).
struct WebviewWidgetView: View {
let widget: DashboardWidget
var fullCanvas: Bool = false
private var webURL: URL? {
guard let urlString = widget.url else { return nil }
return URL(string: urlString)
}
private var viewHeight: CGFloat {
CGFloat(widget.height ?? 400)
}
var body: some View {
if fullCanvas {
fullCanvasView
} else {
cardView
}
}
// MARK: - Full Canvas (Site tab)
private var fullCanvasView: some View {
VStack(spacing: 0) {
if let url = webURL {
WebViewRepresentable(url: url)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
ContentUnavailableView {
Label("Invalid URL", systemImage: "globe")
} description: {
Text(widget.url ?? "No URL provided")
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Card (inline widget)
private var cardView: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
if let icon = widget.icon {
Image(systemName: icon)
.foregroundStyle(.secondary)
.font(.caption)
}
Text(widget.title)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if let urlString = widget.url {
Text(urlString)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
}
}
if let url = webURL {
WebViewRepresentable(url: url)
.frame(height: viewHeight)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
ContentUnavailableView {
Label("Invalid URL", systemImage: "globe")
} description: {
Text(widget.url ?? "No URL provided")
}
.frame(height: viewHeight)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - WKWebView Wrapper
private struct WebViewRepresentable: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: url))
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
if webView.url != url {
webView.load(URLRequest(url: url))
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, WKNavigationDelegate {
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
print("[Scarf] WebView navigation failed: \(error.localizedDescription)")
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
print("[Scarf] WebView failed to load: \(error.localizedDescription)")
}
}
}
@@ -0,0 +1,23 @@
import SwiftUI
/// Map a server-supplied color name to a SwiftUI `Color`. iOS twin of
/// the Mac helper at `scarf/Features/Projects/Views/Widgets/WidgetHelpers.swift`.
/// Unknown names default to `.blue` to keep dashboards visually
/// consistent across platforms.
func parseColor(_ name: String?) -> Color {
switch name?.lowercased() {
case "red": return .red
case "orange": return .orange
case "yellow": return .yellow
case "green": return .green
case "blue": return .blue
case "purple": return .purple
case "pink": return .pink
case "teal", "cyan": return .teal
case "indigo": return .indigo
case "mint": return .mint
case "brown": return .brown
case "gray", "grey": return .gray
default: return .blue
}
}
@@ -0,0 +1,151 @@
import SwiftUI
import ScarfCore
/// Browse / search the Hermes skills hub. Source picker is a `Menu`
/// (more compact than Mac's segmented Picker on a phone-width screen).
/// Search submits on Return; empty query falls through to a "browse"
/// listing (top results across the chosen source).
struct HubBrowseView: View {
@Bindable var vm: SkillsViewModel
var body: some View {
VStack(spacing: 0) {
toolbar
Divider()
content
}
}
@ViewBuilder
private var toolbar: some View {
VStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search skills…", text: $vm.hubQuery)
.textFieldStyle(.roundedBorder)
.submitLabel(.search)
.onSubmit { vm.searchHub() }
Menu {
Picker("Source", selection: $vm.hubSource) {
ForEach(vm.hubSources, id: \.self) { src in
Text(src).tag(src)
}
}
} label: {
HStack(spacing: 4) {
Text(vm.hubSource)
.font(.callout)
Image(systemName: "chevron.down")
.font(.caption2)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
HStack(spacing: 8) {
Button {
vm.searchHub()
} label: {
Label("Search", systemImage: "magnifyingglass")
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(vm.isHubLoading)
Button {
vm.browseHub()
} label: {
Label("Browse", systemImage: "books.vertical")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(vm.isHubLoading)
Spacer()
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
@ViewBuilder
private var content: some View {
if vm.hubResults.isEmpty {
ContentUnavailableView {
Label("Browse the Hub", systemImage: "books.vertical")
} description: {
Text("Search for a skill or tap Browse to see top results across registries (skills.sh, official, etc.).")
.font(.caption)
} actions: {
Button {
vm.browseHub()
} label: {
Label("Browse top skills", systemImage: "books.vertical")
}
.buttonStyle(.borderedProminent)
.disabled(vm.isHubLoading)
}
} else {
List {
ForEach(vm.hubResults) { hubSkill in
HubSkillRow(skill: hubSkill, isInstalling: vm.isHubLoading) {
vm.installHubSkill(hubSkill)
}
.scarfGoCompactListRow()
}
}
.scarfGoListDensity()
}
}
}
private struct HubSkillRow: View {
let skill: HermesHubSkill
let isInstalling: Bool
let onInstall: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "books.vertical")
.foregroundStyle(.tint)
.font(.title3)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(skill.name)
.font(.callout.monospaced())
.fontWeight(.medium)
if !skill.source.isEmpty {
Text(skill.source)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.tint.opacity(0.15))
.clipShape(Capsule())
}
}
if !skill.description.isEmpty {
Text(skill.description)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
}
}
Spacer(minLength: 8)
Button {
onInstall()
} label: {
Image(systemName: "arrow.down.circle.fill")
.font(.title2)
.foregroundStyle(.tint)
}
.buttonStyle(.plain)
.disabled(isInstalling)
.accessibilityLabel("Install \(skill.name)")
.accessibilityHint(skill.description)
}
.padding(.vertical, 4)
.accessibilityElement(children: .contain)
}
}
@@ -0,0 +1,54 @@
import SwiftUI
import ScarfCore
/// Installed skills sub-tab. Category-grouped list; tapping a row
/// pushes `SkillDetailView` for that skill. Filtering uses the VM's
/// `filteredCategories` derivation so the search field works against
/// the same model the Mac uses.
struct InstalledSkillsListView: View {
@Bindable var vm: SkillsViewModel
var body: some View {
Group {
if vm.isLoading && vm.categories.isEmpty {
ProgressView("Scanning skills…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if vm.categories.isEmpty {
ContentUnavailableView {
Label("No skills installed", systemImage: "lightbulb")
} description: {
Text("Browse the Hub tab to install one, or run `hermes skills install <name>` on the remote.")
.font(.caption)
}
} else {
listContent
}
}
}
@ViewBuilder
private var listContent: some View {
List {
ForEach(vm.filteredCategories) { category in
Section(category.name) {
ForEach(category.skills) { skill in
NavigationLink {
SkillDetailView(skill: skill, vm: vm)
} 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)
}
}
.scarfGoCompactListRow()
}
}
}
}
.scarfGoListDensity()
.searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always))
}
}
@@ -0,0 +1,126 @@
import SwiftUI
import ScarfCore
/// Installed skill detail. Shows location + required-config warning
/// banner + file picker + content viewer. Edit and Uninstall buttons
/// live in the toolbar.
struct SkillDetailView: View {
let skill: HermesSkill
@Bindable var vm: SkillsViewModel
@State private var showEditor: Bool = false
var body: some View {
List {
Section("Location") {
LabeledContent("Category", value: skill.category)
Text(skill.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if !vm.missingConfig.isEmpty {
Section {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 4) {
Text("Required config not set")
.font(.callout)
.fontWeight(.semibold)
Text("Add these keys to ~/.hermes/config.yaml:")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(vm.missingConfig, id: \.self) { key in
Text("\(key)")
.font(.caption.monospaced())
}
}
}
.padding(.vertical, 6)
}
}
if !skill.files.isEmpty {
Section("Files") {
ForEach(skill.files, id: \.self) { file in
Button {
vm.selectFile(file)
} label: {
HStack {
Text(file)
.font(.callout.monospaced())
Spacer()
if vm.selectedFileName == file {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
.font(.caption)
}
}
}
.buttonStyle(.plain)
.scarfGoCompactListRow()
}
}
}
if vm.selectedFileName != nil {
Section("Content") {
if vm.skillContent.isEmpty {
Text("(empty file)")
.font(.caption)
.foregroundStyle(.tertiary)
} else if vm.isMarkdownFile {
Text(markdown(vm.skillContent))
.font(.callout)
.textSelection(.enabled)
} else {
Text(vm.skillContent)
.font(.footnote.monospaced())
.textSelection(.enabled)
}
}
}
}
.scarfGoListDensity()
.navigationTitle(skill.name)
.navigationBarTitleDisplayMode(.inline)
.task {
// Selecting the skill (re)loads its main file content +
// missingConfig diagnostics. Idempotent on re-appears.
vm.selectSkill(skill)
}
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
if vm.selectedFileName != nil {
Button {
vm.startEditing()
showEditor = true
} label: {
Label("Edit", systemImage: "pencil")
}
}
Menu {
Button(role: .destructive) {
vm.uninstallHubSkill(skill.id)
} label: {
Label("Uninstall", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.sheet(isPresented: $showEditor) {
SkillEditorSheet(vm: vm, fileName: vm.selectedFileName ?? "")
}
}
private func markdown(_ raw: String) -> AttributedString {
let opts = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace
)
return (try? AttributedString(markdown: raw, options: opts)) ?? AttributedString(raw)
}
}
@@ -0,0 +1,39 @@
import SwiftUI
import ScarfCore
/// Sheet-presented TextEditor for the currently-selected skill file.
/// Save commits via `vm.saveEdit()` (which calls `transport.writeFile`);
/// Cancel discards. Validation lives entirely in the VM
/// (`isValidSkillPath` guard) so the sheet is purely UI.
struct SkillEditorSheet: View {
@Bindable var vm: SkillsViewModel
let fileName: String
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
TextEditor(text: $vm.editText)
.font(.footnote.monospaced())
.padding(8)
.navigationTitle(fileName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
vm.cancelEditing()
dismiss()
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
vm.saveEdit()
dismiss()
}
.fontWeight(.semibold)
}
}
}
.presentationDetents([.large])
}
}
-104
View File
@@ -1,104 +0,0 @@
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)
}
}
.scarfGoCompactListRow()
}
}
}
}
}
.scarfGoListDensity()
.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)
}
}
+112
View File
@@ -0,0 +1,112 @@
import SwiftUI
import ScarfCore
/// iOS Skills tab 3-tab segmented surface mirroring the Mac
/// `SkillsView`. Owns one `SkillsViewModel` (ScarfCore-side, unified
/// in v2.5) shared across the three sub-tabs so installed-list state +
/// hub query/results + update results all live in one place.
///
/// Sub-tabs:
/// - **Installed**: category-grouped list. Tap a skill to view its
/// files, edit content, or uninstall.
/// - **Browse Hub**: search + source picker. Tap to install. Calls
/// remote `hermes skills search/browse` over SSH.
/// - **Updates**: check + update-all buttons. Calls remote
/// `hermes skills check / update --yes`.
struct SkillsView: View {
let config: IOSServerConfig
@State private var vm: SkillsViewModel
@State private var currentTab: Tab = .installed
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1"
)!
enum Tab: String, CaseIterable, Identifiable {
case installed = "Installed"
case hub = "Browse Hub"
case updates = "Updates"
var id: String { rawValue }
var displayName: String { rawValue }
}
init(config: IOSServerConfig) {
self.config = config
let ctx = config.toServerContext(id: Self.sharedContextID)
_vm = State(initialValue: SkillsViewModel(context: ctx))
}
var body: some View {
VStack(spacing: 0) {
tabPicker
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 6)
statusBanner
Divider()
content
}
.navigationTitle(titleString)
.navigationBarTitleDisplayMode(.inline)
.task { await vm.load() }
.refreshable { await vm.load() }
}
private var titleString: String {
vm.totalSkillCount > 0 ? "Skills (\(vm.totalSkillCount))" : "Skills"
}
@ViewBuilder
private var tabPicker: some View {
Picker("Section", selection: $currentTab) {
ForEach(Tab.allCases) { tab in
Text(tab.displayName).tag(tab)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var statusBanner: some View {
if let msg = vm.hubMessage {
HStack(spacing: 6) {
if vm.isHubLoading {
ProgressView()
.controlSize(.small)
}
Text(msg)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 4)
.background(Color.secondary.opacity(0.08))
} else if vm.isHubLoading {
HStack(spacing: 6) {
ProgressView()
.controlSize(.small)
Text("Working…")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 4)
.background(Color.secondary.opacity(0.08))
}
}
@ViewBuilder
private var content: some View {
switch currentTab {
case .installed:
InstalledSkillsListView(vm: vm)
case .hub:
HubBrowseView(vm: vm)
case .updates:
UpdatesView(vm: vm)
}
}
}
@@ -0,0 +1,87 @@
import SwiftUI
import ScarfCore
/// Updates sub-tab. Mirrors Mac: Check button populates `vm.updates`;
/// Update All button is enabled only when there's at least one
/// available update. Both calls run remote `hermes skills` over SSH;
/// the parse logic is shared with Mac via `HermesSkillsHubParser`.
struct UpdatesView: View {
@Bindable var vm: SkillsViewModel
var body: some View {
VStack(spacing: 0) {
toolbar
Divider()
content
}
}
@ViewBuilder
private var toolbar: some View {
HStack(spacing: 8) {
Button {
vm.checkForUpdates()
} label: {
Label("Check for Updates", systemImage: "arrow.triangle.2.circlepath")
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(vm.isHubLoading)
if !vm.updates.isEmpty {
Button {
vm.updateAll()
} label: {
Label("Update All", systemImage: "arrow.down.app")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(vm.isHubLoading)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
@ViewBuilder
private var content: some View {
if vm.updates.isEmpty {
ContentUnavailableView {
Label("No updates", systemImage: "checkmark.circle.fill")
} description: {
Text("Tap Check for Updates to query each installed skill against its source registry.")
.font(.caption)
}
} else {
List {
ForEach(vm.updates) { update in
HStack(spacing: 10) {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(.orange)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(update.identifier)
.font(.callout.monospaced())
HStack(spacing: 6) {
Text(update.currentVersion)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
Image(systemName: "arrow.right")
.font(.caption2)
.foregroundStyle(.tertiary)
Text(update.availableVersion)
.font(.caption.monospaced())
.foregroundStyle(.green)
}
}
Spacer()
}
.padding(.vertical, 4)
.scarfGoCompactListRow()
}
}
.scarfGoListDensity()
}
}
}
@@ -1,331 +0,0 @@
import Foundation
import ScarfCore
import os
/// A single search/browse result from a skill registry.
struct HermesHubSkill: Identifiable, Sendable, Equatable {
var id: String { identifier }
let identifier: String // e.g. "openai/skills/skill-creator"
let name: String
let description: String
let source: String // "official" | "skills-sh" | etc.
}
/// A local skill that has an upstream version available.
struct HermesSkillUpdate: Identifiable, Sendable, Equatable {
var id: String { identifier }
let identifier: String
let currentVersion: String
let availableVersion: String
}
@Observable
final class SkillsViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "SkillsViewModel")
let context: ServerContext
private let fileService: HermesFileService
init(context: ServerContext = .local) {
self.context = context
self.fileService = HermesFileService(context: context)
}
// MARK: - Installed skills (existing behavior)
var categories: [HermesSkillCategory] = []
var selectedSkill: HermesSkill?
var skillContent = ""
var selectedFileName: String?
var searchText = ""
var missingConfig: [String] = []
var isEditing = false
var editText = ""
private var currentConfig = HermesConfig.empty
// MARK: - Hub integration (new)
var hubQuery = ""
var hubResults: [HermesHubSkill] = []
var updates: [HermesSkillUpdate] = []
var isHubLoading = false
var hubMessage: String?
var hubSource: String = "all"
let hubSources = ["all", "official", "skills-sh", "well-known", "github", "clawhub", "lobehub"]
var filteredCategories: [HermesSkillCategory] {
guard !searchText.isEmpty else { return categories }
return categories.compactMap { category in
let filtered = category.skills.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
$0.category.localizedCaseInsensitiveContains(searchText)
}
guard !filtered.isEmpty else { return nil }
return HermesSkillCategory(id: category.id, name: category.name, skills: filtered)
}
}
var totalSkillCount: Int {
categories.reduce(0) { $0 + $1.skills.count }
}
func load() {
let svc = fileService
// loadSkills walks ~/.hermes/skills/* many transport ops on remote.
Task.detached { [weak self] in
let cats = svc.loadSkills()
let cfg = svc.loadConfig()
await MainActor.run { [weak self] in
self?.categories = cats
self?.currentConfig = cfg
}
}
}
func selectSkill(_ skill: HermesSkill) {
selectedSkill = skill
let mainFile = skill.files.first(where: { $0.hasSuffix(".md") }) ?? skill.files.first
if let file = mainFile {
selectedFileName = file
skillContent = fileService.loadSkillContent(path: skill.path + "/" + file)
} else {
selectedFileName = nil
skillContent = ""
}
missingConfig = computeMissingConfig(for: skill)
}
private func computeMissingConfig(for skill: HermesSkill) -> [String] {
guard !skill.requiredConfig.isEmpty else { return [] }
guard let yaml = context.readText(context.paths.configYAML) else {
return skill.requiredConfig
}
return skill.requiredConfig.filter { key in
!yaml.contains(key)
}
}
func selectFile(_ file: String) {
guard let skill = selectedSkill else { return }
selectedFileName = file
skillContent = fileService.loadSkillContent(path: skill.path + "/" + file)
}
var isMarkdownFile: Bool {
selectedFileName?.hasSuffix(".md") == true
}
private var currentFilePath: String? {
guard let skill = selectedSkill, let file = selectedFileName else { return nil }
return skill.path + "/" + file
}
func startEditing() {
editText = skillContent
isEditing = true
}
func saveEdit() {
guard let path = currentFilePath else { return }
fileService.saveSkillContent(path: path, content: editText)
skillContent = editText
isEditing = false
}
func cancelEditing() {
isEditing = false
}
// MARK: - Hub browse/search/install/update
func browseHub() {
isHubLoading = true
Task.detached { [fileService, hubSource] in
var args = ["skills", "browse", "--size", "40"]
if hubSource != "all" { args += ["--source", hubSource] }
let result = fileService.runHermesCLI(args: args, timeout: 30)
let parsed = Self.parseHubList(result.output)
await MainActor.run {
self.isHubLoading = false
self.hubResults = parsed
if parsed.isEmpty {
self.hubMessage = result.exitCode == 0 ? "No results" : "Browse failed"
} else {
self.hubMessage = nil
}
}
}
}
func searchHub() {
guard !hubQuery.isEmpty else {
browseHub()
return
}
isHubLoading = true
Task.detached { [fileService, hubSource, hubQuery] in
var args = ["skills", "search", hubQuery, "--limit", "40"]
if hubSource != "all" { args += ["--source", hubSource] }
let result = fileService.runHermesCLI(args: args, timeout: 30)
let parsed = Self.parseHubList(result.output)
await MainActor.run {
self.isHubLoading = false
self.hubResults = parsed
if parsed.isEmpty {
self.hubMessage = "No matches"
} else {
self.hubMessage = nil
}
}
}
}
func installHubSkill(_ skill: HermesHubSkill) {
isHubLoading = true
hubMessage = "Installing \(skill.identifier)"
Task.detached { [fileService] in
// --yes skips confirmation since we're running non-interactively.
let result = fileService.runHermesCLI(args: ["skills", "install", skill.identifier, "--yes"], timeout: 120)
await MainActor.run {
self.isHubLoading = false
self.hubMessage = result.exitCode == 0 ? "Installed \(skill.identifier)" : "Install failed"
self.load()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.hubMessage = nil
}
}
}
}
func uninstallHubSkill(_ identifier: String) {
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["skills", "uninstall", identifier, "--yes"], timeout: 60)
await MainActor.run {
self.hubMessage = result.exitCode == 0 ? "Uninstalled" : "Uninstall failed"
self.load()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.hubMessage = nil
}
}
}
}
func checkForUpdates() {
isHubLoading = true
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["skills", "check"], timeout: 60)
let parsed = Self.parseUpdateList(result.output)
await MainActor.run {
self.isHubLoading = false
self.updates = parsed
self.hubMessage = parsed.isEmpty ? "No updates available" : "\(parsed.count) update(s)"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.hubMessage = nil
}
}
}
}
func updateAll() {
Task.detached { [fileService] in
let result = fileService.runHermesCLI(args: ["skills", "update", "--yes"], timeout: 300)
await MainActor.run {
self.hubMessage = result.exitCode == 0 ? "Updated" : "Update failed"
self.load()
self.checkForUpdates()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.hubMessage = nil
}
}
}
}
// MARK: - Parsers (best-effort, tolerant of format changes)
// `nonisolated` so callers in `Task.detached` can run them off the main actor.
/// Parse `hermes skills browse|search` output.
///
/// Hermes emits a Rich box-drawn table with vertical bars as column separators:
///
/// # Name Description Source Trust
///
/// 1 1password Set up and use 1Pass official official
///
/// Description cells can wrap across multiple rows the continuation rows have
/// an empty `#` column. We join consecutive rows with the same skill by checking
/// if the first column (after ``) is whitespace-only.
nonisolated private static func parseHubList(_ output: String) -> [HermesHubSkill] {
var results: [HermesHubSkill] = []
for raw in output.components(separatedBy: "\n") {
let line = raw
// Skip everything that isn't a data row. Data rows start with `` and
// contain multiple `` separators. Border rows (``, ``, ``, ``, etc.)
// are drawn with `` or `` and should be skipped.
guard line.contains("") else { continue }
let cells = line.split(separator: "", omittingEmptySubsequences: false).map { $0.trimmingCharacters(in: .whitespaces) }
// Expect at least: leading empty, #, Name, Description, Source, Trust, trailing empty
guard cells.count >= 6 else { continue }
let numCell = cells[1]
let nameCell = cells[2]
let descCell = cells[3]
let sourceCell = cells[4]
// Trust column (index 5) is informational only we ignore it in the UI.
// Continuation row: `#` column is empty. Merge its description into the
// last-added entry if present.
if numCell.isEmpty {
guard !results.isEmpty else { continue }
let last = results.removeLast()
let merged = [last.description, descCell].filter { !$0.isEmpty }.joined(separator: " ")
results.append(HermesHubSkill(
identifier: last.identifier,
name: last.name,
description: merged,
source: last.source
))
continue
}
// Header row first data-looking row whose number cell isn't a digit.
if Int(numCell) == nil { continue }
// Empty name cell shouldn't happen but guard anyway.
guard !nameCell.isEmpty else { continue }
// Identifier: `hermes skills browse` shows the short name in the Name
// column. For install we need the full identifier like
// `<source>/<name>`. The CLI accepts just the name for official hub,
// so we use that as the install target.
let source = sourceCell
.replacingOccurrences(of: "", with: "")
.trimmingCharacters(in: .whitespaces)
results.append(HermesHubSkill(
identifier: nameCell, // hermes skills install accepts the name for official/hub-indexed skills
name: nameCell,
description: descCell,
source: source
))
}
return results
}
/// Parse `hermes skills check` output for available updates. Format is
/// undocumented; we look for `` (U+2192) or `->` arrow markers between
/// version strings.
nonisolated private static func parseUpdateList(_ output: String) -> [HermesSkillUpdate] {
var results: [HermesSkillUpdate] = []
for raw in output.components(separatedBy: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.contains("") || line.contains("->") else { continue }
let marker = line.contains("") ? "" : "->"
let parts = line.components(separatedBy: marker)
guard parts.count == 2 else { continue }
let left = parts[0].trimmingCharacters(in: .whitespaces)
let available = parts[1].trimmingCharacters(in: .whitespaces)
let leftTokens = left.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
guard leftTokens.count >= 2 else { continue }
let identifier = leftTokens[0]
let current = leftTokens[leftTokens.count - 1]
results.append(HermesSkillUpdate(identifier: identifier, currentVersion: current, availableVersion: available))
}
return results
}
}
@@ -36,7 +36,13 @@ struct SkillsView: View {
}
}
.navigationTitle("Skills (\(viewModel.totalSkillCount))")
.onAppear { viewModel.load() }
// SkillsViewModel.load() is async after the v2.5 ScarfCore
// promotion. Wrap in a Task here so the existing onAppear
// contract (fire-and-forget) keeps working without making
// the Mac UI care about the new isolation.
.onAppear {
Task { await viewModel.load() }
}
}
private var modePicker: some View {