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
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}"'
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
4. Load it
tmux source-file ~/.tmux.conf
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
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
~/.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()
}
Logs land in ~/.tmux/logs, one file per pane, written live. Nothing rotates
them, so prune the directory on a schedule.
Top comments (0)