Skip to content

[Proposal] Add a search-tail extension API #40

Description

@imbajin

Note

Background

Documentation sites may optionally add AI features such as Ask AI. When enabled, Ask AI should fit into the existing search flow; keeping it completely separate makes the overall interaction feel fragmented.

Problem

OINK owns the Command Palette result model, DOM, selection, activation, ARIA, and focus lifecycle. A downstream site can configure safe URL commands or executors for OINK's built-in actions, but it has no supported way to add a row that depends on the current search query.

The available workarounds all cross a private boundary:

  • observe and rebuild Palette DOM after every render;
  • fork the Palette;
  • patch private runtime state.

OINK needs one narrow runtime hook: trusted site JavaScript may contribute data-only action rows after native search results. OINK still renders and operates every row.

Search-tail extension architecture: a private DOM patch becomes an OINK-owned result-model hook

Current boundary

Verified against main@93ac292. The relevant files are unchanged from v1.0.0.

This API should not widen the Action Registry. Static site configuration must remain inert.

Goals

  • Let trusted site JavaScript add query-aware action rows after native results.
  • Keep all markup, ordering, selection, keyboard behavior, ARIA, pending state, and focus handling inside OINK.
  • Support synchronous row creation and asynchronous activation.
  • Preserve current DOM, announcements, and network behavior when no extension is registered.
  • Fail one extension without breaking local search or other extensions.

Non-goals

  • Arbitrary HTML, DOM nodes, custom groups, or custom rendering.
  • Remote search providers or asynchronous rows().
  • Multiple placement slots or a general plugin framework.
  • JavaScript callbacks in YAML or the action manifest.
  • Sandboxing trusted site JavaScript or proving that a browser event is user-generated.

Proposed v1 API

The first contract should name the one supported slot instead of exposing a generic placement system:

if (
  window.OinkCommandPalette &&
  typeof window.OinkCommandPalette.registerSearchTail === "function"
) {
  const unregister = window.OinkCommandPalette.registerSearchTail({
    id: "ask-ai",

    rows(context) {
      return [{
        id: "ask-ai",
        title: "Ask AI",
        description: `Ask about "${context.query}"`,
        icon: "fa-solid fa-wand-magic-sparkles"
      }];
    },

    activate(row, context) {
      return openOptionalAssistant(context.query, {
        locale: context.locale,
        signal: context.signal
      });
    }
  });
}

registerSearchTail() returns an idempotent unregister function. Duplicate extension IDs are rejected. Registration order determines extension order.

Registration or unregistration while the Palette is open schedules one OINK-owned render. It never patches the current DOM directly. Unregistering also aborts a pending activation owned by that extension.

The Palette bundle remains conditional. A site must feature-detect the API on pages where local search may be disabled.

Row contract

rows(context) is synchronous, pure, and safe to call more than once. OINK does not promise an exact callback count.

Each returned row is data:

{
  id: "ask-ai",                   // required, unique in this extension's current result set
  title: "Ask AI",                // required
  description: "Ask about...",    // optional
  icon: "fa-solid fa-wand-magic-sparkles", // optional class tokens
  available: true,                // optional, defaults to true
  disabledReason: ""              // optional
}

OINK copies these allowed fields and renders strings as text. A title such as Array<T> stays text; it is not interpreted as HTML. Extensions can use the row ID to distinguish actions.

Row IDs need to be unique only within one extension's current result set. OINK creates its internal identity from the extension ID and row ID, for example extension:ask-ai:ask-ai.

If rows() throws or returns an invalid descriptor, OINK skips that extension for the current render. Native search and other extensions continue to work.

Search context

OINK calls rows(context) only after local search settles for a non-empty text query:

{
  query: "server auth",             // trimmed
  locale: "en",
  phase: "results" | "empty" | "error",
  pageResultCount: 4
}

phase and pageResultCount describe the local page index. Native actions and extension rows do not change them. pageResultCount is the number of local page rows returned after the configured result limit.

OINK does not call extensions for:

  • an empty Palette;
  • a > command query;
  • choice mode;
  • index loading.

OINK first builds the same native view it builds today, including its current loading, empty, or index-unavailable message. It then appends extension rows to the existing Actions group, or creates that group with the existing localized label when needed. This keeps native page results first and avoids a new translation key.

The context used to create a row is stored with that row. Activation receives the same snapshot, plus its AbortSignal; it does not rebuild context from a newer input value.

Activation lifecycle

OINK never calls an extension's activate() while registering, evaluating, or rendering rows. It calls the callback only when that row enters the Palette's existing activation path.

This is a sequencing guarantee, not a security sandbox. An extension is trusted site code. It can call public APIs or perform network work on its own, so the site remains responsible for keeping rows() pure and for asking for any consent its provider requires.

For an extension row:

  1. OINK blocks duplicate activation while the first activation is pending.
  2. OINK invokes activate() and catches both synchronous throws and Promise rejection.
  3. OINK ignores the callback's fulfillment value. A successful activation closes the Palette through the existing focus-restoration path.
  4. A failure keeps the Palette open and announces the existing localized action-failed message.
  5. Closing the Palette, starting a new Palette session, or unregistering the extension aborts the signal and makes late settlement a no-op.

OINK does not impose an activation timeout. The extension may implement one with the provided signal.

State behavior

Palette state Extension behavior
Empty/browse Not called
Command query (>...) Not called
Choice mode Not called
Search index loading Not called; current loading behavior stays unchanged
Page results Rows follow all native results and actions
No page results Current native empty/action view stays; extension rows follow it
Index error Current index-unavailable view and input-triggered retry stay; extension rows may follow

Acceptance checks

  1. With no registered extension, current DOM order, announcements, focus behavior, and network behavior remain unchanged.
  2. Repeated renders, index completion, failure, and retry never duplicate extension rows. rows() remains safe when called more than once.
  3. Extension rows use the current native interactions: pointer, Arrow keys, Cmd/Ctrl+Home/End, and Enter. Space remains normal search input.
  4. Display fields render as text. Invalid field types and unsafe or duplicate IDs are rejected without breaking native search.
  5. A throwing rows() affects only that extension. A synchronous throw, rejected Promise, or aborted activation releases pending state and leaves the Palette usable.
  6. Registration, unregistration, and late registration do not add duplicate listeners or retain stale rows. An old unregister handle cannot remove a later registration that reused the same ID.
  7. Search-off and Print pages still omit the Palette bundle. Markdown and no-JavaScript behavior remain unchanged. Normal HTML keeps the same server-rendered Palette structure; its production script fingerprint will change.
  8. Existing page, quick-link, action, command, and choice rows retain their current behavior.

Compatibility

The action manifest remains data-only and cannot register JavaScript. This runtime API is available only to trusted site code.

OINK still ships no remote search fallback or telemetry. By default, every query stays in the browser. A site extension may send a query after activation, or earlier if it violates the documented rows() contract; OINK cannot enforce extension behavior.

The accepted behavior must update the bilingual Shell contract and owning checker before implementation. The public contract should keep the default local-only behavior explicit.

Downstream reference

HugeGraph evaluated three downstream compositions. The selected composition keeps native documentation results first, appends Ask AI as the search tail, and provides a separate Floating Launcher for a blank assistant session. The other two images explain the UI choice; they are not additional OINK requirements.

Selected: Search Tail + Floating Launcher Alternative: AI-first row Alternative: persistent side panel
Native results remain primary; Ask AI follows them and the launcher remains available outside search. Ask AI precedes local results and receives too much visual priority. Search and assistant share the Palette and leave less room for documentation results.
HugeGraph search tail after native results with a separate floating Ask AI launcher HugeGraph alternative with Ask AI placed before native documentation results HugeGraph alternative with a persistent Ask AI side panel beside documentation results

HugeGraph can keep its compatibility wrapper until a tagged OINK release provides this contract. Upstream acceptance does not block the downstream deployment.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions