// // CommandPaletteView.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The ⌘K command palette: one fuzzy-filtered list over app actions // (New Task, Open Settings, toggle panels, audit log, template browser), // the task library, and every task template. Enter runs the top hit, // Esc dismisses. Rendered as an overlay card on the main window. // import SwiftUI /// One palette entry. struct PaletteItem: Identifiable { enum Kind { case action case template case task } let id: String var kind: Kind var title: String var subtitle: String? var symbolName: String var perform: () -> Void } struct CommandPaletteView: View { var items: [PaletteItem] var onDismiss: () -> Void @State private var query = "" @FocusState private var focused: Bool private var filtered: [PaletteItem] { let trimmed = query.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return items } return items .compactMap { item -> (PaletteItem, Int)? in guard let score = Self.fuzzyScore(needle: trimmed, haystack: item.title) else { if let subtitle = item.subtitle, let subScore = Self.fuzzyScore(needle: trimmed, haystack: subtitle) { return (item, subScore + 100) } return nil } return (item, score) } .sorted { $0.1 < $1.1 } .map(\.0) } var body: some View { VStack(spacing: 0) { HStack(spacing: ZyquoSpacing.xs) { Image(systemName: "command") .font(.system(size: 13)) .foregroundStyle(ZyquoColor.accent) TextField("Type a command, template, or task…", text: $query) .textFieldStyle(.plain) .font(ZyquoFont.body(size: 15)) .focused($focused) .onSubmit(runFirst) Text("esc") .font(ZyquoFont.code(size: 10)) .foregroundStyle(ZyquoColor.textTertiary) .padding(.horizontal, ZyquoSpacing.xxs) .padding(.vertical, 1) .background( RoundedRectangle(cornerRadius: 3, style: .continuous) .fill(ZyquoColor.surfaceSecondary) ) } .padding(ZyquoSpacing.sm) ZyquoHairline() ScrollView { LazyVStack(spacing: 1) { if filtered.isEmpty { Text("No matches") .font(ZyquoFont.body(size: 12.5)) .foregroundStyle(ZyquoColor.textTertiary) .padding(ZyquoSpacing.sm) } ForEach(Array(filtered.prefix(40).enumerated()), id: \.element.id) { index, item in row(item, isFirst: index == 0) } } .padding(ZyquoSpacing.xxs) } .frame(maxHeight: 320) } .frame(width: 560) .background( RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) .fill(ZyquoColor.surface) ) .overlay( RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) ) .zyquoSoftShadow() .onAppear { focused = true } .onExitCommand { onDismiss() } } private func row(_ item: PaletteItem, isFirst: Bool) -> some View { Button { onDismiss() item.perform() } label: { HStack(spacing: ZyquoSpacing.xs) { Image(systemName: item.symbolName) .font(.system(size: 12)) .foregroundStyle(ZyquoColor.accent) .frame(width: 18) Text(item.title) .font(ZyquoFont.body(size: 13)) .foregroundStyle(ZyquoColor.textPrimary) .lineLimit(1) if let subtitle = item.subtitle { Text(subtitle) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) .lineLimit(1) } Spacer(minLength: 0) ZyquoBadge(text: kindLabel(item.kind)) if isFirst && !query.isEmpty { Text("↩") .font(ZyquoFont.code(size: 10)) .foregroundStyle(ZyquoColor.textTertiary) } } .padding(.horizontal, ZyquoSpacing.xs) .padding(.vertical, 5) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(isFirst && !query.isEmpty ? ZyquoColor.accentSubtle : .clear) ) .contentShape(Rectangle()) } .buttonStyle(.plain) .zyquoHoverHighlight() } private func kindLabel(_ kind: PaletteItem.Kind) -> String { switch kind { case .action: return "action" case .template: return "template" case .task: return "task" } } private func runFirst() { guard let first = filtered.first else { return } onDismiss() first.perform() } /// Case-insensitive subsequence match; lower score = tighter match /// (prefix matches beat scattered ones). Nil = no match. static func fuzzyScore(needle: String, haystack: String) -> Int? { let needleChars = Array(needle.lowercased()) let haystackChars = Array(haystack.lowercased()) guard !needleChars.isEmpty else { return 0 } var score = 0 var haystackIndex = 0 for character in needleChars { var found = false while haystackIndex < haystackChars.count { if haystackChars[haystackIndex] == character { found = true haystackIndex += 1 break } score += 1 haystackIndex += 1 } if !found { return nil } } return score } }