Merge pull request 'fix(logging): restore AUR progress bars and render clean colored logs' (#44) from fix/aur-wrapper-pty-progress-bars into main
Generate documentation / build-docs (push) Successful in 37s

Reviewed-on: #44
This commit was merged in pull request #44.
This commit is contained in:
2026-06-12 03:02:20 +00:00
5 changed files with 467 additions and 15 deletions
+178
View File
@@ -110,3 +110,181 @@ fish_variables
config.local.fish
# End of https://www.toptal.com/developers/gitignore/api/fish
# id: gi-patterns-e390e9c720b3dda36906a2b4cb76ffdd
# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python
+26 -6
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 1
set -l _paru_wrapper_version 5
# Skip entirely if the real paru binary isn't present
test -x $_paru_real; or return
@@ -38,14 +39,31 @@ 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.' \
'# Tees paru output to a timestamped log file and prunes old ones.' \
'set -o pipefail' \
'# 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"' \
'log_file="$log_dir/paru_$(date +%Y-%m-%d_%H-%M-%S).log"' \
'' \
'/usr/bin/paru "$@" 2>&1 | tee "$log_file"' \
'# Build a safely-quoted command string for script(1).' \
'# script(1) allocates a PTY so paru detects a real terminal and shows progress.' \
'cmd_str="/usr/bin/paru"' \
'for arg in "$@"; do' \
' cmd_str+=" $(printf '"'"'%q'"'"' "$arg")"' \
'done' \
'script -q -e -c "$cmd_str" "$log_file"' \
'exit_code=$?' \
'' \
'# 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)' \
@@ -53,6 +71,8 @@ printf '%s\n' \
'for (( i = ${#logs[@]} - 1; i >= ${#logs[@]} - excess && i >= 0; i-- )); do' \
' rm -f "${logs[$i]}"' \
'done' \
'' \
'exit $exit_code' \
> $_paru_wrapper
chmod +x $_paru_wrapper
+26 -6
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 1
set -l _yay_wrapper_version 5
# Skip entirely if the real yay binary isn't present
test -x $_yay_real; or return
@@ -38,14 +39,31 @@ 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.' \
'# Tees yay output to a timestamped log file and prunes old ones.' \
'set -o pipefail' \
'# 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"' \
'log_file="$log_dir/yay_$(date +%Y-%m-%d_%H-%M-%S).log"' \
'' \
'/usr/bin/yay "$@" 2>&1 | tee "$log_file"' \
'# Build a safely-quoted command string for script(1).' \
'# script(1) allocates a PTY so yay detects a real terminal and shows progress.' \
'cmd_str="/usr/bin/yay"' \
'for arg in "$@"; do' \
' cmd_str+=" $(printf '"'"'%q'"'"' "$arg")"' \
'done' \
'script -q -e -c "$cmd_str" "$log_file"' \
'exit_code=$?' \
'' \
'# 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)' \
@@ -53,6 +71,8 @@ printf '%s\n' \
'for (( i = ${#logs[@]} - 1; i >= ${#logs[@]} - excess && i >= 0; i-- )); do' \
' rm -f "${logs[$i]}"' \
'done' \
'' \
'exit $exit_code' \
> $_yay_wrapper
chmod +x $_yay_wrapper
+12 -3
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,7 +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/) tee 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
@@ -1615,9 +1624,9 @@ __fish_config_op_logging skips all capture and removes the logging wrappers.
───────────────────────────────────────────────────────────────────────────
Scrollback capture Terminal session output saved to:
~/.terminal_history/scrollback_YYYY-MM-DD_HH-MM-SS.log
paru wrapper All paru/AUR output teed to:
paru wrapper All paru/AUR output captured to:
~/.terminal_history/paru_YYYY-MM-DD_HH-MM-SS.log
yay wrapper All yay/AUR output teed to:
yay wrapper All yay/AUR output captured to:
~/.terminal_history/yay_YYYY-MM-DD_HH-MM-SS.log
Kitty watcher watcher.py captures scrollback when Kitty closes
+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()