A terminal SQL client built with ratatui, edtui, and sqlx. The goal is a fast, modal, keyboard-first workspace for writing queries, exploring schemas, and inspecting results — all without leaving the terminal.
SQLite, Postgres, and MySQL/MariaDB are wired end-to-end. Most authoring features (autocomplete, formatting, yank, CSV/TSV/JSON/SQL export, transactions across statements, multi-statement scripts) are shipped — see Roadmap for what's still ahead.
The install script grabs the latest GitHub Release artifact for your
OS/arch and drops the binary in ~/.local/bin:
curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/killertux/rowdy/main/install.sh | shSupported targets: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu,
aarch64-apple-darwin (Apple Silicon). macOS Intel and Windows aren't
covered — build from source instead.
Override via env:
ROWDY_INSTALL_DIR=/usr/local/bin— different install locationROWDY_VERSION=v0.1.0— pin a specific release (default: latest)
If the install dir isn't already on your $PATH, the script prints the
line to add to your shell rc.
When rowdy starts, it asynchronously checks GitHub for a newer release (at most once every 24 hours, with no impact on launch time). If a new version is available, the bottom bar prompts:
⬆ rowdy v0.6.2 → v0.7.0 available — y to update · n/Esc to dismiss
Pressing y re-runs the install script against the running binary's
directory (so it overwrites the rowdy you're currently using). After a
successful install, restart rowdy to pick up the new binary. Pressing
n records the dismissed version in ~/.rowdy/config.toml so we don't
re-prompt for the same release on every launch.
To disable the check entirely, add to ~/.rowdy/config.toml:
check_for_updates = falseRequires Rust 2024 edition (≥ 1.86) and a terminal that supports truecolor for accurate theme rendering.
git clone https://github.com/killertux/rowdy
cd rowdy
cargo build --release
# binary at ./target/release/rowdyPoint rowdy at any database via a connection URL:
rowdy --connection sqlite:./sample.dbOr run straight from a checkout:
cargo run -- --connection sqlite:./sample.dbA seed program creates a sample SQLite database to poke at: a small
e-commerce schema (users / products / orders / order_items plus a
recent_orders view), an events table with 5000 rows, a wide_metrics
table with 32 columns to exercise horizontal scroll, and 10 small lookup
tables to exercise the schema panel's vertical scroll.
cargo run --example seed_sqlite -- ./sample.db
cargo run -- --connection sqlite:./sample.dbRe-running the seeder is safe — it drops and re-creates the tables.
You can save connection URLs (optionally encrypted) and switch between
them inside the TUI. Open the connection list with :conn, or manage
them without launching the TUI:
rowdy connections list
rowdy connections add <name> --url <url> [--password <pw>]
rowdy connections edit <name> --url <url> [--password <pw>] # overwrite
rowdy connections delete <name>Password handling mirrors the TUI:
- Flag absent — prompts on stdin (masked).
rpasswordfalls back to reading from a pipe if stdin isn't a TTY. --password X(non-empty) — usesX. On a fresh store this also initialises the crypto block.--password ""— explicit "no encryption". Only valid against an empty store or an existing plaintext store; refused against an encrypted one.
list and delete never touch the password — they're pure config edits.
On startup rowdy creates .rowdy/ in the current working directory if it
doesn't exist. It holds:
config.toml— theme, schema-panel width, saved connections, and (when the encrypted store is in use) the argon2id/chacha20-poly1305 crypto block. Written lazily on the first change away from defaults.<datetime>.log— one file per session, named for the launch time. Append-only. The app and every datasource log into it (connect / execute / cancel / errors / session save+load). URL passwords are redacted. Only the 5 most recent log files are kept; older ones are deleted at the start of the next launch.sessions/<connection-name>/session_<N>.sql— editor buffers for each saved connection, indexed from0. Each connection starts withsession_0.sql; create more with:session newand switch between them with:session next/prev/<N>or<Space>n. The active buffer is auto-saved 800ms after the last edit and reloaded on the next connect (or session switch). Indices may have holes after deletion (session_0.sql+session_2.sqlis fine without asession_1.sql). Connection names are sanitised for path safety, so two names that differ only in path-unsafe characters share a sessions directory for now.chats/<connection-name>/session.jsonl— the LLM chat history for each saved connection. Append-only JSONL; oneChatMessageper line. System messages are filtered out on append so we don't persist tool prompts. Wiped by:chat clear.
Rowdy also reads from $HOME/.rowdy/ (Unix) or
%USERPROFILE%\.rowdy\ (Windows) for cross-project defaults. The
directory is never auto-created — rowdy only reads from it.
config.toml— user-level defaults forthemeandschema_width. Both fields are optional; missing fields fall through to the compiled defaults. Project-level./.rowdy/config.tomloverrides the user file per-field on read, so per-project pins still win.keybindings.toml— sparse override of the default keymap. Only the chords you change need to be listed; everything else stays on its default.
Connections, the encrypted crypto block, LLM API keys, per-session
editor buffers, chat logs, and the on-disk session log all stay
per-project in ./.rowdy/. They are not user-wide.
Runtime mutators (:theme dark, :width 48) write to the
project file, not the user file — the project file remains the
deliberate-pin lever.
:source re-reads ~/.rowdy/config.toml, ./.rowdy/config.toml
(UI prefs only — theme + schema_width), ~/.rowdy/keybindings.toml,
and the llm_providers block of both files. The active connection,
the encrypted crypto block, the worker pool, and any in-flight query
are not touched. On a malformed keybindings.toml the previous
keymap stays active — you don't lose your working overrides because
of one typo.
# Sparse override — only the chords you change need to appear.
# Defaults that aren't listed stay on their built-in chord.
[leader]
# `<Space>r` cancels the running query instead of prompting to run
# the statement under the cursor.
r = "cancel-query"
[global_immediate]
# `=` opens the autocomplete popover instead of formatting.
"=" = "open-completion-popover"
[schema]
# `o` collapses-or-ascends instead of toggling.
o = "schema-collapse-or-ascend"Tables are keyed by context (global_immediate, leader,
schema, result, chat_normal, chat_insert). Inside each table,
keys are chord-notation strings and values are bindable action IDs.
Chord notation. Bare characters (r, :, 0, $), named
keys (<Esc>, <Enter>, <Tab>, <Up>, <Down>, <Left>,
<Right>, <Home>, <End>, <PageUp>, <PageDown>, <Space>,
<BackTab>, <Backspace>), and modifier prefixes (<C-x>,
<S-r>, <C-S-r>, <C-Space>). Two-step sequences are written
adjacent (gg, <Space>r, <C-w>l).
Chord openers are NOT rebindable. <Space> (leader chord),
Ctrl+W (window chord), and g/G (GG navigation) trigger state
transitions into the chord machine — they don't fire actions
themselves and putting them in any context table is a parse error.
Rebind chords under them (e.g. <Space>r) instead.
Bindable action IDs by context.
| Context | Action ID | Description |
|---|---|---|
global_immediate |
open-command |
Open command prompt |
global_immediate |
format-buffer |
Format SQL under the cursor |
global_immediate |
grow-schema |
Grow schema panel width |
global_immediate |
shrink-schema |
Shrink schema panel width |
global_immediate |
open-completion-popover |
Open autocomplete popover |
leader |
run-prompt-or-selection |
Run selection (Visual) / prompt to run statement (Normal) |
leader |
run-statement-under-cursor |
Run the statement under the cursor — no prompt |
leader |
cancel-query |
Cancel the in-flight query |
leader |
expand-latest-result |
Expand the latest result to full view |
leader |
toggle-theme |
Toggle Dark / Light theme |
leader |
set-right-panel-schema |
Switch right panel to schema (and focus) |
leader |
set-right-panel-chat |
Switch right panel to chat (and focus) |
leader |
next-session |
Cycle to the next per-connection editor session |
leader |
session-switch-1 … -9 |
Jump straight to session N (default chord: <Space> then the shifted digit !@#$%^&*() |
schema |
schema-up / schema-down |
Move selection |
schema |
schema-collapse-or-ascend |
Collapse node or ascend |
schema |
schema-expand-or-descend |
Expand node or descend |
schema |
schema-toggle |
Toggle expand / collapse |
schema |
schema-bottom |
Jump to bottom |
result |
result-{up,down,left,right} |
Move cell cursor |
result |
result-line-start / -end |
First / last column in row |
result |
result-bottom |
Last row |
result |
result-yank |
Yank selected cell or selection |
result |
result-column-{move-left,move-right,hide,reset} |
Column ops |
chat_normal |
chat-enter-insert |
Focus the composer |
chat_normal |
chat-{scroll-up,scroll-down} |
Scroll log line by line |
chat_normal |
chat-{page-up,page-down} |
Scroll log by a page |
chat_normal |
chat-{top,bottom} |
Jump to top / bottom of log |
chat_insert |
chat-{scroll-up,scroll-down,page-up,page-down} |
Log scroll while composer is focused |
Today only the global_immediate, leader, and schema
tables are routed through the keymap inside event::translate. The
result, chat_normal, and chat_insert tables are accepted by the
parser and surfaced to the help popover but the corresponding
keystrokes still flow through the hardcoded matches in src/event.rs,
because their behaviour depends on per-mode sub-state (Visual vs
Normal, composer focused vs scroll). Wiring these last three is
tracked as Phase 2 follow-up.
Sparse overrides + new defaults. Newly-added defaults reach existing users automatically. If you happened to bind the same chord to a different action, the new default is shadowed (your override wins).
keybindings.toml deletion is a valid state — defaults go back
into effect at the next launch / :source. Rowdy does not error
when the file is missing.
URL scheme dispatches to the driver:
| Scheme | Driver | Example |
|---|---|---|
sqlite: |
SQLite | sqlite:./sample.db, sqlite::memory:?cache=shared |
postgres: / postgresql: |
Postgres | postgres://user:pass@host:5432/db |
mysql: / mariadb: |
MySQL | mysql://user:pass@host:3306/db |
mariadb:// is rewritten to mysql:// before sqlx sees it — same wire
protocol, same driver. postgres:// and postgresql:// are interchangeable.
In-memory SQLite caveat: the worker uses a connection pool, and each SQLite memory connection gets its own database unless you opt into shared cache. Use
sqlite::memory:?cache=shared(or a file path) so introspection sees the data your queries created.
System schemas are hidden by default — Postgres
pg_catalog,information_schema,pg_toast,pg_temp_*; MySQLinformation_schema,mysql,performance_schema,sys. You can still query them by name.
- Three-pane layout
- Editor (left): a vim-mode SQL editor powered by edtui.
- Schema browser (right): a collapsible tree of catalogs / schemas / tables / views / columns / indices, populated lazily from the live connection — each level loads on first expand.
- Status / command bar (bottom): vim-style modeline that doubles as a
:commandprompt and as the run-confirmation prompt.
- Async query execution through a tokio worker. The UI never blocks on
the database; a single in-flight query is enforced and
:cancelaborts it. - Session-pinned connection. Each driver holds one connection across
execute()calls, soBEGIN/COMMIT/ROLLBACKissued as separate statements all land on the same backend — and an interactive transaction can span several<Space>rruns. Introspection and:cancelstill use the pool, so neither stalls behind a slow query. - Multi-statement execution. A selection (Visual
<Space>r) or buffer containing several semicolon-separated statements runs them in order on the pinned session. The bottom bar showsok — N rows in T · ran K statements. Execution stops at the first error so a broken script doesn't keep mutating state past the failure. - Confirm-before-run:
<Space>rhighlights the SQL statement under the cursor and asks before executing it.<Space>Rbypasses the confirmation; in editor Visual mode<Space>rruns the explicit selection straight away. - Typed result cells (
Null / Bool / Int / Float / Text / Bytes / Timestamp / Date / Time / Uuid / Other) — preserved end-to-end so the CSV / TSV / JSON / SQL exporters keep type fidelity. The TUI renders each cell via its owndisplay();NULLcells are dimmed. The bottom row of the expanded result view shows the full value of the selected cell so wide values stay readable when the column is narrower than them. - Yank and export in the expanded result view:
ycopies the current cell to the clipboard,venters Visual mode for a rectangular selection, and:export csv|tsv|json|sql(oryfrom Visual) copies the result — full or selected — in the chosen format.:export sqlinfers the source table from simpleSELECT … FROM tshapes; pass it explicitly for joins/aggregates. Pass a path after the format to write to disk instead of the clipboard. - SQL autocomplete — syntax-aware popover via sqlparser's tokenizer.
Completes keywords, tables, columns, SQL functions, and CTE names with
FROM/JOIN alias resolution. Auto-triggers in Insert mode after
.or 2+ identifier chars (Ctrl+Spaceforces it). Fuzzy-ranked, kind-boosted, dialect-quoted on insert. Schema cache primed at connect time; columns load lazily; DDL run from rowdy auto-reloads. See the Autocomplete reference for the full behaviour. - SQL formatter —
=in editor Normal mode formats the whole buffer; in Visual mode formats the selection.:formatdoes the same. - Three SQL drivers sharing the same
Datasourcetrait:- SQLite — in-memory or file-based, schema via
sqlite_masterandpragma_*virtual tables. - Postgres — schema via
pg_namespace+information_schema, indices viapg_class/pg_indexfor the uniqueness flag. User-definedENUMtypes decode to their variant string. - MySQL / MariaDB — schema via
information_schema,column_typefor declared types (preservesunsigned, display widths, etc.).
- SQLite — in-memory or file-based, schema via
- LLM chat companion in the right panel — multi-provider (OpenAI,
Anthropic, Ollama, Google, DeepSeek, OpenRouter via OpenAI-compat),
streaming, with first-class tools for the schema and editor buffer:
- Schema tools (
list_catalogs/list_schemas/list_tables/describe_table) auto-load on first use against the live connection, so the model can answer "describeusers" without you expanding the panel by hand. - Buffer tools —
read_buffer(paginated by line) lets the model ground answers in the actual SQL you have;write_bufferis a precise find/replace that errors when the search snippet matches zero or multiple times, so the model can't accidentally clobber the buffer. - No execute-SQL tool by design — drafts land in the editor and you review/run them yourself.
- Modal chat panel — Normal mode (scrolling, globals work) and
Insert mode (composer focused). Press
ito type,Escto scroll;Escagain returns focus to the editor without flipping the panel. - Per-connection session persistence — chat history saves to
./.rowdy/chats/<connection>/session.jsonland reloads on the next connect.:chat clearwipes it. - Encrypted API keys — the LLM keystore shares the same crypto block as saved connections, so one password unlocks both.
- Schema tools (
- Two themes (Dark / Light) switchable at runtime, both tuned for high
text contrast. Theme + schema-panel width persist to
./.rowdy/config.toml. - Saved connections in
./.rowdy/config.toml, optionally encrypted with a password (argon2id + chacha20-poly1305). Pick one from:conn, switch live with:conn use NAME. The password prompts in-TUI on launch or via--password. Manage them without the TUI viarowdy connections …. The connection form includesCtrl+Tto test a URL before saving. - Per-connection editor sessions persisted at
./.rowdy/sessions/<name>/session_0.sql. The buffer is flushed 800ms after the last edit and reloaded on the next launch (or:conn useswitch). - Vim-style modal input end-to-end: editor uses real vim bindings via
edtui; the schema panel and result viewer use the same
hjkl/gg/Gvocabulary. - File logger at
./.rowdy/<datetime>.log— connect / execute / cancel / errors. URL passwords are redacted; only the 5 most recent logs are kept.
rowdy uses vim-style bindings everywhere. Three layers determine what a
key does: the global app Mode, the focused panel, and the editor's own vim
mode (Normal / Insert / Visual / Search).
Available wherever the editor is in vim Normal or Visual mode, or the
schema/chat panel is focused in its own Normal mode — i.e. everywhere
except a text-input mode (editor Insert, chat composer, modal forms).
In an insert mode, press Esc first.
| Keys | Action |
|---|---|
: |
Open command prompt |
Esc (Schema/Chat) |
Focus editor (right panel keeps painting whatever it had) |
Ctrl+W then h/l |
Focus editor / right panel (schema or chat) |
Ctrl+W then </> |
Grow / shrink schema panel width |
Ctrl+C |
Panic exit (use :q for a clean quit) |
In any input modal (auth prompt, connection form, : command prompt) the
standard system-clipboard shortcuts are wired up:
| Keys | Action |
|---|---|
Ctrl+V / Ctrl+Shift+V / Cmd+V |
Paste |
Ctrl+C / Ctrl+Shift+C / Cmd+C (with selection) |
Copy |
Ctrl+X / Ctrl+Shift+X / Cmd+X (with selection) |
Cut |
Copy is suppressed in the password prompt — exposing the masked buffer
would defeat the masking. Bracketed paste from the terminal (which is what
most macOS terminals deliver for Cmd+V) is also accepted.
In the SQL editor, edtui's vim bindings drive the clipboard: y yanks,
p pastes, d cuts. They go through the system clipboard automatically
(via arboard), so you can yank in rowdy and paste into another app, or
vice versa.
App-wide — fires from editor Normal/Visual, the schema panel, or chat normal mode. Not in any insert mode (Esc out first).
| Keys | Action |
|---|---|
<Space> r |
(Editor Normal) Highlight the statement under the cursor, prompt to run |
<Space> r |
(Editor Visual) Run the current selection — no prompt |
<Space> R |
Run the statement under the cursor immediately — no prompt |
<Space> e |
Expand the latest result to full view |
<Space> c |
Cancel the in-flight query |
<Space> t |
Toggle Dark / Light theme |
<Space> S |
Switch right panel to schema (and focus it) |
<Space> C |
Switch right panel to chat (and focus it, in normal mode) |
<Space> n |
Cycle to the next per-connection editor session |
<Space> Shift+1…<Space> Shift+9 |
Switch directly to session 1…9 (US layout: <Space> ! … <Space> () |
= |
Format SQL (Visual: selection; Normal: whole buffer) |
Ctrl+Space |
Open SQL autocomplete popover (works in any editor mode) |
The editor itself is a full vim implementation — i, Esc, hjkl, w,
b, dd, yy, p, u, Ctrl+R, visual mode, search, etc. See
edtui's keymap for the
complete list.
After <Space>r in Normal mode, the editor shows a highlight over the
statement and the bottom bar reads:
▶ run highlighted statement? Enter to confirm · Esc to cancel
| Keys | Action |
|---|---|
Enter |
Run the statement |
Esc |
Cancel |
All other keys are intentionally ignored to prevent accidental edits.
When focused (Ctrl+W l).
| Keys | Action |
|---|---|
j / k |
Move selection down / up |
h |
Collapse node, or move to parent if already collapsed |
l |
Expand node (loads on first expand), or descend |
o / Enter |
Toggle expand / collapse |
gg |
Jump to top |
G |
Jump to bottom |
< / > |
Grow / shrink the panel width |
Nodes show their load state inline:
(loading…)while a request is in flight(error: …)and a red label on a failed load — pressl/Enterto retry
Toggle the right panel to chat with :chat or <Space> C. The panel is
modal, like edtui: it opens in Normal mode (composer dormant,
keystrokes scroll the log) and you press i to enter Insert mode
(composer focused, type your message). Globals (:, leader, Ctrl+W)
fire from Normal but not from Insert — Esc drops you back.
| Keys | Action |
|---|---|
i / I |
Enter Insert mode (focus the composer) |
↑ / k / h |
Scroll the message log up by one line |
↓ / j / l |
Scroll the message log down by one line |
PgUp / PgDn |
Scroll by a page |
gg / G |
Jump to the top / bottom of the log (G re-engages auto-follow) |
Home / End |
Jump to the top / bottom of the log |
Esc |
Focus editor (right panel keeps painting chat) |
| Keys | Action |
|---|---|
Enter |
Submit the composer · Shift+Enter inserts a newline |
Esc |
Drop back to chat Normal (composer keeps its contents) |
Ctrl+U |
Clear the composer (message log untouched) |
Ctrl+W then h |
Hop directly to the editor without leaving Insert first |
PgUp / PgDn |
Scroll the message log by a page |
Ctrl+↑ / Ctrl+↓ |
Scroll the message log line by line (plain ↑/↓ move the composer cursor) |
Ctrl+Home / Ctrl+End |
Jump to top / bottom of the log |
The log auto-follows new content while you're at the bottom. Scrolling
up disengages auto-follow so streaming tokens don't yank you away from
history; scrolling back to the bottom (or G / Ctrl+End) re-engages it.
The chat panel registers six tools the model can call. They all run on the UI thread against the live app state — no separate worker.
| Tool | Purpose |
|---|---|
list_catalogs |
List databases on the active connection |
list_schemas |
List schemas inside a catalog |
list_tables |
List tables / views inside a (catalog, schema) |
describe_table |
Columns + types for a table or view; auto-loads on first use |
read_buffer |
Read the editor buffer with line pagination (start_line, limit) |
write_buffer |
Find/replace in the editor buffer — search must match exactly once (start_line constrains scope); zero or multiple matches return an error so the model extends the snippet |
Schema tools auto-trigger introspection on cache miss, so the model can
ask about a table you haven't expanded yet. write_buffer parks the
cursor at the modified line so the change is immediately visible.
Open the settings modal with :chat settings. Pick a backend, enter a
model id and an API key. Keys are encrypted with the same argon2id + chacha20-poly1305 block used for connection passwords, so unlocking the
store at startup unlocks both. Backends ship as Cargo features
(openai, anthropic, ollama, google, deepseek); OpenRouter is
served via the OpenAI-compatible base URL.
When you've expanded a result block (<Space>e or :expand).
| Keys | Action |
|---|---|
h j k l |
Move cell cursor |
0 / $ |
First / last column in row |
gg / G |
First / last row |
y |
Yank — current cell (Normal) or selection (Visual, prompts for format) |
v |
Toggle Visual mode (rectangular cell selection) |
q / Esc |
Visual: exit Visual · Normal: close expanded view |
When the result has more columns than fit on screen the view scrolls
horizontally to keep the cursor visible. The title shows cols X-Y of Z
with ‹/› markers when there are columns off-screen on either side. The
inline preview shows only the leftmost columns that fit and a +N → count
of how many were truncated — expand it to navigate.
The bottom row of the expanded view shows column: value for the selected
cell, clipped with … when the value is wider than the row. Use yank to
get at the full text when the badge is clipped.
y in Normal sub-mode copies the current cell's rendered text straight
to the system clipboard — no header, no quoting.
y in Visual sub-mode opens a tiny prompt at the bottom of the screen:
yank as: [c]sv [t]sv [j]son [s]ql · Esc cancel. A single key picks the
format and the selection is copied; Esc returns you to Visual with the
selection intact.
:export csv|tsv|json|sql does the same thing from the command bar.
With an active Visual selection it exports just the rectangle; otherwise
it exports the latest result block in full.
Pass a path after the format to write to disk instead of the clipboard:
:export csv path/to/out.csv. A leading > is optional and ignored
(:export csv > out.csv is the same call). ~ and ~/ expand to
$HOME; everything else is passed verbatim to the OS. The parent
directory must already exist; existing files are overwritten without a
prompt.
Format details:
- CSV — RFC 4180. Fields with commas, quotes, or newlines are quoted;
internal
"is doubled;NULLbecomes an empty field. - TSV — tabs separate fields; tabs / newlines / carriage returns inside a cell are replaced with spaces so the table shape survives a paste into a spreadsheet. Use CSV if you need exact round-trip.
- JSON —
[{column: value, …}, …].Bool/Int/UInt/Floatcells become native JSON values,Nullbecomesnull, bytes render as a hex string ("0xdeadbeef"), andNUMERIC/DECIMALcome through as JSON strings (preserves precision; round-trips intoBigDecimal::from_str). Everything else is a string. NaN / infinity floats fall through tonull. - SQL — multi-row
INSERT INTO <table> (cols) VALUES (...);, chunked at 100 rows per statement. Identifiers are dialect-quoted ("x"for SQLite/Postgres,`x`for MySQL); strings double internal'; bytes render asX'…'for SQLite/MySQL or'\x…'::byteafor Postgres; SQLite booleans become1/0.- Source-table inference.
:export sql(no table) parses the originating query and accepts: a single bare-tableFROM(no JOIN/CTE/subquery) plus a projection that's either a pure wildcard (*or<table>.*) or a list of bare/qualified identifiers without aliases. Anything else (joins, aggregates, aliased projections, computed columns) refuses inference and asks for:export sql <table>. Visual selection only requires the selected projection items to satisfy the rule, so a column-subset of a join can still infer if those particular columns are clean. - Limitations. No
CREATE TABLEprelude (target schema must already exist), noBEGIN/COMMITwrapping, noON CONFLICT/ON DUPLICATE KEYclauses; selecting a column subset that excludesNOT NULLcolumns won't round-trip cleanly.
- Source-table inference.
The popover auto-opens in editor Insert mode after you type . or
at least N identifier characters (default 2, configurable via
completion_trigger_threshold in ~/.rowdy/config.toml);
Ctrl+Space forces it open in any editor mode.
Selection and acceptance:
| Keys | Action |
|---|---|
Up, Ctrl+P |
Previous candidate |
Down, Ctrl+N |
Next candidate |
Enter, Tab |
Accept the highlighted candidate |
Esc |
Close the popover (and snooze auto-trigger here) |
While the popover is open you can keep typing — each keystroke
re-filters the candidate list. Pressing Esc snoozes auto-trigger for
the current word; move the cursor or start a new word to re-enable it.
Ctrl+Space always opens regardless of snooze.
Context awareness. Tokens around the cursor determine the suggestions:
- After
FROM/JOIN/INTO/UPDATE/TABLE→ tables in the default schema (or the named schema after<schema>.), plus any CTE names declared withWITH. - After
<alias>.or<table>.→ columns of that bound table. CTE bodies with named columns surface those columns;SELECT *bodies resolve against the schema cache when the FROM has a single base table. - After
SELECT/WHERE/ON/AND/,/INSERT INTOcolumn list / operators → columns unioned across FROM/JOIN bindings (qualifier-free) plus SQL functions (per-dialect curated list). - Statement start, after
;, or unrecognised slot → keywords.
FROM/JOIN/INSERT aliases are resolved by a forward pass over the whole
statement, so SELECT u.| autocompletes correctly even when the
FROM users u clause comes after the cursor. CTE definitions
(WITH name AS (…), optional RECURSIVE, multiple comma-separated
CTEs) and derived-table aliases are recognized; their bodies'
projections are extracted for column completion.
Ranking. Candidates are scored with nucleo-matcher (fuzzy
subsequence match) and re-ordered by:
- Score (higher = better, with a +500 bonus when the label has the user's exact prefix).
- Kind bonus matched to the syntactic context:
+1000for columns in column slots, tables in table slots, etc., so a shorter coincidentally-matching keyword can't shadow the right answer. - Shorter labels first.
- Alphabetical.
Insert. Accepting an item writes into the buffer with three refinements:
- Quoting when the identifier can't sit unquoted: any uppercase
char (Postgres folds unquoted to lowercase), any non-
[A-Za-z0-9_]char, leading digit, or one of the curated reserved keywords. The quote style follows the dialect —"x"for SQLite/Postgres,`x`for MySQL — and any internal quote chars are doubled. Keywords and functions are inserted as displayed, never quoted. - Trail depends on item kind:
- Table / view in a FROM/JOIN/INSERT INTO slot → auto-generated
short alias (e.g.
users ufrom first letters of underscore segments). - Function with arguments → appended
()with the cursor between them, ready for arguments. - Function with no arguments (
NOW,CURRENT_TIMESTAMP,CURDATE, …) → appended(), cursor at the end. - Column / keyword / CTE → no trail.
- Table / view in a FROM/JOIN/INSERT INTO slot → auto-generated
short alias (e.g.
Schema cache. Catalogs, schemas of the default catalog, and tables
of the default schema are eagerly loaded on connect. Columns load
lazily the first time you reference a table; the popover shows a
"loading…" placeholder briefly and refreshes when the data arrives.
DDL statements (CREATE, ALTER, DROP, TRUNCATE, RENAME)
executed from rowdy auto-reload the cache. For schema changes made
outside rowdy, run :reload to re-prime.
After pressing :.
| Keys | Action |
|---|---|
Enter |
Submit |
Esc |
Cancel |
Backspace |
Delete character |
Left / Right |
Move cursor |
| typing | Insert character |
| Command | Effect |
|---|---|
:q, :quit |
Quit |
:help, :? |
Open the help popover (bindings + commands) |
:run, :r |
Run the statement under the cursor (no confirmation) |
:cancel |
Cancel the in-flight query |
:expand, :e |
Expand the latest result |
:collapse, :c |
Close the expanded result view |
:width <cols> |
Set schema panel width (clamped 12–80) |
:theme dark | light |
Switch theme |
:theme toggle | :theme |
Flip between Dark and Light |
:export csv | tsv | json [path] |
Copy the latest result (or Visual selection) to the clipboard, or write to path if given |
:export sql [table] [path] |
Emit INSERT statements. Table is inferred from the query for simple SELECT * FROM t / SELECT cols FROM t shapes; pass <table> explicitly for joins, aggregates, aliases, etc. :export sql > path writes to disk with inferred table |
:format, :fmt |
Format the SQL buffer (or active Visual selection) via sqlformat. Undo via edtui's u won't restore the pre-format text — yank first if you need a backup |
:reload |
Drop and re-prime the autocomplete schema cache against the active connection (use after DDL outside the app) |
:reset |
Roll back any open transaction on the pinned session connection and drop it; the next query re-acquires a fresh connection |
:clear |
Wipe the editor buffer, drop result blocks, and reset the session (useful after a multi-statement script you'd like to walk away from cleanly) |
:source |
Re-read user + project UI prefs, the user keybindings file, and LLM provider records. Connections, crypto, the worker pool, and any in-flight query are NOT touched |
:conn, :conn list |
Open the connection list |
:conn add <name> |
Open the form to create <name> |
:conn edit <name> |
Open the form pre-filled with <name>'s URL (overwrite on save) |
:conn delete <name> |
Remove <name> (refuses if it's the active connection) |
:conn use <name> |
Switch the active connection live |
:chat |
Toggle the right panel between schema and chat (focus follows) |
:chat clear |
Wipe the chat log and the persisted session for this connection |
:chat settings, :chat config |
Open the LLM provider modal (backend / model / API key) |
:session, :session list |
Show the connection's session indices and the active one (bottom bar) |
:session next | prev |
Cycle through the connection's sessions (<Space>n does next) |
:session new |
Create a fresh session at the lowest unused index and switch to it |
:session <N> |
Switch to session <N> (must already exist) |
:session delete <N> |
Delete session <N> (refuses if it's the only remaining one) |
Opened via :conn. Browseable with vim keys; the active connection is
marked with ●.
| Keys | Action |
|---|---|
j / k |
Move selection |
g / G |
Jump to top / bottom |
Enter / u |
Switch to the selected connection |
a |
Add a new connection (form opens) |
e |
Edit the selected (form opens, pre-filled) |
d |
Delete the selected (y/Enter confirms, n/Esc) |
Esc / q |
Close the list |
The Postgres and MySQL drivers have integration tests gated on
ROWDY_POSTGRES_URL and ROWDY_MYSQL_URL — when either is unset the
test prints a skip notice and returns Ok, so cargo test is green on a
machine without those databases. To exercise them locally:
docker compose up -d
ROWDY_POSTGRES_URL=postgres://rowdy:rowdy@localhost:55432/rowdy_test \
ROWDY_MYSQL_URL=mysql://rowdy:rowdy@localhost:53306/rowdy_test \
cargo testThe non-default ports (55432 / 53306) are deliberate so they don't
collide with a system Postgres / MySQL on the standard ports. CI starts
the same images via GitHub Actions services and uses the standard
ports there.
The codebase is a small, MVC-flavoured loop with an async worker on the side:
┌──────────────────────────────────────────────────┐
│ tokio runtime │
│ │
│ main task (event loop) │
│ ┌───────────────────────────────────────────┐ │
│ │ select!: │ │
│ │ crossterm EventStream → Action │ │
│ │ worker → app channel → Action::Worker │ │
│ └───────────────────────────────────────────┘ │
│ │ ▲ │
│ cmd_tx │ │ evt_rx │
│ ▼ │ │
│ worker task │
│ ┌───────────────────────────────────────────┐ │
│ │ owns Arc<dyn Datasource> (sqlx::Pool) │ │
│ │ tracks current query JoinHandle │ │
│ │ dispatches Execute / Cancel / Introspect │ │
│ └───────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
App(src/app.rs) owns the entire UI state and thecmd_txhandle.Action(src/action.rs) enumerates every legal mutation;apply()is the single dispatcher.event::translate(src/event.rs) is a pure function that turns acrossterm::Eventinto anActionbased on the currentModeandFocus.- View functions under
src/ui/derive entirely fromApp— they never mutate state. Datasource(src/datasource/mod.rs) is the cross-driver trait:introspect_catalogs,introspect_schemas,introspect_tables,introspect_columns,introspect_indices,execute,cancel,close. Drivers live undersrc/datasource/sql/.- The worker (
src/worker/mod.rs) owns the live connection pool, runs at most one query at a time, and fans introspection out concurrently.:cancelaborts the in-flightJoinHandleand sends a server-side cancel (pg_cancel_backendfor Postgres,KILL QUERYfor MySQL) so the database doesn't keep grinding on a query the user gave up on. SQLite has no server-side cancel; the abort is the cancel.
State is encoded so that invalid combinations are unrepresentable wherever possible:
Focus { Editor, Schema }— exactly one panel owns input.Mode { Normal, Command(CommandBuffer), ResultExpanded { id, cursor, col_offset, row_offset }, ConfirmRun { statement }, Auth(AuthState), EditConnection(ConnFormState), ConnectionList(ConnListState), Connecting { name } }— every variant carries the data its UI needs; no "expanded but no result", no "in command mode but no buffer", no "awaiting confirmation but no statement".QueryStatus { Idle, Running, Succeeded, Failed, Cancelled }— replaces a bag of booleans /Option<String>fields.LoadState { NotLoaded, Loading, Loaded, Failed(error) }on every schema node — drives the lazy-load UX without any "is_loading + error" pairs.IntrospectTarget— a single value identifies both which level to load and which DB entity it belongs to, so worker events reattach to the right node deterministically.
src/
main.rs async entry point + tokio::select event loop
app.rs App state + cmd_tx handle to the worker
action.rs Action enum, apply() dispatcher, command parser
event.rs crossterm Event → Action translation
cli.rs clap arg parsing (--connection NAME, --password)
clipboard.rs arboard wrapper for paste/copy/cut into inputs
crypto.rs argon2id KDF + chacha20poly1305 AEAD primitives
connections.rs ConnectionStore: encrypt/decrypt, unlock, make_entry
config.rs .rowdy/config.toml load + lazy save
log.rs Logger — Arc<Mutex<File>>, info/warn/error
export.rs CSV / TSV / JSON / SQL formatters for yank + :export
session.rs .rowdy/sessions/<name>/session_0.sql load + save
subcommands.rs non-TUI `rowdy connections …` handlers
terminal.rs terminal init / restore / panic hook
state/ sub-state modules
editor.rs EditorPanel + statement-under-cursor parser
schema.rs SchemaPanel + LoadState + tree population
results.rs ResultBlock + ResultCursor
command.rs CommandBuffer
focus.rs Focus + Mode + PendingChord
status.rs QueryStatus
auth.rs AuthState (password buffer + attempt counter)
conn_form.rs ConnFormState (name + url two-field form)
conn_list.rs ConnListState (saved connections, with delete-confirm)
datasource/
mod.rs Datasource trait + connect() factory
cell.rs typed Cell enum + display helpers
schema.rs CatalogInfo / SchemaInfo / TableInfo / …
error.rs DatasourceError
sql/
sqlite.rs SqliteDatasource (sqlx)
postgres.rs PostgresDatasource (sqlx)
mysql.rs MysqlDatasource (sqlx, also handles mariadb://)
worker/
mod.rs tokio worker task, command/event channels
request.rs RequestId newtype + counter
ui/
mod.rs render() — layout + cursor placement
editor_view.rs edtui rendering with themed block + highlights
schema_view.rs tree + load-state glyphs
results_view.rs inline preview + expanded grid + cell badge
auth_view.rs centered password prompt
conn_form_view.rs centered name+url form
conn_list_view.rs centered connection picker
help_view.rs `:help` popover (bindings + commands cheat sheet)
bottom_bar.rs status / command / confirm-run prompt
theme.rs Dark + Light palettes
examples/
seed_sqlite.rs creates a sample SQLite DB to test against
Next likely steps, roughly ordered:
- Cell zoom / detail view for long TEXT / JSON cells. The bottom-row
badge shows the full single-line value for the selected cell, but
multi-line or very long values still need a scrollable modal — open
it with
Enter(or similar) on a cell in the expanded view. - Multiple result blocks stacked under the editor with scrolling (currently only the latest is shown).
- Query history surfaced under each result block.
:explain/<Space>xthat wraps the statement under the cursor inEXPLAIN(orEXPLAIN ANALYZE) for the active dialect.