Skip to content

ctx.ui stable

User-interaction — the only sanctioned way to talk to the user. The host renders the chrome; your extension just asks. Five kinds of interaction ship today: native OS dialogs (folder / open-file / save pickers), transient toast notifications, menus (context menus and button dropdowns), modal dialogs (confirm / prompt, plus showModal for your own custom content), and openExternal to send a URL out to the browser / mail client, and getActiveSelectionText to read the user's current selection. Mirrors VS Code's window.show*.

ts
ctx.ui: UiService

Example

tsx
// ask the user to pick a folder
const dir = await ctx.ui.pickFolder();
if (dir) addFolder(dir);

// pick a JSON file to import
const file = await ctx.ui.pickFile({
  filters: [{ name: "JSON", extensions: ["json"] }],
});

// pick a save destination (seed it with a suggested name)
const dest = await ctx.ui.savePath({
  defaultPath: "my-theme.json",
  filters: [{ name: "JSON", extensions: ["json"] }],
});
if (dest) await ctx.files.writeText(dest, json);

// tell the user something happened
ctx.ui.notify("info", "Theme exported.");
ctx.ui.notify("error", "Couldn't read that file.");

// an error toast with a title and an action that opens the full detail in a
// modal (the toast stays up until dismissed, so the action isn't lost)
ctx.ui.notify("error", summary, {
  title: "Commit failed",
  actions: [
    {
      label: "View details",
      run: () =>
        ctx.ui.showModal((close) => <pre>{fullError}</pre>, {
          title: "Commit failed",
          dismissible: true,
        }),
    },
  ],
});

// a right-click context menu at the cursor
element.addEventListener("contextmenu", (e) => {
  e.preventDefault();
  ctx.ui.showMenu({
    items: [
      { label: "Rename", accelerator: "↵", run: rename },
      { type: "separator" },
      { label: "Delete", danger: true, run: del },
    ],
  });
});

// a dropdown anchored under a button, with a checked (current) row
ctx.ui.showMenu({
  items: [
    { type: "header", label: "Theme" },
    { label: "Dark", checked: true, run: () => setTheme("dark") },
    { label: "Light", run: () => setTheme("light") },
  ],
  anchor: buttonEl,
});

// a row that cascades a submenu (give it `submenu` instead of `run`)
ctx.ui.showMenu({
  items: [
    { label: "Switch to…", run: switchWorkspace },
    { type: "separator" },
    {
      label: "Add",
      submenu: [
        { label: "Existing…", run: addExisting },
        { label: "New…", run: addNew },
      ],
    },
  ],
  anchor: buttonEl,
});

// confirm before a destructive action (resolves true/false)
if (
  await ctx.ui.confirm({
    title: "Delete workspace?",
    body: `"${name}" and its saved terminals will be permanently removed.`,
    confirmLabel: "Delete",
    danger: true,
  })
) {
  service.delete(id);
}

// prompt for a string (resolves the text, or null on cancel)
const renamed = await ctx.ui.prompt({ title: "Rename", initialValue: current });
if (renamed !== null) rename(renamed);

// a modal around your own content (resolves whatever you pass to `close`)
const changes = await ctx.ui.showModal<Changes>(
  (close) => <MyForm onCancel={() => close()} onSave={(c) => close(c)} />,
  { title: "Properties", size: "md" },
);
if (changes) apply(changes);

// open a link out in the browser / mail client (scheme-guarded to http(s)/mailto)
await ctx.ui.openExternal("https://silo.dev/docs");

// read whatever the user has selected in the focused editor or terminal
const selected = ctx.ui.getActiveSelectionText();
if (selected) runSearch(selected);

Methods

UiService (ctx.ui):

MethodWhat it does
pickFolder(opts?)Show the native folder picker. Resolves to an absolute path, or null if cancelled.
pickFile(opts?)Show the native open-file picker (single selection), optionally filtered. Resolves to an absolute path, or null if cancelled.
savePath(opts?)Show the native save dialog, optionally filtered. Resolves to the chosen absolute path, or null if cancelled.
notify(level, message, opts?)Show a transient toast ("info" / "warn" / "error"). Fire-and-forget; info/warn auto-dismiss. Pass NotifyOptions for a title and action buttons.
showMenu(opts)Pop a menu — the same themed primitive behind every context menu and dropdown in Silo. Resolves when an item runs or it's dismissed.
confirm(opts)Pop a host-rendered confirm dialog. Resolves true (confirm) / false (cancel). Always dismissible; set danger for destructive actions.
prompt(opts)Pop a single-line input dialog. Resolves the entered string, or null if cancelled.
showModal(render, opts?)Pop a modal around your own custom content (a form / bespoke layout). Resolves the value your content passes to close, or undefined. Not dismissible by default.
openExternal(url)Hand a URL to the OS — http(s) opens the browser, mailto: the mail client. Scheme-guarded: any other scheme (file:, javascript:, …) rejects, so untrusted URLs are safe to pass straight in.
getActiveSelectionText()The text selected in the focused surface — the active editor or a focused terminal — or null when nothing is selected. Lets a command (e.g. "Find in Files") seed itself from the user's selection.

Each picker accepts an options object: defaultPath seeds the dialog's location (and, for savePath, the suggested filename); filters is a list of FileFilter (pickFile / savePath only) that restricts the file-type dropdown.

showMenu takes a ShowMenuOptions: an items list of MenuEntry (each a MenuItem, { type: "separator" }, or { type: "header", label }) plus where to place it. Position resolves anchorat → the current cursor, so calling it with just items from a right-click handler opens at the mouse. Only one menu is open at a time, and re-opening with the same anchor toggles it closed — so a second click on a dropdown button dismisses it (pass toggle: false to keep the always-open behaviour). An item's run fires when it's chosen (the menu closes first); set danger for destructive rows, checked for the current selection, disabled to dim a row, and trailing for a secondary per-row control (see MenuItemTrailing). Give a row a submenu (a nested MenuEntry list) instead of a run to make it a parent that cascades that menu to its side on hover.

notify takes a level, a message, and an optional NotifyOptions: a bold title, an actions list of NotifyAction buttons, and a durationMs override. By default info / warn toasts auto-dismiss after ~4s, while errors and any toast with actions stay until dismissed — so a "View details" action (typically pairing with showModal) isn't lost to the timer. An action's run fires on click and the toast then closes, unless the action sets keepOpen.

confirm takes a ConfirmOptions (title, optional body, confirmLabel / cancelLabel, danger) and prompt a PromptOptions (title, plus label, initialValue, placeholder, and the button labels). Both are always dismissible — Escape and a backdrop-click resolve the safe choice (false / null).

For anything beyond a yes/no or single field, reach for showModal: pass a render callback that receives a close function and returns your content, plus a ModalOptions (title, size, bare, dismissible, …). Wire your buttons to close(result) (or close() to cancel); the returned promise resolves with that result, or undefined on dismiss. Unlike confirm/prompt, showModal is not dismissible by default — so a stray click-away can't discard staged edits. (The flip side: because it settles only on close, a non-dismissible modal whose content never calls close leaves the promise pending forever — make sure every path out calls it.) Lay the footer buttons out in a .silo-modal-actions row, and style them with the global button classes. All of these ride the same host-arbitrated stacking and focus trap.

openExternal(url) is the host's gateway to the world outside the app — it hands the URL to the OS, opening http(s) links in the default browser and mailto: links in the mail client. It's scheme-guarded: only http:, https:, and mailto: are opened; anything else (notably file: and javascript:) rejects the returned promise without opening. That guard makes it safe to pass URLs you didn't author — e.g. a link clicked inside a rendered Markdown document — straight through, catching the rejection to warn the user about an unopenable link.

Types

UiService · FileFilter · ShowMenuOptions · MenuEntry · MenuItem · MenuItemTrailing · MenuSeparator · MenuHeader · ConfirmOptions · PromptOptions · ModalOptions · NotifyOptions · NotifyAction.

getActiveSelectionText() reads the most-recently-focused surface's selection — the active editor or a focused terminal — so a command works regardless of which one has focus. It returns null (never throws) when nothing is focused or selected. Pairs naturally with ctx.search and ctx.editors.open's selection to build a "search for the selected text, then jump to a hit" flow.

Notes

Pickers are user-interaction, so they live here rather than on ctx.filesfiles stays pure host-mediated I/O. The dialogs are the platform's native ones; the host owns the privileged platform access.

notify is the only way an extension can proactively message the user. Keep messages short — they're toasts, not modals. For long or structured output (stderr, a log), put a short summary in the toast and offer a "View details" action that opens the full text via showModal.

confirm and prompt are modal — they block on the user's choice. Reach for a toast (notify) when you just need to inform, and confirm/prompt when you need an answer before continuing.

Planned

More host-rendered chrome is designed but not yet shipped — quickPick and progress. See the roadmap.