Low Config TUIs


In the docs for zero-config TUIs leading up to this point, we saw a few ways to start up interfaces without any project integration.

In the end though, the CLI starts to get pretty clunky, and it's better to have somewhere else to put configuration. For compose.mk, it's often the case that configuration is code and vice versa, or in other words, you can do a surprising amount of work in a tidy declarative style without writing any custom target-bodies.

Basic Example


Let's revisit a previous example in a less adhoc way, basically extending the classic clean/build/test project Makefile with a TUI as before, but using code this time. As before, the goal is to loop the test task, and run the clean/build tasks sequentially-- in separate panes at the same time.

Summary
#!/usr/bin/env -S make -f
# demos/dashboarding.mk: 
#   A very basic demo for getting started with custom TUIs.  
#
# Part of the `compose.mk` repo. This file runs as part of the test-suite.  
# USAGE: ./demos/dashboarding.mk demo.ui


include compose.mk 

# Boilerplate & fake targets for classic clean/build/test
__main__: clean build test
clean:; echo cleaning 
build:; echo building 
test:; echo testing


# Full specification for a working TUI, using existing targets.
# The "top" pane will run the test target in a loop forever, and
# the "bottom" pane will run clean/build sequentially, then wait
# for user input.
top: flux.loopf/test
bottom: flux.and/clean,build
demo.ui: tux.open.horizontal/top,bottom

Look, no target bodies! The declarative flavor is nice, and this is frequently the case for TUI customization: mostly we just need declare groups of existing targets and/or flow between them. We've seen that this frequently involves compose.mk::flux targets, like flux.loopf/<target_name> and flux.and/<targets>.

Running it looks like this:

It's really no surprise that the implementation is small, since it only needs minor changes compared with the CLI version we saw earlier.

Adding a Container as a "Widget"


One powerful technique is to run a container inside a dedicated pane as a "widget". As a simple example, we'll extend the example so far to include moncho/dry, a docker-monitoring application.

Adding a new "middle" pane using this container image only changes two lines, and doesn't break the declarative style:

Summary
#!/usr/bin/env -S make -f
# demos/tui/dashboarding-wdgets.mk: 
#   Expanding thedashboarding.mk TUI demo to include containers-as-widgets.
#   Part of the `compose.mk` repo. This file runs as part of the test-suite.  
#
# USAGE: ./demos/tui/dashboarding-widgets.mk demo.ui

include compose.mk 

# Boilerplate & fake targets for classic clean/build/test
.DEFAULT_GOAL := all 
all: clean build test
clean:; echo cleaning 
build:; echo building 
test:; echo testing


# Full specification for a working TUI, using existing targets.
# The "top" pane will run the test target in a loop forever
# The "bottom" pane will run clean/build sequentially, then wait.
# The "middle" pane starts the "dry" container, i.e. a dockerized 
# version of a docker system-monitor. See https://github.com/moncho/dry
top: flux.loopf/test
middle: docker.start.tty/moncho/dry
bottom: flux.and/clean,build
demo.ui: tux.open.horizontal/top,middle,bottom

You might recognize the new widget, since we just recreated part of the docker.commander TUI mentioned in docs for zero-config TUIs. Our work so far looks like this:

Above, the docker.start.tty/<img> target does the heavy lifting for us, and depending on your exact use-case you might have to write a target-body, or use other related targets like docker.start/<img> , docker.dispatch/ , or docker.run.sh.

Crucially, the docker.* targets are designed to launch tool containers, so they always share the working directory and the docker socket by default. You can always launch a dockerized "widget" by writing any target-body that directly calls docker run ..., but if you do it this way you probably need to remember add volume-mounts.

Mixing in Container Dispatch


Eagle-eyed readers will have noticed that our fake clean/build/test targets don't need special tools, and that we are lucky so far because these targets are running inside the embedded TUI container, and not on the host or in a dedicated tool container.

Of course, if your automation fails to run inside the embedded TUI container, then it would probably also fail to run inside any fresh environment... or in other words, your automation is already broken =P

Regardless of whether you want to use the TUI, the way to begin to address this problem may involve some combination of container dispatch with internal or externally-defined Dockerfiles, compose-files, etc. In many cases though, tool containers are already available, and already ship with basics like make and bash. For this situation, we can again get away with very minimal changes.

To see what it looks like, let's suppose for the next example that we need golang tools available for clean/build phases.

The tightest way to describe this is very functional, but not very pretty. We'll use it as a starting place though, and refactor from there.


Summary
#!/usr/bin/env -S make -f
# demos/tui/dashboarding-dispatch.mk: 
#   Expanding the dashboarding.mk TUI demo to include container dispatch.
#   Part of the `compose.mk` repo. This file runs as part of the test-suite.  
# USAGE: ./demos/tui/dashboarding-widgets.mk demo.ui

include compose.mk 

golang.base=golang\:1.24-bookworm

__main__: clean build test

# Fake targets for classic clean/build/test
clean:; which gofmt; echo cleaning 
build:; which go; go version
test:; echo testing

# Full specification for a working TUI, using existing targets.
# The "top" pane will run the test target in a loop forever
# The "bottom" pane will run clean/build sequentially, then wait.
top: flux.loopf/test
bottom: docker.image.dispatch/${golang.base}/flux.and/clean,build,io.shell
demo.ui: tux.open.horizontal/top,bottom

Closer Look

The magic line above uses docker.image.dispatch/<image>/<target>, and in this case the image is golang:1.24-bookworm. (To stick with the declarative style, we have to escape the colon to declare a legal prerequisite target.)

Above, we've also changed the clean/build targets to actually use golang tools. And we changed flux.and arguments to include io.shell at the end so that the interactive shell we're left with in that pane is running inside the golang container, and not the TUI container.


As with the zero-config TUI examples before, this is getting somewhat unwieldy and hard to read. But since we're scripting now, there are several ways to refactor it as something cleaner.

For demonstrating that, we'll include the last example and just change the bottom target. Typically for something like this you just need to introduce 1 or more intermediate targets:

Summary
#!/usr/bin/env -S make -f
# demos/tui/dashboarding-dispatch-2.mk: 
#   Expanding the dashboarding-dispatch.mk TUI demo to show a refactored example.
#   Part of the `compose.mk` repo. This file runs as part of the test-suite.  
#
# USAGE: ./demos/tui/dashboarding-widgets.mk demo.ui

include demos/tui/dashboarding-dispatch.mk
golang.base?=docker.io/golang:1.24-bookworm
bottom: golang.dispatch/chain
chain: flux.and/clean,build,io.shell
golang.dispatch/%:
    ${docker.image.dispatch}/${golang.base}/${*}

Next Steps


That's it for the basics of low-config TUIs. In the next section, we'll cover more advanced usage.