Compare commits

...

6 Commits

Author SHA1 Message Date
rootiest 71aa38c7ca 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
2026-06-12 03:02:20 +00:00
rootiest 499bf672f8 chore(gitignore): add python patterns to gitignore 2026-06-11 22:57:30 -04:00
rootiest 477a4ab265 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.
2026-06-11 22:48:02 -04:00
rootiest bd0eac8413 fix(logging): fix CR-collapse and script header filtering in AUR log post-processing
col -bp is designed for backspace-overstriking (man pages), not ANSI
terminal animations; it mangled escape sequences and garbled overprinted
progress bar text. Replace with a perl -ne one-liner that:
  1. Skips Script started/done header lines (next if matches, with -ne
     so next truly suppresses print, unlike -pe which still auto-prints)
  2. Strips trailing \r from PTY \r\n line endings
  3. Collapses CR-redrawn progress bar frames to their final state
  4. Preserves all ANSI color codes so ov renders them correctly
Bump wrapper version to 4 to force regeneration.
2026-06-11 22:23:38 -04:00
rootiest fc63b4f1b0 fix(logging): use col -bp to collapse progress bar redraws in AUR logs
script(1) captures every \r-redrawn frame of the pacman progress bar
as raw bytes. The previous sed pass only stripped ANSI codes, leaving
all intermediate frames concatenated in the log. col -bp semantically
replays the carriage-return overwrites and retains only the final state
of each line; the sed pass then strips residual ANSI codes and the
"Script started/done" header that script(1) writes to the file even
under -q. Bump wrapper version to 3.
2026-06-11 22:10:19 -04:00
rootiest 0cbb7c6892 fix(logging): run paru/yay wrappers in PTY to restore progress bars
Piping through tee caused libalpm to detect a non-TTY stdout and
suppress download progress bars. Switch to script(1) with -q -e -c
which allocates a pseudo-TTY so paru/yay see a real terminal, then
strip ANSI escape sequences from the captured log for readability.
Bump wrapper version to 2 to force regeneration on next shell start.
2026-06-11 22:02:56 -04: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()