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

@ade-dev/chat-ui

v0.5.1

Published

Embeddable React chat components for the ADE SDK.

Readme

@ade-dev/chat-ui

Embeddable React chat components over @ade-dev/sdk.

Docs: Chat UI · ADE SDK

This package renders an agent conversation for your users, not for ADE developers. There are no lanes, projects, repos, or worktrees in any prop or any string it can display. Tool activity is renamed through a label map, so a customer sees "Searching your invoices…" where the agent ran server.tool.

  • Zero runtime dependencies. React and React DOM are peers; @ade-dev/sdk is an optional peer used for types only.
  • Theming is CSS custom properties only — no Tailwind, no class overrides.
  • Every component is importable standalone; <AdeChat> is one assembly of them.
npm install @ade-dev/chat-ui
import { AdeChat, createTheme } from "@ade-dev/chat-ui";

<AdeChat
  client={client}
  threadKey="support-42"
  labels={{ map: { "server.*": "Looking that up…" } }}
  theme={createTheme({ accent: "#7c5cff", background: "#0e0f13" })}
/>;

Layout

packages/chat-ui/
  src/
    index.ts                     public surface
    sdkTypes.ts                  copied SDK contract (see below)
    AdeChat.tsx                  composed default
    composer/
      Composer.tsx
      composerState.ts           pure send/steer/key decisions
    transcript/
      Transcript.tsx             scroll container + row views + ActivityIndicator
      ToolChip.tsx
      ApprovalCard.tsx           inline approval card
      transcriptRows.ts          ported row collapsing/grouping
      markdown.tsx               dependency-free markdown renderer
      links.tsx                  AdeLink + onLinkClick context (every link chat-ui draws)
    models/
      ModelPicker.tsx            rail + search + grouped list
      ProviderCard.tsx           ProviderCard, ProviderCards
      modelSearch.ts             ported scoring + provider grouping
    activity/labels.ts           label map, wildcards, phases, elapsed
    theme/
      createTheme.ts             token generator
      styles.ts                  the stylesheet
      AdeChatStyles.tsx
    context/AdeChatContext.tsx   provider + useAdeProviders + useAdeThread
  examples/
    fakeClient.ts                typed in-memory client
    basic.tsx                    full assembly, both shapes
  test/                          225 tests

Ported files carry a provenance header naming their ADE desktop source and what was trimmed. transcriptRows.ts and modelSearch.ts are ports; Composer.tsx and the model picker components are fresh implementations that follow the desktop interaction/visual structure without inheriting its props.

Components

<AdeChat>

Transcript above, composer with the model rail below. No header bar.

| Prop | Type | Default | | |---|---|---|---| | client | AdeChatClient | — | required | | threadKey | string | — | changing it opens a different conversation | | defaultModelId / modelId | string | — | uncontrolled / controlled selection | | onModelChange | (model: ModelDescriptor) => void | — | | | labels | ActivityLabelConfig | — | | | theme | Partial<AdeChatTheme> | — | usually createTheme(...) | | disableStyles | boolean | false | skip the injected stylesheet | | placeholder, sendOnEnter, onRequestAttachment | | | forwarded to <Composer> | | value / onValueChange | | — | controlled draft, forwarded to <Composer> (0.4) | | onSend | (input, thread) => AdeChatSendResult \| Promise<…> | — | runs before a new message is sent; false cancels and keeps the draft, "handled" means you sent it yourself (0.4) | | children | (thread: ThreadState) => ReactNode | — | drawn between transcript and composer (0.4) | | threadRef | { current: ThreadState \| null } | — | the live thread state for code outside the tree (0.4) | | onLinkClick | (href, { source, text }) => void | — | handle links yourself; pass it in Electron (0.4) | | modelRail | ReactNode | built-in picker | replaces the default picker; null = empty rail | | actions | ReactNode | — | extra composer controls (right rail) | | attachments / onAttachmentsChange | | — | controlled staging; see the merge rule under <Composer> | | reasoningEffort / onReasoningEffortChange | | — | the picker's effort control; the host applies it | | renderToolResult, toolChipActions | | | forwarded to <Transcript> | | historyPageSize | number | 200 | events per history page, when the thread has historyPage | | styleNonce | string | — | CSP nonce for the injected <style> | | hideToolCalls, hideReasoning, renderMarkdown, emptyState | | | forwarded to <Transcript> | | approvals | { render?, labels? } | — | approval card wording, or a replacement card | | hideModelPicker | boolean | false | when the host pins a model | | className | string | — | |

The approval card itself is not opt-in. A provider that asks for permission parks its turn until someone answers, so a host that drew nothing would show a conversation that had silently stopped. approvals changes only how it looks.

<Composer>

| Prop | Type | Default | | |---|---|---|---| | onSend | (input: SendInput) => void \| Promise<void> | — | required | | onSteer | same | — | omit to disable steering entirely | | onInterrupt | () => void \| Promise<void> | — | omit to hide Stop | | status | "idle" \| "running" \| "error" | "idle" | | | ready | boolean | true | false while the thread resolves | | disabled | boolean | false | | | value / onValueChange | string / (v) => void | — | controlled draft | | placeholder | string | "Send a message…" | | | sendOnEnter | boolean | true | false swaps to Cmd/Ctrl+Enter | | autoFocus | boolean | false | | | maxRows | number | 12 | autosize ceiling | | onRequestAttachment | () => Promise<ChatAttachment[] \| null> \| … | — | omit to hide the button | | attachments / onAttachmentsChange | | — | controlled staging | | modelRail | ReactNode | — | slot for the model rail | | actions | ReactNode | — | extra controls | | error | string \| null | — | | | className | string | — | |

Controlled attachments: onAttachmentsChange always receives the FULL new list, so replace your state with it and never append. A picker result is merged into the latest list (not the list from when the picker opened), duplicates are removed by id, and a remove click removes one id. Every attachment is sent with type: "image" for an image/* MIME type or an image extension (bmp, gif, heic, heif, ico, jpeg, jpg, png, svg, tif, tiff, webp), else "file". This is the runtime's own rule.

Submitting during a running turn dispatches onSteer, never a second onSend, and staged attachments go with the steer. Enter sends, Shift+Enter is a newline, IME composition never submits, Escape interrupts a running turn. A failed send restores the draft rather than losing it.

<Transcript>

| Prop | Type | Default | | |---|---|---|---| | rows | readonly TranscriptRow[] | — | from buildTranscriptRows() or useAdeThread() | | status | "idle" \| "running" \| "error" | "idle" | drives the live indicator | | labels | ActivityLabelConfig | — | | | hideToolCalls | boolean | false | hides chips entirely | | hideReasoning | boolean | false | | | expandReasoning | boolean | false | reasoning starts collapsed | | renderMarkdown | (text: string) => ReactNode | built-in | | | onLinkClick | (href, { source, text }) => void | — | chat-ui calls preventDefault() and hands you the link. Without it, links open with target="_blank" — in Electron, a chrome-less app window (0.4) | | onApprove | (itemId, decision) => void \| Promise<void> | — | omit to render approval cards read-only | | approvals | { render?, labels? } | — | custom approval card and button wording | | emptyState | ReactNode | "No messages yet." | | | renderToolResult | (row: ToolChipRow) => ReactNode | — | custom result view under a chip | | toolChipActions | (row: ToolChipRow) => { label, onSelect }[] | — | buttons on a chip. Prefer row.resourceLinks; when a tool returns only ids, parse row.result yourself | | hasOlder / loadingOlder / onLoadOlder | | — | "Load older messages" paging | | windowThreshold | number | 150 | row count above which rows are windowed | | overscan | number | 8 | rows mounted on each side of the view | | className | string | — | |

Card set: user text, assistant markdown, collapsed reasoning, tool chips, approvals, error. Pinned to the bottom and released as soon as the reader scrolls up. Above windowThreshold rows only the rows in view plus overscan are mounted (no dependency; every row renders where ResizeObserver is missing). Tool chip rows carry identity: { server, tool } and, when the provider passes them, resourceLinks.

Approval cards

An approval_request becomes an approval row and renders inline where the request happened, not as a modal — the reader needs to see what the agent was doing when it asked. pending_input_resolved settles the same card in place, and a turn that ends unanswered marks it expired. The card never disappears and never takes focus from the composer.

Two rules keep a live card from being drawn dead, which is a hang rather than a cosmetic bug — the buttons go read-only while the runtime still waits:

  • A turn ending is done, or a status of completed / failed / interrupted. Never error. An error ends no turn on either layer: an OpenCode per-tool failure emits one and keeps streaming, and the Codex planning-approval guard emits one to decline a single request.
  • A restored row is live by construction. pendingApprovals() is the engine's authoritative "still blocked right now" list, read after the history window, so no ending in that history expires it. Only an explicit resolution settles it. Restored rows sort in at the instant they were read rather than being appended.

| Decision | Button | Meaning | |---|---|---| | accept | Allow once | this call only | | accept_always | Always allow | stop asking for this in this session | | reject | Reject | refuse, and let the model hear why |

requestKind of question, structured_question, plan_approval or model_selection renders read-only: those want prose or a choice this surface cannot carry, and @ade-dev/sdk's own approve() refuses them with invalid_option rather than sending a verdict the request cannot use. A thread with no approve also renders read-only, with a line saying the host cannot answer.

<ModelPicker>

| Prop | Type | Default | | |---|---|---|---| | value | string \| null | — | selected model id | | onChange | (model: ModelDescriptor) => void | — | required | | models / providers | arrays | — | supply data directly; omit to read from the client | | client | AdeChatClient | context | used only when data is not supplied | | searchable | boolean | true | | | renderProviderNotice | (status, providerId) => ReactNode | — | drawn under an unusable provider group | | className | string | — | |

Models group under their provider; rows are disabled when the provider is not installed/authenticated or the model reports available: false.

<ProviderCard> / <ProviderCards>

Free-floating — the host decides placement.

| Prop | Type | | |---|---|---| | status | ProviderStatus | required on ProviderCard | | renderAction | (command, kind: "install" \| "login") => ReactNode | replace the copy button | | onCopy | (command: string) => void \| Promise<void> | override the clipboard write | | showDetail | boolean (default false) | add a line with the version and a truncated binary path | | className | string | |

<ProviderCards> adds statuses, client, and onlyNeedsAttention (default true) and renders one card per provider needing action. showDetail is forwarded to every card.

A missing provider reads "Not installed" only when source is "probed" — a runtime looked and found nothing. When the status was derived from the model catalog nobody looked, so the card says "Not detected" instead. Telling someone to install a CLI they already have is the failure that distinction removes.

Activity labels

labels={{
  map: {
    "server.tool": { running: "Searching…", done: "Searched", error: "Search failed" },
    "server.*": "Talking to your account…",   // string = running phase only
    "*": "Working…",
  },
  resolve: (source) => source.tool === "x" ? "Custom" : null,  // wins over map
  icons: { "server.*": <ServerIcon /> },
  thinkingLabel: "Thinking…",
  elapsedAfterMs: 3000,
}}

Resolution order: resolve() → exact key → longest wildcard prefix → * → the raw tool name. A bare string labels the running phase only, so a finished chip never keeps saying "Searching…". Labels apply to the live thinking indicator, tool chips, and error text. An elapsed suffix appears after 3s of running and is formatted 45s / 1m 35s / 1h 5m. prefers-reduced-motion is respected in both CSS and the indicator's animated ellipsis.

An MCP tool may be keyed in any spelling — mcp:versic:search, mcp__versic__search, versic:search, or the bare search — and one key matches the tool under every provider. A server-qualified key matches a tool the provider reported by its bare name only when the event names that server (ToolChipRow.identity). A bare name with no server on the event matches only a bare key, and the matcher never guesses a server from the name.

A bare key is compared with the tool's own name, so the bare key to write is the name your providers report. One wildcard covers a whole family when your injected tools carry a prefix:

labels={{
  map: {
    "mcp:versic:*": "Checking your projects…", // wins when the event names the server
    "versic_*": "Checking your projects…",     // the tool's own name, e.g. versic_projects
    "*": "Working…",
  },
}}

versic_* matches versic_projects, whether it arrives bare or as mcp__versic__versic_projects. It does not match a bare search that belongs to the versic server — that name starts with search — so only * or an exact search key reaches that one.

Theming

createTheme({ accent, background, foreground, muted, danger, success, radius, fontFamily, monoFontFamily, fontSize, space, scheme }) returns the full token set; hovers, borders and subtle tints are derived. Pass it to theme, or set the tokens yourself on any ancestor.

--adechat-bg            --adechat-accent           --adechat-radius
--adechat-bg-subtle     --adechat-accent-fg        --adechat-radius-sm
--adechat-bg-raised     --adechat-accent-subtle    --adechat-font
--adechat-fg            --adechat-border           --adechat-font-mono
--adechat-muted         --adechat-border-strong    --adechat-font-size
--adechat-danger        --adechat-hover            --adechat-space
--adechat-danger-subtle --adechat-success

--adechat-root-bg is the chat root's background: transparent for createTheme({ background: "transparent" }), else var(--adechat-bg). Pass scheme with a transparent background. injectAdeChatStyles(target?, { nonce }) and <AdeChatStyles nonce> set a CSP nonce on the injected <style>.

Light/dark is inferred from the background's luminance (override with scheme). Non-hex colors (var(--brand), color-mix(...)) pass straight through; derived tints are only computed for hex inputs.

What adaptSdkClient produces

src/sdkTypes.ts is the view contract this package renders against — a copy, not an import, so the package is standalone and any client shape can satisfy it. src/adapters/sdkClient.ts bridges a real @ade-dev/sdk client onto it and does import that package's types (type-only, and @ade-dev/sdk is an optional peer, so nothing reaches the bundle).

The list below is the adapter's output contract: what every component here may assume about the client it is handed. Write your own client — a WebSocket proxy, an Electron IPC bridge, a fake — and these are the rules to meet.

One such client is checked here rather than described: test/electronBridge.test.tsx assigns createAdeIpcClient() from @ade-dev/sdk/electron/renderer to SdkLikeChatClient with no cast, then drives a full turn through registerAdeIpc and <AdeChat>. A drift between that bridge and this contract is a typecheck failure, not a runtime surprise.

  1. client.providers.status() resolves ProviderStatus[] with installed and authenticated as separate booleans. Both true means selectable. loginCommand / installCommand are copy-pasteable shell strings. source says whether installed was probed or derived, and the card's wording depends on it.
  2. client.providers.onChange(cb) fires with the full status list (not a delta) and returns an unsubscribe function. A status change may also change the model catalog, so the hook re-reads models.list() after each one. refresh() is optional and is never called by a poll.
  3. client.models.list() resolves the full catalog. providerId must match a ProviderStatus.id; models whose provider has no status entry still render (grouped under the raw id) but are never selectable.
  4. client.threads.open(key, opts) is idempotent per key within a session — re-opening the same key returns a handle onto the same conversation.
  5. thread.history() returns envelopes in transcript order. Ordering is envelope-based (sequence, then timestamp); provider clocks are not trusted. Events emitted while history() is in flight must also reach the "event" subscriber — the hook de-duplicates the overlap on sessionId:sequence:timestamp:type and then re-sorts, so a live envelope that beat the page still renders in its own place. The canonical definition of that key is envelopeDedupeKey in @ade-dev/sdk (src/electron/protocol.ts); this package mirrors it because the SDK is an optional peer.
  6. Streaming text may be sent either as growing snapshots or as deltas; both collapse correctly. Chunks of one message must share a messageId, or failing that a turnId + itemId.
  7. tool_result must reuse its tool_call's itemId, or carry a matching logicalItemId when the provider renumbers items. Otherwise the result renders as a second, orphaned chip.
  8. thread.on("status") reports running for the whole duration of a turn and returns to idle when it ends. The composer's send-vs-steer decision is driven entirely by this.
  9. steer() does not start a turn. It delivers into the running one and must be safe to call while status.state === "running".
  10. Unknown event types are ignored, not rendered. The SDK may add event kinds without breaking this package, but anything it wants drawn here needs a matching row kind.
  11. approval_request blocks the turn until it is answered. An approval_request and its pending_input_resolved must share an itemId (or a matching logicalItemId), and a turn that ends without an answer must emit done, error, or a terminal status so the card can settle.
  12. thread.approve and thread.pendingApprovals are optional. A client that omits approve gets a read-only card with a line saying why, never a throw. pendingApprovals() is read once on open, so a reload restores the cards a lost live stream would otherwise have taken with it.

What @ade-dev/sdk actually returns

The adapter exists because the two shapes do not meet. The differences that catch people writing their own proxy:

  • providers.status() returns a Record<string, ProviderStatus> keyed by provider id, not an array.
  • A status record calls the id provider, not id, and carries available / requiresConfiguration / modelCount / stale — fields this package folds into installed and a detail sentence.
  • A catalog entry calls its provider provider and its usability isAvailable, where the view contract uses providerId and available.
  • threads.open(key, opts) takes the SDK's own option names (provider, model), which adaptSdkClient maps from providerId / modelId.
  • send(text, { attachments }) is positional, and an attachment is a { path } file ref rather than the view's ChatAttachment.
  • on("status") and on("usage") deliver raw envelopes; the adapter maps them to { state, turnId } and { inputTokens, … } and drops anything that maps to nothing.

Development

Requires Node 22 (/opt/homebrew/opt/node@22/bin on macOS).

npm install            # in packages/chat-ui
npm test               # vitest, 225 tests
npm run typecheck      # tsc --noEmit
npm run build          # tsup → dist/ (ESM + CJS + d.ts)

From the repo root: npm run test:chat-ui, npm run build:chat-ui.

License

MIT. See LICENSE. ADE itself remains AGPL-3.0-only; see the ADE Runtime Embedding Exception for shipping the runtime binary in your app.