From 420830faf53b7d6ca0a5ac8c2d9e4fe8181ae093 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 14:21:55 -0600 Subject: [PATCH 1/4] Pane(feat[capture_pane]): Add 5 new flag parameters why: Expose more tmux capture-pane capabilities for advanced use cases like capturing colored output, handling wrapped lines, and controlling trailing space behavior. what: - Add escape_sequences parameter (-e flag) for ANSI escape sequences - Add escape_non_printable parameter (-C flag) for octal escapes - Add join_wrapped parameter (-J flag) for joining wrapped lines - Add preserve_trailing parameter (-N flag) for trailing spaces - Add trim_trailing parameter (-T flag) with tmux 3.4+ version check - Issue warning when trim_trailing used with tmux < 3.4 --- src/libtmux/pane.py | 65 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index b6cef7981..351a3333f 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -320,8 +320,14 @@ def capture_pane( self, start: t.Literal["-"] | int | None = None, end: t.Literal["-"] | int | None = None, + *, + escape_sequences: bool = False, + escape_non_printable: bool = False, + join_wrapped: bool = False, + preserve_trailing: bool = False, + trim_trailing: bool = False, ) -> list[str]: - """Capture text from pane. + r"""Capture text from pane. ``$ tmux capture-pane`` to pane. ``$ tmux capture-pane -S -10`` to pane. @@ -344,17 +350,74 @@ def capture_pane( Negative numbers are lines in the history. ``-`` is the end of the visible pane. Default: None + escape_sequences : bool, optional + Include ANSI escape sequences for text and background attributes + (``-e`` flag). Useful for capturing colored output. + Default: False + escape_non_printable : bool, optional + Escape non-printable characters as octal ``\\xxx`` format + (``-C`` flag). Useful for binary-safe capture. + Default: False + join_wrapped : bool, optional + Join wrapped lines and preserve trailing spaces (``-J`` flag). + Lines that were wrapped by tmux will be joined back together. + Default: False + preserve_trailing : bool, optional + Preserve trailing spaces at each line's end (``-N`` flag). + Default: False + trim_trailing : bool, optional + Trim trailing positions with no characters (``-T`` flag). + Only includes characters up to the last used cell. + Requires tmux 3.4+. If used with tmux < 3.4, a warning + is issued and the flag is ignored. + Default: False Returns ------- list[str] Captured pane content. + + Examples + -------- + >>> pane = window.split(shell='sh') + >>> pane.capture_pane() + ['$'] + + >>> pane.send_keys('echo "Hello world"', enter=True) + + >>> pane.capture_pane() + ['$ echo "Hello world"', 'Hello world', '$'] + + >>> print(chr(10).join(pane.capture_pane())) + $ echo "Hello world" + Hello world + $ """ + import warnings + + from libtmux.common import has_gte_version + cmd = ["capture-pane", "-p"] if start is not None: cmd.extend(["-S", str(start)]) if end is not None: cmd.extend(["-E", str(end)]) + if escape_sequences: + cmd.append("-e") + if escape_non_printable: + cmd.append("-C") + if join_wrapped: + cmd.append("-J") + if preserve_trailing: + cmd.append("-N") + if trim_trailing: + if has_gte_version("3.4"): + cmd.append("-T") + else: + warnings.warn( + "trim_trailing requires tmux 3.4+, ignoring", + stacklevel=2, + ) return self.cmd(*cmd).stdout def send_keys( From 601c32241d6d147fc51090788ad32b6e218cac7a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 14:22:04 -0600 Subject: [PATCH 2/4] tests(pane): Add exhaustive capture_pane() flag tests why: Ensure comprehensive coverage of all capture_pane() flag combinations with parametrized test cases. what: - Add CapturePaneCase NamedTuple for test case definitions - Add 16 parametrized test cases covering all flag variations - Add backward compatibility test for existing code - Add start/end parameters test with new flags - Add trim_trailing warning test for tmux version check - Use marker-based completion detection for reliability --- tests/test_pane_capture_pane.py | 472 ++++++++++++++++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 tests/test_pane_capture_pane.py diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py new file mode 100644 index 000000000..19d7f9b70 --- /dev/null +++ b/tests/test_pane_capture_pane.py @@ -0,0 +1,472 @@ +"""Tests for Pane.capture_pane() with new flag parameters. + +This module provides comprehensive parametrized tests for the capture_pane() method, +covering all flag variations: -e, -C, -J, -N, -T. +""" + +from __future__ import annotations + +import re +import shutil +import typing as t + +import pytest + +from libtmux.common import has_gte_version +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +# ============================================================================= +# Test Case Definition +# ============================================================================= + + +class CapturePaneCase(t.NamedTuple): + """Test case for capture_pane() parameter variations. + + This NamedTuple defines the parameters for parametrized tests covering + all combinations of capture_pane() flags. + + Attributes + ---------- + test_id : str + Unique identifier for the test case, used in pytest output. + command : str + Shell command to execute in the pane. + escape_sequences : bool + Whether to include ANSI escape sequences (-e flag). + escape_non_printable : bool + Whether to escape non-printable chars as octal (-C flag). + join_wrapped : bool + Whether to join wrapped lines (-J flag). + preserve_trailing : bool + Whether to preserve trailing spaces (-N flag). + trim_trailing : bool + Whether to trim trailing positions (-T flag). + expected_pattern : str | None + Regex pattern that must match in the output. + not_expected_pattern : str | None + Regex pattern that must NOT match in the output. + min_tmux_version : str | None + Minimum tmux version required for this test. + """ + + test_id: str + command: str + escape_sequences: bool + escape_non_printable: bool + join_wrapped: bool + preserve_trailing: bool + trim_trailing: bool + expected_pattern: str | None + not_expected_pattern: str | None + min_tmux_version: str | None + + +# ============================================================================= +# Test Cases +# ============================================================================= + + +CAPTURE_PANE_CASES: list[CapturePaneCase] = [ + # --- Basic Tests (no flags) --- + CapturePaneCase( + test_id="basic_capture", + command='echo "hello world"', + escape_sequences=False, + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"hello world", + not_expected_pattern=None, + min_tmux_version=None, + ), + CapturePaneCase( + test_id="basic_multiline", + command='printf "line1\\nline2\\nline3\\n"', + escape_sequences=False, + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"line1.*line2.*line3", + not_expected_pattern=None, + min_tmux_version=None, + ), + # --- escape_sequences (-e) Tests --- + CapturePaneCase( + test_id="escape_sequences_red", + command='printf "\\033[31mRED\\033[0m"', + escape_sequences=True, + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"\x1b\[31m", # Should contain ANSI red code + not_expected_pattern=None, + min_tmux_version=None, + ), + CapturePaneCase( + test_id="escape_sequences_green", + command='printf "\\033[32mGREEN\\033[0m"', + escape_sequences=True, + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"\x1b\[32m", # Should contain ANSI green code + not_expected_pattern=None, + min_tmux_version=None, + ), + CapturePaneCase( + test_id="escape_sequences_bold", + command='printf "\\033[1mBOLD\\033[0m"', + escape_sequences=True, + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"\x1b\[1m", # Should contain ANSI bold code + not_expected_pattern=None, + min_tmux_version=None, + ), + CapturePaneCase( + test_id="no_escape_sequences", + command='printf "\\033[31mRED\\033[0m"', + escape_sequences=False, # Should NOT include ANSI codes + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"RED", + not_expected_pattern=r"\x1b", # Should NOT have escape char + min_tmux_version=None, + ), + # --- escape_non_printable (-C) Tests --- + CapturePaneCase( + test_id="escape_non_printable_basic", + command='printf "\\001\\002\\003"', + escape_sequences=False, + escape_non_printable=True, + join_wrapped=False, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"\\00[123]", # Octal escapes like \001, \002, \003 + not_expected_pattern=None, + min_tmux_version=None, + ), + CapturePaneCase( + test_id="escape_non_printable_tab", + command='printf "a\\tb"', + escape_sequences=False, + escape_non_printable=True, + join_wrapped=False, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"a.*b", # Tab may be preserved or escaped + not_expected_pattern=None, + min_tmux_version=None, + ), + # --- join_wrapped (-J) Tests --- + CapturePaneCase( + test_id="join_wrapped_long_line", + command='printf "%s" "$(python3 -c \'print("x" * 200)\')"', + escape_sequences=False, + escape_non_printable=False, + join_wrapped=True, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"x{100,}", # Should have many x's joined + not_expected_pattern=None, + min_tmux_version=None, + ), + CapturePaneCase( + test_id="join_wrapped_numbers", + command='printf "%s" "$(seq -s "" 1 100)"', + escape_sequences=False, + escape_non_printable=False, + join_wrapped=True, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"123.*99100", # Numbers should be joined + not_expected_pattern=None, + min_tmux_version=None, + ), + # --- preserve_trailing (-N) Tests --- + CapturePaneCase( + test_id="preserve_trailing_spaces", + command='printf "text \\n"', # 3 trailing spaces + escape_sequences=False, + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=True, + trim_trailing=False, + expected_pattern=r"text ", # Should have trailing spaces + not_expected_pattern=None, + min_tmux_version=None, + ), + CapturePaneCase( + test_id="no_preserve_trailing", + command='printf "text \\n"', # 3 trailing spaces + escape_sequences=False, + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=False, # Should trim trailing spaces + trim_trailing=False, + expected_pattern=r"text", + not_expected_pattern=None, + min_tmux_version=None, + ), + # --- trim_trailing (-T) Tests --- + CapturePaneCase( + test_id="trim_trailing_basic", + command='echo "short"', + escape_sequences=False, + escape_non_printable=False, + join_wrapped=False, + preserve_trailing=False, + trim_trailing=True, + expected_pattern=r"short", + not_expected_pattern=None, + min_tmux_version="3.4", # -T flag requires tmux 3.4+ + ), + # --- Combination Tests --- + CapturePaneCase( + test_id="escape_sequences_with_join", + command='printf "\\033[32mGREEN TEXT\\033[0m"', + escape_sequences=True, + escape_non_printable=False, + join_wrapped=True, + preserve_trailing=False, + trim_trailing=False, + expected_pattern=r"\x1b\[32m", # Should have ANSI codes + not_expected_pattern=None, + min_tmux_version=None, + ), + CapturePaneCase( + test_id="join_with_preserve_trailing", + command='printf "%s " "$(python3 -c \'print("z" * 100)\')"', + escape_sequences=False, + escape_non_printable=False, + join_wrapped=True, + preserve_trailing=True, + trim_trailing=False, + expected_pattern=r"z{50,}", # Should have z's + not_expected_pattern=None, + min_tmux_version=None, + ), + CapturePaneCase( + test_id="all_flags_except_trim", + command='printf "\\033[34mBLUE: %s \\033[0m" "test"', + escape_sequences=True, + escape_non_printable=True, + join_wrapped=True, + preserve_trailing=True, + trim_trailing=False, + expected_pattern=r"\x1b\[34m|BLUE", # Should have color or text + not_expected_pattern=None, + min_tmux_version=None, + ), +] + + +# ============================================================================= +# Parametrized Test +# ============================================================================= + + +@pytest.mark.parametrize( + list(CapturePaneCase._fields), + CAPTURE_PANE_CASES, + ids=[case.test_id for case in CAPTURE_PANE_CASES], +) +def test_capture_pane_flags( + test_id: str, + command: str, + escape_sequences: bool, + escape_non_printable: bool, + join_wrapped: bool, + preserve_trailing: bool, + trim_trailing: bool, + expected_pattern: str | None, + not_expected_pattern: str | None, + min_tmux_version: str | None, + session: Session, +) -> None: + """Test capture_pane() with various flag combinations. + + This parametrized test covers all combinations of capture_pane() flags + including escape_sequences, escape_non_printable, join_wrapped, + preserve_trailing, and trim_trailing. + + Parameters + ---------- + test_id : str + Unique identifier for the test case. + command : str + Shell command to execute. + escape_sequences : bool + Whether to include ANSI escape sequences. + escape_non_printable : bool + Whether to escape non-printable chars. + join_wrapped : bool + Whether to join wrapped lines. + preserve_trailing : bool + Whether to preserve trailing spaces. + trim_trailing : bool + Whether to trim trailing positions. + expected_pattern : str | None + Regex pattern that must match. + not_expected_pattern : str | None + Regex pattern that must NOT match. + min_tmux_version : str | None + Minimum tmux version required. + session : Session + pytest fixture providing tmux session. + """ + # Skip if tmux version too old + if min_tmux_version and not has_gte_version(min_tmux_version): + pytest.skip(f"Requires tmux {min_tmux_version}+") + + # Find env for predictable shell + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + # Create pane with predictable shell + window = session.new_window( + attach=True, + window_name=f"cap_{test_id[:10]}", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + # Wait for shell prompt + def prompt_ready() -> bool: + return "$" in "\n".join(pane.capture_pane()) + + retry_until(prompt_ready, 2, raises=True) + + # Send command with a unique marker to detect completion + marker = f"__DONE_{test_id}__" + full_command = f'{command}; echo "{marker}"' + pane.send_keys(full_command, literal=False, suppress_history=False) + + # Wait for marker to appear + def command_complete() -> bool: + output = "\n".join(pane.capture_pane()) + return marker in output + + retry_until(command_complete, 5, raises=True) + + # Capture with specified flags + output = pane.capture_pane( + escape_sequences=escape_sequences, + escape_non_printable=escape_non_printable, + join_wrapped=join_wrapped, + preserve_trailing=preserve_trailing, + trim_trailing=trim_trailing, + ) + output_str = "\n".join(output) + + # Verify expected pattern matches + if expected_pattern: + assert re.search(expected_pattern, output_str, re.DOTALL), ( + f"Expected pattern '{expected_pattern}' not found in output:\n{output_str}" + ) + + # Verify not_expected pattern does NOT match + if not_expected_pattern: + assert not re.search(not_expected_pattern, output_str, re.DOTALL), ( + f"Unexpected pattern '{not_expected_pattern}' found in output" + ) + + +# ============================================================================= +# Additional Targeted Tests +# ============================================================================= + + +def test_capture_pane_backward_compatible(session: Session) -> None: + """Test that capture_pane() works without any new parameters. + + This ensures backward compatibility with existing code that doesn't + use the new flag parameters. + """ + pane = session.active_window.split(shell="sh") + + def prompt_ready() -> bool: + return "$" in "\n".join(pane.capture_pane()) + + retry_until(prompt_ready, 2, raises=True) + + pane.send_keys('echo "backward compat test"', enter=True) + + def output_ready() -> bool: + return "backward compat test" in "\n".join(pane.capture_pane()) + + retry_until(output_ready, 2, raises=True) + + # Call with no new parameters - should work exactly as before + output = pane.capture_pane() + assert isinstance(output, list) + assert any("backward compat test" in line for line in output) + + +def test_capture_pane_start_end_with_flags(session: Session) -> None: + """Test that start/end parameters work with new flags.""" + pane = session.active_window.split(shell="sh") + + def prompt_ready() -> bool: + return "$" in "\n".join(pane.capture_pane()) + + retry_until(prompt_ready, 2, raises=True) + + # Generate some output + pane.send_keys('echo "line1"; echo "line2"; echo "line3"', enter=True) + + def output_ready() -> bool: + return "line3" in "\n".join(pane.capture_pane()) + + retry_until(output_ready, 2, raises=True) + + # Capture with start/end AND new flags + output = pane.capture_pane( + start=0, + end="-", + preserve_trailing=True, + ) + assert isinstance(output, list) + assert len(output) > 0 + + +def test_capture_pane_trim_trailing_warning( + session: Session, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test that trim_trailing issues a warning on tmux < 3.4.""" + import warnings + + from libtmux import common + + # Mock has_gte_version to return False for 3.4 + monkeypatch.setattr(common, "has_gte_version", lambda v: v != "3.4") + + pane = session.active_window.split(shell="sh") + + def prompt_ready() -> bool: + return "$" in "\n".join(pane.capture_pane()) + + retry_until(prompt_ready, 2, raises=True) + + # Should issue a warning + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + pane.capture_pane(trim_trailing=True) + + # Check warning was issued + assert len(w) == 1 + assert "trim_trailing requires tmux 3.4+" in str(w[0].message) From b6c6f338ce61cfa3612da62ecf6439112f3b7497 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 14:22:12 -0600 Subject: [PATCH 3/4] docs(CHANGES): Document capture_pane() enhancements why: Document the new capture_pane() parameters for the changelog. what: - Add section for Pane.capture_pane() enhanced - Document all 5 new parameters with flag mappings - Add code examples for colored output and joined lines - Note trim_trailing requires tmux 3.4+ --- CHANGES | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGES b/CHANGES index c9fc83111..e90c51403 100644 --- a/CHANGES +++ b/CHANGES @@ -34,6 +34,41 @@ $ uvx --from 'libtmux' --prerelease allow python _Upcoming changes will be written here._ +#### Pane.capture_pane() enhanced (#614) + +The {meth}`~libtmux.pane.Pane.capture_pane` method now supports 5 new parameters +that expose additional tmux `capture-pane` flags: + +| Parameter | tmux Flag | Description | +|-----------|-----------|-------------| +| `escape_sequences` | `-e` | Include ANSI escape sequences (colors, attributes) | +| `escape_non_printable` | `-C` | Escape non-printable chars as octal `\xxx` | +| `join_wrapped` | `-J` | Join wrapped lines back together | +| `preserve_trailing` | `-N` | Preserve trailing spaces at line ends | +| `trim_trailing` | `-T` | Trim trailing empty positions (tmux 3.4+) | + +**Capturing colored output:** + +```python +# Capture with ANSI escape sequences preserved +pane.send_keys('printf "\\033[31mRED\\033[0m"', enter=True) +output = pane.capture_pane(escape_sequences=True) +# Output contains: '\x1b[31mRED\x1b[0m' +``` + +**Joining wrapped lines:** + +```python +# Long lines that wrap are joined back together +output = pane.capture_pane(join_wrapped=True) +``` + +**Version compatibility:** + +The `trim_trailing` parameter requires tmux 3.4+. If used with an older version, +a warning is issued and the flag is ignored. All other parameters work with +libtmux's minimum supported version (tmux 3.2a). + ## libtmux 0.51.0 (2025-12-06) ### Breaking changes From 279b6d4f36a7f432b620051d2c0e2c6a42210a5a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Dec 2025 14:22:19 -0600 Subject: [PATCH 4/4] docs(pane_interaction): Add capture_pane() flag examples why: Provide practical examples for the new capture_pane() parameters in the pane interaction topic documentation. what: - Add section for capturing ANSI escape sequences - Add section for joining wrapped lines - Add section for preserving trailing spaces - Add capture flags summary table - Add note about trim_trailing tmux 3.4+ requirement --- docs/topics/pane_interaction.md | 69 +++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/docs/topics/pane_interaction.md b/docs/topics/pane_interaction.md index e0ee05c43..5163023f2 100644 --- a/docs/topics/pane_interaction.md +++ b/docs/topics/pane_interaction.md @@ -131,6 +131,75 @@ True True ``` +### Capture with ANSI escape sequences + +Capture colored output with escape sequences preserved using `escape_sequences=True`: + +```python +>>> import time + +>>> pane.send_keys('printf "\\033[31mRED\\033[0m \\033[32mGREEN\\033[0m"') +>>> time.sleep(0.1) + +>>> # Capture with ANSI codes stripped (default) +>>> output = pane.capture_pane() +>>> 'RED' in '\\n'.join(output) +True + +>>> # Capture with ANSI escape sequences preserved +>>> colored_output = pane.capture_pane(escape_sequences=True) +>>> isinstance(colored_output, list) +True +``` + +### Join wrapped lines + +Long lines that wrap in the terminal can be joined back together: + +```python +>>> import time + +>>> # Send a very long line that will wrap +>>> pane.send_keys('echo "' + 'x' * 200 + '"') +>>> time.sleep(0.1) + +>>> # Capture with wrapped lines joined +>>> output = pane.capture_pane(join_wrapped=True) +>>> isinstance(output, list) +True +``` + +### Preserve trailing spaces + +By default, trailing spaces are trimmed. Use `preserve_trailing=True` to keep them: + +```python +>>> import time + +>>> pane.send_keys('printf "text \\n"') # 3 trailing spaces +>>> time.sleep(0.1) + +>>> # Capture with trailing spaces preserved +>>> output = pane.capture_pane(preserve_trailing=True) +>>> isinstance(output, list) +True +``` + +### Capture flags summary + +| Parameter | tmux Flag | Description | +|-----------|-----------|-------------| +| `escape_sequences` | `-e` | Include ANSI escape sequences (colors, attributes) | +| `escape_non_printable` | `-C` | Escape non-printable chars as octal `\xxx` | +| `join_wrapped` | `-J` | Join wrapped lines back together | +| `preserve_trailing` | `-N` | Preserve trailing spaces at line ends | +| `trim_trailing` | `-T` | Trim trailing empty positions (tmux 3.4+) | + +:::{note} +The `trim_trailing` parameter requires tmux 3.4+. If used with an older version, +a warning is issued and the flag is ignored. +::: + ## Waiting for Output A common pattern in automation is waiting for a command to complete.