Skip to content

Plan universal palette and fuzzy search index #49

Description

@tdeverx

Summary

Future architecture/design work: evolve the command palette and fuzzy search from a centralized app-built list into a reusable, app-wide action/index system.

The goal is that any app surface can expose a command/search entry through one shared route, while the palette, toolbar search, page search, keyboard shortcuts, and future fuzzy surfaces all resolve the same action, route, labels, scoring, and visual cell.

Current State

Current implementation is mostly centralized, not fully inline:

  • Sources/ContainedApp/Features/Palette/PaletteSearch.swift owns the shared scorer.
  • Sources/ContainedApp/Features/Palette/CommandPalette.swift owns PaletteItem, PaletteScope, PaletteItemKind, visuals, accessories, and the app-wide PaletteItem.all(app:ui:) builder.
  • Sources/ContainedApp/Navigation/ToolbarPanels/ToolbarCommandPalette.swift renders the toolbar palette, handles scoped Docker Hub/local image modes, and also creates a few scoped dynamic PaletteItem values.
  • Tests/ContainedAppTests/PaletteSearchTests.swift covers exact/prefix/fuzzy/initials/typo scoring and deduping.

So the scorer is shared and tested, but item registration is still largely a hand-maintained app list. Local views cannot simply opt into the palette by declaring their own entries.

Proposed Direction

Explore a reusable palette/search package, likely something like ContainedPalette or ContainedSearch, that owns the generic search/index contracts while the app owns strings, actions, routes, and presentation policy.

A possible public shape could look like:

.paletteEntry(
    id: id,
    title: title,
    subtitle: subtitle,
    body: body,
    kind: kind,
    keywords: keywords,
    aliases: aliases,
    route: route,
    action: action,
    cell: cell
)

or a provider/result-builder form for static sections:

PaletteProvider {
    PaletteEntry(...)
    PaletteEntry(...)
}

The important behavior is that the entry calls the same underlying action as the original button/function, can optionally route/pop to the right app location first, exposes its type/kind, and can provide richer cells for resource-specific results like containers, images, volumes, networks, settings, and future runtime backends.

Centralized Package Implementation Sketch

A useful split may be two targets: a pure ContainedPalette target for search/index contracts, and an optional ContainedPaletteUI target for SwiftUI modifiers/preferences.

Dynamic alias and synonym enrichment should be an optional indexing step, not the source of truth. Curated aliases stay authoritative, and generated candidates should be stored/scored separately so poor matches can be ignored, downgraded, or surfaced as suggestions.

Apple API notes:

  • DictionaryServices can look up words and phrases in system dictionaries, but it is not a clean synonym-generation API for a controllable app search index.
  • NaturalLanguage is a better fit for semantic help. NLEmbedding.wordEmbedding(for:) can find nearby words in an embedding vocabulary, and sentence embeddings can compare a query with longer palette phrases without copying every possible synonym into each document.
  • App Intents DisplayRepresentation/TypeDisplayRepresentation synonyms are explicit localized phrases for App Intents vocabulary. They are useful if palette entries later mirror Shortcuts/App Intents, but they should not be treated as a global synonym database.

Package sketch:

public struct PaletteAliasSeed: Sendable {
    public var title: String
    public var subtitle: String?
    public var keywords: [String]
    public var curatedAliases: [String]
}

public struct PaletteAliasCandidate: Hashable, Sendable {
    public var value: String
    public var confidence: Double
    public var source: PaletteAliasSource
}

public enum PaletteAliasSource: Hashable, Sendable {
    case curated
    case embedding
    case history
    case appProvided(String)
}

public protocol PaletteAliasExpander: Sendable {
    func aliases(for seed: PaletteAliasSeed, locale: Locale) async -> [PaletteAliasCandidate]
}

Natural Language-backed sketch:

import NaturalLanguage

public struct NaturalLanguageAliasExpander: PaletteAliasExpander {
    public var language: NLLanguage

    public init(language: NLLanguage = .english) {
        self.language = language
    }

    public func aliases(for seed: PaletteAliasSeed, locale: Locale) async -> [PaletteAliasCandidate] {
        guard let embedding = NLEmbedding.wordEmbedding(for: language) else {
            return []
        }

        let terms = ([seed.title, seed.subtitle].compactMap { $0 } + seed.keywords)
            .flatMap { $0.split(separator: " ") }
            .map(String.init)

        return terms.flatMap { term in
            embedding.neighbors(for: term, maximumCount: 8, distanceType: .cosine)
                .map { neighbor in
                    PaletteAliasCandidate(
                        value: neighbor.0,
                        confidence: max(0, 1 - neighbor.1),
                        source: .embedding
                    )
                }
        }
    }
}

Index usage:

let generatedAliases = await aliasExpander.aliases(for: document.aliasSeed, locale: locale)
let curatedAliases = document.aliases.map {
    PaletteAliasCandidate(value: $0, confidence: 1, source: .curated)
}

let searchableAliases = PaletteAliasMerger.merge(
    curatedAliases + generatedAliases,
    minimumConfidence: 0.65
)

The package should keep this pluggable so the app can start with no dynamic expansion, later enable on-device embeddings, and eventually add app-learned aliases from command history or accepted search corrections. Dynamic aliases should affect score as a weighted signal; they should not mutate the app's canonical command/action definitions.

App Intents bridge for global system use

Yes: the fuzzy package can support App Intents as an optional bridge so the same palette/action index can power in-app search plus system surfaces such as Shortcuts, Spotlight, Siri, widgets, controls, and future Apple Intelligence entry points.

The split should stay layered:

  • ContainedPalette: pure fuzzy documents, scoring, indexing, providers, aliases, context boosts, and action IDs.
  • ContainedPaletteUI: optional SwiftUI registration and result-list helpers.
  • ContainedPaletteAppIntents: optional Apple-platform bridge that maps palette documents/actions/entities to App Intents types.
  • The app: localized strings, concrete routes, concrete action execution, permissions, and activity/error presentation.

Important App Intents constraint: App Intents are discovered from Swift source at build time, and some metadata must be constant. That means the package should not try to generate arbitrary new intent types at runtime. Instead, it should provide reusable bridge contracts, query helpers, entity wrappers, and resolver plumbing. The app still declares the finite set of exported intents/shortcuts, while dynamic resources such as containers/images/volumes are resolved through AppEntity queries backed by the same fuzzy index.

Practical rule: App Intents should be treated as static shells with dynamic parameters/entities.

Can update dynamically:

  • searchable entities, such as containers, images, volumes, settings, and exported palette actions
  • suggested parameter values returned from entity queries
  • display data for entities, within system caching limits
  • the underlying fuzzy index searched by EntityStringQuery
  • stored shortcut parameters via updateAppShortcutParameters() when the app needs the system to refresh shortcut parameter metadata

Should not rely on being dynamic:

  • brand new AppIntent types at runtime
  • brand new static App Shortcut phrase structures at runtime
  • arbitrary new system-visible actions without Swift source defining the exported intent/action shape
  • App Intent metadata Apple requires to be compile-time constant

For this package, the desired model is one or more compiled intents like RunPaletteActionIntent(action: PaletteActionEntity), where RunPaletteActionIntent is static and PaletteActionEntityQuery dynamically searches the shared fuzzy index. That gives the app a global system entry point without pretending the system can discover unlimited new intent types at runtime.

Package bridge sketch:

public struct PaletteIntentEntityRecord: Identifiable, Hashable, Sendable, Codable {
    public var id: String
    public var title: String
    public var subtitle: String?
    public var kind: String
    public var actionID: String
    public var routeID: String?
    public var synonyms: [String]
}

public protocol PaletteIntentIndexProvider: Sendable {
    func entity(id: String) async throws -> PaletteIntentEntityRecord?
    func entities(matching query: String, limit: Int) async throws -> [PaletteIntentEntityRecord]
    func suggestedEntities(limit: Int) async throws -> [PaletteIntentEntityRecord]
}

public protocol PaletteIntentActionResolver: Sendable {
    func runPaletteAction(id: String) async throws
    func openPaletteRoute(id: String?) async throws
}

App-side App Entity wrapper:

import AppIntents
import ContainedPaletteAppIntents

struct PaletteActionEntity: AppEntity {
    static let typeDisplayRepresentation = TypeDisplayRepresentation(
        name: "Action",
        synonyms: ["Command", "Palette Item", "Shortcut"]
    )

    static let defaultQuery = PaletteActionEntityQuery()

    var id: String
    var title: String
    var subtitle: String?
    var actionID: String
    var routeID: String?

    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(
            title: LocalizedStringResource(stringLiteral: title),
            subtitle: subtitle.map { LocalizedStringResource(stringLiteral: $0) },
            synonyms: []
        )
    }
}

struct PaletteActionEntityQuery: EntityStringQuery {
    @Dependency var indexProvider: AppPaletteIntentIndexProvider

    func entities(for identifiers: [String]) async throws -> [PaletteActionEntity] {
        try await identifiers.asyncCompactMap { id in
            try await indexProvider.entity(id: id).map(PaletteActionEntity.init)
        }
    }

    func entities(matching string: String) async throws -> [PaletteActionEntity] {
        try await indexProvider.entities(matching: string, limit: 12).map(PaletteActionEntity.init)
    }

    func suggestedEntities() async throws -> [PaletteActionEntity] {
        try await indexProvider.suggestedEntities(limit: 12).map(PaletteActionEntity.init)
    }
}

App-side exported intent:

import AppIntents
import ContainedPaletteAppIntents

struct RunPaletteActionIntent: AppIntent {
    static let title: LocalizedStringResource = "Run Action"
    static let description = IntentDescription("Runs a Contained action.")
    static let supportedModes: IntentModes = .foreground

    @Parameter(title: "Action") var action: PaletteActionEntity
    @Dependency var resolver: AppPaletteIntentResolver

    func perform() async throws -> some IntentResult {
        try await resolver.openPaletteRoute(id: action.routeID)
        try await resolver.runPaletteAction(id: action.actionID)
        return .result()
    }
}

App shortcut sketch:

struct ContainedAppShortcuts: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: RunPaletteActionIntent(),
            phrases: [
                "Run \(.applicationName) action",
                "Search \(.applicationName) actions"
            ],
            shortTitle: "Run Action",
            systemImageName: "command"
        )
    }
}

This gives us one authoritative fuzzy/action model with several front doors: in-app palette, toolbar search, keyboard shortcuts, Spotlight/Siri/Shortcuts through App Intents, and later widgets/controls. The app can expose only safe/exportable actions to App Intents while keeping destructive, permission-sensitive, or context-only actions in the private in-app palette.

Package-owned pure model:

public struct PaletteDocument<ID: Hashable & Sendable,
                              Route: Hashable & Sendable,
                              Visual: Sendable>: Identifiable, Sendable {
    public var id: ID
    public var title: String
    public var subtitle: String?
    public var body: String?
    public var kind: PaletteKind
    public var keywords: [String]
    public var aliases: [String]
    public var route: Route?
    public var actionID: ID
    public var availability: PaletteAvailability
    public var visual: Visual?

    public init(id: ID,
                title: String,
                subtitle: String? = nil,
                body: String? = nil,
                kind: PaletteKind,
                keywords: [String] = [],
                aliases: [String] = [],
                route: Route? = nil,
                actionID: ID,
                availability: PaletteAvailability = .available,
                visual: Visual? = nil) {
        self.id = id
        self.title = title
        self.subtitle = subtitle
        self.body = body
        self.kind = kind
        self.keywords = keywords
        self.aliases = aliases
        self.route = route
        self.actionID = actionID
        self.availability = availability
        self.visual = visual
    }
}

public enum PaletteKind: String, Sendable {
    case action, create, navigation, settings, toggle, image, container, resource, search
}

public enum PaletteAvailability: Sendable, Equatable {
    case available
    case disabled(reasonCode: String)
    case hidden
}

Action execution stays app-owned, so the index can be cached safely without storing long-lived closures:

@MainActor
public protocol PaletteActionResolver<ActionID> {
    associatedtype ActionID: Hashable & Sendable
    func runPaletteAction(_ actionID: ActionID) async
}

public protocol PaletteRouteResolver<Route> {
    associatedtype Route: Hashable & Sendable
    @MainActor func openPaletteRoute(_ route: Route?)
}

Reusable provider/index contracts:

public protocol PaletteProvider<Document>: Sendable {
    associatedtype Document: Identifiable & Sendable
    func paletteDocuments() async -> [Document]
}

public struct PaletteSearchContext<Route: Hashable & Sendable, ActionID: Hashable & Sendable>: Sendable {
    public var currentRoute: Route?
    public var selectedRoute: Route?
    public var recentActionIDs: [ActionID]
    public var disabledPenalty: Int
    public var currentRouteBoost: Int
    public var selectedRouteBoost: Int
}

public struct PaletteSearchResult<Document: Identifiable & Sendable>: Identifiable, Sendable {
    public var id: Document.ID { document.id }
    public var document: Document
    public var score: Int
}

public struct PaletteIndex<Document: Identifiable & Sendable> {
    public var documents: [Document]

    public func search(_ query: String,
                       fields: (Document) -> [String],
                       contextScore: (Document) -> Int = { _ in 0 }) -> [PaletteSearchResult<Document>] {
        documents.compactMap { document in
            PaletteScorer.score(query: query, in: fields(document)).map {
                PaletteSearchResult(document: document, score: $0 + contextScore(document))
            }
        }
        .sorted { $0.score == $1.score ? String(describing: $0.id) < String(describing: $1.id) : $0.score > $1.score }
    }
}

SwiftUI modifier layer, if we choose view-local registration for visible/local actions:

public struct PaletteEntryModifier<ID: Hashable & Sendable,
                                   Route: Hashable & Sendable,
                                   Visual: Sendable>: ViewModifier {
    let document: PaletteDocument<ID, Route, Visual>

    public func body(content: Content) -> some View {
        content.preference(key: PaletteEntryPreferenceKey<ID, Route, Visual>.self,
                           value: [document])
    }
}

public extension View {
    func paletteEntry<ID: Hashable & Sendable,
                      Route: Hashable & Sendable,
                      Visual: Sendable>(_ document: PaletteDocument<ID, Route, Visual>) -> some View {
        modifier(PaletteEntryModifier(document: document))
    }
}

View-local modifier usage:

Button(AppText.restart) {
    Task { await app.containers.restart(snapshot.id) }
}
.paletteEntry(
    PaletteDocument(
        id: AppPaletteAction.restartContainer(snapshot.id),
        title: AppText.paletteRestartContainer(displayName),
        subtitle: snapshot.image,
        kind: .container,
        keywords: [snapshot.id, snapshot.image, displayName],
        aliases: ["bounce", "reboot"],
        route: AppPaletteRoute.container(snapshot.id),
        actionID: AppPaletteAction.restartContainer(snapshot.id),
        visual: AppPaletteVisual.container(snapshot.id)
    )
)

Static provider usage for global commands that should exist even when their view is not mounted:

struct AppCommandPaletteProvider: PaletteProvider {
    func paletteDocuments() async -> [AppPaletteDocument] {
        [
            AppPaletteDocument(
                id: .openSettings(.appearance),
                title: AppText.paletteSettingsTitle("Appearance"),
                subtitle: AppText.paletteSettingsSubtitle,
                kind: .settings,
                keywords: ["preferences", "theme", "material"],
                route: .settings(.appearance),
                actionID: .openSettings(.appearance),
                visual: .settings("paintpalette")
            ),
            AppPaletteDocument(
                id: .runContainer,
                title: AppText.paletteRunContainer,
                subtitle: AppText.paletteCreateSubtitle,
                kind: .create,
                keywords: ["create", "new", "container"],
                route: .creation(.configure),
                actionID: .runContainer,
                visual: .symbol("shippingbox")
            )
        ]
    }
}

Dynamic provider usage for containers/images/resources:

struct ContainerPaletteProvider: PaletteProvider {
    let snapshots: @Sendable () async -> [ContainerSnapshot]
    let displayName: @Sendable (ContainerSnapshot) async -> String

    func paletteDocuments() async -> [AppPaletteDocument] {
        await snapshots().flatMap { snapshot in
            let name = await displayName(snapshot)
            return [
                AppPaletteDocument(
                    id: .openContainer(snapshot.id),
                    title: name,
                    subtitle: snapshot.image,
                    kind: .container,
                    keywords: [snapshot.id, snapshot.image, name],
                    route: .container(snapshot.id),
                    actionID: .openContainer(snapshot.id),
                    visual: .container(snapshot.id)
                ),
                AppPaletteDocument(
                    id: .restartContainer(snapshot.id),
                    title: AppText.paletteRestartContainer(name),
                    subtitle: snapshot.image,
                    kind: .container,
                    keywords: ["restart", "reboot", snapshot.id, snapshot.image],
                    route: .container(snapshot.id),
                    actionID: .restartContainer(snapshot.id),
                    visual: .container(snapshot.id)
                )
            ]
        }
    }
}

Central app-side resolver:

@MainActor
struct AppPaletteResolver: PaletteActionResolver, PaletteRouteResolver {
    let app: AppModel
    let ui: UIState

    func openPaletteRoute(_ route: AppPaletteRoute?) {
        switch route {
        case .container(let id): ui.openContainer(id)
        case .image(let reference): ui.openImage(reference)
        case .settings(let page): ui.openSettings(to: page)
        case .creation(let entry): ui.openCreationPanel(entry: entry)
        case nil: break
        }
    }

    func runPaletteAction(_ actionID: AppPaletteAction) async {
        switch actionID {
        case .restartContainer(let id): await app.containers.restart(id)
        case .stopContainer(let id): await app.containers.stop(id)
        case .openSettings(let page): ui.openSettings(to: page)
        case .runContainer: ui.openCreationPanel(entry: .configure)
        }
    }
}

Palette host usage:

let context = PaletteSearchContext(
    currentRoute: ui.currentPaletteRoute,
    selectedRoute: ui.selectedPaletteRoute,
    recentActionIDs: app.recentPaletteActionIDs,
    disabledPenalty: -500,
    currentRouteBoost: 600,
    selectedRouteBoost: 350
)

let results = index.search(ui.searchText, fields: { document in
    [document.title, document.subtitle ?? "", document.body ?? ""] + document.keywords + document.aliases
}, contextScore: { document in
    var score = 0
    if document.route == context.currentRoute { score += context.currentRouteBoost }
    if document.route == context.selectedRoute { score += context.selectedRouteBoost }
    if context.recentActionIDs.contains(document.actionID) { score += 200 }
    if case .disabled = document.availability { score += context.disabledPenalty }
    return score
})

PaletteResultsList(results) { result in
    resolver.openPaletteRoute(result.document.route)
    await resolver.runPaletteAction(result.document.actionID)
}

Expected package boundary:

  • Package owns scoring, documents, indexing, provider contracts, context-score hooks, and optional SwiftUI entry collection.
  • App owns localized strings, routes, action IDs, action execution, visual mapping, and Activity/error presentation.
  • Design system owns reusable row/card/chip visuals if palette rows become shared chrome.
  • Navigation owns route/morph behavior; palette entries only describe intended destination.

Architecture Questions To Answer

  • Should the pure scorer/index live in a standalone package, separate from app-owned command routing?
  • Should visual palette rows live in the design system, the palette package, or the app?
  • Should view-local entries be discovered via SwiftUI preference keys/environment, explicit providers, macros/source generation, or a hybrid?
  • How do we avoid only indexing currently visible views if actions should remain globally searchable?
  • How do dynamic resources participate without rebuilding the whole app index every render?
  • How do we keep package strings display-neutral while still making aliases/synonyms localization-ready?

Compile-Time / Background Indexing Options

Potential model:

  • Static actions: declared through providers, result builders, or a future Swift macro/source-generation pass so missing IDs, route metadata, strings, icons, and tests can be caught early.
  • Dynamic actions: supplied by runtime providers for containers, images, volumes, networks, registries, settings, and future backends.
  • Background index: app maintains a lightweight searchable document/index cache that updates when relevant stores change.
  • No build-time-only index for live resources, because containers/images/settings state changes at runtime.

Dynamic Scoring Ideas

Scoring should become context-aware without making results feel random:

  • Boost current page/section and visible route-local actions.
  • Boost actions relevant to selected/expanded resources.
  • Boost exact resource IDs, image references, aliases, and user nicknames.
  • Boost recently-used commands and successful actions, with decay.
  • Deprioritize disabled/unavailable actions while still showing them when useful.
  • Weight backend/runtime availability later, so Docker/Podman/Lima/remote-specific actions only compete when relevant.
  • Keep the base scorer deterministic and unit-tested; apply context weights as a separate explainable layer.

Acceptance Criteria

  • Shared scorer/index contracts are package-owned and tested outside the app target.
  • App surfaces can register palette/search entries without editing one giant central list for every local action.
  • Global entries and dynamic resource entries use the same entry model and scoring pipeline.
  • Entries can carry route/link metadata plus an action closure so search results behave like the original UI action.
  • Palette results support generic cells plus app/resource-specific visuals without leaking app state into reusable packages.
  • Context-aware scoring has tests for current page boosts, selected-resource boosts, disabled penalties, aliases/synonyms, and deterministic tie-breaking.
  • Existing toolbar palette behavior, Docker Hub scope, local image scope, keyboard navigation, and current PaletteSearchTests remain covered.

Notes

This is long-term architecture work, not part of the current foundational PR unless we explicitly pull it forward. It should be planned after the package/runtime/Xcode foundation settles.

Metadata

Metadata

Assignees

No one assigned

    Labels

    appSwiftUI app shell or app-wide behaviorfeatureNew user-facing capability or product improvementnavigationNavigation, toolbar, panels, routing, measurement, and morphsplannedAccepted and planned work

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions