diff --git a/conf.d/paru-wrapper.fish b/conf.d/paru-wrapper.fish index c801e65..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 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)' \ diff --git a/conf.d/yay-wrapper.fish b/conf.d/yay-wrapper.fish index 324945b..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 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)' \ diff --git a/docs/fish-config.md b/docs/fish-config.md index aaeafed..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,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 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()