diff --git a/.gitignore b/.gitignore index b855c37..1bccd22 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/conf.d/paru-wrapper.fish b/conf.d/paru-wrapper.fish index 2bb7de5..a3d2a01 100644 --- a/conf.d/paru-wrapper.fish +++ b/conf.d/paru-wrapper.fish @@ -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 diff --git a/conf.d/yay-wrapper.fish b/conf.d/yay-wrapper.fish index 4e16e05..1a42a6b 100644 --- a/conf.d/yay-wrapper.fish +++ b/conf.d/yay-wrapper.fish @@ -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 diff --git a/docs/fish-config.md b/docs/fish-config.md index a24b2c4..1c19733 100644 --- a/docs/fish-config.md +++ b/docs/fish-config.md @@ -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 diff --git a/scripts/clean_progress_log.py b/scripts/clean_progress_log.py new file mode 100755 index 0000000..e68fd0a --- /dev/null +++ b/scripts/clean_progress_log.py @@ -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 [ . 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()