Simple TUIs with Babashka and Gum

I've been trying to learn Clojure for a long time. I recently discovered Babashka (bb) and now I finally feel like I'm getting somewhere. It's been a lot of fun!

Babashka ships as a self-contained binary loaded with an amazing suite of libraries to get your work done. In addition to libraries, you can use Babashka pods to make a bridge to just about anything else you need. There's a whole box of tools at your disposal! A growing subset of the Clojure ecosystem is also available to load as additional dependencies, such as the wonderful HoneySQL.

One element missing from the bb toolbox was an answer for making Terminal UIs (TUI) - fortunately there's another tool that composes well with Babashka to fill that gap: gum.

gum - from charm.sh

The Go community has an amazing library for making TUI applications, called bubbletea, along with companion libraries lipgloss for styling, and some reusable components as bubbles. If you weren't writing Go you were out of luck, until fairly recently!

The folks at charm.sh released a tool called gum that, similar to bb, ships as a single binary loaded with functionality. gum is intended to be shelled out to from scripts - perfect for bb!

bbgum (recorded with charm.sh vhs)

Test Drive

Recently I supported a training event that required the management of 100+ virtual machines. These machines we assigned to various teams, and each team could request configuration changes. To manage this I used a few scripts and a SQLite database. With bb that would have been much nicer.

To give gum and bb a proper test drive I wrote a program that:

  1. Prompts the user for a SQL query to run
    • Uses gum writer to prompt the user, similar to a textarea
  2. Runs the query and shows the results
    • Shells out to sqlite3 to run the query
    • Uses gum table to display as a table
  3. Confirms before moving forward
    • Uses gum confirm to show a confirm dialog, an error exit code is used to denote a negative response
  4. Prompts for a selection of templates to run
    • Uses gum choose --no-limit with a list of files from the templates directory
  5. Executes each template and exits
    • Uses selmer - a Django inspired template system that is included with Babashka

Calling Gum

Shelling out to gum is pretty straight forward. The return values and exit codes from gum make getting user input easy.

(defn header [msg]
  (shell (format "gum style --padding 1 --foreground 212 '%s'" msg)))

(defn input [& {:keys [value placeholder] :or {value "" placeholder ""}}]
  (-> (shell {:out :string}
             (format "gum input --placeholder '%s' --value '%s'" placeholder value))
      :out
      str/trim))

(defn write [value placeholder]
  (-> (shell {:out :string}
             (format "gum write --show-line-numbers --placeholder '%s' --value '%s'" placeholder value))
      :out
      str/trim))

(defn table [csv]
  (let [data (csv/read-csv csv)
        headers (->> data
                     first
                     (map str/upper-case))
        num-headers (count headers)
        width (int (/ 100.0 num-headers))
        cmd (format "gum table --widths %s" (str/join "," (repeat num-headers width)))]
    (shell {:in csv :out :string} cmd)))

(defn confirm [msg]
  (-> (shell {:continue true}
             (format "gum confirm '%s'" msg))
      :exit
      zero?))

(defn choose
  [opts & {:keys [no-limit limit]}]
  (let [opts (str/join " " opts)
        limit (str "--limit " (or limit 1))
        no-limit (if no-limit (str "--no-limit") "")
        cmd (format "gum choose %s %s %s" limit no-limit opts)]
    (-> (shell {:out :string} cmd)
        :out
        str/trim
        str/split-lines)))

Update! Since publishing this blog post a Clojure library for interacting with Gum has been published: bblgum.

This library provides a simple but expressive interface to the underlying Gum controls. Here's how you would display a confirm dialog that returns a bool:

(b/gum {:cmd :confirm :as :bool :args ["Are you ready?"]})

The source code of the library is short and instructive - definitely worth reading!

Generating Data

To generate the example data for the demo I wrote another Babashka script which uses a few additional dependencies not included by default.

Dependencies are managed by a bb.edn file, similar to deps.edn from Clojure.

;; bb.edn
{:paths ["src"]
 :deps
 {faker/faker {:mvn/version "0.2.2"}
  com.github.seancorfield/honeysql {:mvn/version "2.4.969"}}}

Using faker to generate the domain names simplified a lot of the data generation, I only had to generate a random IP address and assign teams.

For IP address generation I wanted addresses in the 10.0.0.0/8 range, so I generated a random int in that range. Clojure has a nice radix notation for writing number literals.

This example also shows some interop with the Java classes that Babashka also includes.

(require '[clojure.string :as str]
         '[faker.internet :as internet]
         '[babashka.process :refer [process check]]
         '[honey.sql :as sql]
         '[honey.sql.helpers :refer [insert-into columns values]])

(import java.net.InetAddress
        java.nio.ByteBuffer)

(defn rand-10-addr-int []
  (let [min 2r00001010000000000000000000000001
        max 2r00001010111111111111111111111111
        range (- max min)]
    (+ min (rand-int range))))

(defn ip-address [n]
  (-> (.getByAddress InetAddress
                     (-> (ByteBuffer/allocate 4)
                         (.putInt n)
                         (.array)))
      .toString
      (str/replace-first "/" "")))

(defn gen-data [n]
  (map vector
       (take n (repeatedly #(ip-address (rand-10-addr-int))))
       (take n (cycle ["a" "b" "c" "d" "e"]))
       (take n (repeatedly internet/domain-name))))

(comment
  (let [data (gen-data 85)
        sql (sql/format
             (-> (insert-into :inventory)
                 (columns :ip :team :domain)
                 (values data))
             {:inline true})
        sql (first sql)]
    (-> (process {:in sql :out :string} "sqlite3 some.db")
        check
        :out))
  *e)

You can checkout the full code here

Extending the example

Possible further work on this example program:

  1. Use Babashka tasks to run the data generation script
  2. Distribute it with bbin
  3. Accept command-line arguments with babashka.cli or clojure.tools.cli
  4. Publish a gum pod for easier integration

Other Options

Admittedly there are some limitations to the UX exposed by Gum. Another option that keeps you in a REPL with parentheses is Node Babashka. Advertised as "Ad-hoc CLJS scripting on Node.js" nbb allows you to write CLJS without any JVM in sight.

The docs showcase using ink to build UIs in the terminal using React with Reagent.

Final Notes

This was my first blog post ever! I hope it was helpful.

For more details on how bb works check out:

  1. How GraalVM Helped Create a Fast-Starting Scripting Environment for Clojure
  2. clojureD 2020: "Babashka and Small Clojure Interpreter: Clojure in new contexts" by Michiel Borkent

Great thanks and admiration to the awesome @borkdude, the mind behind Babashka, Node.js Babashka, the Small Clojure Interpreter (SCI) and countless other amazing projects

This blog is also powered by quickblog (another borkdude project 🤯)

The gif was recorded with vhs (also from charm.sh)

Published: 2023-01-16

Tagged: clojure tui cli gum bb sql

Archive