DEV Community

973ba00b
973ba00b

Posted on

Logging Every tmux Pane

The tmux-logging plugin is a manual toggle. I wanted every pane logged from the
moment it exists, in a file I can actually read.

Written and tested on macOS (tmux 3.7c, BSD awk 20200816, zsh). The awk is
plain POSIX, so it should carry to Linux, but I have not tested it there.

1. Install tpm and create the log directory

git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm
mkdir -p ~/.tmux/logs
Enter fullscreen mode Exit fullscreen mode

2. Put this in ~/.tmux.conf

Replace /Users/you with your own home directory β€” tmux does not expand
$HOME or ~ inside option values.

set -g @plugin 'tmux-plugins/tpm'
set -g @plugin 'tmux-plugins/tmux-logging'

set -g @logging-path '/Users/you/.tmux/logs'
set -g @logging-filename '#{session_name}-#{window_name}-#{pane_index}-%Y%m%d-%H%M%S.log'

# Keep tpm last.
run '~/.tmux/plugins/tpm/tpm'

# These must come after tpm, which binds P itself.
bind-key P run-shell "$HOME/.tmux/logging-live.sh toggle #{pane_id}"

set-hook -g after-new-session  'run-shell "$HOME/.tmux/logging-live.sh start #{pane_id}"'
set-hook -g after-new-window   'run-shell "$HOME/.tmux/logging-live.sh start #{pane_id}"'
set-hook -g after-split-window 'run-shell "$HOME/.tmux/logging-live.sh start #{pane_id}"'
Enter fullscreen mode Exit fullscreen mode

Three hooks cover every way a pane comes into existence.

3. Save the two scripts

Copy them from the bottom of this post to ~/.tmux/logging-live.sh and
~/.tmux/strip-ansi.awk, then:

chmod +x ~/.tmux/logging-live.sh
Enter fullscreen mode Exit fullscreen mode

4. Load it

tmux source-file ~/.tmux.conf
Enter fullscreen mode Exit fullscreen mode

Then press prefix + I (capital i) inside tmux to make tpm download
the logging plugin. prefix is Ctrl+b unless you changed it.

Every pane created from now on logs itself. Panes that already existed need a
one-time start:

for id in $(tmux list-panes -a -F '#{pane_id}'); do
  tmux run-shell "~/.tmux/logging-live.sh start $id"
done
Enter fullscreen mode Exit fullscreen mode

prefix + P toggles logging for the current pane by hand.

~/.tmux/logging-live.sh

#!/usr/bin/env bash
#
# Live-flushing front end for tmux-plugins/tmux-logging.
#
# Why this exists instead of a patch to the plugin: the plugin pipes panes
# through BSD sed, whose stdout is block-buffered when it points at a file, so
# an active log reads as 0 bytes until the buffer fills or logging stops. This
# pipes through awk with fflush() per line instead. It lives outside the plugin
# directory so `prefix U` (tpm update) does not overwrite it.
#
# Reuses the plugin's own @logging-path / @logging-filename options via
# variables.sh, so the config in .tmux.conf remains the single source of truth.
#
# Usage: logging-live.sh [toggle|start|stop] [pane-id]
#
# The pane id must be passed in, expanded by tmux as #{pane_id} in the
# run-shell command. `run-shell -t` does NOT export TMUX_PANE, so without an
# explicit id every tmux call below would silently resolve to whichever pane
# happens to be active - which is the right pane for a hook firing on a new
# pane, but the wrong one for a key binding pressed in a background pane.

PLUGIN_SCRIPTS="$HOME/.tmux/plugins/tmux-logging/scripts"
FILTER="$HOME/.tmux/strip-ansi.awk"
MODE="${1:-toggle}"
PANE="${2:-}"

[ -f "$PLUGIN_SCRIPTS/variables.sh" ] || { tmux display-message "tmux-logging not installed"; exit 1; }
source "$PLUGIN_SCRIPTS/variables.sh"   # provides $logging_full_filename

if [ -n "$PANE" ]; then
    TARGET=(-t "$PANE")
else
    TARGET=()
fi

pane_query() {
    tmux display-message "${TARGET[@]}" -p "$1"
}

# Expands #{...} formats and strftime %... in the configured path, then makes
# sure the directory exists.
expand_path() {
    local full_path
    full_path=$(pane_query "$1")
    mkdir -p "${full_path%/*}"
    printf '%s' "$full_path"
}

state_var() {
    local id="${PANE:-$(pane_query '#{pane_id}')}"
    printf '@logging_%s' "${id#%}"
}

is_logging() {
    [ "$(tmux show-option -gqv "$(state_var)")" = "logging" ]
}

start_logging() {
    is_logging && return 0
    local file
    file=$(expand_path "$logging_full_filename")
    tmux pipe-pane "${TARGET[@]}" "exec awk -f '$FILTER' >> '$file'"
    tmux set-option -gq "$(state_var)" "logging"
    [ "$MODE" = "toggle" ] && tmux display-message "Logging to $file"
    return 0
}

stop_logging() {
    tmux pipe-pane "${TARGET[@]}"
    tmux set-option -gq "$(state_var)" "not logging"
    [ "$MODE" = "toggle" ] && tmux display-message "Logging stopped"
    return 0
}

case "$MODE" in
    start)  start_logging ;;
    stop)   stop_logging ;;
    toggle) if is_logging; then stop_logging; else start_logging; fi ;;
    *)      tmux display-message "usage: logging-live.sh [toggle|start|stop]"; exit 1 ;;
esac
Enter fullscreen mode Exit fullscreen mode

~/.tmux/strip-ansi.awk

# Turn a raw tmux pipe-pane stream into a readable log, flushing every line.
#
# Two problems this solves:
#
#   1. Buffering. stdout aimed at a file is block-buffered, so without the
#      fflush() below a live log reads as 0 bytes until ~4-64KB accumulates.
#   2. Redraws. Interactive shells repaint the prompt line constantly (syntax
#      highlighting, autosuggestions), moving the cursor backwards and
#      overwriting in place. Deleting the escape codes alone leaves every
#      repaint concatenated: "echo RAWMARKERecho". So lines that move the
#      cursor are replayed into a buffer the way a terminal would apply them.

function render(s,   out, i, n, c, pos, len, esc, cmd, num) {
    # Replay s into out[], honouring cursor motion. pos is 0-based.
    pos = 0; len = 0; n = length(s)
    for (i = 1; i <= n; i++) {
        c = substr(s, i, 1)
        if (c == "\033") {
            esc = substr(s, i + 1)
            if (match(esc, /^\[[0-9;?]*[a-zA-Z]/)) {
                cmd = substr(esc, RLENGTH, 1)
                num = substr(esc, 2, RLENGTH - 2) + 0
                if (num == 0) num = 1
                if      (cmd == "D") pos = (pos > num) ? pos - num : 0      # cursor back
                else if (cmd == "C") pos += num                             # cursor forward
                else if (cmd == "G") pos = num - 1                          # column address
                else if (cmd == "K") { if (pos < len) len = pos }           # erase to EOL
                i += RLENGTH
            } else if (match(esc, /^\][^\033\007]*(\007|\033\\)/) ||        # OSC
                       match(esc, /^k[^\033]*\033\\/)) {                    # screen title
                i += RLENGTH
            } else if (match(esc, /^([()][A-Za-z0-9]|[=><\\])/)) {
                i += RLENGTH
            }
            continue
        }
        if (c == "\r")   { pos = 0;                      continue }
        if (c == "\010") { if (pos > 0) pos--;           continue }
        if (c == "\007")  {                              continue }
        out[pos++] = c
        if (pos > len) len = pos
    }
    s = ""
    for (i = 0; i < len; i++) s = s (i in out ? out[i] : " ")
    delete out
    return s
}

{
    original = $0

    sub(/\r+$/, "")   # plain CRLF ending, not a redraw

    if ($0 ~ /\033\[[0-9;?]*[DCGK]/ || $0 ~ /\r/ || $0 ~ /\010/) {
        # Line repositions the cursor: replay it properly.
        $0 = render($0)
    }

    # Remaining escapes carry no positioning meaning; strip them outright.
    gsub(/\033\][^\033\007]*(\007|\033\\)/, "")   # OSC: title, cwd
    gsub(/\033k[^\033]*\033\\/, "")               # screen/tmux window title
    gsub(/\033\[[0-9;?]*[a-zA-Z]/, "")            # CSI: colors, modes
    gsub(/\033[()][A-Za-z0-9]/, "")               # charset selection
    gsub(/\033[=><]/, "")                         # keypad / cursor key modes
    gsub(/\033\\/, "")                            # stray string terminator
    gsub(/\007/, "")                              # bell

    sub(/[[:space:]]+$/, "")

    # Drop pure redraw noise, but keep genuinely blank output lines.
    if ($0 ~ /^[[:space:]]*$/ && original !~ /^[[:space:]]*$/) next

    print
    fflush()
}
Enter fullscreen mode Exit fullscreen mode

Logs land in ~/.tmux/logs, one file per pane, written live. Nothing rotates
them, so prune the directory on a schedule.

Top comments (0)