mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
4757b5ae49
Catches the Curator surface up to Hermes v0.13's new write-side verbs
(`archive <skill>`, `prune`, `list-archived`, synchronous `run`). Adds
a new `CuratorService` actor in ScarfCore mirroring `KanbanService`'s
pattern (Sendable, pure I/O, `Task.detached(priority: .utility)` per
verb), tolerantly-decoded `HermesCuratorArchivedSkill` /
`CuratorPruneSummary` models, and `CuratorError` for inline-banner
surfacing.
Mac UX gains an "Archived" section between the leaderboards and the
last-report block (per-row Restore button), an "archivebox" button on
every active-skill leaderboard row to manually archive, a destructive
"Prune Archived…" confirm sheet enumerating each skill (template-
uninstall pattern — Cancel owns `.defaultAction`, Prune is on the red
`ScarfDestructiveButton`), and a synchronous-with-progress "Run Now"
on v0.13+ hosts (600s timeout, `ProgressView` while in-flight).
Failure path routes through a yellow inline error banner instead of a
modal alert. The legacy `CuratorRestoreSheet` stays accessible from
the overflow menu but only on pre-v0.13 hosts; on v0.13+ the per-row
Restore in the new Archived section replaces it.
All new surfaces gate on `HermesCapabilities.hasCuratorArchive` —
pre-v0.13 hosts see the v2.7.x layout unchanged. iOS picks up the new
`runNow(synchronous:)` signature with the v0.13 capability flag; the
read-only Archived section + WS-9 marker is left for the next stream.
14 new parser tests in `HermesCuratorParserTests` cover the JSON
happy path, the `{"archived": [...]}` envelope, the text fallback
(`--json` not supported), `"no archived skills"` sentinel folding,
prune-dry-run with both wrapper + bare-array shapes, and zero-skill
prune. All 369 ScarfCore tests pass; `xcodebuild` for the `scarf`
scheme succeeds.
Wire-shape unknowns (CLI flag presence on real v0.13) carry
`// TODO(WS-4-Q<N>)` markers in `CuratorService` and fall back
defensively when a flag isn't recognized. Implements WS-4 of Scarf
v2.8.0 (Hermes v0.13.0 catch-up). Plan:
scarf/docs/v2.8/WS-4-curator-archive-plan.md (on
coordination/v2.8.0-plans).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
207 lines
7.5 KiB
Swift
207 lines
7.5 KiB
Swift
import SwiftUI
|
|
import ScarfCore
|
|
import ScarfDesign
|
|
|
|
#if canImport(SQLite3)
|
|
|
|
/// iOS Curator surface — read-mostly view of `hermes curator status`
|
|
/// with Run Now / Pause / Resume actions and inline pin toggles on
|
|
/// the leaderboard rows. Mirrors the Mac surface visually but folds
|
|
/// into a single SwiftUI List for thumb-friendly scrolling.
|
|
///
|
|
/// Capability-gated upstream: only routed when
|
|
/// `HermesCapabilities.hasCurator` is true.
|
|
struct CuratorView: View {
|
|
@State private var viewModel: CuratorViewModel
|
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
|
|
|
// TODO(WS-9): add a read-only "Archived" section mirroring the Mac
|
|
// surface (no per-row Restore/Prune mutations on iOS in this
|
|
// release). Gate on `capabilitiesStore?.capabilities.hasCuratorArchive`.
|
|
|
|
init(context: ServerContext) {
|
|
_viewModel = State(initialValue: CuratorViewModel(context: context))
|
|
}
|
|
|
|
/// Whether the connected host runs curator synchronously. Threaded
|
|
/// into `runNow` so v0.13+ hosts block-with-spinner; pre-v0.13 fire
|
|
/// and forget. WS-9 will surface a richer iOS progress affordance
|
|
/// alongside the read-only Archived section.
|
|
private var archiveAvailable: Bool {
|
|
capabilitiesStore?.capabilities.hasCuratorArchive ?? false
|
|
}
|
|
|
|
var body: some View {
|
|
List {
|
|
Section {
|
|
statusRow
|
|
LabeledContent("Last run", value: viewModel.status.lastRunISO ?? "Never")
|
|
if let summary = viewModel.status.lastSummary {
|
|
LabeledContent("Summary", value: summary)
|
|
}
|
|
LabeledContent("Interval", value: viewModel.status.intervalLabel)
|
|
LabeledContent("Stale after", value: viewModel.status.staleAfterLabel)
|
|
LabeledContent("Archive after", value: viewModel.status.archiveAfterLabel)
|
|
LabeledContent("Runs", value: "\(viewModel.status.runCount)")
|
|
} header: {
|
|
Text("Status")
|
|
} footer: {
|
|
actionFooter
|
|
}
|
|
|
|
Section("Skills") {
|
|
LabeledContent("Total", value: "\(viewModel.status.totalSkills)")
|
|
LabeledContent("Active", value: "\(viewModel.status.activeSkills)")
|
|
LabeledContent("Stale", value: "\(viewModel.status.staleSkills)")
|
|
LabeledContent("Archived", value: "\(viewModel.status.archivedSkills)")
|
|
}
|
|
|
|
if !viewModel.status.pinnedNames.isEmpty {
|
|
Section("Pinned") {
|
|
ForEach(viewModel.status.pinnedNames, id: \.self) { name in
|
|
HStack {
|
|
Image(systemName: "pin.fill")
|
|
.foregroundStyle(ScarfColor.accent)
|
|
Text(name)
|
|
Spacer()
|
|
Button("Unpin") {
|
|
Task { await viewModel.unpin(name) }
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !viewModel.status.leastRecentlyActive.isEmpty {
|
|
rowsSection(title: "Least recently active", rows: viewModel.status.leastRecentlyActive)
|
|
}
|
|
if !viewModel.status.mostActive.isEmpty {
|
|
rowsSection(title: "Most active", rows: viewModel.status.mostActive)
|
|
}
|
|
if !viewModel.status.leastActive.isEmpty {
|
|
rowsSection(title: "Least active", rows: viewModel.status.leastActive)
|
|
}
|
|
|
|
if let report = viewModel.lastReportMarkdown {
|
|
Section("Last report") {
|
|
Text(report)
|
|
.font(ScarfFont.monoSmall)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Curator")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.refreshable {
|
|
await viewModel.load()
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
if let toast = viewModel.transientMessage {
|
|
toastView(toast)
|
|
}
|
|
}
|
|
.task { await viewModel.load() }
|
|
}
|
|
|
|
private var statusRow: some View {
|
|
HStack {
|
|
Text("Curator")
|
|
Spacer()
|
|
statusBadge
|
|
}
|
|
}
|
|
|
|
private var statusBadge: some View {
|
|
let kind: ScarfBadgeKind
|
|
let label: String
|
|
switch viewModel.status.state {
|
|
case .enabled: kind = .success; label = "Enabled"
|
|
case .paused: kind = .warning; label = "Paused"
|
|
case .disabled: kind = .neutral; label = "Disabled"
|
|
case .unknown: kind = .neutral; label = "Unknown"
|
|
}
|
|
return ScarfBadge(label, kind: kind)
|
|
}
|
|
|
|
private var actionFooter: some View {
|
|
HStack(spacing: 8) {
|
|
Button {
|
|
Task { await viewModel.runNow(synchronous: archiveAvailable, timeout: 600) }
|
|
} label: {
|
|
Label("Run now", systemImage: "play.fill")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
.disabled(viewModel.isLoading)
|
|
|
|
if viewModel.status.state == .enabled {
|
|
Button {
|
|
Task { await viewModel.pause() }
|
|
} label: {
|
|
Label("Pause", systemImage: "pause.fill")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
} else if viewModel.status.state == .paused {
|
|
Button {
|
|
Task { await viewModel.resume() }
|
|
} label: {
|
|
Label("Resume", systemImage: "play.fill")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.top, 6)
|
|
}
|
|
|
|
private func rowsSection(title: String, rows: [HermesCuratorSkillRow]) -> some View {
|
|
Section(title) {
|
|
ForEach(rows) { row in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(row.name)
|
|
.font(.body)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
Button {
|
|
Task { await viewModel.pin(row.name) }
|
|
} label: {
|
|
Image(systemName: viewModel.status.pinnedNames.contains(row.name) ? "pin.fill" : "pin")
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
HStack(spacing: 6) {
|
|
Text("use \(row.useCount) · view \(row.viewCount) · patch \(row.patchCount)")
|
|
.font(ScarfFont.monoSmall)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(row.lastActivityLabel)
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func toastView(_ text: String) -> some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(ScarfColor.success)
|
|
Text(text).font(.caption)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(.regularMaterial)
|
|
.clipShape(Capsule())
|
|
.padding(.bottom, 12)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
|
|
#endif
|