npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@opentui-ui/dialog

v0.1.2

Published

A dialog/modal library for terminal UIs built on OpenTUI

Readme

Features

  • Lightweight default styling
  • Size presets (small, medium, large, full)
  • Click-to-close backdrop (opt-in)
  • ESC key to close
  • Dialog stack support (multiple dialogs)
  • Focus management (saves/restores focus on open/close)
  • Theme presets (minimal, unstyled)
  • React and Solid.js integrations

Table of Contents

Installation

bun add @opentui-ui/dialog

Core Usage

Use the core API when working directly with @opentui/core renderables without a framework.

Quick Start

import { DialogContainerRenderable, DialogManager } from "@opentui-ui/dialog";
import { TextRenderable } from "@opentui/core";

// 1. Create the manager and container
const manager = new DialogManager(renderer);
const container = new DialogContainerRenderable(renderer, { manager });
renderer.root.add(container);

// 2. Show dialogs from anywhere!
manager.show({
  content: (ctx) => new TextRenderable(ctx, { content: "Hello World!" }),
});

Quick Reference

// Show dialogs
manager.show({ content: (ctx) => new TextRenderable(ctx, { content: "Hello" }) });
manager.show({ content: fn, size: "large" });        // With size preset
manager.show({ content: fn, id: "my-dialog" });      // With custom ID

// Close dialogs
manager.close();        // Close top-most
manager.close(id);      // Close specific
manager.closeAll();     // Close all
manager.replace({...}); // Close all and show new

// Query state
manager.isOpen();       // boolean
manager.getDialogs();   // readonly Dialog[]
manager.getTopDialog(); // Dialog | undefined

Choosing the Right Method

| Method | Best For | dialogId Available | Keyboard Handling | |--------|----------|---------------------|-------------------| | confirm() | Yes/No decisions | ✅ via context | ✅ useDialogKeyboard works | | alert() | Acknowledgments | ✅ via context | ✅ useDialogKeyboard works | | prompt<T>() | Getting user input | ✅ via context | ✅ useDialogKeyboard works | | choice<K>() | Multiple options | ✅ via context | ✅ useDialogKeyboard works | | show() | Non-interactive display | ❌ | ❌ Manual workarounds needed |

Use async methods for any dialog requiring user interaction or keyboard handling. They provide dialogId in the content context, enabling proper keyboard event scoping.

Use show() only for non-interactive dialogs that need external lifecycle control:

// Loading dialog pattern - close from outside
const loadingId = manager.show({
  content: (ctx) => new TextRenderable(ctx, { content: "Loading..." }),
});

await performOperation();

manager.close(loadingId);

Async Prompts

For common dialog patterns, use the built-in async methods that return Promises. Content functions receive both renderCtx and the dialog context, with renderCtx first to match the OpenTUI pattern:

import { BoxRenderable, TextRenderable } from "@opentui/core";

// Confirmation dialog - returns boolean
const confirmed = await manager.confirm({
  content: (renderCtx, { resolve, dismiss }) => {
    const box = new BoxRenderable(renderCtx, { flexDirection: "column" });
    const title = new TextRenderable(renderCtx, { content: "Delete file?" });
    box.add(title);

    const buttons = new BoxRenderable(renderCtx, { flexDirection: "row" });
    const cancelBtn = new TextRenderable(renderCtx, { content: "Cancel" });
    cancelBtn.on("mouseUp", dismiss);
    const confirmBtn = new TextRenderable(renderCtx, { content: "Confirm" });
    confirmBtn.on("mouseUp", () => resolve(true));
    buttons.add(cancelBtn);
    buttons.add(confirmBtn);
    box.add(buttons);

    return box;
  },
  fallback: false, // Optional: value when dismissed via ESC/backdrop (default: false)
});

// Alert dialog - returns void
await manager.alert({
  content: (renderCtx, { dismiss }) => {
    const box = new BoxRenderable(renderCtx, { flexDirection: "column" });
    const text = new TextRenderable(renderCtx, { content: "Operation complete!" });
    box.add(text);
    const okBtn = new TextRenderable(renderCtx, { content: "OK" });
    okBtn.on("mouseUp", dismiss);
    box.add(okBtn);
    return box;
  },
});

// Choice dialog - returns selected key or undefined
const action = await manager.choice<"save" | "discard">({
  content: (renderCtx, { resolve, dismiss }) => {
    const box = new BoxRenderable(renderCtx, { flexDirection: "column" });
    // ... build UI
    return box;
  },
  fallback: "discard", // Optional: value when dismissed via ESC/backdrop
});

// Generic prompt - returns typed value or undefined
const value = await manager.prompt<string>({
  content: (renderCtx, { resolve, dismiss }) => {
    const box = new BoxRenderable(renderCtx, { flexDirection: "column" });
    // ... build UI
    return box;
  },
  fallback: "default", // Optional: value when dismissed via ESC/backdrop
});

Context Methods at a Glance

| Method | Returns | Context Properties | Notes | | ------ | ------- | ------------------ | ----- | | confirm() | Promise<boolean> | resolve(boolean), dismiss(), dialogId | resolve(true) = confirm, dismiss() = cancel | | alert() | Promise<void> | dismiss(), dialogId | Just acknowledge and close | | choice<K>() | Promise<K \| undefined> | resolve(key), dismiss(), dialogId | dismiss() returns undefined | | prompt<T>() | Promise<T \| undefined> | resolve(value), dismiss(), dialogId | dismiss() returns undefined |

Pattern summary:

  • resolve(value) — complete the dialog with a value
  • dismiss() — close without a value (or acknowledge for alerts)
  • dialogId — unique ID for this dialog

Programmatic close: If you need to close an async dialog externally (e.g., from a timer or external event), capture the dialogId from the context. Calling manager.close(dialogId) will close the dialog and resolve the promise with the fallback value (undefined, false, etc.) — the promise will not be left pending.

API Reference

DialogManager

const manager = new DialogManager(renderer);

// Show a dialog - returns the dialog ID
const id = manager.show({
  content: (ctx) => new TextRenderable(ctx, { content: "Hello" }),
  size?: "small" | "medium" | "large" | "full",
  style?: DialogStyle,
  unstyled?: boolean,
  backdropColor?: string, // default: "#000000"
  backdropOpacity?: number | string, // 0-1 or "50%" (default: 0.35)
  closeOnEscape?: boolean, // default: true (per-dialog override)
  closeOnClickOutside?: boolean, // default: false
  onClose?: () => void,
  onOpen?: () => void,
  onBackdropClick?: () => void,
  id?: string | number, // optional custom ID
});

// Close dialogs
manager.close();        // Close top-most
manager.close(id);      // Close specific
manager.closeAll();     // Close all
manager.replace({...}); // Close all and show new

// Query state
manager.isOpen();       // boolean
manager.getDialogs();   // readonly Dialog[]
manager.getTopDialog(); // Dialog | undefined

// Async prompt methods
const confirmed = await manager.confirm({ content: ... });
const value = await manager.prompt<string>({ content: ... });
await manager.alert({ content: ... });
const choice = await manager.choice<"a" | "b">({ content: ... });

// Subscribe to changes
const unsubscribe = manager.subscribe((data) => {
  // Called when dialogs change
});

// Cleanup
manager.destroy();

DialogContainerRenderable

const container = new DialogContainerRenderable(renderer, {
  manager, // Required: DialogManager instance
  size: "medium", // Default size preset
  dialogOptions: {
    // Default style options for all dialogs
    style: DialogStyle,
  },
  sizePresets: {
    // Custom size presets (terminal columns)
    small: 40,
    medium: 60,
    large: 80,
  },
  backdropColor: "#000000", // Default backdrop color
  backdropOpacity: 0.35, // 0-1 or "50%" (default: 0.35)
  closeOnEscape: true, // ESC key closes top dialog (default: true)
  closeOnClickOutside: false, // Backdrop click closes top dialog (default: false)
  unstyled: false, // Disable default styles (default: false)
});

// Add to render tree
renderer.root.add(container);

DialogStyle

interface DialogStyle {
  // Content panel
  backgroundColor?: string; // Default: "#262626"
  borderColor?: string;
  borderStyle?: BorderStyle;
  border?: boolean; // Default: false

  // Sizing
  width?: number | string;
  maxWidth?: number;
  minWidth?: number;
  maxHeight?: number;

  // Padding (default: 1 cell all around)
  padding?: number;
  paddingX?: number;
  paddingY?: number;
  paddingTop?: number;
  paddingRight?: number;
  paddingBottom?: number;
  paddingLeft?: number;
}

Framework Adapters (React & Solid)

Both frameworks share the same API pattern with DialogProvider, useDialog(), and useDialogState().

Setup

// React
import {
  DialogProvider,
  useDialog,
  useDialogKeyboard,
  useDialogState,
} from "@opentui-ui/dialog/react";

// Solid
import {
  DialogProvider,
  useDialog,
  useDialogKeyboard,
  useDialogState,
} from "@opentui-ui/dialog/solid";

function App() {
  return (
    <DialogProvider size="medium">
      <MyContent />
    </DialogProvider>
  );
}

Using Themes

import { DialogProvider } from "@opentui-ui/dialog/react";
import { themes } from "@opentui-ui/dialog/themes";

function App() {
  return (
    <DialogProvider {...themes.minimal}>
      <MyContent />
    </DialogProvider>
  );
}

Choosing the Right Method

See the comparison table in Core Usage for when to use show() vs async methods.

TL;DR: Use async methods (confirm, alert, prompt, choice) for interactive dialogs. Use show() only for non-interactive display (loading spinners) that need external lifecycle control:

// Loading dialog - close from outside
const loadingId = dialog.show({ content: () => <LoadingSpinner /> });
await performOperation();
dialog.close(loadingId);

Async Prompts (Framework)

Content functions receive only the dialog context (resolve, dismiss, dialogId) — no renderCtx needed:

// Confirmation dialog - returns boolean
const confirmed = await dialog.confirm({
  content: (ctx) => (
    <box flexDirection="column">
      <text>Delete this file?</text>
      <box flexDirection="row" gap={1}>
        <text onMouseUp={ctx.dismiss}>Cancel</text>
        <text onMouseUp={() => ctx.resolve(true)}>Confirm</text>
      </box>
    </box>
  ),
  fallback: false, // Optional: value when dismissed via ESC/backdrop (default: false)
});

// Alert dialog - returns void (just acknowledgment)
await dialog.alert({
  content: (ctx) => (
    <box flexDirection="column">
      <text>Operation complete!</text>
      <text onMouseUp={ctx.dismiss}>OK</text>
    </box>
  ),
});

// Choice dialog - returns selected key or undefined
const action = await dialog.choice<"save" | "discard">({
  content: (ctx) => (
    <box flexDirection="column">
      <text>Unsaved changes</text>
      <text onMouseUp={() => ctx.resolve("save")}>Save</text>
      <text onMouseUp={() => ctx.resolve("discard")}>Discard</text>
      <text onMouseUp={ctx.dismiss}>Cancel</text>
    </box>
  ),
  fallback: "discard", // Optional: value when dismissed via ESC/backdrop
});

// Generic prompt - returns typed value or undefined
const name = await dialog.prompt<string>({
  content: (ctx) => (
    <box flexDirection="column">
      <text>Enter your name:</text>
      <input onSubmit={(value) => ctx.resolve(value)} />
      <text onMouseUp={ctx.dismiss}>Cancel</text>
    </box>
  ),
  fallback: "Anonymous", // Optional: value when dismissed via ESC/backdrop
});

Building Custom Async Dialogs

The confirm(), alert(), and choice() methods are convenience wrappers. For custom patterns, use prompt<T>() directly:

// Example: A rating dialog that returns 1-5
const rating = await dialog.prompt<number>({
  content: (ctx) => (
    <box flexDirection="column">
      <box flexDirection="row" gap={1}>
        {[1, 2, 3, 4, 5].map((n) => (
          <text key={n} onMouseUp={() => ctx.resolve(n)}>
            {n}
          </text>
        ))}
      </box>
      <text onMouseUp={ctx.dismiss}>Skip rating</text>
    </box>
  ),
  fallback: 0, // Return 0 if dismissed via ESC/backdrop
});

useDialog() Hook

Returns dialog actions for imperatively controlling dialogs.

const dialog = useDialog();

// Show a dialog (content must be a function for both React and Solid)
dialog.show({
  content: () => <MyContent />,
  size: "medium",
  style: { backgroundColor: "#1a1a1a" },
  unstyled: false,
  backdropColor: "#000000",
  backdropOpacity: 0.5,
  closeOnEscape: true,
  closeOnClickOutside: true,
  onClose: () => {},
  onOpen: () => {},
  id: "my-dialog",
});

// Close dialogs
dialog.close();        // Close top-most
dialog.close(id);      // Close specific
dialog.closeAll();     // Close all
dialog.replace({...}); // Close all and show new

// Async prompt methods
const confirmed = await dialog.confirm({ content: ... });
const value = await dialog.prompt<string>({ content: ... });
await dialog.alert({ content: ... });
const choice = await dialog.choice<"a" | "b">({ content: ... });

useDialogState() Hook

Subscribe to reactive dialog state using a selector.

interface DialogState {
  isOpen: boolean; // Whether any dialog is open
  count: number; // Number of open dialogs
  dialogs: readonly Dialog[]; // All active dialogs (oldest first)
  topDialog: Dialog | undefined; // The top-most dialog
}

const isOpen = useDialogState((s) => s.isOpen);
const count = useDialogState((s) => s.count);
const topDialog = useDialogState((s) => s.topDialog);

[!WARNING] Always select primitives not new objects.

// Good - selects primitives
const isOpen = useDialogState((s) => s.isOpen);
const count = useDialogState((s) => s.count);

// Bad - creates new object every time, always re-renders
const state = useDialogState((s) => ({ isOpen: s.isOpen, count: s.count }));

Key difference: React returns values directly, Solid returns accessors you must call.

// React - values directly
if (isOpen) {
  console.log(`${count} dialog(s) open`);
}

// Solid - call accessors
if (isOpen()) {
  console.log(`${count()} dialog(s) open`);
}

useDialogKeyboard() Hook

When using useKeyboard from @opentui/react or @opentui/solid inside dialog content, keyboard events fire for all stacked dialogs simultaneously. This is because useKeyboard registers global listeners with no focus scoping.

useDialogKeyboard solves this by only firing the handler when the dialog is the topmost in the stack:

import { useDialogKeyboard, type ConfirmContext } from "@opentui-ui/dialog/react";

function DeleteConfirmDialog({ resolve, dialogId }: ConfirmContext) {
  const [selected, setSelected] = useState<"cancel" | "delete">("cancel");

  // Only fires when THIS dialog is topmost
  useDialogKeyboard((key) => {
    if (key.name === "tab") {
      setSelected((prev) => (prev === "cancel" ? "delete" : "cancel"));
    }
    if (key.name === "return") {
      resolve(selected === "delete");
    }
    if (key.name === "escape") {
      resolve(false);
    }
  }, dialogId);

  return (
    <box flexDirection="column">
      <text>Delete this file?</text>
      <box flexDirection="row" gap={1}>
        <text bg={selected === "cancel" ? "#333" : undefined}>Cancel</text>
        <text bg={selected === "delete" ? "#c00" : undefined}>Delete</text>
      </box>
    </box>
  );
}

Manual Implementation with dialogId

If you need more control, use dialogId with useDialogState and the standard useKeyboard hook:

import { useKeyboard } from "@opentui/react";
import { useDialogState, type ConfirmContext } from "@opentui-ui/dialog/react";

function MyDialog({ resolve, dialogId }: ConfirmContext) {
  // Reactively check if this dialog is topmost
  const isTopmost = useDialogState((s) => s.topDialog?.id === dialogId);

  useKeyboard((key) => {
    // Guard: only handle events when topmost
    if (!isTopmost) return;

    if (key.name === "return") {
      resolve(true);
    }
  });

  return <text>Press Enter to confirm</text>;
}

Full Example

// React (content must be a function)
function MyContent() {
  const dialog = useDialog();
  const isOpen = useDialogState((s) => s.isOpen);

  return (
    <box>
      <text>{isOpen ? "Dialog open" : "No dialog"}</text>
      <box onMouseUp={() => dialog.show({ content: () => <text>Hello!</text> })}>
        <text>Open Dialog</text>
      </box>
    </box>
  );
}

// Solid - note: content is a function, accessors are called
function MyContent() {
  const dialog = useDialog();
  const isOpen = useDialogState((s) => s.isOpen);

  return (
    <box>
      <text>{isOpen() ? "Dialog open" : "No dialog"}</text>
      <box
        onMouseUp={() => dialog.show({ content: () => <text>Hello!</text> })}
      >
        <text>Open Dialog</text>
      </box>
    </box>
  );
}

Customization

These options work with both core and framework usage.

Default Styling

Out of the box, dialogs use the minimal theme:

  • Lighter backdrop (35% opacity)
  • No borders
  • Tighter padding (1 cell all around)

This provides a clean, unobtrusive appearance while still being usable immediately.

Themes

Theme presets provide alternative visual styles. Import from @opentui-ui/dialog/themes:

import { DialogContainerRenderable, DialogManager } from "@opentui-ui/dialog";
import { themes } from "@opentui-ui/dialog/themes";

const container = new DialogContainerRenderable(renderer, {
  manager,
  ...themes.unstyled, // Start from scratch with no default styles
});

| Theme | Description | | ----- | ----------- | | minimal | Lighter backdrop (35%), no borders, tighter padding (default) | | unstyled | No backdrop, no background, no border, no padding |

Customize a theme:

const container = new DialogContainerRenderable(renderer, {
  manager,
  ...themes.minimal,
  size: "large", // Override specific options
});

Unstyled Mode

For full control over dialog styling, use unstyled: true. This disables all default styles (backdrop, background, border, padding):

const container = new DialogContainerRenderable(renderer, {
  manager,
  unstyled: true,
  dialogOptions: {
    style: {
      // Add your own styles
      backgroundColor: "#262626",
      border: true,
      borderColor: "#525252",
    },
  },
});

Size Presets

Default size presets (in terminal columns):

| Size | Width | | ------ | ------------------ | | small | 40 | | medium | 60 | | large | 80 | | full | terminal width - 4 |

Override with sizePresets option:

const container = new DialogContainerRenderable(renderer, {
  manager,
  sizePresets: {
    small: 35,
    medium: 55,
    large: 75,
  },
});

TypeScript

Full TypeScript support with exported types:

// Core types
import type {
  // Dialog types
  Dialog,
  DialogContainerOptions,
  DialogContentFactory,
  DialogId,
  DialogShowOptions,
  DialogSize,
  DialogState,
  DialogStyle,
  DialogTheme,
  DialogToClose,
  // Async prompt contexts
  AlertContext,
  ChoiceContext,
  ConfirmContext,
  PromptContext,
  // Async dialog options (for imperative usage)
  AlertOptions,
  ChoiceOptions,
  ConfirmOptions,
  PromptOptions,
  // Base types (for building custom adapters)
  AsyncDialogOptions,
  BaseAlertOptions,
  BaseChoiceOptions,
  BaseConfirmOptions,
  BaseDialogActions,
  BasePromptOptions,
} from "@opentui-ui/dialog";

// Type guard for close events
import { isDialogToClose } from "@opentui-ui/dialog";

// Themes and default style constants
import {
  DEFAULT_BACKDROP_COLOR,
  DEFAULT_BACKDROP_OPACITY,
  DEFAULT_PADDING,
  DEFAULT_STYLE,
  themes,
  type DialogTheme,
} from "@opentui-ui/dialog/themes";

License

MIT