A terminal database client for MySQL, PostgreSQL and SQLite — with Vim-style keys.
A project is a group of databases; a database carries exactly one connection and its tables. 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 editing —
Enteredits the cell under the cursor, with the value typed against the column's SQL type. $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.
- 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.
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.dbThe parser is deliberately permissive:
postgresql://andfile://also work, and a bare path is taken as SQLite. -
ttests the connection,Entersaves,ereturns to the explorer. -
Enteron a database connects and opens its first table.
| 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.
| Key | Action |
|---|---|
j / k (↓ / ↑) |
Move down / up |
gg / G |
First / last |
h / l |
Focus sidebar / table |
| ← / → | Previous / next column |
Enter |
Open a table, or edit the cell under the cursor |
i |
Edit the current row in $EDITOR |
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 |
| Key | Action |
|---|---|
j / k (↓ / ↑), gg / G |
Move, first / last |
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 |
: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
├── connection/ ConnectionManager connect, query, status, retry-with-backoff
├── inspector/ SchemaInspector listTables, describeTable
├── 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,ConnectionManager,SchemaInspectorandQueryExecutorreturns 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.