diff --git a/scarf/Scarf iOS/Components/FlowLayout.swift b/scarf/Scarf iOS/Components/FlowLayout.swift new file mode 100644 index 0000000..87cd795 --- /dev/null +++ b/scarf/Scarf iOS/Components/FlowLayout.swift @@ -0,0 +1,53 @@ +import SwiftUI + +/// Minimal flow layout — wraps subviews onto multiple lines when they +/// overflow the available width. Built on the Layout protocol; no +/// third-party dependency. +/// +/// Used for chip rows that need to wrap on iPhone-narrow screens — +/// SKILL.md frontmatter chips (`allowed_tools` / `related_skills` / +/// `dependencies`) on the Skills detail view, and any future place +/// that wants pill-shaped wrapping content. +/// +/// Promoted from the deleted `iOS Skills/SkillsListView.swift`'s +/// inline definition during the v2.5 iOS-design merge so multiple +/// views can share it without duplicating ~30 lines. +struct FlowLayout: Layout { + var spacing: CGFloat = 4 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + guard let maxWidth = proposal.width else { return .zero } + var rowWidth: CGFloat = 0 + var totalHeight: CGFloat = 0 + var rowHeight: CGFloat = 0 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if rowWidth + size.width > maxWidth, rowWidth > 0 { + totalHeight += rowHeight + spacing + rowWidth = 0 + rowHeight = 0 + } + rowWidth += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + totalHeight += rowHeight + return CGSize(width: maxWidth, height: totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x = bounds.minX + var y = bounds.minY + var rowHeight: CGFloat = 0 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x + size.width > bounds.maxX, x > bounds.minX { + x = bounds.minX + y += rowHeight + spacing + rowHeight = 0 + } + subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} diff --git a/scarf/Scarf iOS/Skills/Installed/SkillDetailView.swift b/scarf/Scarf iOS/Skills/Installed/SkillDetailView.swift index 3702978..92ed87b 100644 --- a/scarf/Scarf iOS/Skills/Installed/SkillDetailView.swift +++ b/scarf/Scarf iOS/Skills/Installed/SkillDetailView.swift @@ -4,11 +4,23 @@ import ScarfCore /// Installed skill detail. Shows location + required-config warning /// banner + file picker + content viewer. Edit and Uninstall buttons /// live in the toolbar. +/// +/// v2.5 surfaces three Hermes v0.11+ pieces here: +/// - Spotify info row (when this is the spotify skill) — points users +/// back to Mac for the OAuth flow. +/// - design-md `npx` prereq banner — flagged when Node.js isn't on +/// the host's PATH. +/// - SKILL.md frontmatter chip rows (`allowed_tools` / `related_skills` +/// / `dependencies`) — populated by `SkillsScanner` from the file's +/// YAML frontmatter, hidden when nil. struct SkillDetailView: View { let skill: HermesSkill @Bindable var vm: SkillsViewModel + @Environment(\.serverContext) private var serverContext @State private var showEditor: Bool = false + /// design-md npx probe result. Refetched per-skill via .task(id:). + @State private var npxStatus: SkillPrereqService.Status? var body: some View { List { @@ -20,6 +32,71 @@ struct SkillDetailView: View { .textSelection(.enabled) } + // v2.5 design-md prereq banner. Only when this is the + // design-md skill AND `which npx` came back missing. + if skill.name.lowercased() == "design-md", + case .missing(let hint) = npxStatus { + Section("Prerequisite missing") { + Label { + VStack(alignment: .leading, spacing: 4) { + Text("`npx` not found on the Hermes host.") + .font(.callout.weight(.medium)) + Text(hint) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + .padding(.vertical, 4) + } + } + + // v2.5 Spotify auth note. iOS doesn't run the OAuth flow + // in-app (phones + browser callbacks are awkward); points + // users at the Mac sheet or shell. Once authed, the iOS + // skill picks up the credential from ~/.hermes/auth.json. + if skill.name.lowercased() == "spotify" { + Section("Authentication") { + Label { + VStack(alignment: .leading, spacing: 4) { + Text("Spotify needs OAuth") + .font(.callout.weight(.medium)) + Text("Run `hermes auth spotify` from the Scarf macOS app or a shell — it opens your browser to complete the OAuth flow. Once authorised, this skill picks up the credentials from `~/.hermes/auth.json` automatically.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } icon: { + Image(systemName: "music.note") + .foregroundStyle(.green) + } + .padding(.vertical, 4) + } + } + + // v2.5 SKILL.md frontmatter chip rows. Each section + // hides itself when its corresponding HermesSkill field + // is nil — old skills without v0.11 frontmatter show + // none of these. + if let tools = skill.allowedTools, !tools.isEmpty { + Section("Allowed tools") { + chipRow(tools) + } + } + if let related = skill.relatedSkills, !related.isEmpty { + Section("Related skills") { + chipRow(related) + } + } + if let deps = skill.dependencies, !deps.isEmpty { + Section("Dependencies") { + chipRow(deps) + } + } + if !vm.missingConfig.isEmpty { Section { HStack(alignment: .top, spacing: 8) { @@ -91,6 +168,19 @@ struct SkillDetailView: View { // missingConfig diagnostics. Idempotent on re-appears. vm.selectSkill(skill) } + .task(id: skill.id) { + // v2.5: probe `npx` only when this is the design-md skill — + // the only skill that surfaces a host-side prereq today. + // Cheap (single SSH `which`); not cached across navigations + // so users see a fresh result if they install Node and come + // back. + guard skill.name.lowercased() == "design-md" else { + npxStatus = nil + return + } + let svc = SkillPrereqService(context: serverContext) + npxStatus = await svc.probe(binary: "npx") + } .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { if vm.selectedFileName != nil { @@ -123,4 +213,23 @@ struct SkillDetailView: View { ) return (try? AttributedString(markdown: raw, options: opts)) ?? AttributedString(raw) } + + /// Render a list of strings as wrapping pill chips (v2.5 SKILL.md + /// frontmatter sections). Uses the shared `FlowLayout` from + /// `Components/FlowLayout.swift` so chips wrap onto multiple lines + /// on iPhone-narrow screens. + @ViewBuilder + private func chipRow(_ items: [String]) -> some View { + FlowLayout(spacing: 6) { + ForEach(items, id: \.self) { item in + Text(item) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.secondary.opacity(0.12), in: Capsule()) + } + } + .padding(.vertical, 4) + } } diff --git a/scarf/Scarf iOS/Skills/SkillsView.swift b/scarf/Scarf iOS/Skills/SkillsView.swift index 1692c00..610103b 100644 --- a/scarf/Scarf iOS/Skills/SkillsView.swift +++ b/scarf/Scarf iOS/Skills/SkillsView.swift @@ -18,6 +18,10 @@ struct SkillsView: View { @State private var vm: SkillsViewModel @State private var currentTab: Tab = .installed + /// v2.5 SkillSnapshotService diff against the per-server last-seen + /// snapshot. Drives the "What's New" pill at the top of the tab. + /// Nil before first compute or when there's nothing changed. + @State private var snapshotDiff: SkillSnapshotDiff? private static let sharedContextID: ServerID = ServerID( uuidString: "00000000-0000-0000-0000-0000000000A1" @@ -39,6 +43,15 @@ struct SkillsView: View { var body: some View { VStack(spacing: 0) { + // v2.5 "What's New" pill — surfaced above the sub-tab + // picker when the per-server snapshot diff has changes. + // First-load with no prior snapshot silently primes (no + // pill, the snapshot just records what's there). + if let diff = snapshotDiff, + diff.hasChanges, + !diff.previousSnapshotEmpty { + whatsNewPill(diff: diff) + } tabPicker .padding(.horizontal) .padding(.top, 8) @@ -49,8 +62,54 @@ struct SkillsView: View { } .navigationTitle(titleString) .navigationBarTitleDisplayMode(.inline) - .task { await vm.load() } - .refreshable { await vm.load() } + .task { + await vm.load() + recomputeSnapshotDiff() + } + .refreshable { + await vm.load() + recomputeSnapshotDiff() + } + } + + /// Compute the snapshot diff against the per-server last-seen + /// state. First load with no prior snapshot silently primes — + /// the pill never renders for users on day one. + private func recomputeSnapshotDiff() { + let allSkills = vm.categories.flatMap(\.skills) + let svc = SkillSnapshotService(serverID: Self.sharedContextID) + let diff = svc.diff(against: allSkills) + if diff.previousSnapshotEmpty { + svc.markSeen(allSkills) + snapshotDiff = nil + } else { + snapshotDiff = diff + } + } + + /// "2 new, 4 updated since you last looked" pill at the top of + /// the tab. Tapping "Seen" persists the current set as the new + /// baseline + dismisses the pill. + @ViewBuilder + private func whatsNewPill(diff: SkillSnapshotDiff) -> some View { + HStack(spacing: 8) { + Image(systemName: "sparkles") + .foregroundStyle(.tint) + Text(diff.label) + .font(.callout) + .foregroundStyle(.primary) + Spacer() + Button("Seen") { + SkillSnapshotService(serverID: Self.sharedContextID) + .markSeen(vm.categories.flatMap(\.skills)) + snapshotDiff = nil + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.tint.opacity(0.1)) } private var titleString: String {