Muxy

Extension Modal

A native, searchable picker overlay. The extension supplies a list; Muxy owns the UI, the search field, keyboard navigation, and open/close. Selecting an item (click or Return) delivers that item; dismissing (Esc, click outside) delivers null.

modal is available on all three surfaces: webview pages (tabs, panels, popovers) via window.muxy, runScript palette-command scripts via muxy, and the background script muxy global. It needs no permission — the user drives every selection themselves, so there is nothing to gate (what permissions don't gate).

Delivery of the choice is via an onSelect(choice) callback, which fires when the user picks or dismisses. On runScript and background scripts modal.open returns immediately (it does not block); onSelect is the only way to read the result. On webview pages modal.open also returns a Promise of the choice, so you may await it instead of using onSelect.

open

Opens the picker with your items; onSelect receives the selected item, or null if dismissed.

muxy.modal.open({
  placeholder: 'Pick a fruit...',   // search field placeholder
  emptyLabel: 'No items',           // shown when the list is empty
  noMatchLabel: 'No matches',       // shown when the query matches nothing
  searchToolbar: true,              // optional Aa / W / .* search option toolbar
  items: [
    { id: 'apple', title: 'Apple', subtitle: 'Crisp and red' },
    { id: 'banana', title: 'Banana' },
  ],
  onSelect(choice) {
    if (choice) { /* choice = { id, title, subtitle } */ }
  },
});
FieldTypeRequiredNotes
itemsobject[] or functionyesThe rows to show — an array, or an items(emit) producer (see Streaming).
onSelectfunctionno*onSelect(choice) fires with the chosen item or null. Required on runScript/background (which don't return the choice); optional on webview where you can await the result instead.
onQueryfunctionnoonQuery(query, emit, options) fires when the search text or search options change, letting you supply a fresh list per query (async/server-side search — see Dynamic results).
placeholderstringnoSearch field placeholder. Defaults to "Search...".
emptyLabelstringnoMessage when there are no items. Defaults to "No items".
noMatchLabelstringnoMessage when the query matches nothing. Defaults to "No matches".
searchToolbarbooleannoShows the footer search option toolbar (Aa, W, .*) when true. Defaults to false. Honored on runScript and background surfaces; the webview-page bridge currently ignores it.

Each item:

FieldTypeRequiredNotes
idstringyesReturned to you on selection; identify the choice by this.
titlestringyesThe bold primary line.
subtitlestringnoThe dimmed secondary line.

Muxy filters the list as the user types (case-insensitive substring match on title and subtitle), highlights with the arrow keys, and selects on Return or click. Filtering is native — by default, once your items are supplied, typing never calls back into your code, so search stays instant and the UI can never hang no matter how large the list or how fast the user types. To drive results from the query yourself (async or server-side search), opt in with onQuery; native filtering still runs on top of whatever you supply.

Streaming large lists (items producer)

A static items array enumerates everything upfront — fine for small lists, but for a big repo you don't want to block the open while you gather every file. Instead pass items as a function. Muxy opens the picker immediately (with a spinner) and calls your producer once, off the UI thread; you push rows in via emit(batch) and the list fills as they arrive. The user can type against whatever has loaded so far, and Muxy filters it natively.

muxy.modal.open({
  placeholder: 'Open file…',
  items(emit) {
    const files = listAllFiles();             // you own the enumeration
    for (const chunk of batches(files, 5000)) {
      emit(chunk.map(f => ({ id: f.path, title: f.name, subtitle: f.path })));
    }
  },
  onSelect(choice) {
    if (choice) { /* { id, title, subtitle } */ }
  },
});
items formBehavior
arrayThe full list, supplied at once. Best for small, bounded sets.
items(emit) functionCalled once. Call emit(batchArray) any number of times to stream rows; you may also just return the full array instead of emitting. The picker opens before this finishes.
  • emit takes an array of { id, title, subtitle? } (entries missing id/title are dropped). Returning an array from the producer is equivalent to emitting it once.
  • On webview pages the producer may be async (do await emit(...)); in runScript and background scripts it runs synchronously — call muxy.exec, muxy.files.*, etc. directly and emit. Filtering is always native, so modal.open never blocks on it; the choice arrives via onSelect.
  • The dataset is capped at 100,000 rows; id, title, and subtitle are capped at 200 chars each. Producing nothing just shows the empty label.
  • Because filtering is native, you never debounce or handle the query yourself — Muxy owns search, paging, and cancellation. For a static items list there is no per-keystroke callback; opt into onQuery when you want to feed results from the query.

Dynamic results (onQuery)

A static items list (or a producer that runs once) is filtered natively and never calls back. When the result set depends on the query itself — a server-side search, a remote API, a fuzzy index you own — pass an onQuery(query, emit, options) handler. Muxy debounces the search field and calls onQuery with the current text and search options on every change; you return (or emit) the rows for that query, and Muxy swaps them in. Native substring filtering still runs on top of whatever you supply, so partial matches within your result set keep working.

// background.js — shell out per query (background scripts have muxy.exec, not muxy.http)
muxy.modal.open({
  placeholder: 'Search npm…',
  items: [],                                  // initial list before the user types
  onQuery(query) {
    if (!query) return [];
    const out = muxy.exec(['curl', '-s', `https://registry.example/-/v1/search?text=${encodeURIComponent(query)}`]);
    const data = JSON.parse(out.stdout || '{}');
    return (data.objects || []).map(o => ({ id: o.package.name, title: o.package.name, subtitle: o.package.description }));
  },
  onSelect(choice) {
    if (choice) muxy.notifications.notify({ title: 'Picked', body: choice.title });
  },
});

On a webview page (tab/panel/popover) onQuery may be async and use muxy.http.fetch:

async onQuery(query) {
  if (!query) return [];
  const res = await muxy.http.fetch(`https://registry.example/-/v1/search?text=${encodeURIComponent(query)}`);
  return res.body ? JSON.parse(res.body).objects.map(o => ({ id: o.package.name, title: o.package.name })) : [];
}
  • onQuery(query, emit, options) receives the trimmed query string, an emit(batch) you can call to stream rows (same shape as the producer's emit), and { caseSensitive, wholeWord, regex } search options. The footer toolbar that lets users change those options is shown only when searchToolbar: true; returning an array is equivalent to emitting it once.
  • onQueryChange(query, options) is kept as a compatibility alias for older extensions. New code should prefer onQuery because it also receives emit and uses the stale-query protection built into the dynamic modal pipeline.
  • Each call replaces the list for that query. Muxy tags every call with a revision and drops responses for superseded queries, so a slow request that resolves late never overwrites a newer one. On runScript scripts, queued queries that are already superseded are skipped before your handler runs, so a slow synchronous search (e.g. muxy.exec over a large repo) never piles up once per keystroke.
  • The initial items (array or producer) still supplies the list shown before the user types; onQuery takes over once the query changes, including when it is cleared back to empty.
  • onQuery may return a Promise. In runScript, map muxy.execAsync(...).result into rows, for example return job.result.then(result => parseRows(result.stdout)), so long-running commands stay cancellable; other bridge calls remain synchronous. The spinner shows while async work is pending.
  • The same caps apply: 100,000 rows; id/title/subtitle 200 chars each.

Opening from a shortcut

The modal has no shortcut of its own — wire one through a palette command. Declare a command with a defaultShortcut, listen for its event in background.js, then open the modal:

{
  "muxy": {
    "background": "background.js",
    "permissions": ["notifications:write"],
    "events": ["command.pick"],
    "commands": [
      { "id": "pick", "title": "Pick an Item", "action": { "kind": "event" }, "defaultShortcut": "cmd+shift+m" }
    ]
  }
}
// background.js
muxy.events.subscribe('command.pick', () => {
  muxy.modal.open({
    placeholder: 'Pick a fruit...',
    items: [
      { id: 'apple', title: 'Apple', subtitle: 'Crisp and red' },
      { id: 'banana', title: 'Banana', subtitle: 'Soft and yellow' },
    ],
    onSelect(choice) {
      if (choice) muxy.notifications.notify({ title: 'Picked', body: choice.title });
    },
  });
});

Notes

  • On runScript/background, modal.open returns immediately and the choice arrives via onSelect; the script does not block waiting for the user. On webview pages modal.open also returns a Promise you can await.
  • Only one modal is shown at a time. Opening a new one while another is showing closes the existing modal — its onSelect fires with null — and presents the new picker.
  • placeholder and the labels are capped at 200 characters; id, title, and subtitle per item at 200. The dataset (array or streamed via the producer) is capped at 100,000 rows; items missing id or title are dropped.
  • The modal presents on the main Muxy window.

Webview modal (openWebview)

When a picker is not enough, open a webview modal: a top-centered, omnibox-style overlay that renders your own HTML in a fresh page. The content is entirely yours — a form, an informational panel, a list, a confirmation, or any mix. Muxy owns the scrim, the frame, and Escape/outside-click dismissal; you own everything inside.

openWebview is available on webview pages (tabs, panels, popovers, sidebars) and in background.js via window.muxy. It needs the panels:write permission. To open it from a shortcut with nothing else on screen, prefer background.js (always running) or the declarative openModal command action.

// from background.js (recommended for shortcut-driven modals) or any page
const result = await muxy.modal.openWebview({
  entry: 'modal/index.html',       // required — an HTML asset in your extension
  width: 460,
  height: 300,
  dismissOnOutsideClick: true,     // default true; false keeps it open on outside clicks
  data: { greeting: 'Hello' },     // exposed as muxy.data inside the modal page
});
// result is whatever the modal chose to submit, or null if it was dismissed

The modal page is a normal extension page: it gets the full window.muxy, the theme variables, and muxy.data. If it has an input, auto-focus it with the standard autofocus attribute or .focus() — the modal page owns its own keyboard focus.

Inside the modal page:

// OPTIONAL: hand a value back to the opener and close. Only for modals that return
// something — an informational modal or self-contained page never needs this.
muxy.modal.submitWebview({ name: nameField.value });

// close without a result (opener's promise resolves null)
muxy.lifecycle.close();

A modal does not have to return anything. An informational or self-contained modal (one that does its own muxy.storage/muxy.http/etc. work) just calls muxy.lifecycle.close(), or lets the user dismiss it — the opener's promise resolves null. Use submitWebview(value) only when the opener actually wants a value back.

From the opener you can also close it programmatically:

muxy.modal.closeWebview();   // opener-side close; the promise resolves with null
FieldTypeRequiredNotes
entrystringyesHTML asset path inside your extension (must resolve inside the extension directory).
widthnumbernoCard width. Defaults to 480, clamped to 120–900.
heightnumbernoCard height. Defaults to 320, clamped to 120–760.
dismissOnOutsideClickbooleannoWhen true (default), clicking the scrim closes with null. When false, only Escape / submitWebview / closeWebview / lifecycle.close() close it.
dataanynoJSON exposed as muxy.data inside the modal page.

Opening from a shortcut (openModal)

To open a webview modal directly from a keyboard shortcut with no page or background.js running, declare an openModal command action. Muxy opens the modal natively; the modal page is self-contained — it does its own work and closes itself (there is no opener awaiting a result here).

{
  "muxy": {
    "permissions": ["panels:write"],
    "commands": [
      {
        "id": "quick-note",
        "title": "Quick Note",
        "defaultShortcut": "cmd+ctrl+m",
        "action": {
          "kind": "openModal",
          "entry": "modal/index.html",
          "width": 460,
          "height": 300,
          "dismissOnOutsideClick": true
        }
      }
    ]
  }
}

The openModal action carries the modal fields inline (entry required; width, height, dismissOnOutsideClick, data optional) and needs panels:write. When you want the result back or dynamic data, subscribe to the command event in background.js and call muxy.modal.openWebview(...) there instead.

  • The result flows back only via submitWebview(value)lifecycle.close() and outside-click / Escape resolve with null. The result payload is capped at 256 KB.
  • Only one webview modal is shown at a time; opening a new one resolves the previous opener with null.
  • muxy.lifecycle.onBeforeClose(({ surface }) => ...) runs before close (surface is "modalWebview"), so the modal page can guard against losing unsaved input.
  • The overlay presents on the main Muxy window at the same top-center position as the omnibox.