A floating widget that tracks how fast you type, anywhere on your machine.
Always on top, counts keystrokes system-wide, shows live WPM. No server, no cloud, no account. Everything stays on your computer.
- Preview
- Privacy
- Tech stack
- How it works
- Getting started
- Commands
- Building installers
- Platform notes
- Project structure
- License
The widget. Session average leads, live speed sits beside it, and the sparkline tracks the last thirty seconds.
Settings. Opacity, position lock, which stats appear, the global shortcut and start on login. Every change applies immediately.
History. Daily averages over time, with your best day, current streak and all-time average. Shown here with sample data.
Promptu does not know what you type. The global hook counts that a key was pressed and when. It never reads, logs, stores, buffers, or sends the character or key code. There is no network code in the app at all.
If you would rather check than take that on trust, the whole listener is src/main/keylistener.ts. It is about forty lines.
The main process owns everything that touches the machine. The renderer only draws what it is sent.
Live speed is measured over a rolling four second window and drops to zero after a second and a half of silence, so the number tracks what you are doing now rather than what you did ten seconds ago. Session average divides total keystrokes by active typing time, where gaps longer than five seconds count as breaks rather than slow typing.
pnpm install
pnpm devThe widget appears floating above your other windows and adds a tray icon. Closing the widget hides it. Quit from the tray menu.
Ctrl+Shift+P (Cmd+Shift+P on a Mac) shows and hides it from anywhere, and
you can rebind that in Settings.
| Command | What it does |
|---|---|
pnpm dev |
Run in development, with hot reload on the renderer |
pnpm build |
Type-check, then build main, preload and renderer |
pnpm lint |
Type-check only |
pnpm test |
Run the stats and history tests |
pnpm build:linux |
AppImage, deb and rpm into release/ |
pnpm build:mac |
dmg into release/ |
pnpm build:win |
NSIS installer into release/ |
Each target has to be built on its own operating system. There is no cross-compiling here: the dmg needs macOS and the NSIS installer needs Windows or wine.
The Linux build produces an AppImage, a .deb and an .rpm. The AppImage
needs no install and runs on Arch-based distributions too. Building the .rpm
needs rpmbuild on the machine, which Ubuntu does not ship by default:
sudo apt install rpmmacOS builds are not signed, so the first launch needs a right-click then Open to get past Gatekeeper.
| Platform | Status |
|---|---|
| Linux X11 | Works with no setup |
| Windows | Works with no setup |
| macOS | Needs Accessibility permission, granted by hand |
| Linux Wayland | Global capture is blocked by the display server |
Wayland blocks applications from listening to global input. That is a deliberate security boundary in the display server, not a bug in Promptu, and it affects Fedora, Omarchy, and Ubuntu 22.04 and newer, which all default to Wayland.
On a Wayland session the hook starts but may never receive an event. Promptu detects the session type at startup and says so in the widget rather than showing a WPM of zero and leaving you guessing. The global shortcut is blocked the same way.
The reliable fix is to log out and pick an X11 session at the login screen.
Reading raw device events through evdev, by adding your user to the input
group, sometimes works instead. It grants that user the ability to read all
keyboard input on the machine, so treat it as an experiment rather than a
recommendation.
Promptu needs Accessibility permission before the hook will receive anything. Grant it by hand in System Settings, under Privacy & Security then Accessibility.
Chromium sandboxes its renderer processes, and on Ubuntu 24.04 both routes it
can take are closed by default. Unprivileged user namespaces are restricted by
AppArmor, and the chrome-sandbox helper that pnpm install unpacks is not
setuid root, because only root can set that bit.
pnpm dev then aborts with a message about chrome-sandbox. One command
fixes it for good:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/60-apparmor-userns.confInstalled packages are unaffected. The .deb sets the setuid bit on
chrome-sandbox in its postinstall when user namespaces are unavailable.
promptu/
├── src/
│ ├── main/ Electron main process, owns everything OS facing
│ │ ├── index.ts App lifecycle, windows, tray, IPC handlers
│ │ ├── keylistener.ts Global hook, counts events and nothing else
│ │ ├── stats.ts Rolling live WPM and session totals
│ │ ├── history.ts Daily aggregation, streaks, best day
│ │ ├── store.ts electron-store wrapper
│ │ ├── autolaunch.ts Start on login
│ │ └── *.test.mts Tests, sat beside the code they cover
│ ├── preload/ Typed bridge, the renderer's only route to main
│ │ └── index.ts
│ ├── renderer/ React UI, one root per window
│ │ ├── widget/ The floating box
│ │ ├── settings/ The settings window
│ │ ├── history/ The history window
│ │ └── shared/ Components, hooks, design tokens, bundled fonts
│ └── shared/ Types and constants used by both sides
├── build/ App and tray icons
├── docs/ Screenshots, logos, and the architecture diagram
│ └── architecture.mmd Mermaid source for the diagram above
├── electron.vite.config.ts Build config for all three entry points
└── electron-builder.yml Packaging targets and metadata
The main process is the single source of truth for stats. The renderer never touches the hook, storage or OS APIs, only what IPC sends it.


