A terminal database client for MySQL, PostgreSQL, SQLite and Redis — with Vim-style keys.
A project is a group of databases; a database carries exactly one connection and its tables (or keyspace, for Redis). The sidebar is that tree, the grid is the table, and the keyboard never leaves them.
┌ PROJECTS ────────────────┐┌ ORDERS ────────────────────────────────────┐
│ ▾ demo ││ ID ITEM QTY TOTAL │
│ ▾ ▶ ● sample SQLite ││ 1 widget 3 9.99 │
│ ▸ orders ││ 2 gadget 1 24.50 │
│ ▸ users ││ 3 gizmo 7 11.00 │
└──────────────────────────┘└────────────────────────────────────────────┘
NORMAL dient sample · sqlite · orders 3 rows
Schematic. ▾/▸ expand a node, ▶ marks the
active connection, ● is connection status.
- Fuzzy finder —
Spacesearches every project, database and table at once; jumping to a database expands and connects it in a single keystroke. - In-place inspection —
Enterpreviews the row under the cursor;eexpands a truncated cell without leaving the table. $EDITORround-trip —iwrites the current row to a temp file ascolumn: valuepairs and reads the edits back on save.- Linewise yank —
Vthenj/kthenycopies whole rows to the system clipboard, one block per row, comments stripped. - Pager, filter, search —
Ctrl+f/Ctrl+bpage 200 rows at a time,/filters the page,n/Nwalk the matches. - Connection management — projects, databases and connections, with a live "test connection" probe and a confirmation before any delete.
- Redis browsing and editing —
SCAN-based keyspace, type-aware key views: strings and RedisJSON as wrapped text, hashes/lists/sets/sorted sets as tables, and streams as timestamped cards.irenames keys, edits string and JSON values, and edits collection fields/members/scores.
- Bun 1.3.0+ — runtime, package manager and test runner.
- A terminal OpenTUI can drive. The highlight bands want truecolor. The finder, settings and help panels size themselves against the terminal width; the confirm dialog is a fixed 60 columns.
- Docker + Compose (optional) — only needed to run the sample PostgreSQL, MySQL, Redis and RabbitMQ stack described below.
bun install
bun dev # run from source, with --watchOr compile a standalone binary and run that instead:
bun run build # → ./dient
./dientThen, in the app:
-
sopens settings. -
aadds a database — orppastes a connection URI:postgres://user:pass@host:5432/database mysql://user:pass@host:3306/database sqlite:///absolute/path/to/file.db redis://user:pass@host:6379/0 rediss://user:pass@host:6380/0 # TLSThe parser is deliberately permissive:
postgresql://,valkey://,valkeys://andfile://also work, and a bare path is taken as SQLite. The Redis path is a numeric database index, not a database name.Every URI pauses on an optional name. The suggested value is the URL tail (
…/shop→shop), the SQLite file stem, orhost:port/indexfor Redis; pressEnterto keep it or type a new name. In the field form the name is also optional and falls back to the database/file stem orhost:port. -
ttests the connection,Entersaves,ereturns to the explorer. On a database row,rrenames the display name. -
Enteron a database connects and opens its first table.
A ready-to-browse stack lives in docker-compose.yml. It
starts PostgreSQL, MySQL, Redis Stack (with RedisJSON) and RabbitMQ (with the
management plugin), and seeds them with multiple databases, representative
rows/keys, and a sample message topology.
docker compose up -d
docker compose logs -f redis-seed rabbitmq-seed # Ctrl-C after both finish| Connection | URL |
|---|---|
PostgreSQL · dient |
postgres://dient:dient@localhost:55432/dient |
PostgreSQL · analytics |
postgres://dient:dient@localhost:55432/analytics |
MySQL · shop |
mysql://dient:dient@localhost:53306/shop |
MySQL · game_service |
mysql://dient:dient@localhost:53306/game_service |
| Redis · DB 0 | redis://localhost:56379/0 |
| Redis · DB 1 | redis://localhost:56379/1 |
RabbitMQ · vhost / |
rabbitmq://dient:dient@localhost:55672/ |
The PostgreSQL databases contain users, products, orders, and 250 JSON events
for paging. MySQL contains a shop schema and a game_service schema with 250
runs. Redis DB 0 has strings (including a 70 KB value for the truncation
notice), a hash, list, set, sorted set, stream, TTL key, and a RedisJSON
document; DB 1 has a separate greeting to exercise the /1 database suffix.
RabbitMQ exposes the management API on 55672 and AMQP on 55673; dient uses
the management API, so connect with a rabbitmq:// URL. The sample broker has a
durable dient.events topic exchange bound to dient.orders (orders.*) and
dient.notifications (notifications.#, a quorum queue), with JSON and
plain-text messages ready to peek and republish.
The seed is idempotent, so docker compose up -d after a normal docker compose down keeps your changes; the RabbitMQ seeder only fills a sample queue when it
is empty, so reruns do not duplicate messages. To wipe the volumes and reseed
everything:
docker compose down -v
docker compose up -dIf 55432, 53306, 56379, 55672 or 55673 is already in use, change the
host side of the port mapping in docker-compose.yml. SQLite needs no service —
paste an absolute path such as sqlite:///absolute/path/to/file.db.
| Command | What it does |
|---|---|
bun dev |
Run from source with --watch |
bun run typecheck |
tsc --noEmit |
bun test |
Unit and TUI integration tests |
bun run format |
prettier --write . |
bun run build |
Compile a standalone binary to ./dient |
? opens this reference in-app. The tables below mirror
src/ui/help-screen.tsx — change both together.
Panes are numbered left to right from 1; pressing a pane's number focuses it.
The rule is the same on every screen with more than one pane.
| Key | Action |
|---|---|
j / k (↓ / ↑) |
Move down / up; scroll a Redis string or stream |
gg / G |
First / last; top / bottom of a Redis value |
1 / 2 |
Focus pane 1 (sidebar) / pane 2 (table) |
h / l |
Scroll columns left / right |
| ← / → | Previous / next column |
Enter |
Open a Redis key, or preview the row |
Esc |
Leave an open Redis key for the keyspace |
i |
Edit the current row/value in $EDITOR (Redis: rename a key in the keyspace) |
V |
Start a row selection |
y |
Copy the selection to the clipboard |
/ |
Filter the current page |
n / N |
Next / previous match |
Ctrl+f / Ctrl+b |
Next / previous page |
Tab |
Next connection |
Space |
Open the quick finder |
s |
Settings |
A Redis connection opens on a keys table. Enter drills into the key under
the cursor: strings and RedisJSON values open in a wrapped text panel; streams
open as cards of field/value pairs; hashes, lists, sets and sorted sets use the
normal table (with field/value, index/value, member, and
rank/score/member columns). Paging uses SCAN cursors for keys, hashes and
sets, offsets for lists and sorted sets, and entry IDs for streams.
i edits what is under the cursor. In the keyspace it renames the key
(RENAMENX, so an existing destination is never clobbered). On a string or
RedisJSON key it opens the whole value in $EDITOR; string writes use
SET … KEEPTTL, and RedisJSON writes use JSON.SET. On a hash, list, set or
sorted set it opens the row as column: value fields: hash fields and set/zset
members can be renamed, list values and zset scores can be changed, and the
list index / zset rank columns are fixed identifiers. Collection commands
preserve TTL naturally. Stream entries are Redis-append-only, so the stream
card view remains a reader; its key can still be renamed from the keyspace.
The driver is standalone (no Cluster/Sentinel) and understands the core Redis types plus RedisJSON; module-specific types without a dedicated view fall back to a metadata row.
| Key | Action |
|---|---|
j / k (↓ / ↑), gg / G |
Move, first / last |
1 / 2 |
Focus pane 1 (workspaces) / pane 2 (details) |
Enter |
Expand / select |
a |
Add a database |
p |
Paste a connection URI |
Shift+P |
Add a project |
r / t / d |
Rename / test connection / delete |
e |
Back to the explorer |
Type : for the command line.
| Command | Action |
|---|---|
:e <table> |
Open a table |
:connect <name> |
Switch database |
:refresh |
Reload the current table |
:upgrade |
Install the pending update |
:ignore-update |
Skip the pending update |
:version |
Show the app version |
:settings / :explorer |
Switch screen |
:help |
Keybindings |
:q |
Quit (from settings, back to the explorer) |
:w |
No-op — cell and row edits are written as you save them |
| Path | Contents |
|---|---|
~/.dient/config.db |
Projects, databases, connections. A SQLite file, migrated in place on open. |
~/.dient/error.log |
One line per unexpected failure, tail-capped at 2000 lines. Logging never blocks or crashes the UI. |
A copy goes to the host clipboard (so it works over SSH into a local display) and is mirrored over OSC 52 when the terminal advertises support. The host writer is kept alive for the life of the process, because a Wayland/X11 clipboard is an offer to own the selection, not a stored value — dispose the writer and every copy reports success while pasting nothing. OSC 52 is what makes a copy survive quitting dient.
src/
├── index.tsx boot: renderer, theme, services, render <App>
├── runtime.ts the Layer.provide graph — the service boundary
├── effect/run.ts runService: the one place a service Effect becomes a Promise
│
├── config/ ConfigStore SQLite-backed, tagged ConfigError
├── drivers/ DatabaseDriver Postgres / MySQL / SQLite behind one port
│ RedisDriver raw Redis commands, Bun's client
├── connection/ ConnectionManager connect, query, status, retry-with-backoff
├── inspector/ SchemaInspector listTables, describeTable
├── redis/ RedisInspector SCAN + type-aware key pages and writes
├── query/ QueryExecutor parameter binding, identifier quoting
│
├── sidebar/ the project → database → table tree
├── explorer/ ExplorerScreen connect, paging, cell and row saves
├── table/ the data grid
├── finder/ the Space-bar fuzzy finder
├── settings/ connection management
│
├── editor/ $EDITOR round-trip, row serialization, clipboard payloads
├── ui/ clipboard, toasts, modals, text entry and windowing
├── vim/ motions and linewise visual mode
├── theme.ts palette, light/dark detection
└── errors/ turning any throwable into one readable line
The split that matters: config/ through query/ is the service layer —
pure Effect, typed failures, no React. Everything from sidebar/ down is the
React surface that drives it fire-and-forget.
These are rules the codebase already follows, not aspirations.
- Effect owns the service layer. Every method on
ConfigStore,DatabaseDriver,RedisDriver,ConnectionManager,SchemaInspector,RedisInspectorandQueryExecutorreturns anEffect, built as a fullEffect.genpipeline. Dependencies are wired explicitly withLayer.provideinsrc/runtime.ts, so the assembled graph needs nothing from the environment. - Failures are typed, never thrown. Use
Data.TaggedErrorandEffect.mapError— seeConfigError,ConnectionError,QueryError.onErrorinsrc/config/config-store.tsis the reference: fold every underlying error into one tagged error while preservingcause. - Cross the boundary with
runService, neverEffect.runPromise.Effect.runPromiserejects with aFiberFailure, which carries onlynameandstack— the realConnectionError, and through itECONNREFUSED, is unreachable from it.runServiceconverts toEitherfirst so the rejection carries the typed error. Use it wherever a service Effect is awaited inside a.catchthat shows a message. - React is the frame loop, Effect the service boundary. Hooks run every
effect fire-and-forget and never block a render. They hold mutable truth in
refs, so a chord pressed within one frame (
jthenEnter) acts on the live cursor and item list rather than the previous render's snapshot. PreferOptionover nullable sentinels, and a stale-request counter over stale async results. - Never interpolate a name into SQL. Pass table and column names through
cleanIdentifier(src/query/identifier.ts) and bind every value. - Sync-only stays sync.
src/fs/path-complete.tsruns insideuseMemo; it is not a gap waiting to be effectified. - Tests.
bun test. TUI integration tests render throughtest/support/render-ui.ts(renderApp,pressKeys,waitForFrame). Note that onepressKeysbatch reaches the handler with pre-batch state, so split dispatches when a key changes focus.
Scaffolded with bun create tui —
create-tui is the easiest way to start a
new OpenTUI project.