SPB Git

spb/zyquo-agent Public MIT

The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.

Swift 94.7% Shell 4.1% Python 0.7% Makefile 0.5%
6.4 KB · 188 lines swift
Raw Blame History
1//2//  CommandPaletteView.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The ⌘K command palette: one fuzzy-filtered list over app actions9//  (New Task, Open Settings, toggle panels, audit log, template browser),10//  the task library, and every task template. Enter runs the top hit,11//  Esc dismisses. Rendered as an overlay card on the main window.12//1314import SwiftUI1516/// One palette entry.17struct PaletteItem: Identifiable {18    enum Kind {19        case action20        case template21        case task22    }2324    let id: String25    var kind: Kind26    var title: String27    var subtitle: String?28    var symbolName: String29    var perform: () -> Void30}3132struct CommandPaletteView: View {33    var items: [PaletteItem]34    var onDismiss: () -> Void3536    @State private var query = ""37    @FocusState private var focused: Bool3839    private var filtered: [PaletteItem] {40        let trimmed = query.trimmingCharacters(in: .whitespaces)41        guard !trimmed.isEmpty else { return items }42        return items43            .compactMap { item -> (PaletteItem, Int)? in44                guard let score = Self.fuzzyScore(needle: trimmed, haystack: item.title) else {45                    if let subtitle = item.subtitle,46                       let subScore = Self.fuzzyScore(needle: trimmed, haystack: subtitle) {47                        return (item, subScore + 100)48                    }49                    return nil50                }51                return (item, score)52            }53            .sorted { $0.1 < $1.1 }54            .map(\.0)55    }5657    var body: some View {58        VStack(spacing: 0) {59            HStack(spacing: ZyquoSpacing.xs) {60                Image(systemName: "command")61                    .font(.system(size: 13))62                    .foregroundStyle(ZyquoColor.accent)63                TextField("Type a command, template, or task…", text: $query)64                    .textFieldStyle(.plain)65                    .font(ZyquoFont.body(size: 15))66                    .focused($focused)67                    .onSubmit(runFirst)68                Text("esc")69                    .font(ZyquoFont.code(size: 10))70                    .foregroundStyle(ZyquoColor.textTertiary)71                    .padding(.horizontal, ZyquoSpacing.xxs)72                    .padding(.vertical, 1)73                    .background(74                        RoundedRectangle(cornerRadius: 3, style: .continuous)75                            .fill(ZyquoColor.surfaceSecondary)76                    )77            }78            .padding(ZyquoSpacing.sm)79            ZyquoHairline()80            ScrollView {81                LazyVStack(spacing: 1) {82                    if filtered.isEmpty {83                        Text("No matches")84                            .font(ZyquoFont.body(size: 12.5))85                            .foregroundStyle(ZyquoColor.textTertiary)86                            .padding(ZyquoSpacing.sm)87                    }88                    ForEach(Array(filtered.prefix(40).enumerated()), id: \.element.id) { index, item in89                        row(item, isFirst: index == 0)90                    }91                }92                .padding(ZyquoSpacing.xxs)93            }94            .frame(maxHeight: 320)95        }96        .frame(width: 560)97        .background(98            RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)99                .fill(ZyquoColor.surface)100        )101        .overlay(102            RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)103                .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)104        )105        .zyquoSoftShadow()106        .onAppear { focused = true }107        .onExitCommand { onDismiss() }108    }109110    private func row(_ item: PaletteItem, isFirst: Bool) -> some View {111        Button {112            onDismiss()113            item.perform()114        } label: {115            HStack(spacing: ZyquoSpacing.xs) {116                Image(systemName: item.symbolName)117                    .font(.system(size: 12))118                    .foregroundStyle(ZyquoColor.accent)119                    .frame(width: 18)120                Text(item.title)121                    .font(ZyquoFont.body(size: 13))122                    .foregroundStyle(ZyquoColor.textPrimary)123                    .lineLimit(1)124                if let subtitle = item.subtitle {125                    Text(subtitle)126                        .font(ZyquoFont.caption)127                        .foregroundStyle(ZyquoColor.textTertiary)128                        .lineLimit(1)129                }130                Spacer(minLength: 0)131                ZyquoBadge(text: kindLabel(item.kind))132                if isFirst && !query.isEmpty {133                    Text("↩")134                        .font(ZyquoFont.code(size: 10))135                        .foregroundStyle(ZyquoColor.textTertiary)136                }137            }138            .padding(.horizontal, ZyquoSpacing.xs)139            .padding(.vertical, 5)140            .background(141                RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)142                    .fill(isFirst && !query.isEmpty ? ZyquoColor.accentSubtle : .clear)143            )144            .contentShape(Rectangle())145        }146        .buttonStyle(.plain)147        .zyquoHoverHighlight()148    }149150    private func kindLabel(_ kind: PaletteItem.Kind) -> String {151        switch kind {152        case .action: return "action"153        case .template: return "template"154        case .task: return "task"155        }156    }157158    private func runFirst() {159        guard let first = filtered.first else { return }160        onDismiss()161        first.perform()162    }163164    /// Case-insensitive subsequence match; lower score = tighter match165    /// (prefix matches beat scattered ones). Nil = no match.166    static func fuzzyScore(needle: String, haystack: String) -> Int? {167        let needleChars = Array(needle.lowercased())168        let haystackChars = Array(haystack.lowercased())169        guard !needleChars.isEmpty else { return 0 }170        var score = 0171        var haystackIndex = 0172        for character in needleChars {173            var found = false174            while haystackIndex < haystackChars.count {175                if haystackChars[haystackIndex] == character {176                    found = true177                    haystackIndex += 1178                    break179                }180                score += 1181                haystackIndex += 1182            }183            if !found { return nil }184        }185        return score186    }187}188