fix(logging): render AUR progress animations to clean logs via terminal emulator

pacman/paru download progress is a multi-line terminal animation: it
repaints lines in place using ANSI cursor-movement (ESC[<n>A) and
erase-line (ESC[K) sequences, not just carriage returns. The previous
line-wise regex approaches could not reconstruct this — frames either
concatenated into one giant line (CR removed) or collapsed to blanks
(CR kept), discarding the final 100% frame.

Add scripts/clean_progress_log.py, a small dependency-free terminal
screen-buffer emulator that replays the cursor movements against an
in-memory grid and dumps the final static frame, preserving SGR color
so logs still render with color in ov/bat/less -R. It also drops the
script(1) header/footer.

Both wrappers now pipe the raw PTY capture through this cleaner (bumped
to version 5), falling back to stripping only the script(1) header when
python3 is unavailable. Document the scripts/ dir and mechanism in
AGENTS.md and docs/fish-config.md.
This commit is contained in:
2026-06-11 22:48:02 -04:00
parent bd0eac8413
commit 477a4ab265
4 changed files with 265 additions and 16 deletions
+15 -7
View File
@@ -2,8 +2,9 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# Generates ~/.local/bin/paru on first run (and on version bump) when
# /usr/bin/paru is installed. The wrapper tees paru output to a
# timestamped log file and prunes old logs, mirroring smart_exit behavior.
# /usr/bin/paru is installed. The wrapper runs paru in a PTY so progress
# bars are preserved, renders the captured animation to a clean static log
# (via scripts/clean_progress_log.py), and prunes old logs.
# Auto-generating a wrapper in ~/.local/bin is opinionated (C2 auto-exec).
# Wrapper generation is also gated by C5 (Logging & Capture).
@@ -20,7 +21,7 @@ end
set -l _paru_real /usr/bin/paru
set -l _paru_wrapper "$HOME/.local/bin/paru"
set -l _paru_wrapper_version 4
set -l _paru_wrapper_version 5
# Skip entirely if the real paru binary isn't present
test -x $_paru_real; or return
@@ -38,8 +39,8 @@ printf '%s\n' \
'#!/usr/bin/env bash' \
"# paru-wrapper-version: $_paru_wrapper_version" \
'# Auto-generated by conf.d/paru-wrapper.fish — do not edit by hand.' \
'# Runs paru in a PTY via script(1) so progress bars are preserved,' \
'# then strips escape sequences from the captured log file.' \
'# Runs paru in a PTY via script(1) so progress bars are preserved on screen,' \
'# then renders the captured terminal animation to a clean static log.' \
'' \
'log_dir="${SCROLLBACK_HISTORY_DIR:-$HOME/.terminal_history}"' \
'mkdir -p "$log_dir"' \
@@ -54,8 +55,15 @@ printf '%s\n' \
'script -q -e -c "$cmd_str" "$log_file"' \
'exit_code=$?' \
'' \
'# Collapse CR-redrawn frames; keep ANSI codes for ov rendering; strip script(1) headers.' \
'perl -ne '"'"'next if /^Script (started|done)/; s/\r$//; s/.*\r([^\r]+)$/$1/; print'"'"' < "$log_file" > "${log_file}.tmp" 2>/dev/null && mv "${log_file}.tmp" "$log_file" || rm -f "${log_file}.tmp"' \
'# Render the captured terminal animation (progress bars repaint in place via' \
'# cursor moves) down to its final static frame, preserving ANSI color. Falls' \
'# back to dropping only the script(1) header/footer when python3 is missing.' \
'cleaner="${XDG_CONFIG_HOME:-$HOME/.config}/fish/scripts/clean_progress_log.py"' \
'if command -v python3 >/dev/null 2>&1 && [[ -f "$cleaner" ]]; then' \
' python3 "$cleaner" < "$log_file" > "${log_file}.tmp" 2>/dev/null && mv "${log_file}.tmp" "$log_file" || rm -f "${log_file}.tmp"' \
'else' \
' sed -i "/^Script \(started\|done\) on /d" "$log_file" 2>/dev/null || true' \
'fi' \
'' \
'max_files="${SCROLLBACK_HISTORY_MAX_FILES:-100}"' \
'mapfile -t logs < <(ls -1t "$log_dir"/paru_*.log 2>/dev/null)' \
+15 -7
View File
@@ -2,8 +2,9 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# Generates ~/.local/bin/yay on first run (and on version bump) when
# /usr/bin/yay is installed. The wrapper tees yay output to a
# timestamped log file and prunes old logs, mirroring smart_exit behavior.
# /usr/bin/yay is installed. The wrapper runs yay in a PTY so progress
# bars are preserved, renders the captured animation to a clean static log
# (via scripts/clean_progress_log.py), and prunes old logs.
# Auto-generating a wrapper in ~/.local/bin is opinionated (C2 auto-exec).
# Wrapper generation is also gated by C5 (Logging & Capture).
@@ -20,7 +21,7 @@ end
set -l _yay_real /usr/bin/yay
set -l _yay_wrapper "$HOME/.local/bin/yay"
set -l _yay_wrapper_version 4
set -l _yay_wrapper_version 5
# Skip entirely if the real yay binary isn't present
test -x $_yay_real; or return
@@ -38,8 +39,8 @@ printf '%s\n' \
'#!/usr/bin/env bash' \
"# yay-wrapper-version: $_yay_wrapper_version" \
'# Auto-generated by conf.d/yay-wrapper.fish — do not edit by hand.' \
'# Runs yay in a PTY via script(1) so progress bars are preserved,' \
'# then strips escape sequences from the captured log file.' \
'# Runs yay in a PTY via script(1) so progress bars are preserved on screen,' \
'# then renders the captured terminal animation to a clean static log.' \
'' \
'log_dir="${SCROLLBACK_HISTORY_DIR:-$HOME/.terminal_history}"' \
'mkdir -p "$log_dir"' \
@@ -54,8 +55,15 @@ printf '%s\n' \
'script -q -e -c "$cmd_str" "$log_file"' \
'exit_code=$?' \
'' \
'# Collapse CR-redrawn frames; keep ANSI codes for ov rendering; strip script(1) headers.' \
'perl -ne '"'"'next if /^Script (started|done)/; s/\r$//; s/.*\r([^\r]+)$/$1/; print'"'"' < "$log_file" > "${log_file}.tmp" 2>/dev/null && mv "${log_file}.tmp" "$log_file" || rm -f "${log_file}.tmp"' \
'# Render the captured terminal animation (progress bars repaint in place via' \
'# cursor moves) down to its final static frame, preserving ANSI color. Falls' \
'# back to dropping only the script(1) header/footer when python3 is missing.' \
'cleaner="${XDG_CONFIG_HOME:-$HOME/.config}/fish/scripts/clean_progress_log.py"' \
'if command -v python3 >/dev/null 2>&1 && [[ -f "$cleaner" ]]; then' \
' python3 "$cleaner" < "$log_file" > "${log_file}.tmp" 2>/dev/null && mv "${log_file}.tmp" "$log_file" || rm -f "${log_file}.tmp"' \
'else' \
' sed -i "/^Script \(started\|done\) on /d" "$log_file" 2>/dev/null || true' \
'fi' \
'' \
'max_files="${SCROLLBACK_HISTORY_MAX_FILES:-100}"' \
'mapfile -t logs < <(ls -1t "$log_dir"/yay_*.log 2>/dev/null)' \
+10 -2
View File
@@ -60,6 +60,9 @@ The configuration is split across:
completions/ Tab completion scripts
integrations/
fzf.fish FZF Catppuccin theme and key binding config
scripts/
clean_progress_log.py Renders script(1) typescripts (paru/yay progress
animations) to clean static logs, preserving color
docs/ Offline documentation and compiled man page
fish-config.md Primary source manual (terminal-readable)
fish-config.1 Compiled man page (auto-generated by CI)
@@ -216,8 +219,13 @@ scrollback snapshot to SCROLLBACK_HISTORY_DIR. Files are named:
scrollback_YYYY-MM-DD_HH-MM-SS.log
The paru and yay wrappers (auto-generated in ~/.local/bin/) run inside a PTY
via script(1) so progress bars are preserved, and capture all output to:
The paru and yay wrappers (auto-generated in ~/.local/bin/) run the command
inside a PTY via script(1) so download progress bars are preserved on screen,
then render the captured terminal animation down to a clean static log via
scripts/clean_progress_log.py (a small terminal-screen emulator that replays
cursor movements, collapses repainted progress frames to their final state,
and preserves ANSI color). If python3 is unavailable the wrapper falls back to
dropping only the script(1) header/footer. Output is saved to:
paru_YYYY-MM-DD_HH-MM-SS.log
yay_YYYY-MM-DD_HH-MM-SS.log
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# SYNOPSIS
# clean_progress_log.py < typescript.raw > clean.log
#
# DESCRIPTION
# Renders a script(1) typescript containing terminal animations (pacman/paru
# download progress bars, which repaint in place using carriage returns and
# ANSI cursor-movement/erase sequences) down to a clean, static log that shows
# only the final state of each line. SGR color sequences are preserved so the
# output still renders with color in pagers such as ov, bat, or less -R.
#
# It implements a minimal terminal screen-buffer emulator: it replays the
# cursor movements against an in-memory grid of cells, so the concatenated
# redraw frames collapse to the final frame exactly as a real terminal would
# display them. script(1) "Script started/done" header and footer lines are
# dropped.
#
# RETURNS
# 0 Always (best-effort; unparseable bytes are passed through as text).
#
# EXAMPLE
# script -q -e -c 'paru -Syu' raw.log
# clean_progress_log.py < raw.log > clean.log
import re
import sys
# One CSI sequence: ESC [ <params> <final>. Covers SGR (m), cursor moves
# (A/B/C/D/E/F/G/H/f), erases (J/K), and private modes (?25l/h) alike.
_CSI = re.compile(r"\x1b\[([0-9;?]*)([A-Za-z])")
# OSC sequence (e.g. window title): ESC ] ... BEL or ESC ] ... ESC \
_OSC = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
# script(1) header/footer lines to drop from the final render.
_SCRIPT_LINE = re.compile(r"^Script (started|done) on ")
class Screen:
"""A minimal, unbounded-height terminal emulator.
Each cell is a [sgr, char] pair, where sgr is the full active SGR escape
string in effect when the character was written ('' means default). The
grid grows downward as needed; relative cursor moves index into it, so
in-place repaints overwrite earlier frames just like a real terminal.
"""
def __init__(self):
self.rows = [[]]
self.row = 0
self.col = 0
self.sgr = ""
def _ensure_row(self, r):
while len(self.rows) <= r:
self.rows.append([])
def _ensure_col(self, c):
row = self.rows[self.row]
while len(row) <= c:
row.append(["", " "])
def write_char(self, ch):
self._ensure_row(self.row)
self._ensure_col(self.col)
self.rows[self.row][self.col] = [self.sgr, ch]
self.col += 1
def newline(self):
# script(1) typescripts always emit CRLF (the PTY's ONLCR translates
# LF to CRLF on output), so a bare LF starts a fresh line. Reset the
# column to avoid "staircase" artifacts if a bare LF ever appears.
self.row += 1
self.col = 0
self._ensure_row(self.row)
def carriage_return(self):
self.col = 0
def backspace(self):
if self.col > 0:
self.col -= 1
def tab(self):
self.col = (self.col // 8 + 1) * 8
def set_sgr(self, params):
# Treat a bare reset (ESC[0m / ESC[m) as clearing all attributes;
# otherwise adopt the new full sequence. pacman always emits complete
# attribute sets, so replacement (rather than merging) is exact here.
if params in ("", "0"):
self.sgr = ""
else:
self.sgr = "\x1b[" + params + "m"
def csi(self, params, final):
if final == "m":
self.set_sgr(params)
return
# Numeric argument (default 1 for moves, 0 for erases).
nums = [int(p) for p in params.split(";") if p.isdigit()]
n = nums[0] if nums else None
if final == "A":
self.row = max(0, self.row - (n or 1))
elif final == "B" or final == "E":
self.row += (n or 1)
self._ensure_row(self.row)
if final == "E":
self.col = 0
elif final == "F":
self.row = max(0, self.row - (n or 1))
self.col = 0
elif final == "C":
self.col += (n or 1)
elif final == "D":
self.col = max(0, self.col - (n or 1))
elif final == "G":
self.col = (n or 1) - 1
elif final in ("H", "f"):
self.row = (nums[0] - 1) if len(nums) >= 1 else 0
self.col = (nums[1] - 1) if len(nums) >= 2 else 0
self._ensure_row(self.row)
elif final == "K":
self._erase_line(n or 0)
elif final == "J":
self._erase_display(n or 0)
# Any other final (private modes like ?25l/h, etc.) is ignored.
def _erase_line(self, mode):
self._ensure_row(self.row)
row = self.rows[self.row]
if mode == 0: # cursor to end of line
del row[self.col:]
elif mode == 1: # start of line to cursor
for c in range(min(self.col + 1, len(row))):
row[c] = ["", " "]
elif mode == 2: # whole line
row.clear()
def _erase_display(self, mode):
if mode == 2: # whole screen
self.rows = [[]]
self.row = 0
self.col = 0
elif mode == 0: # cursor to end of screen
self._erase_line(0)
del self.rows[self.row + 1:]
def feed(self, data):
i = 0
n = len(data)
while i < n:
ch = data[i]
if ch == "\x1b":
m = _CSI.match(data, i)
if m:
self.csi(m.group(1), m.group(2))
i = m.end()
continue
m = _OSC.match(data, i)
if m:
i = m.end()
continue
# Charset designators (ESC ( B etc.) and other 2-char escapes.
if i + 1 < n and data[i + 1] in "()":
i += 3
else:
i += 2
continue
if ch == "\n":
self.newline()
elif ch == "\r":
self.carriage_return()
elif ch == "\b":
self.backspace()
elif ch == "\t":
self.tab()
elif ch == "\x07": # bell
pass
else:
self.write_char(ch)
i += 1
def render(self):
out_lines = []
for row in self.rows:
last_sgr = ""
buf = []
for sgr, ch in row:
if sgr != last_sgr:
if sgr == "":
buf.append("\x1b[0m")
else:
buf.append(sgr)
last_sgr = sgr
buf.append(ch)
if last_sgr != "":
buf.append("\x1b[0m")
line = "".join(buf)
# Strip trailing whitespace (and any trailing reset that follows it).
line = re.sub(r"[ \t]+(\x1b\[0m)?$", r"\1", line)
out_lines.append(line)
# Drop script(1) header/footer lines (compare against de-escaped text).
kept = []
for line in out_lines:
plain = _CSI.sub("", line)
if _SCRIPT_LINE.match(plain):
continue
kept.append(line)
# Trim trailing blank lines.
while kept and kept[-1].strip() == "":
kept.pop()
return "\n".join(kept) + ("\n" if kept else "")
def main():
data = sys.stdin.buffer.read().decode("utf-8", "replace")
screen = Screen()
screen.feed(data)
sys.stdout.write(screen.render())
if __name__ == "__main__":
main()