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

whatty

v0.1.1

Published

A terminal UI WhatsApp client — like WhatsApp Web, but in your terminal. Built on [Baileys](https://github.com/WhiskeySockets/Baileys) and [OpenTUI](https://github.com/sst/opentui) (with React bindings).

Readme

whatty

A terminal UI WhatsApp client — like WhatsApp Web, but in your terminal. Built on Baileys and OpenTUI (with React bindings).

Prerequisites

  • Linux terminal (full-screen, alternate-screen UI — think vim)
  • Node.js 20+
  • pnpm
  • Bun — the app runs on the Bun runtime (pnpm start / pnpm dev invoke bun under the hood)

Getting started

pnpm install
pnpm start

pnpm dev runs the same entry point with file-watching (bun --watch) for development.

First run (no linked accounts)

A QR code is printed to the terminal:

  1. On your phone: WhatsApp → Settings → Linked devices → Link a device.
  2. Scan the QR code shown in the terminal.

Once paired, the app transitions straight to the chat list.

Subsequent runs (one or more linked accounts)

A boot-time account selector is shown. Use / to navigate and Enter to open an account. The last entry is always Link new device, which starts a fresh QR pairing flow and adds the new account to the list.

On launch the selector also checks npm for a newer release. If one is available it shows an ↑ whatty vX.Y.Z available — update: npm i -g whatty@latest notice. The check runs fresh on every start (no caching), is best-effort, and is silently skipped when offline so it never delays startup.

Session expiry / unlinking

If a device is removed from your phone, the app detects the dead session and removes that account from the selector. Chat history and media are kept on disk — if the same phone re-links later via QR, the account reappears in the selector and resumes its previous history.

Where data is stored

Everything lives under the platform data directory — ~/.local/share/whatty on Linux (honouring $XDG_DATA_HOME), ~/Library/Application Support/whatty on macOS, %LOCALAPPDATA%\whatty\Data on Windows — overridable with the WHATTY_DATA_DIR environment variable:

| Path | Contents | | --- | --- | | accounts/<id>/chats.db | SQLite database for one account: chats, messages, reactions, group membership, the people directory (accounts + account_jids tables — every person's addresses and names), and the Baileys session credentials | | accounts/<id>/media/ | Downloaded media (images, video, audio, documents, stickers, view-once) | | accounts/<id>/queue/ | Durable sync queue: every WhatsApp event is journaled to pending/ before processing and removed once applied, so a crash never loses data — leftover jobs simply resume on the next start. Jobs that keep failing are parked in failed/ for inspection | | accounts/<id>/app.lock | Single-instance lock (see below) | | whatty.log | Application log (structured JSON via pino — set WHATTY_LOG_LEVEL to adjust verbosity, e.g. debug) | | sync-queue.log | Sync-queue processor log: every job lifecycle including payloads (capped at 100 MB, one rotated generation kept as .1) |

<id> is the account's own normalized WhatsApp JID (e.g. [email protected]). Because chats are namespaced under their account, two accounts can have separate conversations with the same contact without collision.

One instance per account. Opening an account that is already open in another running instance fails back to the account selector (two instances would fight over the WhatsApp session, disconnecting each other every few seconds). The lock clears itself: if the holding process died (even kill -9), the next start takes the lock over automatically.

Breaking change: databases created by earlier versions (schema v1) are incompatible with this build. There is no migration — the app refuses to start with an error until you delete the data directory and re-link your device (history is then re-synced from your phone).

Nothing is ever deleted. Sync with your phone is purely additive: it can update or extend local records, but a chat or message that exists locally is never removed — even if it's deleted on the phone. Single-message deletions are reflected as a "deleted" marker while the original content is retained locally. "Removing" an account from the selector only deletes its credentials (auth/); chat history and media are never touched.

Keybindings

| Key | Account selector | Chat list | Chat view | | --- | --- | --- | --- | | / | Move selection | Move chat selection | Scroll history (step matches while searching) | | Enter | Open account / link new device | Open the selected chat | Send the draft (jump to the match while searching) | | R | — | Toggle read receipts on/off | — | | H | — | Show / hide help | — | | Ctrl+F / Space | — | Search chats by name or number | Ctrl+F: search messages | | End | — | — | Jump to the latest message | | Esc | — | Clear an active search, else back to account selection | Clear an active search, else back to the chat list | | Ctrl+C | Copy the selection | Copy the selection | Copy the selection | | Ctrl+D | Quit the app | Quit the app | Quit the app | | Paste | — | — | Inserts clipboard text into the draft (single line) | | (typing) | — | — | Edits the draft input (navigation keys above still work) |

Ctrl+C copies the current selection to the system clipboard. The app captures the mouse, so drag-selecting highlights just the message text; Ctrl+C then copies it. The copy is piped to a native clipboard helper — wl-copy (Wayland), xclip (X11), pbcopy (macOS) or clip (Windows) — so the matching tool must be installed; OSC 52 is not used, since VTE terminals (tilix, gnome-terminal) don't honour it. If your terminal already binds Ctrl+C to copy (e.g. tilix terminal-copy), that binding only fires when a native terminal selection exists, so it doesn't conflict with the in-app copy; to use it, hold Shift while dragging to make a native selection. Ctrl+D quits. Paste uses your terminal's normal paste action (Ctrl+Shift+V, middle-click, or whatever you've bound it to), delivered via bracketed paste; newlines are flattened to spaces.

Searching the chat list. Press Ctrl+F (or Space) on the chat list to open a Search: bar above the read-receipts line. Type to filter the list live — the match is case-insensitive and partial, against both the chat name and (for individual chats) the phone number. Press Enter to keep the filter and hand focus back to the list, where /, R, H and Enter work as usual on the matches. Press Esc to clear the search (a second Esc, with no search active, opens the back-to-account prompt). Opening a chat and returning leaves the search filter and your selected chat unchanged.

Searching within a chat. Press Ctrl+F in a chat to open a Search: bar above the message input. Type to filter the conversation live — the match is case-insensitive and partial, against the message text, the displayed timestamp, and (in groups) the sender name. The matching messages are shown with the most recent one selected (subtle reverse-video highlight); / step through matches while you keep typing. Press Enter to clear the search and scroll the full conversation to the selected message (centred, with the messages before and after it back in view), or Esc to cancel and return to the latest message. Press End at any time to jump straight to the latest message.

History sync

Linking a device makes the phone upload its full message history, and the app now ingests all of it — including the deep-history (FULL) chunks that earlier builds silently discarded — so old conversations are browsable after a fresh link. Media older than the eager auto-download window (below) is listed with a "not downloaded" hint and fetched on demand when you scroll the message into view.

Media download

Media attached to messages is downloaded eagerly for messages received within the last 7 days — these land in the background shortly after the message arrives. Older messages (e.g. from a fresh history sync on a newly linked device) aren't fetched up front; their entry in the chat view shows a [type — not downloaded] hint instead of a file path.

Scrolling such a message into view downloads its media on demand, regardless of age. The fetch runs in the background and the entry switches from the hint to a file link when it lands — no key to press. A download that failed for a transient reason (offline, a network blip) retries when you scroll back to it.

If WhatsApp's servers no longer have the media — the blob was evicted from the CDN (HTTP 410) or the URL is rejected (HTTP 403) — it can't be recovered, so the entry switches to a [type — unavailable] hint and the app stops retrying it (re-scrolling won't re-attempt a dead URL).

Downloaded media is shown as a plain-text file:// URL pointing at a short symlink (<tmpdir>/wt/<hash>.<ext>, e.g. /tmp/wt/ab12cd34.jpg) so the line stays compact — most terminals let you open or copy it directly. Symlinks are created on the fly as messages scroll into view; the temp directory is volatile (cleared on reboot), but links are recreated automatically the next time the message is displayed.

Downloads survive restarts: an interrupted or failed download is retried (with backoff) from the durable queue, and on every start the app sweeps recent messages with missing media and re-fetches them.

Re-fetchability caveat: WhatsApp media URLs expire within roughly the same 7-day window. An on-demand fetch of much older media can still fail server-side if the URL has expired and the original sender's device is unreachable for a re-upload — in that case the entry keeps showing the "not downloaded" hint. The eager window stays small to avoid pulling years of media up front; on-demand fetching only pays the cost for what you actually look at.

Business & interactive messages

Template, button and list messages sent by business accounts (the kind the phone renders with tappable buttons) are shown as plain text: title, body and footer lines followed by one [label] line per button — [label: url] for link buttons. The buttons themselves aren't interactive. On startup the app re-examines stored messages that had no renderable text and backfills them, so business chats that previously showed empty entries populate retroactively.

v1 scope & limitations

  • Outbound: text only. Sending media, replies, and reactions isn't supported yet (incoming media, replies and reactions are still received, downloaded/stored, and shown).
  • No reactions rendering. Reactions are received and stored but not displayed.
  • No unread tracking. The app never computes, stores, or displays unread counts.
  • Read receipts off by default (lurk-friendly). When off, opening a chat never notifies the sender. Press R on the chat list to toggle read receipts on or off; the preference is saved per account. When on, opening a chat with unread messages sends read receipts (blue ticks) for them and clears its unread badge. Delivery/read ticks shown for your outbound messages still update live as the recipient's device reports them regardless of this setting.
  • No search/filter in the chat list — just the full, sorted, scrollable list.

Development

pnpm test        # vitest — core logic (persistence, reconciliation, mappers, stores, …)
pnpm test:watch  # vitest in watch mode
pnpm typecheck   # tsc --noEmit

UI screens are exercised manually (they depend on @opentui/core's Bun-only renderer and can't run under vitest/Node) — core logic and data-mapping have full automated coverage.