A TUI for querying parquet files with DuckDB. Built with Bubble Tea — cmd/<app>/main.go entry, feature packages under internal/. In the results pane, press / to open a client-side incremental search: typing filters highlights live across every loaded row (case-insensitive), Enter cycles to the next match (wraps around), and Esc exits search — arrow keys keep scrolling while highlights stay on.
This is primarily a macOS app. Keybindings should feel native. Always register both a Cmd form and a Ctrl form for every primary action — the Cmd form is what the user reaches for, the Ctrl form is the fallback for terminals that don't forward Cmd.
| Action | Bind |
|---|---|
| Run query (auto-focuses results) | super+r, ctrl+r |
| Export to Excel | super+e, ctrl+e |
| Toggle editor ↔ results | esc (primary), ctrl+q (editor-only fallback) |
| Word left | alt+left, alt+b |
| Word right | alt+right, alt+f |
| Line start / end | home/end (add ctrl+a/ctrl+e if needed) |
Focus model is query-driven: running a query auto-focuses results, and esc is a symmetric toggle between the two panes (issue #1). The editor→results half of the toggle is gated on a result set being loaded — without one there's nothing to scroll, so esc is a no-op there. Don't re-introduce a manual super+t / ctrl+t focus-results binding — cmd+t is swallowed by every macOS terminal (New Tab), and the toggle covers the same need with one fewer key. The editor's own esc handler (dismiss completion list) takes priority when a completion is open.
Cmd as super+*: On macOS the Command key is reported as super by the Kitty keyboard protocol. This app targets Bubble Tea v2, which requests Kitty by default, so terminals that support it (Ghostty, Kitty, WezTerm, modern iTerm2 with reporting enabled) deliver super+r etc. to the app. Terminals that don't support Kitty (macOS Terminal.app, older iTerm2 configs) swallow the Cmd chord and it never arrives — that's when the ctrl fallback saves the user.
Option as both alt+<arrow> and alt+<letter>: macOS Terminal (default) and iTerm2's "Natural text editing" preset send Option+Arrow as ESC b/ESC f (readline backward-word/forward-word), which Bubble Tea surfaces as alt+b/alt+f. Users who've set "Left Option key = Esc+" or enabled CSI-u mode get alt+left/alt+right instead. Handling only one form breaks the app for half the userbase.
When you add a new shortcut, ask:
- Is there a Cmd-equivalent a macOS user would reach for? Bind both
super+*andctrl+*. - Does it involve Option+Arrow? Bind both
alt+<arrow>and the readlinealt+<letter>alias. - Don't use
cmd+c/super+c— it collides with Copy in every terminal.ctrl+cstays Quit.
cmd/lazyduckdb/main.go— entrypoint, handles the CLI arg / picker branchinternal/duck— DuckDB session (opens parquet as viewt, runs queries)internal/editor— multi-line SQL editor with tab-completeinternal/table— horizontally scrollable results viewinternal/export— xlsx export via excelizeinternal/picker— parquet file selector (used when no CLI arg)internal/app— Bubble Tea root model that wires everything togetherinternal/keymap— binding defaults, centralized so they're easy to audit
go run ./cmd/lazyduckdb [parquet_file ...]
With no argument it lists *.parquet in the current directory and lets you pick one.
There are three ways files end up mounted, and they're not interchangeable — preserve the distinction when touching cmd/lazyduckdb/main.go:
- Explicit list of unrelated files (
lazyduckdb a.parquet b.parquet) → mounted ast1,t2, … viaduck.Open+Session.Attach. Existing cross-file-join workflow. - Shell-expanded glob (
lazyduckdb Ford*.parquet, which the shell turns intoFord1.parquet Ford2.parquet …before lazyduckdb sees it) → folded into a single unionedt/t1viaduck.OpenGroup. The fold is gated bydetectGlobExpansioninmain.go: same directory, no already-glob arg, ≥ 2 chars of common basename prefix or suffix-body. Anything weaker falls back to (1) — that's intentional; don't loosen the threshold without tests covering the regressions inmain_test.go. - Quoted glob (
lazyduckdb 'Ford*.parquet') → reaches the binary unexpanded;duck.IsGlobshort-circuits the per-arg stat, andduck.Openhands the pattern toread_parquetfor DuckDB-side expansion. PickerCtrl+Alands here too viaOpenGroupwith the picker's reconstructed label.
When adding a new arg-handling code path, decide which of these three buckets it belongs to before writing — mixing them produces hard-to-debug "why are some queries hitting only one file" reports.
When the user's prompt is just the word install (case-insensitive, with or without trailing punctuation), run:
go install ./cmd/lazyduckdb
This drops a fresh lazyduckdb binary into $(go env GOPATH)/bin (~/go/bin on this machine, which is on the user's PATH). Confirm with which lazyduckdb && lazyduckdb -v and report the result. Don't ask for confirmation — install is the confirmation.
When the user's prompt is release or release <X.Y.Z> (case-insensitive, optional v prefix), cut a full release end-to-end: bump, commit, tag, push, and publish a GitHub Release. The word itself is the confirmation; don't ask again, but DO run the steps in order and stop on the first failure.
Pushing to master may trip the auto-mode classifier (the default branch normally requires PR review). If git push github master is denied, surface the block and wait for the user to type push — then re-run from the push step onward. Do not stop at "local-only" otherwise; the prior contract that left tags unpushed produced incomplete releases (commit + tag locally, no GitHub Release ever published).
If no version was given, infer the next one: read the latest tag with git tag --list 'v*' --sort=-v:refname | head -1, fall back to the version constant in cmd/lazyduckdb/main.go if there are no tags, and bump the patch component (v0.1 → v0.1.1, v0.2.3 → v0.2.4). If the user gave a version, use it verbatim — don't second-guess major/minor bumps.
Steps, in this order:
- Pre-flight:
git status --porcelainmust be empty and the current branch must bemaster. Refuse otherwise; tell the user what's dirty. - Bump the version constant in
cmd/lazyduckdb/main.go— theversionvar. Strip the leadingv(the constant stores0.2.0, the tag isv0.2.0). The update-check ininternal/updatecompares against this string, so the two must stay in lockstep. - Build and test:
go build ./... && go test ./.... Stop on failure. - Commit: stage only
cmd/lazyduckdb/main.go, messagerelease vX.Y.Z. Heredoc form with the standardCo-Authored-Bytrailer. - Tag:
git tag -a vX.Y.Z -m "vX.Y.Z"(annotated, not lightweight —gh releaseandgo install @vX.Y.Zboth prefer annotated). - Push:
git push github master && git push github vX.Y.Z. The remote is namedgithub, notorigin(seegit remote -v). If the classifier denies the master push, stop and wait for the user'spushconfirmation, then continue. - Publish the GitHub Release:
gh release create vX.Y.Z --generate-notes --title "vX.Y.Z". Prior releases use auto-generated notes only (just the "Full Changelog" compare link); don't hand-write notes unless asked. Print the resulting URL.
Don't git tag -d or git reset to "undo" a release commit — if the user wants to abandon, they can drop the tag and reset themselves; the release flow leaves the working tree at a publishable state and that's the contract. Never use --force on a tag push, and don't amend the release commit after pushing — cut a new patch instead.