diff --git a/README.md b/README.md index bcbfed1..58a36e3 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,12 @@ and what they've **left behind**: | 🪶 **Zero footprint** | No third-party dependencies or telemetry, and an auditable all-Swift codebase using macOS's system SQLite for minimal local detection metadata. If Perch dies, your agents don't even notice. |
-🧭 Footholds · 🌳 Worktrees · 📊 token dashboard screenshots +🧭 Footholds · 🔎 Insights · 🌳 Worktrees · 📊 token dashboard screenshots
Footholds page: agent-config, instructions/memory, and system-persistence surfaces with per-item state — a non-Perch hook in settings.json, recently-changed project CLAUDE.md files, LaunchAgents and shell profiles

+Insights window: caution and danger totals, a 24-hour stacked detection timeline, findings by stable code, detections by agent and tool, and per-session finding clusters — read locally from the metadata-only detection store +

Worktrees window: summary tiles for count, total size, and reclaimable bytes; per-project rows with reclaimable / review / active / orphaned tier badges, dirty-file and commits-ahead notes, and a Copy cleanup commands button — read-only, Perch never deletes

Token usage dashboard: daily stacked chart plus per-day, per-model and per-project breakdowns diff --git a/Sources/Perch/App/ShowcaseRenderer.swift b/Sources/Perch/App/ShowcaseRenderer.swift index f089b8f..41da312 100644 --- a/Sources/Perch/App/ShowcaseRenderer.swift +++ b/Sources/Perch/App/ShowcaseRenderer.swift @@ -13,6 +13,7 @@ enum ShowcaseRenderer { try renderIntegrity(to: dir.appendingPathComponent("integrity.png")) try renderUsage(to: dir.appendingPathComponent("usage.png")) try renderWorktrees(to: dir.appendingPathComponent("worktrees.png")) + try renderInsights(to: dir.appendingPathComponent("insights.png")) } /// Synthetic worktree audit covering all four tiers. @@ -70,6 +71,10 @@ enum ShowcaseRenderer { detail: "present", lastModified: nil, status: .unchanged), IntegrityItem(id: "pl", category: .agentConfig, label: "~/.claude/plugins", detail: "3 plugins", lastModified: nil, status: .unchanged), + IntegrityItem(id: "mcp", category: .agentConfig, label: "MCP servers", + detail: "2 configured", lastModified: nil, status: .unchanged), + IntegrityItem(id: "cx", category: .agentConfig, label: "~/.codex/config.toml + hooks.json", + detail: "hooks present · trusted", lastModified: nil, status: .unchanged), IntegrityItem(id: "cm", category: .memory, label: "Project CLAUDE.md / AGENTS.md", detail: "5 across active projects", lastModified: nil, status: .changedRecently), IntegrityItem(id: "mem", category: .memory, label: "~/.claude/memory", @@ -84,11 +89,11 @@ enum ShowcaseRenderer { private static func renderIntegrity(to url: URL) throws { let model = IntegrityModel() model.injectSnapshot(demoIntegritySnapshot()) - // All showcase shots share a 760pt output width so galleries can - // display them at one uniform size without per-image caps. - let size = CGSize(width: 720, height: 430) + // Canvas matches the worktrees shot exactly (760x520 with margins) so + // the two sit in one gallery row at equal height and column width. + let size = CGSize(width: 720, height: 480) let view = IntegrityView(model: model, renderStatic: true) - .frame(width: 680, height: 390, alignment: .top) + .frame(width: 680, height: 440, alignment: .top) .frame(width: size.width, height: size.height, alignment: .top) .padding(20) .background( @@ -233,6 +238,57 @@ enum ShowcaseRenderer { try writePNG(view, size: size, to: url) } + // MARK: - Insights window + + /// Synthetic past-24h detection metadata: a burst on the api-server + /// session, an older Codex credential read, and background cautions — + /// aggregated through the real Insights pipeline so buckets, ordering, + /// and labels match what the window computes from SQLite. + static func demoInsightsSnapshot(now: Date = Date()) -> DetectionInsightsSnapshot { + func row(_ eventID: String, _ hoursAgo: Double, _ agent: AgentKind, + _ session: String, _ tool: String, _ risk: RiskLevel, + _ code: String, _ level: RiskLevel) -> DetectionInsightsSourceRow { + DetectionInsightsSourceRow( + eventID: eventID, + observedAt: now.addingTimeInterval(-hoursAgo * 3600), + agent: agent, sessionID: session, toolName: tool, + riskLevel: risk, findingCode: code, findingLevel: level) + } + let api = "0f47a2c9-8f4e-4f6a-b0d3-2d9c51e7aa41" + let data = "5a19e7d4-63bb-4c21-9e5a-0c7f4b8812df" + let rows = [ + row("e1", 1.4, .claude, api, "Bash", .danger, "pipe-to-shell", .danger), + row("e1", 1.4, .claude, api, "Bash", .danger, "privilege-escalation", .danger), + row("e2", 1.6, .claude, api, "Bash", .caution, "force-push", .caution), + row("e3", 4.5, .claude, api, "Edit", .caution, "memory-pollution", .caution), + row("e4", 8.2, .claude, api, "Bash", .caution, "destructive-delete", .caution), + row("e5", 12.3, .codex, data, "shell", .danger, "credential-access", .danger), + row("e6", 12.8, .codex, data, "shell", .caution, "insecure-url", .caution), + row("e7", 21.5, .codex, data, "shell", .caution, "destructive-delete", .caution), + ] + return DetectionInsightsSnapshot.aggregate( + rows: rows, range: .hours24, now: now, calendar: Calendar.current) + } + + private static func renderInsights(to url: URL) throws { + // The store is never opened: injectSnapshot short-circuits refresh(), + // so this render touches no SQLite file. + let model = InsightsModel(store: DetectionStore()) + model.injectSnapshot(demoInsightsSnapshot()) + + // Width is chosen so the aspect ratio equals the usage shot's + // (760x780): the two share a gallery row at equal height and column + // width. The window is resizable in the app, so the wide layout is + // exactly what a user would see. + let size = CGSize(width: 1091, height: 1120) + let view = InsightsView(model: model, renderStatic: true) + .frame(width: size.width, height: size.height, alignment: .top) + .background(Color(nsColor: .windowBackgroundColor)) + .environment(\.colorScheme, .dark) + + try writePNG(view, size: size, to: url) + } + // MARK: - Usage dashboard /// Two weeks of plausible-looking demo traffic, shared by the notch diff --git a/Sources/Perch/Model/DetectionInsights.swift b/Sources/Perch/Model/DetectionInsights.swift index 484dbbd..e12a567 100644 --- a/Sources/Perch/Model/DetectionInsights.swift +++ b/Sources/Perch/Model/DetectionInsights.swift @@ -419,6 +419,13 @@ final class InsightsModel: ObservableObject { self.store = store } + /// Showcase support: adopt a prebuilt snapshot without touching the store. + func injectSnapshot(_ snap: DetectionInsightsSnapshot) { + generation += 1 // invalidate any in-flight refresh + snapshot = snap + state = .loaded + } + func refresh(now: Date = Date()) { generation += 1 let requestGeneration = generation diff --git a/Sources/Perch/UI/InsightsWindow.swift b/Sources/Perch/UI/InsightsWindow.swift index 3b80128..478b3b7 100644 --- a/Sources/Perch/UI/InsightsWindow.swift +++ b/Sources/Perch/UI/InsightsWindow.swift @@ -42,6 +42,7 @@ private struct InsightsTimelinePoint: Identifiable { struct InsightsView: View { @ObservedObject var model: InsightsModel + var renderStatic = false var body: some View { VStack(spacing: 0) { @@ -75,41 +76,57 @@ struct InsightsView: View { .controlSize(.small) .accessibilityLabel("Loading Insights") } - Picker("Time range", selection: $model.selectedRange) { - ForEach(InsightsRange.allCases) { range in - Text(range.rawValue) - .tag(range) - .accessibilityLabel(range.accessibilityLabel) + if renderStatic { + staticRangePicker + } else { + Picker("Time range", selection: $model.selectedRange) { + ForEach(InsightsRange.allCases) { range in + Text(range.rawValue) + .tag(range) + .accessibilityLabel(range.accessibilityLabel) + } } + .pickerStyle(.segmented) + .frame(width: 180) } - .pickerStyle(.segmented) - .frame(width: 180) Button("Refresh") { model.refresh() } .disabled(model.state == .loading) } } + /// ImageRenderer draws the AppKit-bridged segmented Picker as a missing- + /// view placeholder, so showcase renders draw this SwiftUI stand-in. + private var staticRangePicker: some View { + HStack(spacing: 2) { + ForEach(InsightsRange.allCases) { range in + Text(range.rawValue) + .font(.system(size: 11, weight: .medium)) + .padding(.horizontal, 12) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(range == model.selectedRange + ? Color.white.opacity(0.22) + : Color.clear)) + } + } + .padding(2) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(Color.black.opacity(0.28))) + } + @ViewBuilder private var content: some View { if model.state == .unavailable { unavailableState } else if let snapshot = model.snapshot { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - summary(snapshot) - timeline(snapshot) - if snapshot.isEmpty { - emptyState - } else { - findings(snapshot) - HStack(alignment: .top, spacing: 14) { - agents(snapshot) - tools(snapshot) - } - sessions(snapshot) - } + if renderStatic { + contentBody(snapshot) + } else { + ScrollView { + contentBody(snapshot) } - .padding(16) } } else { centeredState( @@ -119,6 +136,24 @@ struct InsightsView: View { } } + private func contentBody(_ snapshot: DetectionInsightsSnapshot) -> some View { + VStack(alignment: .leading, spacing: 14) { + summary(snapshot) + timeline(snapshot) + if snapshot.isEmpty { + emptyState + } else { + findings(snapshot) + HStack(alignment: .top, spacing: 14) { + agents(snapshot) + tools(snapshot) + } + sessions(snapshot) + } + } + .padding(16) + } + private func summary(_ snapshot: DetectionInsightsSnapshot) -> some View { HStack(spacing: 12) { summaryTile( @@ -325,17 +360,11 @@ struct InsightsView: View { .font(.system(size: 11, design: .monospaced)) .lineLimit(1) .truncationMode(.middle) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 5) { - ForEach(session.findings) { finding in - Text("\(finding.code) ×\(finding.count)") - .font(.system(size: 9, weight: .medium, design: .monospaced)) - .foregroundStyle(severityColor(finding.level)) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background( - Capsule().fill(severityColor(finding.level).opacity(0.10))) - } + if renderStatic { + findingChips(session) + } else { + ScrollView(.horizontal, showsIndicators: false) { + findingChips(session) } } } @@ -347,6 +376,20 @@ struct InsightsView: View { + "highest severity \(session.highestLevel.label)") } + private func findingChips(_ session: DetectionSessionAggregate) -> some View { + HStack(spacing: 5) { + ForEach(session.findings) { finding in + Text("\(finding.code) ×\(finding.count)") + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(severityColor(finding.level)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + Capsule().fill(severityColor(finding.level).opacity(0.10))) + } + } + } + private var emptyState: some View { HStack(spacing: 10) { Image(systemName: "checkmark.shield") diff --git a/docs/img/insights.png b/docs/img/insights.png new file mode 100644 index 0000000..bbac80a Binary files /dev/null and b/docs/img/insights.png differ diff --git a/docs/img/integrity.png b/docs/img/integrity.png index 4f1372f..0907561 100644 Binary files a/docs/img/integrity.png and b/docs/img/integrity.png differ