Repository navigation
Developing
tql is a Rust program. The interface is drawn with ratatui over crossterm; the CLI commands and the MCP server are thin layers over the same database code the interface uses. The only unusual parts are the drawing rules, and they are written down below because every one of them was learned by breaking something.
git clone https://github.com/VheissuLabs/tql.git
cd tql
cargo runYou need a recent stable Rust (edition 2024), and a Nerd Font in your terminal if you want the driver icons to look like icons. Nothing else: the database drivers, SQLite included, are compiled in. A database to poke at:
curl -L -o chinook.db https://github.com/lerocha/chinook-database/raw/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite
cargo run -- chinook.dbRunning from source uses your real ~/.config/tql. To work against a throwaway
one, point XDG_CONFIG_HOME somewhere else:
XDG_CONFIG_HOME=/tmp/tql-scratch cargo runThe dev profile is optimised, so a debug build feels like the real thing.
main.rs |
starts tql: the staged update, the argv rewrite, the console |
app/console/ |
the command layer: signatures, flags, prompts, the progress bar |
app/commands/ |
one file per command: browse, open, export, dump, load, sync, tableplus, the agent commands, mcp:start, update
|
app/tui/ |
the interface: the connection list (picker.rs), forms, lists, the editor |
app/tui/browser/ |
the browser itself: state, keys and what each one does, split by concern |
app/tui/chrome.rs |
islands, modals, the theme, the hotkey bar |
app/terminal/ |
the event loop, keys, the mouse, margins, and Fake for tests |
app/database/ |
one file per SQL driver behind the Database trait (MySQL, Postgres, SQLite, SQL Server, DuckDB, libSQL, ClickHouse), Table and schemas, filters, sorting, the exporter, TLS |
app/keystore/ |
Redis and Valkey behind the KeyStore trait |
app/tui/keys/ |
the key browser: the namespace tree, the value views, editing, the command pane |
app/dump/ |
the engines behind dump, load and sync
|
app/ai/ |
asking for SQL: providers and their wire formats |
app/mcp/ |
the MCP server and its tools |
app/agent.rs |
what the agent commands and the MCP tools share |
app/live.rs |
how an MCP server hands SQL to the tql you have open |
app/config_file/ |
config.toml: the template, topping it up, --tidy
|
app/settings.rs |
settings as tql reads them, TQL_* overrides included |
app/store.rs, app/store_writer.rs
|
tql's own store: connections and query history |
app/tunnel.rs |
SSH tunnels |
app/updates/ |
the daily check and the self-updater |
parity/ |
the comparison with tql 0.11.0, and why it differs where it does |
The event loop in app/terminal/mod.rs reads keys and the mouse, hands them to
the app, and redraws when something changed. An app is anything that implements
Tui: on_key, render, and a few optional hooks (on_mouse, on_paste,
idle, the margins). The connection list and the browser are the two apps.
render never draws characters at loose coordinates. It builds islands:
bordered boxes that each own a rectangle, draw their own content into it, and
answer hit tests for it. A click asks the island that drew those columns rather
than re-deriving where they were. Every mouse bug in this project came from having
two copies of that arithmetic.
A modal is a Modal: one or more islands, centred in the frame, on an opaque
backdrop with an optional ring around it. Adding a pane means adding an island;
adding a modal means building one and drawing it last.
theme answers what colour a thing is, from the config. The top and side margins
are applied around the whole app in app/terminal/margin.rs, so an app draws as
if it had the window to itself.
idle runs between key presses. It is where work that finishes later lands: an
answer from the model, SQL handed over by an agent, a status message fading.
These are the ones that bite.
Positions are 1-based in the browser's layout. Islands are placed from column
1 and row 1, the way the frame was designed. Size a pane from area.width + 1,
not area.width, or the last column stays empty.
Every line must fit the width. ratatui clips, so nothing wraps, but a status or
hotkey line that was cut by characters rather than display width loses its end.
Truncate with text::truncate, which counts what the terminal will show.
A paste is one event. It arrives as on_paste with the whole text, never as a
run of keys, so the vim editor in normal mode does not run it as commands.
Long work never blocks the loop. A network call or anything else slow runs on a
thread and reports back through idle, so the frame keeps drawing and esc still
works.
A key. Actions and their default keys are in app/tui/keymap.rs; give the
action a name there and handle it in app/tui/browser/keys.rs. Help, the hotkey
bar, the palette and [keys] rebinding all read the keymap, so there is nothing
else to update but Keys and the README's key table.
A pane or a modal. Build an Island with its content and draw it from the
browser's render.rs; for a modal, wrap it in a Modal. Keys go to it while it is
open by checking for it at the top of on_key.
A command. A file in app/commands/ implementing Command, registered in
app/mod.rs. Its signature is written Laravel style, so
"tables {connection : The connection} {--json : JSON}" declares an argument and a
flag with their help. cargo run -- make:command Name writes the skeleton (debug
builds only). Commands an agent runs answer in JSON when piped and as a table in a
terminal; app/commands/answers.rs does that for them.
An MCP tool. A variant of Tool in app/mcp/tools.rs, with its name,
description and schema. Queries through MCP are read-only and that is enforced in
app/agent.rs; keep it there.
A config setting. Three places: a field and its default in app/settings.rs,
with its key in Settings::set; an entry in app/config_file/template.rs, which
is what writes and tops up the user's file, with its comment and the version it
arrived in; and a row in the README and Configuration. A new entry
in the template bumps template::VERSION so existing files are topped up with it.
cargo test
cargo test --test picker # one file
cargo test group # anything with "group" in its name
cargo fmt
cargo clippy --all-targetsThe interface is tested headlessly with Fake, which drives an app the way a
terminal would and hands back the screen as text:
let mut fake = Fake::sized(Browser::new(connection, database).unwrap(), 120, 30);
fake.press("f"); // keys, as the bytes a terminal sends
fake.press("\x1b"); // escape
fake.click(5, 4); // the mouse
fake.paste("select 1"); // a paste
assert!(fake.screen().contains("ROWS"));Assert on the screen as text, and on the app's state where that is clearer. Tests keep out of your real connections: settings are installed per test, and anything that touches the store uses a scratch directory.
Some tests need things a laptop may not have, and are #[ignore]d unless you ask
for them with --ignored:
| needs | |
|---|---|
tests/parity.rs |
PHP, and a checkout of tql 0.11.0 with composer install at TQL_PATH (default ~/Code/tql); TQL_PARITY_MYSQL=1 and TQL_PARITY_PGSQL=1 add MySQL and Postgres |
tests/kinds.rs |
a local MySQL and Postgres |
tests/live_tls_ssh.rs |
Docker, for throwaway MySQL, Postgres and sshd containers |
tests/sqlsrv.rs |
a SQL Server at TQL_TEST_SQLSRV=host:port, with TQL_TEST_SQLSRV_PASSWORD for sa
|
tests/clickhouse.rs |
a ClickHouse at TQL_TEST_CLICKHOUSE=host:port, user tql, password tql
|
tests/libsql.rs |
a libSQL server (sqld) at TQL_TEST_LIBSQL=http://host:port
|
tests/redis.rs, tests/redis_tls.rs, tests/keys_browser.rs
|
Redis or Valkey at TQL_TEST_REDIS, TQL_TEST_VALKEY and TQL_TEST_VALKEY9, each host:port; Valkey 9 covers field TTLs and the command log |
CI runs all of these against service containers: ClickHouse, libSQL, Redis 7, Valkey 8 and Valkey 9.
Parity renders the same scenarios in tql 0.11.0 and in this tql and compares
every frame, text and colour. Where they differ on purpose, tests/parity.rs says
why and parity/deliberate.md lists what no frame shows.
cargo build --release
./target/release/tql --versionA release is one command, from a clean main that matches GitHub:
bin/release 0.12.1It sets the version in Cargo.toml (the binary's --version comes from there, and
the updater only installs a download that says it is the version it was published
as), runs the tests, commits and pushes the bump, drafts the tag message from the
feat: and fix: commits since the last tag, opens it in your editor, and pushes
the tag. See Packaging and releasing for what the tag
triggers.
Connection passwords and SSH passwords are encrypted with AES-256-CBC using a key
at ~/.config/tql/key, 0600, in the format tql has always written, so a store
from any earlier version opens. The key sits beside the database, so this protects
against casual reading of the file, not against someone with access to your
account.
Filter values are always bound, never interpolated. The SQL pane shows them filled in for reading; that string is never what runs.
Getting started
Working with SQL
Moving data
Reference
Project