grish.dev

WENV: Stay in the Terminal, Own Your Workflow

To many, tmux is simply a convenient way to keep a process running on a remote server once your ssh session disconnects. Given that its the Current Year, tmux seems like an unnecessary program when you can easily open a new tab in your favorite terminal emulator, or stick to your terminal window in VS Code. And even if you see the value of tmux, you may not see much value in using multiple tmux sessions. Most of tmux's value can surely be achieved by opening a few panes/windows, and the real value is in the ability to detach and come back later.

Not only do I find tmux useful for the reasons listed above - to me, tmux is a portable window manager that functions as the bones of my entire development environment.

To explain that, we also have to talk about the shell - let's focus on zsh, which is what I use. Like tmux, I've been using zsh since college, and have always enjoyed the mileage I could get out of chaining shell builtins, command line programs (think perl, grep, fzf, jq, curl, etc.), and my own functions/aliases together. As frustrating as chaining typeless strings between esoteric programs can be, I always found it very satisfying to combine these building blocks in just the right way to accomplish my goal.

Although I loved the aesthetic of using raw tmux/zsh for development, I began to find the lack of structure limiting. I wanted a way for different projects to have their own project-specific shell state, including shell functions and environment variables. I wanted to easily navigate between different projects, and have it always be obvious where I was. I wanted my text editor, Kakoune or 'kak', to be seemlessly integrated into this workflow.

Wenvs (Working ENVironments) accomplish one very simple task: isolate each project to its own tmux session. This makes it so that:

There are a few additional features that improve the usability of wenvs:

Example Wenv

Here's an example of a wenv definition file:

 1#!/usr/bin/env zsh
 2
 3wenv_dir="/home/grish/src/jai/bbmp"
 4wenv_deps=('jai/jai')
 5wenv_extensions=('c' 'edit')
 6
 7startup_wenv() {
 8    tmux split-pane -v
 9    tmux resize-pane -y 25%
10    tmux send-keys 'monitor git jai -import_dir . main.jai' ENTER
11    tmux select-pane -U
12    kak main.jai
13}
14
15((only_load_wenv_vars == 1)) && return 0
16
17alias build='jai -import_dir . main.jai'
18
19# for c extension
20declare -Ag wenv_dirs
21wenv_dirs[library]="$MEDIA/music/library"
22
23# for edit extension
24declare -Ag wenv_files
25wenv_files[changelog]="$wenv_dirs[jai]/CHANGELOG.txt"

When this wenv is active (i.e. by running wenv start bbmp), the following environment variables will be defined in all shells of its tmux session:

WENV='bbmp'
WENV_DIR='/home/grish/src/jai/bbmp'
WENV_EXTENSIONS=('c' 'edit')
WENV_DEPS=('jai/jai')

The WENV env var tells us which env var is active. This is initialized in my .zshrc like so:

source $SRC/wenv/wenv
[[ -n $WENV ]] && wenv_source -c $WENV

It is then stored in the tmux sessions's state by the wenv start command mentioned earlier:

$ tmux show-environment WENV
WENV=bbmp

Here's a screenshot that shows some of how the wenv state looks for an active wenv:

Active wenv screenshot

This is how the active wenv is carried throughout the tmux session:

  1. A new tmux session is created for the wenv (if one doesn't already exist).
  2. The WENV var is set in both the zsh and tmux environments.
  3. When a new pane is opened, tmux automatically passes the WENV from its env into the new zsh shell's env.
  4. wenv_source then picks up on that WENV shell var, and loads the corresponding wenv.
    • All of the wenv's dependenices and extensions will be loaded in this step as well.

Let's break down the bbmp wenv file:

1#!/usr/bin/env zsh
2
3wenv_dir="/home/grish/src/jai/bbmp"
4wenv_deps=('jai/jai')
5wenv_extensions=('c' 'edit')

These define the 'wenv variables' described previously. They map directly to the corresponding shell variables describe earlier: WENV_DIR, WENV_DEPS, and WENV_EXTENSIONS, respectively. When a new pane is opened, .zshrc's call to wenv_source -c will automatically change into the directory defined by wenv_dir. Then, any entries in wenv_extensions will have their corresponding wenv extension files loaded. Finally, it will source any other wenvs listed in wenv_deps.

 7startup_wenv() {
 8    tmux split-pane -v
 9    tmux resize-pane -y 25%
10    tmux send-keys 'monitor git jai -import_dir . main.jai' ENTER
11    tmux select-pane -U
12    kak main.jai
13}

startup_wenv() is always ran in the first tmux pane created whenever wenv start is called. In this case, we

  1. Create a new pane below the current one.
  2. Resize the new pane to be 25% of the total vertical height.
  3. Run a custom monitor script that rebuilds the project when something changes.
  4. Re-focuses the top pane, and opens main.jai for editing.

Here's a clip where I run wenv start bbmp, then edit main.jai a couple of times to show the auto-rebuild in the bottom pane:

15((only_load_wenv_vars == 1)) && return 0

This is the barrier between the variables/functions that are defined for wenvs in general, and any user-defined shell state that should be loaded in every pane. This is used internally by the Wenv implementation when it needs to source a wenv to access its wenv vars, but without running all of the commands/declarations that come after.

17alias build='jai -import_dir . main.jai'

This is a simple example of the type of alias you might create in a wenv. While build is a very common name that might be used across multiple projects, the fact that it's defined in the wenv file isolates it from all other wenvs and makes it easy to find.

19# for c extension
20declare -Ag wenv_dirs
21wenv_dirs[library]="$MEDIA/music/library"
22
23# for edit extension
24declare -Ag wenv_files
25wenv_files[changelog]="$wenv_dirs[jai]/CHANGELOG.txt"

These are associative arrays that're used by the c and edit extensions. The declarations here enable the following functionality:

The c and edit extensions support tab completion for the keys in wenv_dirs and wenv_files. Any wenv_dirs or wenv_files entries will be inherited when sourcing the wenv's dependencies.

The wd Extension

The wenv_dirs associative array gives wenvs a way to specify a set of additional directories aside from WENV_DIR that you want to be able to quickly navigate to. But what if you want to do something other than cd into one of those dirs?

The c extension is enabled by the more general wd function:

 1wd() {
 2    (($# != 1)) && { echo "$WENV_DIR" ; return 0 }
 3    local input="$1"
 4    shift
 5
 6    [ "${wenv_dirs[$input]+0}" ] || { echo "no entry '$input'" >&2 ; return 1 }
 7    dir="${wenv_dirs[$input]}"
 8
 9    local abs
10    if [[ $dir != /* ]]; then
11        abs=$(realpath "$WENV_DIR/$dir")
12    else
13        abs=$dir
14    fi
15    echo "$abs"
16}

wd takes an input key, looks it up in wenv_dirs, then prints out the corresponding directory. With no argument it just prints WENV_DIR. The [[ $dir != /* ]] check is to allow relative path values to be considered as relative to WENV_DIR.

Recall that the bbmp wenv was dependent on jai/jai, which includes the following additions to wenv_dirs:

wenv_dir="/home/grish/src/jai"
# ...
wenv_dirs[jai]="$wenv_dir/jai"
wenv_dirs[custom-modules]="$wenv_dir/modules"
wenv_dirs[modules]="$wenv_dir/jai/modules"
wenv_dirs[examples]="$wenv_dir/jai/examples"
wenv_dirs[how-to]="$wenv_dir/jai/how_to"
wenv_dirs[releases]="$wenv_dir/releases"

(Note that these are all absolute paths, because any wenv that inherits jai/jai will have its own WENV_DIR that would likely break any relative pathing assumptions.)

Here are a few examples of running wd from the bbmp wenv:

$ wd
/home/grish/src/jai/bbmp

$ wd <tab>
custom-modules  examples        how-to          jai             library         modules         releases

$ wd releases
/home/grish/src/jai/releases

This can be useful on its own - for example, let's say I just took a screenshot that I want to add to the media directory of a blog post I'm working on:

$ cp 2026-01-02_12-06.png `wd wenv-post`/media/bbmp-wenv.png

But we can also extend the functionality more generally by wrapping wd in other shell functions, with the added bonus of its tab completion function _wd being at their disposal.

c() { # `cd` into target dir
    dir=$(wd $@) || return $?
    cd $dir
}
complete -F _wd c

lfwd() { # start `lf` in the target dir
    dir=$(wd $@) || return $?
    lf $dir
}
complete -F _wd lfwd

lfc() { # start `lfcd` in the target dir
    dir=$(wd $@) || return $?
    lfcd $dir
}
complete -F _wd lfc

wd is a perfect example of a tool that takes advantage of core wenv state (WENV_DIR), adds its own state for wenvs to build off of (wenv_dirs), and works as a building block for other extensions.

Text Editor Integration

As mentioned before, I use kak as my text editor. kak supports sessions via its client-server architecture, which integrates perfectly with wenvs. The idea is that each wenv gets its own kak session, with the base directory set to WENV_DIR. I achieve this by aliasing kak to my kak_session script:

1#!/usr/bin/env zsh
2
3[[ -z "$WENV" ]] && { /usr/bin/kak $@ ; return 0 }
4local server_name=$(sed 's/\//-/g' <<< $WENV)
5local socket_file=$(grep ^"$server_name"$ <<< $(/usr/bin/kak -l))
6[[ $socket_file == "" ]] && /usr/bin/kak -d -s $server_name &
7/usr/bin/kak -c $server_name $@

Whenever I run kak <file> within a wenv, the corresponding kak session will be created if it doesn't exist, then the client will connect to that session. Everywhere I call kak (from lf, fzf, lazygit, etc.), this script is used, making it seemless to stay within the wenv's text editor session at all times.

Tidbits

(1) wenv start supports shell expansions:

$ wenv start project/*
started wenv 'project/repo-1'
started wenv 'project/repo-2'
started wenv 'project/repo-3'

$ wenv start project/repo-{1,2}
wenv 'project/repo-1' already running
wenv 'project/repo-2' already running

This ends up being very useful in your zsh history when you restart your computer and need to start your usual set of projects.

(2) The implementation of Wenv is a wenv file itself, as it's useful for development to easily access the implementation via wenv edit wenv. It also sounds ridiculous when discussing the "wenv Wenv".

Aside from the tab completion functions and wenv extension files, the entire implementation is contained within this file and is just a set of zsh functions. cloc counts 659 lines of code, which is fairly verbose zsh (mostly due to most of the functions having getopts handling and usage strings).

Conclusion

The goal of Wenv's was to provide the minimal necessary glue to isolate projects between tmux sessions and enable any additional functionality as desired. I didn't want anything to feel like magic, and I wanted the wenv definition files to have as little boilerplate as possible. I've used wenvs personally and professionally for many years now, and am content with how the experience has held up.

As painful as shell scripting can be, it's certainly not any more painful than dealing with the 'higher level' tools that 1. limit your options as a developer, 2. are often broken, and 3. will inevitably be replaced by some shiny new option that's essentially the same as whatever it replaced. Wenvs integrate well with the 'Unix philosophy', in that they enable you to more easily combine existing tools and use the result across projects. They do this while adding very little conceptual abstraction outside of basic environment variables and sourcing shell files. Working in the realm of shell scripting is by no means a panacea, but

  1. If you're going to do it, then leverage the tools in this ecosystem to make your dev experience better than any corporate IDE, and
  2. If it's not your thing, then consider where there's friction in your current tools + how your life would change if it wasn't there.

The higher goal is to build software that's actually better than what we have now. That means refusing to accept a painful developer experience that isn't worth the tradeoffs to you, getting good with the tools you do want to use, and staying productive along the way.