From bd6d5cc8ac38421914fcc9b99d35305ba8675488 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 24 Mar 2026 04:23:02 -0500 Subject: [PATCH 001/105] ai(claude[plugin]): add tmux-parity plugin for feature parity analysis why: libtmux wraps ~28 of tmux's ~88 commands (~32% coverage). Need tooling to systematically audit gaps, compare across tmux versions, and guide implementation of new command wrappers. what: - Add .claude-plugin/ with manifest, commands, agent, skill, and scripts - /parity-audit: generate full feature parity report (commands, flags, format variables, options) - /version-diff: compare tmux features across 41 version worktrees - /implement-command: guided workflow for wrapping new tmux commands - parity-analyzer agent: auto-triggers on natural language parity queries - tmux-parity skill: shared domain knowledge with reference files (command mapping, implementation patterns, C source navigation) - Extraction scripts: parse tmux cmd-*.c and libtmux .cmd() invocations --- .claude-plugin/agents/parity-analyzer.md | 129 ++++++++++++ .claude-plugin/commands/implement-command.md | 127 +++++++++++ .claude-plugin/commands/parity-audit.md | 84 ++++++++ .claude-plugin/commands/version-diff.md | 106 ++++++++++ .claude-plugin/plugin.json | 14 ++ .../scripts/extract-libtmux-methods.sh | 47 +++++ .../scripts/extract-tmux-commands.sh | 43 ++++ .claude-plugin/skills/tmux-parity/SKILL.md | 81 +++++++ .../tmux-parity/references/command-mapping.md | 119 +++++++++++ .../references/libtmux-patterns.md | 198 ++++++++++++++++++ .../references/tmux-command-table.md | 101 +++++++++ 11 files changed, 1049 insertions(+) create mode 100644 .claude-plugin/agents/parity-analyzer.md create mode 100644 .claude-plugin/commands/implement-command.md create mode 100644 .claude-plugin/commands/parity-audit.md create mode 100644 .claude-plugin/commands/version-diff.md create mode 100644 .claude-plugin/plugin.json create mode 100755 .claude-plugin/scripts/extract-libtmux-methods.sh create mode 100755 .claude-plugin/scripts/extract-tmux-commands.sh create mode 100644 .claude-plugin/skills/tmux-parity/SKILL.md create mode 100644 .claude-plugin/skills/tmux-parity/references/command-mapping.md create mode 100644 .claude-plugin/skills/tmux-parity/references/libtmux-patterns.md create mode 100644 .claude-plugin/skills/tmux-parity/references/tmux-command-table.md diff --git a/.claude-plugin/agents/parity-analyzer.md b/.claude-plugin/agents/parity-analyzer.md new file mode 100644 index 000000000..de9ebdc26 --- /dev/null +++ b/.claude-plugin/agents/parity-analyzer.md @@ -0,0 +1,129 @@ +--- +name: parity-analyzer +description: | + Use this agent when the user asks about "tmux parity", "what commands are missing", "coverage report", "what does libtmux wrap", "unwrapped commands", "missing tmux features", "does libtmux support X", "tmux feature coverage", or when the user wants to understand what tmux functionality libtmux does not yet expose. + + + Context: User wants to know parity status + user: "What tmux commands does libtmux not wrap yet?" + assistant: "I'll use the parity-analyzer agent to scan tmux source and cross-reference with libtmux." + User asking about missing commands, trigger parity analysis. + + + + Context: User considering what to implement next + user: "Which unwrapped tmux commands would be most useful to add?" + assistant: "I'll use the parity-analyzer agent to analyze coverage and prioritize gaps." + User wants prioritized gap analysis, trigger parity-analyzer. + + + + Context: User asks about specific command + user: "Does libtmux support break-pane?" + assistant: "I'll check with the parity-analyzer agent." + Specific command inquiry, use parity-analyzer for accurate answer. + + + + Context: User working on parity branch + user: "What should I work on next for tmux parity?" + assistant: "I'll use the parity-analyzer agent to identify the highest-priority gaps." + Planning parity work, trigger analysis for prioritization. + +model: sonnet +color: cyan +tools: + - Read + - Grep + - Glob + - Bash +--- + +You are a tmux/libtmux feature parity analysis specialist. Analyze the gap between tmux C source and libtmux Python wrappers. + +## Source Locations + +- **tmux C source (HEAD)**: ~/study/c/tmux/ +- **tmux version worktrees**: ~/study/c/tmux-{version}/ (41 versions, 0.8 to 3.6a) +- **libtmux Python source**: src/libtmux/ (in the current project) + +## Analysis Process + +### Step 1: Extract tmux commands + +Run the extraction script for current data: +```bash +bash .claude-plugin/scripts/extract-tmux-commands.sh ~/study/c/tmux +``` +This outputs `command|alias|getopt|target` for all ~88 tmux commands. + +### Step 2: Extract libtmux coverage + +Run the libtmux extraction: +```bash +bash .claude-plugin/scripts/extract-libtmux-methods.sh +``` +This outputs the unique tmux command strings that libtmux invokes. + +Additionally, check mixin files for commands invoked via `tmux_cmd()`: +```bash +grep -rn '"set-environment"\|"show-environment"\|"set-hook"\|"set-option"\|"show-option"\|"capture-pane"\|"move-window"\|"select-layout"\|"kill-pane"' src/libtmux/*.py | grep -oP '"([a-z]+-[a-z-]+)"' | sort -u | tr -d '"' +``` + +### Step 3: Cross-reference + +Classify each tmux command: +- **Wrapped**: Command string appears in libtmux source +- **Not Wrapped**: Command string does not appear + +For wrapped commands, optionally compare the getopt string from tmux against the Python method parameters to identify missing flags. + +### Step 4: Produce report + +Output a structured report: + +```markdown +## tmux/libtmux Parity Report + +### Summary +- Total tmux commands: X +- Wrapped in libtmux: Y (Z%) +- Not wrapped: N + +### Wrapped Commands +| Command | libtmux Location | + +### Not Wrapped — High Priority +| Command | Alias | Target | Why Useful | + +### Not Wrapped — Medium Priority +| Command | Alias | Target | Notes | + +### Not Wrapped — Low Priority +| Command | Alias | Target | Notes | +``` + +### Priority Guidelines + +**High priority** — Commands useful for programmatic tmux control and automation: +- Pane/window manipulation: join-pane, swap-pane, swap-window, break-pane, move-pane +- Process management: respawn-pane, respawn-window, run-shell +- I/O: pipe-pane, clear-history, display-popup + +**Medium priority** — Navigation, buffers, and client management: +- Navigation: last-pane, last-window, next-window, previous-window +- Buffer ops: list-buffers, load-buffer, save-buffer, paste-buffer, set-buffer +- Window linking: link-window, unlink-window +- Synchronization: wait-for +- Conditional: if-shell + +**Low priority** — Interactive UI and configuration (rarely needed in API): +- Interactive: choose-tree, choose-buffer, copy-mode, command-prompt +- Key binding: bind-key, unbind-key +- Security: lock-server, lock-session, lock-client +- Meta: list-commands, list-keys, show-messages +- Config: source-file, start-server + +## Reference Data + +The baseline command mapping is at `.claude-plugin/skills/tmux-parity/references/command-mapping.md`. Use this as a starting point, but always run the extraction scripts for the most current data. diff --git a/.claude-plugin/commands/implement-command.md b/.claude-plugin/commands/implement-command.md new file mode 100644 index 000000000..c9e0ed209 --- /dev/null +++ b/.claude-plugin/commands/implement-command.md @@ -0,0 +1,127 @@ +--- +description: Guide implementing a new tmux command wrapper in libtmux +argument-hint: " — e.g., 'break-pane', 'join-pane', 'swap-window'" +allowed-tools: + - Read + - Write + - Edit + - Grep + - Glob + - Bash + - AskUserQuestion + - Agent +--- + +# Implement Command + +Guide wrapping a tmux command in libtmux, following project coding standards from CLAUDE.md. + +Load the `tmux-parity` skill first for reference data and implementation patterns. + +If `$ARGUMENTS` is empty, ask the user which tmux command to wrap. Consult `.claude-plugin/skills/tmux-parity/references/command-mapping.md` for the "Not Wrapped" list to suggest candidates. + +## Phase 1: Analyze the tmux Command + +1. Read `~/study/c/tmux/cmd-{command}.c` fully +2. Extract from the `cmd_entry` struct: + - **name** and **alias** + - **getopt string** — enumerate all flags, which take values, which are boolean + - **usage string** — human-readable flag descriptions + - **target type** — `CMD_FIND_PANE`, `CMD_FIND_WINDOW`, `CMD_FIND_SESSION`, or none + - **command flags** — `CMD_READONLY`, `CMD_AFTERHOOK`, etc. +3. Read the `exec` function to understand: + - What arguments it processes + - What side effects it has (creates objects, modifies state, produces output) + - What it returns or prints + - Error conditions + +4. Present a summary to the user: + ``` + ## tmux command: {name} ({alias}) + Target: {pane|window|session|none} → libtmux class: {Pane|Window|Session|Server} + Flags: {table of flags with descriptions} + Behavior: {what the command does} + ``` + +## Phase 2: Determine libtmux Placement + +Map the target type to libtmux class: +| Target | Primary Class | File | +|--------|--------------|------| +| `CMD_FIND_PANE` | `Pane` | `src/libtmux/pane.py` | +| `CMD_FIND_WINDOW` | `Window` | `src/libtmux/window.py` | +| `CMD_FIND_SESSION` | `Session` | `src/libtmux/session.py` | +| none | `Server` | `src/libtmux/server.py` | + +Some commands may also get convenience methods on parent classes. Ask the user if they want additional convenience methods. + +## Phase 3: Find a Similar Implementation + +Search libtmux for a wrapped command with similar characteristics: +- Same target type +- Similar flag pattern (boolean flags, value flags, creates objects, etc.) +- Read that method as a template + +Consult `.claude-plugin/skills/tmux-parity/references/libtmux-patterns.md` for the five implementation patterns. + +## Phase 4: Design the Method Signature + +Present a proposed method signature to the user before implementing. Include: +- Method name (snake_case, derived from tmux command name) +- Parameters mapped from tmux flags (with Python-friendly names and types) +- Return type +- Which flags to include (not all flags need wrapping — ask user about ambiguous ones) + +**This is a good point to ask the user to write the method signature and core logic (5-10 lines).** Present the trade-offs: +- Which flags to expose (all vs. commonly used)? +- Return type (Self vs. new object vs. None)? +- Naming conventions for parameters? + +## Phase 5: Implement + +Follow CLAUDE.md coding standards strictly: + +1. **Imports**: `from __future__ import annotations`, `import typing as t` +2. **Method**: Add to the appropriate class file +3. **Docstring**: NumPy format with Parameters, Returns, Examples sections +4. **Doctests**: Working doctests using `doctest_namespace` fixtures (`server`, `session`, `window`, `pane`) + - Use `# doctest: +ELLIPSIS` for variable output + - NEVER use `# doctest: +SKIP` +5. **Logging**: `logger.info("descriptive msg", extra={"tmux_subcommand": "...", ...})` +6. **Error handling**: Check `proc.stderr`, raise `exc.LibTmuxException` + +## Phase 6: Create Tests + +Add tests in `tests/test_{class}.py` (or a new file if warranted): + +1. **Functional tests only** — no test classes +2. **Use fixtures**: `server`, `session`, `window`, `pane` from conftest.py +3. **Test each parameter/flag** combination +4. **Test error cases** if applicable +5. **Use descriptive function names**: `test_{command}_{scenario}` + +## Phase 7: Verify + +Run the full verification workflow: + +```bash +# Format +uv run ruff format . + +# Lint +uv run ruff check . --fix --show-fixes + +# Type check +uv run mypy src tests + +# Test the specific file +uv run pytest tests/test_{class}.py -x -v + +# Run doctests +uv run pytest --doctest-modules src/libtmux/{class}.py -v + +# Full test suite +uv run pytest +``` + +All must pass before considering the implementation complete. diff --git a/.claude-plugin/commands/parity-audit.md b/.claude-plugin/commands/parity-audit.md new file mode 100644 index 000000000..652f4e778 --- /dev/null +++ b/.claude-plugin/commands/parity-audit.md @@ -0,0 +1,84 @@ +--- +description: Generate a feature parity report between tmux commands and libtmux wrappers +argument-hint: "[command-name] — audit a specific command, or leave empty for full audit" +allowed-tools: + - Read + - Grep + - Glob + - Bash + - Agent +--- + +# Parity Audit + +Load the `tmux-parity` skill first to access reference data and domain knowledge. + +## Single Command Audit (when $ARGUMENTS specifies a command name) + +1. **Read the tmux C source** for the specified command: + - Read `~/study/c/tmux/cmd-{command}.c` to find the `cmd_entry` struct + - Extract: name, alias, getopt string, usage, target type, command flags + - Parse the getopt string to enumerate all flags (boolean vs value-taking) + - Read the `exec` function to understand behavior and return semantics + +2. **Search libtmux source** for the command: + - Grep `src/libtmux/*.py` for the command string (e.g., `"send-keys"`) + - For each match, read the surrounding method to understand which flags are exposed as Python parameters + - Check mixins: `src/libtmux/common.py` (EnvironmentMixin), `src/libtmux/options.py`, `src/libtmux/hooks.py` + +3. **Produce a detailed report**: + - Command name, alias, target type + - Table of all tmux flags: flag | description (from usage string) | exposed in libtmux? | Python parameter name + - Missing flags with notes on what they do + - Recommendations for which missing flags to add + +## Full Audit (when no arguments given) + +1. **Run extraction scripts** for up-to-date data: + ```bash + bash .claude-plugin/scripts/extract-tmux-commands.sh ~/study/c/tmux + bash .claude-plugin/scripts/extract-libtmux-methods.sh + ``` + +2. **Cross-reference the results**: + - Parse script output to classify each command: Wrapped, Not Wrapped + - For wrapped commands, compare getopt strings against Python method signatures to find partially-covered commands + +3. **Audit format variables** (optional, if specifically requested): + - Read `~/study/c/tmux/format.c` and search for `format_add` calls to list all format variables + - Compare against `src/libtmux/formats.py` + - Report missing format variables + +4. **Audit options table** (optional, if specifically requested): + - Read `~/study/c/tmux/options-table.c` to list all options with their scopes + - Compare against libtmux options handling + - Report missing options + +5. **Produce the full parity report**: + + ``` + ## tmux/libtmux Parity Report + + ### Summary + - Commands: X/Y wrapped (Z%) + - Partially wrapped: N commands (some flags missing) + + ### Coverage by Category + | Category | Wrapped | Total | % | + |----------|---------|-------|---| + | Session mgmt | ... | ... | ... | + | Window mgmt | ... | ... | ... | + | Pane mgmt | ... | ... | ... | + | ... + + ### Not Wrapped — High Priority + | Command | Alias | Target | Why Important | + + ### Not Wrapped — Medium Priority + ... + + ### Partially Wrapped (Missing Flags) + | Command | libtmux Method | Missing Flags | + ``` + +Consult `.claude-plugin/skills/tmux-parity/references/command-mapping.md` for the baseline mapping data. Run the extraction scripts for the most current data. diff --git a/.claude-plugin/commands/version-diff.md b/.claude-plugin/commands/version-diff.md new file mode 100644 index 000000000..53db7d711 --- /dev/null +++ b/.claude-plugin/commands/version-diff.md @@ -0,0 +1,106 @@ +--- +description: Compare tmux features across versions using source worktrees +argument-hint: " [command-name] — e.g., '3.0 3.6a' or '3.0 3.6a send-keys'" +allowed-tools: + - Read + - Grep + - Glob + - Bash +--- + +# Version Diff + +Compare tmux features between two versions using the source worktrees at `~/study/c/tmux-{version}/`. + +## Parse Arguments + +Extract `version1`, `version2`, and optional `command-name` from `$ARGUMENTS`. + +If no arguments provided, list available versions: +```bash +ls -d ~/study/c/tmux-*/ | sed 's|.*/tmux-||;s|/$||' | sort -V +``` +Then ask the user which two versions to compare. + +## Validate Worktrees + +Verify both worktrees exist: +```bash +ls -d ~/study/c/tmux-{version1}/ ~/study/c/tmux-{version2}/ 2>/dev/null +``` + +## Single Command Comparison (when command-name given) + +1. Check if the command file exists in both versions: + ```bash + ls ~/study/c/tmux-{v1}/cmd-{command}.c ~/study/c/tmux-{v2}/cmd-{command}.c 2>/dev/null + ``` + If missing in v1, the command was introduced between v1 and v2. + +2. Read both `cmd_entry` structs and compare: + - Name/alias changes + - Getopt string differences (new flags, removed flags) + - Usage string changes + - Target type changes + - Flag changes + +3. Diff the exec function to identify behavioral changes: + ```bash + diff ~/study/c/tmux-{v1}/cmd-{command}.c ~/study/c/tmux-{v2}/cmd-{command}.c + ``` + +4. Report: + ``` + ## send-keys: v3.0 → v3.6a + + ### Flag Changes + | Flag | v3.0 | v3.6a | Notes | + | -K | No | Yes | Added: ... | + + ### Behavioral Changes + - [description of exec function changes] + ``` + +## Broad Version Comparison (no command filter) + +1. **List cmd-*.c files in each version**: + ```bash + ls ~/study/c/tmux-{v1}/cmd-*.c | xargs -n1 basename | sort > /tmp/tmux-v1-cmds.txt + ls ~/study/c/tmux-{v2}/cmd-*.c | xargs -n1 basename | sort > /tmp/tmux-v2-cmds.txt + ``` + +2. **Identify new and removed command files**: + ```bash + comm -23 /tmp/tmux-v2-cmds.txt /tmp/tmux-v1-cmds.txt # New in v2 + comm -23 /tmp/tmux-v1-cmds.txt /tmp/tmux-v2-cmds.txt # Removed in v2 + ``` + +3. **For shared commands, compare getopt strings**: + Run `.claude-plugin/scripts/extract-tmux-commands.sh` on both versions and diff the output. + +4. **Compare options-table.c** (if it exists in both versions): + ```bash + diff ~/study/c/tmux-{v1}/options-table.c ~/study/c/tmux-{v2}/options-table.c + ``` + +5. **Report**: + ``` + ## tmux Version Diff: v{v1} → v{v2} + + ### New Commands + | Command | Alias | Getopt | Target | + + ### Removed Commands + ... + + ### Modified Commands (Flag Changes) + | Command | Added Flags | Removed Flags | + + ### New Options + | Option | Scope | Type | Default | + + ### Impact on libtmux + - Commands libtmux wraps that changed: [list] + - New commands worth wrapping: [recommendations] + - Minimum version implications: [notes] + ``` diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 000000000..12af82007 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "tmux-parity", + "version": "0.1.0", + "description": "Analyze and close feature parity gaps between tmux C source and libtmux Python wrappers", + "author": { + "name": "libtmux contributors" + }, + "repository": "https://github.com/tmux-python/libtmux", + "license": "MIT", + "keywords": ["tmux", "parity", "analysis", "code-generation"], + "commands": "./.claude-plugin/commands", + "agents": "./.claude-plugin/agents", + "skills": "./.claude-plugin/skills" +} diff --git a/.claude-plugin/scripts/extract-libtmux-methods.sh b/.claude-plugin/scripts/extract-libtmux-methods.sh new file mode 100755 index 000000000..5588dd507 --- /dev/null +++ b/.claude-plugin/scripts/extract-libtmux-methods.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Extract tmux command invocations from libtmux Python source +# Usage: extract-libtmux-methods.sh [libtmux-src-dir] +# Output: unique tmux command names invoked via .cmd() or as command args +# +# Searches for .cmd("command"), tmux_cmd(..., "command"), and +# args = ["command"] patterns that represent actual tmux command calls. + +set -euo pipefail + +LIBTMUX_DIR="${1:-$HOME/work/python/libtmux/src/libtmux}" + +if [[ ! -d "$LIBTMUX_DIR" ]]; then + echo "Error: libtmux source dir not found at $LIBTMUX_DIR" >&2 + exit 1 +fi + +echo "# Unique tmux commands invoked by libtmux" +{ + # Pattern 1: self.cmd("command-name", ...) or .cmd("command-name") + grep -rn '\.cmd(' "$LIBTMUX_DIR"/*.py 2>/dev/null | \ + grep -oP '\.cmd\(\s*"([a-z]+-[a-z-]+)"' | \ + sed 's/.*"\(.*\)"/\1/' + + # Pattern 2: args/cmd = ["command-name", ...] or args/cmd += ["command-name"] + grep -rn '\(args\|cmd\)\s*[+=]\+\s*\["[a-z]\+-' "$LIBTMUX_DIR"/*.py 2>/dev/null | \ + grep -oP '\["([a-z]+-[a-z-]+)"' | \ + tr -d '["' + + # Pattern 3: tmux_args += ("command-name",) or tmux_args = ("command-name",) + grep -rn 'tmux_args\s*[+=]\+.*"[a-z]\+-' "$LIBTMUX_DIR"/*.py 2>/dev/null | \ + grep -oP '"([a-z]+-[a-z-]+)"' | \ + tr -d '"' + + # Pattern 4: string literals in command-building contexts (hooks.py, options.py, common.py) + # Match lines with command strings used in args lists or cmd() calls + grep -rn '^\s*"[a-z]\+-[a-z-]*",' "$LIBTMUX_DIR"/*.py 2>/dev/null | \ + grep -oP '"([a-z]+-[a-z-]+)"' | \ + tr -d '"' | \ + grep -E '^(capture|kill|move|select|set|show|split|clear)-' +} | sort -u + +echo "" +echo "# Detailed: command|file:line" +grep -rn '\.cmd(\|args\s*[+=]\+\s*\["\|tmux_args\s*[+=]' "$LIBTMUX_DIR"/*.py 2>/dev/null | \ + perl -ne 'if (/^(.+?):(\d+):.*"([a-z]+-[a-z]+-?[a-z]*)"/ && $3 =~ /^(attach|break|capture|choose|clear|clock|command|confirm|copy|customize|delete|detach|display|find|has|if|join|kill|last|link|list|load|lock|move|new|next|paste|pipe|previous|refresh|rename|resize|respawn|rotate|run|save|select|send|server|set|show|source|split|start|suspend|swap|switch|unbind|unlink|wait)-/) { print "$3|$1:$2\n" }' | \ + sort diff --git a/.claude-plugin/scripts/extract-tmux-commands.sh b/.claude-plugin/scripts/extract-tmux-commands.sh new file mode 100755 index 000000000..fd5ffd4f5 --- /dev/null +++ b/.claude-plugin/scripts/extract-tmux-commands.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Extract tmux command entries from cmd-*.c files +# Usage: extract-tmux-commands.sh [tmux-source-dir] +# Output: command_name|alias|getopt_string|target_type +# +# Parses cmd_entry structs to enumerate all tmux commands with their +# flags and target types. + +set -euo pipefail + +TMUX_DIR="${1:-$HOME/study/c/tmux}" + +if [[ ! -d "$TMUX_DIR" ]]; then + echo "Error: tmux source dir not found at $TMUX_DIR" >&2 + exit 1 +fi + +# Process each cmd-*.c file (skip internal files) +for f in "$TMUX_DIR"/cmd-*.c; do + base=$(basename "$f" .c) + case "$base" in + cmd-parse|cmd-queue|cmd-find) continue ;; + esac + + # Use perl for reliable multi-field extraction from cmd_entry structs + perl -0777 -ne ' + while (/const\s+struct\s+cmd_entry\s+\w+\s*=\s*\{(.*?)\n\};/gs) { + my $block = $1; + my ($name, $alias, $args, $target) = ("", "-", "", "none"); + + $name = $1 if $block =~ /\.name\s*=\s*"([^"]+)"/; + $alias = $1 if $block =~ /\.alias\s*=\s*"([^"]+)"/; + $args = $1 if $block =~ /\.args\s*=\s*\{\s*"([^"]*)"/; + + $target = "pane" if $block =~ /CMD_FIND_PANE/; + $target = "window" if $block =~ /CMD_FIND_WINDOW/; + $target = "session" if $block =~ /CMD_FIND_SESSION/; + $target = "client" if $block =~ /CMD_FIND_CLIENT/; + + print "$name|$alias|$args|$target\n" if $name; + } + ' "$f" +done | sort diff --git a/.claude-plugin/skills/tmux-parity/SKILL.md b/.claude-plugin/skills/tmux-parity/SKILL.md new file mode 100644 index 000000000..069480d14 --- /dev/null +++ b/.claude-plugin/skills/tmux-parity/SKILL.md @@ -0,0 +1,81 @@ +--- +name: tmux-parity +description: This skill should be used when analyzing tmux/libtmux feature parity, comparing tmux C source against libtmux Python wrappers, implementing new tmux command wrappers, understanding "what commands are missing", "what does libtmux wrap", reviewing tmux command flags, or comparing tmux versions. Also relevant for queries about "parity", "coverage", "unwrapped commands", "missing features", "tmux source", or "implement command". +version: 0.1.0 +--- + +# tmux/libtmux Feature Parity Analysis + +Analyze and close feature parity gaps between the tmux terminal multiplexer (C source) and the libtmux Python wrapper library. + +## Key Locations + +| Resource | Path | +|----------|------| +| tmux source (HEAD) | `~/study/c/tmux/` | +| tmux version worktrees | `~/study/c/tmux-{0.8..3.6a}/` (41 versions) | +| libtmux source | `src/libtmux/` (relative to project root) | +| libtmux tests | `tests/` | +| Extraction scripts | `.claude-plugin/scripts/extract-tmux-commands.sh`, `.claude-plugin/scripts/extract-libtmux-methods.sh` | + +## How tmux Commands Are Structured + +Each tmux command is defined in a `cmd-{name}.c` file via a `cmd_entry` struct: + +```c +const struct cmd_entry cmd_send_keys_entry = { + .name = "send-keys", + .alias = "send", + .args = { "c:FHKlMN:Rt:X", 0, -1, NULL }, // getopt string + .usage = "[-FHKlMRX] [-c target-client] ...", + .target = { 't', CMD_FIND_PANE, 0 }, // target type + .flags = CMD_AFTERHOOK|CMD_READONLY, + .exec = cmd_send_keys_exec +}; +``` + +Key fields: +- **`.args` getopt string**: Single char = boolean flag, char + `:` = flag with value +- **`.target`**: `CMD_FIND_PANE`, `CMD_FIND_WINDOW`, `CMD_FIND_SESSION`, `CMD_FIND_CLIENT`, or none +- **Command table**: All entries registered in `~/study/c/tmux/cmd.c` as `cmd_table[]` + +## How libtmux Wraps Commands + +libtmux methods call tmux via two patterns: + +1. **Object method**: `self.cmd("command-name", *args)` — on Server/Session/Window/Pane, auto-adds `-t target` +2. **Standalone**: `tmux_cmd("command-name", *args)` — in mixins (EnvironmentMixin, etc.) + +Class hierarchy mapping from tmux target types: +- `CMD_FIND_PANE` → `Pane` class (`src/libtmux/pane.py`) +- `CMD_FIND_WINDOW` → `Window` class (`src/libtmux/window.py`) +- `CMD_FIND_SESSION` → `Session` class (`src/libtmux/session.py`) +- No target / server-level → `Server` class (`src/libtmux/server.py`) +- Environment ops → `EnvironmentMixin` (`src/libtmux/common.py`) +- Option ops → `OptionsMixin` (`src/libtmux/options.py`) +- Hook ops → `HooksMixin` (`src/libtmux/hooks.py`) + +## Current Coverage Summary + +**~28 of ~88 tmux commands wrapped** (~32% coverage, approximate — run extraction scripts for current data). High-priority unwrapped commands include: `join-pane`, `swap-pane`, `swap-window`, `respawn-pane`, `respawn-window`, `run-shell`, `break-pane`, `move-pane`, `pipe-pane`, `display-popup`. + +## Extraction Scripts + +Run these for up-to-date data: + +```bash +# All tmux commands with flags and target types +bash .claude-plugin/scripts/extract-tmux-commands.sh ~/study/c/tmux + +# All tmux commands libtmux currently wraps +bash .claude-plugin/scripts/extract-libtmux-methods.sh +``` + +## Additional Resources + +### Reference Files + +For detailed data, consult: +- **`references/command-mapping.md`** — Complete mapping of all ~88 tmux commands to libtmux methods, with flag coverage +- **`references/libtmux-patterns.md`** — Implementation patterns for wrapping new commands (method signatures, doctests, logging, error handling) +- **`references/tmux-command-table.md`** — Guide to navigating tmux C source: cmd_entry fields, getopt format, target types, options-table.c, format.c diff --git a/.claude-plugin/skills/tmux-parity/references/command-mapping.md b/.claude-plugin/skills/tmux-parity/references/command-mapping.md new file mode 100644 index 000000000..bc2cc61ab --- /dev/null +++ b/.claude-plugin/skills/tmux-parity/references/command-mapping.md @@ -0,0 +1,119 @@ +# tmux Command → libtmux Method Mapping + +Generated from tmux HEAD and libtmux source. Re-generate with: +```bash +bash .claude-plugin/scripts/extract-tmux-commands.sh ~/study/c/tmux +bash .claude-plugin/scripts/extract-libtmux-methods.sh +``` + +## Wrapped Commands (28/88) + +| tmux Command | Alias | Getopt Flags | Target | libtmux Location | Methods | +|---|---|---|---|---|---| +| `attach-session` | `attach` | `c:dEf:rt:x` | none | `server.py` | `Server.attach_session()` | +| `capture-pane` | `capturep` | `ab:CeE:JMNpPqS:Tt:` | pane | `pane.py` | `Pane.capture_pane()` | +| `display-message` | `display` | `aCc:d:lINpt:F:v` | pane | `pane.py` | `Pane.display_message()` | +| `has-session` | `has` | `t:` | session | `server.py` | `Server.has_session()` | +| `kill-pane` | `killp` | `at:` | pane | `pane.py` | `Pane.kill()` | +| `kill-server` | — | (none) | none | `server.py` | `Server.kill()` | +| `kill-session` | — | `aCt:` | session | `server.py`, `session.py` | `Server.kill_session()`, `Session.kill()` | +| `kill-window` | `killw` | `at:` | window | `session.py` | `Session.kill_window()`, `Window.kill()` | +| `list-sessions` | `ls` | `F:f:O:r` | none | `server.py`, `neo.py` | `Server.sessions`, internal fetch | +| `list-windows` | `lsw` | `aF:f:O:rst:` | window | `neo.py` | Internal fetch for `Session.windows` | +| `list-panes` | `lsp` | `aF:f:O:rst:` | window | `neo.py` | Internal fetch for `Window.panes` | +| `move-window` | `movew` | `abdkrs:t:` | window | `window.py` | `Window.move_window()` | +| `new-session` | `new` | `Ac:dDe:EF:f:n:Ps:t:x:Xy:` | session | `server.py` | `Server.new_session()` | +| `new-window` | `neww` | `abc:de:F:kn:PSt:` | window | `session.py` | `Session.new_window()` | +| `rename-session` | `rename` | `t:` | session | `session.py` | `Session.rename_session()` | +| `rename-window` | `renamew` | `t:` | window | `window.py` | `Window.rename_window()` | +| `resize-pane` | `resizep` | `DLMRTt:Ux:y:Z` | pane | `pane.py` | `Pane.resize()` | +| `resize-window` | `resizew` | `aADLRt:Ux:y:` | window | `window.py` | `Window.resize()` | +| `select-layout` | `selectl` | `Enopt:` | pane | `window.py` | `Window.select_layout()` | +| `select-pane` | `selectp` | `DdegLlMmP:RT:t:UZ` | pane | `window.py`, `pane.py` | `Window.select_pane()`, `Pane.select()`, `Pane.set_title()` | +| `select-window` | `selectw` | `lnpTt:` | window | `session.py`, `window.py` | `Session.select_window()`, `Window.select()` | +| `send-keys` | `send` | `c:FHKlMN:Rt:X` | pane | `pane.py` | `Pane.send_keys()` | +| `set-environment` | `setenv` | `Fhgrt:u` | session | `common.py` | `EnvironmentMixin.set_environment()`, `.unset_environment()`, `.remove_environment()` | +| `set-hook` | — | `agpRt:uw` | pane | `hooks.py` | `HooksMixin.set_hook()`, `.unset_hook()` | +| `set-option` | `set` | `aFgopqst:uUw` | pane | `options.py` | `OptionsMixin.set_option()`, `.unset_option()` | +| `show-environment` | `showenv` | `hgst:` | session | `common.py` | `EnvironmentMixin.show_environment()`, `.getenv()` | +| `show-hooks` | — | `gpt:w` | pane | `hooks.py` | `HooksMixin.show_hooks()`, `.show_hook()` | +| `show-options` | `show` | `AgHpqst:vw` | pane | `options.py` | `OptionsMixin.show_options()`, `.show_option()` | +| `split-window` | `splitw` | `bc:de:fF:hIl:p:Pt:vZ` | pane | `pane.py` | `Pane.split()`, `Window.split()` | +| `switch-client` | `switchc` | `c:EFlnO:pt:rT:Z` | none | `server.py`, `session.py` | `Server.switch_client()`, `Session.switch_client()` | + +## Not Wrapped Commands (60/88) + +### High Priority (useful for programmatic/scripting use) + +| tmux Command | Alias | Getopt | Target | Notes | +|---|---|---|---|---| +| `break-pane` | `breakp` | `abdPF:n:s:t:` | window | Move pane to its own window | +| `join-pane` | `joinp` | `bdfhvp:l:s:t:` | pane | Merge pane into another window | +| `move-pane` | `movep` | `bdfhvp:l:s:t:` | pane | Move pane between windows (like join-pane) | +| `respawn-pane` | `respawnp` | `c:e:kt:` | pane | Re-run command in pane | +| `respawn-window` | `respawnw` | `c:e:kt:` | window | Re-run command in all window panes | +| `run-shell` | `run` | `bd:Ct:Es:c:` | pane | Execute shell command in background | +| `swap-pane` | `swapp` | `dDs:t:UZ` | pane | Swap two panes | +| `swap-window` | `swapw` | `ds:t:` | window | Swap two windows | +| `display-popup` | `popup` | `Bb:Cc:d:e:Eh:kNs:S:t:T:w:x:y:` | pane | Create popup overlay (tmux 3.2+) | +| `pipe-pane` | `pipep` | `IOot:` | pane | Pipe pane output to command | +| `clear-history` | `clearhist` | `Ht:` | pane | Clear pane scrollback buffer | + +### Medium Priority (navigation, buffers, info) + +| tmux Command | Alias | Getopt | Target | Notes | +|---|---|---|---|---| +| `last-pane` | `lastp` | `det:Z` | window | Select previous pane | +| `last-window` | `last` | `t:` | session | Select previous window | +| `next-window` | `next` | `at:` | session | Select next window | +| `previous-window` | `prev` | `at:` | session | Select previous window | +| `link-window` | `linkw` | `abdks:t:` | window | Link window to another session | +| `unlink-window` | `unlinkw` | `kt:` | window | Unlink window from session | +| `rotate-window` | `rotatew` | `Dt:UZ` | window | Rotate pane positions | +| `list-buffers` | `lsb` | `F:f:O:r` | none | List paste buffers | +| `list-clients` | `lsc` | `F:f:O:rt:` | session | List connected clients | +| `load-buffer` | `loadb` | `b:t:w` | none | Load file into paste buffer | +| `save-buffer` | `saveb` | `ab:` | none | Save paste buffer to file | +| `set-buffer` | `setb` | `ab:t:n:w` | none | Set paste buffer contents | +| `show-buffer` | `showb` | `b:` | none | Show paste buffer contents | +| `delete-buffer` | `deleteb` | `b:` | none | Delete a paste buffer | +| `paste-buffer` | `pasteb` | `db:prSs:t:` | pane | Paste buffer into pane | +| `wait-for` | `wait` | `LSU` | none | Wait for/signal/lock a channel | +| `if-shell` | `if` | `bFt:` | pane | Conditional command execution | +| `detach-client` | `detach` | `aE:s:t:P` | session | Detach client from session | +| `refresh-client` | `refresh` | `A:B:cC:Df:r:F:lLRSt:U` | none | Refresh client display | +| `show-window-options` | `showw` | `gvt:` | window | Show window options (alias for show-options -w) | +| `set-window-option` | `setw` | `aFgoqt:u` | window | Set window option (alias for set-option -w) | + +### Low Priority (interactive UI, config, rarely scripted) + +| tmux Command | Alias | Getopt | Target | Notes | +|---|---|---|---|---| +| `bind-key` | `bind` | `nrN:T:` | none | Bind key to command | +| `unbind-key` | `unbind` | `anqT:` | none | Unbind a key | +| `choose-buffer` | — | `F:f:K:NO:rt:yZ` | pane | Interactive buffer chooser | +| `choose-client` | — | `F:f:K:NO:rt:yZ` | pane | Interactive client chooser | +| `choose-tree` | — | `F:f:GK:NO:rst:wyZ` | pane | Interactive session/window tree | +| `clock-mode` | — | `t:` | pane | Show clock in pane | +| `command-prompt` | — | `1beFiklI:Np:t:T:` | none | Open command prompt | +| `confirm-before` | `confirm` | `bc:p:t:y` | none | Confirm before running command | +| `copy-mode` | — | `deHMqSs:t:u` | pane | Enter copy mode | +| `customize-mode` | — | `F:f:Nt:yZ` | pane | Enter customize mode | +| `display-menu` | `menu` | `b:c:C:H:s:S:MOt:T:x:y:` | pane | Display popup menu | +| `display-panes` | `displayp` | `bd:Nt:` | none | Show pane numbers | +| `find-window` | `findw` | `CiNrt:TZ` | pane | Search window contents | +| `list-commands` | `lscm` | `F:` | none | List tmux commands | +| `list-keys` | `lsk` | `1aF:NO:P:rT:` | none | List key bindings | +| `lock-client` | `lockc` | `t:` | none | Lock a client | +| `lock-server` | `lock` | (none) | none | Lock the server | +| `lock-session` | `locks` | `t:` | session | Lock a session | +| `next-layout` | `nextl` | `t:` | window | Cycle to next layout | +| `previous-layout` | `prevl` | `t:` | window | Cycle to previous layout | +| `send-prefix` | — | `2t:` | pane | Send prefix key | +| `server-access` | — | `adlrw` | none | Manage server access control | +| `show-messages` | `showmsgs` | `JTt:` | none | Show message log | +| `show-prompt-history` | `showphist` | `T:` | none | Show prompt history | +| `clear-prompt-history` | `clearphist` | `T:` | none | Clear prompt history | +| `source-file` | `source` | `t:Fnqv` | pane | Source a config file | +| `start-server` | `start` | (none) | none | Start server (usually implicit) | +| `suspend-client` | `suspendc` | `t:` | none | Suspend a client | diff --git a/.claude-plugin/skills/tmux-parity/references/libtmux-patterns.md b/.claude-plugin/skills/tmux-parity/references/libtmux-patterns.md new file mode 100644 index 000000000..a18b6b1d4 --- /dev/null +++ b/.claude-plugin/skills/tmux-parity/references/libtmux-patterns.md @@ -0,0 +1,198 @@ +# libtmux Implementation Patterns + +Reference for wrapping new tmux commands in libtmux. Study these patterns when implementing. + +## Pattern 1: Simple Command (No Return Value) + +Example: `Pane.select()` wrapping `select-pane` + +```python +def select(self) -> Self: + """Select pane. Wraps ``$ tmux select-pane``.""" + proc = self.cmd("select-pane") + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + return self +``` + +Key elements: +- Calls `self.cmd("command-name")` which auto-adds `-t {pane_id}` +- Checks `proc.stderr` for errors +- Returns `self` for chaining + +## Pattern 2: Command with Flag Arguments + +Example: `Pane.send_keys()` wrapping `send-keys` + +```python +def send_keys( + self, + cmd: str, + enter: bool = True, + suppress_history: bool = True, + literal: bool = False, +) -> None: + tmux_args: tuple[str | int, ...] = () + if literal: + tmux_args += ("-l",) + tmux_args += (cmd,) + self.cmd("send-keys", *tmux_args) + if enter: + self.cmd("send-keys", "Enter") +``` + +Key elements: +- Map Python kwargs to tmux flags (`literal` → `-l`) +- Build `tmux_args` tuple conditionally +- Boolean params for toggle flags, typed params for value flags + +## Pattern 3: Command Returning a New Object + +Example: `Session.new_window()` wrapping `new-window` + +```python +def new_window( + self, + window_name: str | None = None, + start_directory: StrPath | None = None, + attach: bool = True, + ... +) -> Window: + window_args: tuple[str, ...] = () + if not attach: + window_args += ("-d",) + if window_name is not None: + window_args += ("-n", window_name) + if start_directory is not None: + window_args += ("-c", str(start_directory)) + # Use -P -F to capture created object info + window_args += ("-P", "-F#{window_id}") + proc = self.cmd("new-window", *window_args) + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + window_id = proc.stdout[0].strip() + return fetch_obj("window_id", window_id, self.server) +``` + +Key elements: +- Uses `-P -F#{format}` to capture the new object's ID +- Parses stdout to get the created ID +- Calls `fetch_obj()` to return a fully populated object +- Raises on stderr + +## Pattern 4: Command with Direction/Enum Args + +Example: `Pane.resize()` wrapping `resize-pane` + +```python +def resize( + self, + adjustment_direction: ResizeAdjustmentDirection | None = None, + adjustment: int = 1, + height: int | None = None, + width: int | None = None, + zoom: bool | None = None, +) -> Self: + tmux_args: tuple[str | int, ...] = () + if adjustment_direction: + tmux_args += (RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP[adjustment_direction],) + tmux_args += (str(adjustment),) + if height is not None: + tmux_args += ("-y", str(height)) + if width is not None: + tmux_args += ("-x", str(width)) + if zoom is True: + tmux_args += ("-Z",) + proc = self.cmd("resize-pane", *tmux_args) + ... +``` + +Key elements: +- Uses constants from `libtmux.constants` for flag mapping +- Enum-based direction parameters +- Optional numeric arguments with explicit None checks + +## Pattern 5: Mixin Command (EnvironmentMixin) + +Example: `set_environment()` in `src/libtmux/common.py` + +```python +def set_environment(self, name: str, value: str) -> None: + args = ["set-environment"] + if hasattr(self, "session_id"): + args += ["-t", str(self.session_id)] + else: + args += ["-g"] + args += [name, value] + cmd = tmux_cmd(*args) # Uses standalone tmux_cmd, not self.cmd() +``` + +Key elements: +- Uses standalone `tmux_cmd()` function (not `self.cmd()`) +- Determines scope from object type (session → `-t`, server → `-g`) + +## Doctest Requirements + +All methods MUST have working doctests using fixtures from `doctest_namespace`: + +```python +def swap_pane(self, target: str) -> Self: + """Swap this pane with target. Wraps ``$ tmux swap-pane``. + + Parameters + ---------- + target : str + Target pane identifier + + Returns + ------- + :class:`Pane` + + Examples + -------- + >>> pane = window.active_pane + >>> pane2 = window.split() + >>> pane.swap_pane(pane2.pane_id) # doctest: +ELLIPSIS + Pane(...) + """ +``` + +Available fixtures: `server`, `session`, `window`, `pane`, `Server`, `Session`, `Window`, `Pane` + +Rules: +- Use `# doctest: +ELLIPSIS` for variable output +- Session IDs: `$...`, Window IDs: `@...`, Pane IDs: `%...` +- Never use `# doctest: +SKIP` +- Never convert to `.. code-block::` + +## Logging Pattern + +```python +logger.info( + "pane created", + extra={ + "tmux_subcommand": "split-window", + "tmux_pane": pane_id, + }, +) +``` + +- Use `logger.debug()` for command details, `logger.info()` for lifecycle events +- Always use `extra` dict with `tmux_` prefixed keys +- Use lazy formatting: `logger.debug("msg %s", val)` not f-strings + +## Error Handling + +```python +proc = self.cmd("command-name", *args) +if proc.stderr: + raise exc.LibTmuxException(proc.stderr) +``` + +- Check `proc.stderr` after command execution +- Raise `libtmux.exc.LibTmuxException` +- Do NOT catch-log-reraise without adding context + +## Coding Standards + +See the project's `CLAUDE.md` "Coding Standards" section. diff --git a/.claude-plugin/skills/tmux-parity/references/tmux-command-table.md b/.claude-plugin/skills/tmux-parity/references/tmux-command-table.md new file mode 100644 index 000000000..4fb7ff3fc --- /dev/null +++ b/.claude-plugin/skills/tmux-parity/references/tmux-command-table.md @@ -0,0 +1,101 @@ +# Navigating tmux C Source + +## Command Table (cmd.c) + +File: `~/study/c/tmux/cmd.c` + +The `cmd_table[]` array lists all registered commands as `extern const struct cmd_entry` references. Each entry is defined in the corresponding `cmd-*.c` file. + +Some cmd-*.c files define multiple commands: +- `cmd-send-keys.c`: `send-keys` + `send-prefix` +- `cmd-new-session.c`: `new-session` + `has-session` +- `cmd-capture-pane.c`: `capture-pane` + `clear-history` +- `cmd-choose-tree.c`: `choose-tree` + `choose-client` + `choose-buffer` + `customize-mode` +- `cmd-copy-mode.c`: `copy-mode` + `clock-mode` +- `cmd-detach-client.c`: `detach-client` + `suspend-client` +- `cmd-display-menu.c`: `display-menu` + `display-popup` +- `cmd-set-option.c`: `set-option` + `set-window-option` +- `cmd-show-options.c`: `show-options` + `show-window-options` + +## cmd_entry Struct Fields + +| Field | Type | Description | +|-------|------|-------------| +| `.name` | `const char *` | Full command name (e.g., `"new-session"`) | +| `.alias` | `const char *` | Short alias (e.g., `"new"`) or `NULL` | +| `.args` | `struct args_parse` | `{ getopt_string, min_args, max_args, NULL }` | +| `.usage` | `const char *` | Human-readable usage string | +| `.target` | `struct cmd_find_target` | `{ flag_char, CMD_FIND_TYPE, flags }` | +| `.flags` | `int` | Behavior flags (bitfield) | +| `.exec` | `enum cmd_retval (*)(struct cmd *, struct cmdq_item *)` | Implementation | + +## Getopt String Format + +The first element of `.args` is a `getopt(3)` option string: +- Single char = boolean flag: `d` means `-d` is a boolean toggle +- Char followed by `:` = flag with argument: `t:` means `-t ` +- Example: `"Ac:dDe:EF:f:n:Ps:t:x:Xy:"` means: + - Boolean: `-A`, `-d`, `-D`, `-E`, `-P`, `-X` + - With value: `-c val`, `-e val`, `-F val`, `-f val`, `-n val`, `-s val`, `-t val`, `-x val`, `-y val` + +## Target Types + +| Constant | Meaning | libtmux Class | +|----------|---------|---------------| +| `CMD_FIND_PANE` | Targets a pane (`-t pane_id`) | `Pane` | +| `CMD_FIND_WINDOW` | Targets a window (`-t window_id`) | `Window` | +| `CMD_FIND_SESSION` | Targets a session (`-t session_id`) | `Session` | +| `CMD_FIND_CLIENT` | Targets a client (`-c client`) | (no direct class) | +| (none) | No target required | `Server` | + +## Command Flags + +| Flag | Meaning | +|------|---------| +| `CMD_STARTSERVER` | Command starts server if not running | +| `CMD_READONLY` | Command doesn't modify state | +| `CMD_AFTERHOOK` | Command triggers after-hooks | +| `CMD_CLIENT_CFLAG` | Uses `-c` for client targeting | +| `CMD_CLIENT_CANFAIL` | Client lookup failure is non-fatal | + +## options-table.c + +File: `~/study/c/tmux/options-table.c` + +Defines all tmux options. Each entry specifies: +- **name**: Option name (e.g., `"status-style"`) +- **type**: `OPTIONS_TABLE_STRING`, `OPTIONS_TABLE_NUMBER`, `OPTIONS_TABLE_FLAG`, etc. +- **scope**: `OPTIONS_TABLE_SERVER`, `OPTIONS_TABLE_SESSION`, `OPTIONS_TABLE_WINDOW`, `OPTIONS_TABLE_PANE` +- **default**: Default value +- **minimum/maximum**: For numeric options + +Search pattern: `grep '\.name = "' ~/study/c/tmux/options-table.c` + +## format.c + +File: `~/study/c/tmux/format.c` + +Registers all format variables (`#{variable_name}`) used in `-F` format strings. + +Search for registrations: `grep 'format_add\|format_add_cb' ~/study/c/tmux/format.c` + +Compare against libtmux: `src/libtmux/formats.py` + +## Version Worktrees + +41 versions available at `~/study/c/tmux-{version}/`: +- 0.8, 0.9 +- 1.0 through 1.9, 1.9a +- 2.0 through 2.9, 2.9a +- 3.0, 3.0a, 3.1 through 3.1c, 3.2, 3.2a, 3.3, 3.3a, 3.4, 3.5, 3.5a, 3.6, 3.6a + +To check if a command exists in a version: +```bash +ls ~/study/c/tmux-3.0/cmd-display-popup.c 2>/dev/null # Not found = added later +ls ~/study/c/tmux-3.3/cmd-display-popup.c 2>/dev/null # Found = exists in 3.3 +``` + +To diff a command across versions: +```bash +diff ~/study/c/tmux-3.0/cmd-send-keys.c ~/study/c/tmux-3.6a/cmd-send-keys.c +``` From abd8a4e6adf3f081f19b80257bdd2bbea201ee4e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:08:02 -0500 Subject: [PATCH 002/105] ai(claude[plugin]): move agents/commands/skills to project root for auto-discovery why: Claude Code auto-discovers plugin components at the project root, not inside .claude-plugin/. Agent wasn't showing up because it was nested under .claude-plugin/agents/. what: - Move agents/, commands/, skills/ to project root - Keep scripts/ in .claude-plugin/ (not auto-discovered) - Remove custom path overrides from plugin.json - Update cross-references between components --- .claude-plugin/plugin.json | 5 +---- {.claude-plugin/agents => agents}/parity-analyzer.md | 2 +- {.claude-plugin/commands => commands}/implement-command.md | 4 ++-- {.claude-plugin/commands => commands}/parity-audit.md | 2 +- {.claude-plugin/commands => commands}/version-diff.md | 0 {.claude-plugin/skills => skills}/tmux-parity/SKILL.md | 0 .../tmux-parity/references/command-mapping.md | 0 .../tmux-parity/references/libtmux-patterns.md | 0 .../tmux-parity/references/tmux-command-table.md | 0 9 files changed, 5 insertions(+), 8 deletions(-) rename {.claude-plugin/agents => agents}/parity-analyzer.md (95%) rename {.claude-plugin/commands => commands}/implement-command.md (94%) rename {.claude-plugin/commands => commands}/parity-audit.md (94%) rename {.claude-plugin/commands => commands}/version-diff.md (100%) rename {.claude-plugin/skills => skills}/tmux-parity/SKILL.md (100%) rename {.claude-plugin/skills => skills}/tmux-parity/references/command-mapping.md (100%) rename {.claude-plugin/skills => skills}/tmux-parity/references/libtmux-patterns.md (100%) rename {.claude-plugin/skills => skills}/tmux-parity/references/tmux-command-table.md (100%) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 12af82007..e6293f617 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -7,8 +7,5 @@ }, "repository": "https://github.com/tmux-python/libtmux", "license": "MIT", - "keywords": ["tmux", "parity", "analysis", "code-generation"], - "commands": "./.claude-plugin/commands", - "agents": "./.claude-plugin/agents", - "skills": "./.claude-plugin/skills" + "keywords": ["tmux", "parity", "analysis", "code-generation"] } diff --git a/.claude-plugin/agents/parity-analyzer.md b/agents/parity-analyzer.md similarity index 95% rename from .claude-plugin/agents/parity-analyzer.md rename to agents/parity-analyzer.md index de9ebdc26..10dc9fe3b 100644 --- a/.claude-plugin/agents/parity-analyzer.md +++ b/agents/parity-analyzer.md @@ -126,4 +126,4 @@ Output a structured report: ## Reference Data -The baseline command mapping is at `.claude-plugin/skills/tmux-parity/references/command-mapping.md`. Use this as a starting point, but always run the extraction scripts for the most current data. +The baseline command mapping is at `skills/tmux-parity/references/command-mapping.md`. Use this as a starting point, but always run the extraction scripts for the most current data. diff --git a/.claude-plugin/commands/implement-command.md b/commands/implement-command.md similarity index 94% rename from .claude-plugin/commands/implement-command.md rename to commands/implement-command.md index c9e0ed209..9e70407c9 100644 --- a/.claude-plugin/commands/implement-command.md +++ b/commands/implement-command.md @@ -18,7 +18,7 @@ Guide wrapping a tmux command in libtmux, following project coding standards fro Load the `tmux-parity` skill first for reference data and implementation patterns. -If `$ARGUMENTS` is empty, ask the user which tmux command to wrap. Consult `.claude-plugin/skills/tmux-parity/references/command-mapping.md` for the "Not Wrapped" list to suggest candidates. +If `$ARGUMENTS` is empty, ask the user which tmux command to wrap. Consult `skills/tmux-parity/references/command-mapping.md` for the "Not Wrapped" list to suggest candidates. ## Phase 1: Analyze the tmux Command @@ -62,7 +62,7 @@ Search libtmux for a wrapped command with similar characteristics: - Similar flag pattern (boolean flags, value flags, creates objects, etc.) - Read that method as a template -Consult `.claude-plugin/skills/tmux-parity/references/libtmux-patterns.md` for the five implementation patterns. +Consult `skills/tmux-parity/references/libtmux-patterns.md` for the five implementation patterns. ## Phase 4: Design the Method Signature diff --git a/.claude-plugin/commands/parity-audit.md b/commands/parity-audit.md similarity index 94% rename from .claude-plugin/commands/parity-audit.md rename to commands/parity-audit.md index 652f4e778..8994cdb51 100644 --- a/.claude-plugin/commands/parity-audit.md +++ b/commands/parity-audit.md @@ -81,4 +81,4 @@ Load the `tmux-parity` skill first to access reference data and domain knowledge | Command | libtmux Method | Missing Flags | ``` -Consult `.claude-plugin/skills/tmux-parity/references/command-mapping.md` for the baseline mapping data. Run the extraction scripts for the most current data. +Consult `skills/tmux-parity/references/command-mapping.md` for the baseline mapping data. Run the extraction scripts for the most current data. diff --git a/.claude-plugin/commands/version-diff.md b/commands/version-diff.md similarity index 100% rename from .claude-plugin/commands/version-diff.md rename to commands/version-diff.md diff --git a/.claude-plugin/skills/tmux-parity/SKILL.md b/skills/tmux-parity/SKILL.md similarity index 100% rename from .claude-plugin/skills/tmux-parity/SKILL.md rename to skills/tmux-parity/SKILL.md diff --git a/.claude-plugin/skills/tmux-parity/references/command-mapping.md b/skills/tmux-parity/references/command-mapping.md similarity index 100% rename from .claude-plugin/skills/tmux-parity/references/command-mapping.md rename to skills/tmux-parity/references/command-mapping.md diff --git a/.claude-plugin/skills/tmux-parity/references/libtmux-patterns.md b/skills/tmux-parity/references/libtmux-patterns.md similarity index 100% rename from .claude-plugin/skills/tmux-parity/references/libtmux-patterns.md rename to skills/tmux-parity/references/libtmux-patterns.md diff --git a/.claude-plugin/skills/tmux-parity/references/tmux-command-table.md b/skills/tmux-parity/references/tmux-command-table.md similarity index 100% rename from .claude-plugin/skills/tmux-parity/references/tmux-command-table.md rename to skills/tmux-parity/references/tmux-command-table.md From ee663f140cc08bb8f6796cb48a8b5074af5ab3d5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:08:04 -0500 Subject: [PATCH 003/105] ai(claude[plugin]): move commands/agents to .claude/ for slash command discovery why: Claude Code discovers project commands from .claude/commands/ and agents from .claude/agents/, not top-level directories. what: - Move 3 commands to .claude/commands/ - Move parity-analyzer agent to .claude/agents/ - Remove now-empty top-level commands/ and agents/ dirs --- {agents => .claude/agents}/parity-analyzer.md | 0 {commands => .claude/commands}/implement-command.md | 0 {commands => .claude/commands}/parity-audit.md | 0 {commands => .claude/commands}/version-diff.md | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename {agents => .claude/agents}/parity-analyzer.md (100%) rename {commands => .claude/commands}/implement-command.md (100%) rename {commands => .claude/commands}/parity-audit.md (100%) rename {commands => .claude/commands}/version-diff.md (100%) diff --git a/agents/parity-analyzer.md b/.claude/agents/parity-analyzer.md similarity index 100% rename from agents/parity-analyzer.md rename to .claude/agents/parity-analyzer.md diff --git a/commands/implement-command.md b/.claude/commands/implement-command.md similarity index 100% rename from commands/implement-command.md rename to .claude/commands/implement-command.md diff --git a/commands/parity-audit.md b/.claude/commands/parity-audit.md similarity index 100% rename from commands/parity-audit.md rename to .claude/commands/parity-audit.md diff --git a/commands/version-diff.md b/.claude/commands/version-diff.md similarity index 100% rename from commands/version-diff.md rename to .claude/commands/version-diff.md From b7879846990619de412a82dca9b9af4c04fd6f0c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:15:38 -0500 Subject: [PATCH 004/105] Pane(feat[send_keys]): add reset, copy-mode, repeat, hex, format, client, key-name flags why: send-keys has many useful flags (reset terminal, hex input, repeat count, format expansion, copy-mode commands) that were not exposed in the Python API. what: - Add reset (-R), copy_mode_cmd (-X), repeat (-N), expand_formats (-F), hex_keys (-H), target_client (-c, 3.4+), key_name (-K, 3.4+) parameters - Version-gate target_client and key_name with has_gte_version("3.4") - Add SendKeysCase NamedTuple parametrized tests for all new flags --- src/libtmux/pane.py | 76 +++++++++++++++++++++++++++++++-- tests/test_pane.py | 101 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 3 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 610ba3d2b..d7dbfed3f 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -426,6 +426,13 @@ def send_keys( enter: bool | None = True, suppress_history: bool | None = False, literal: bool | None = False, + reset: bool | None = None, + copy_mode_cmd: str | None = None, + repeat: int | None = None, + expand_formats: bool | None = None, + hex_keys: bool | None = None, + target_client: str | None = None, + key_name: bool | None = None, ) -> None: r"""``$ tmux send-keys`` to the pane. @@ -446,6 +453,29 @@ def send_keys( Default changed from True to False. literal : bool, optional Send keys literally, default False. + reset : bool, optional + Reset terminal state before sending keys (``-R`` flag). + copy_mode_cmd : str, optional + Send a command to copy mode instead of keys (``-X`` flag). + When set, *cmd* is ignored. + repeat : int, optional + Repeat count for the key (``-N`` flag). + expand_formats : bool, optional + Expand tmux format strings in keys (``-F`` flag). + + .. versionadded:: 0.45 + hex_keys : bool, optional + Send keys as hex values (``-H`` flag). + + .. versionadded:: 0.45 + target_client : str, optional + Specify a target client (``-c`` flag). Requires tmux 3.4+. + + .. versionadded:: 0.45 + key_name : bool, optional + Handle keys as key names (``-K`` flag). Requires tmux 3.4+. + + .. versionadded:: 0.45 Examples -------- @@ -463,14 +493,54 @@ def send_keys( Hello world $ """ + import warnings + + from libtmux.common import has_gte_version + prefix = " " if suppress_history else "" + tmux_args: tuple[str, ...] = () + + if reset: + tmux_args += ("-R",) + + if expand_formats: + tmux_args += ("-F",) + + if hex_keys: + tmux_args += ("-H",) + + if key_name: + if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + tmux_args += ("-K",) + else: + warnings.warn( + "key_name requires tmux 3.4+, ignoring", + stacklevel=2, + ) + if literal: - self.cmd("send-keys", "-l", prefix + cmd) + tmux_args += ("-l",) + + if repeat is not None: + tmux_args += ("-N", str(repeat)) + + if target_client is not None: + if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + tmux_args += ("-c", target_client) + else: + warnings.warn( + "target_client requires tmux 3.4+, ignoring", + stacklevel=2, + ) + + if copy_mode_cmd is not None: + tmux_args += ("-X",) + self.cmd("send-keys", *tmux_args, copy_mode_cmd) else: - self.cmd("send-keys", prefix + cmd) + self.cmd("send-keys", *tmux_args, prefix + cmd) - if enter: + if enter and copy_mode_cmd is None: self.enter() @t.overload diff --git a/tests/test_pane.py b/tests/test_pane.py index cc3a0eb33..66c12bec1 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -9,6 +9,7 @@ import pytest +from libtmux.common import has_gte_version from libtmux.constants import PaneDirection, ResizeAdjustmentDirection from libtmux.test.retry import retry_until @@ -443,3 +444,103 @@ def test_split_start_directory_pathlib( actual_path = str(pathlib.Path(new_pane.pane_current_path).resolve()) expected_path = str(user_path.resolve()) assert actual_path == expected_path + + +class SendKeysCase(t.NamedTuple): + """Test case for send_keys() flag variations.""" + + test_id: str + key: str + kwargs: dict[str, t.Any] + expected_in_capture: str | None + not_expected_in_capture: str | None + min_tmux_version: str | None + + +SEND_KEYS_CASES: list[SendKeysCase] = [ + SendKeysCase( + test_id="reset_terminal", + key="", + kwargs={"reset": True, "enter": False}, + expected_in_capture=None, + not_expected_in_capture=None, + min_tmux_version=None, + ), + SendKeysCase( + test_id="repeat_count", + key="a", + kwargs={"repeat": 3, "literal": True, "enter": False}, + expected_in_capture="aaa", + not_expected_in_capture=None, + min_tmux_version=None, + ), + SendKeysCase( + test_id="hex_key_A", + key="41", + kwargs={"hex_keys": True, "enter": False}, + expected_in_capture="A", + not_expected_in_capture=None, + min_tmux_version=None, + ), + SendKeysCase( + test_id="expand_formats", + key="a", + kwargs={"expand_formats": True, "repeat": 2, "enter": False}, + expected_in_capture="aa", + not_expected_in_capture=None, + min_tmux_version=None, + ), + SendKeysCase( + test_id="key_name_flag", + key="a", + kwargs={"key_name": True, "enter": False}, + expected_in_capture=None, + not_expected_in_capture=None, + min_tmux_version="3.4", + ), +] + + +@pytest.mark.parametrize( + list(SendKeysCase._fields), + SEND_KEYS_CASES, + ids=[c.test_id for c in SEND_KEYS_CASES], +) +def test_send_keys_flags( + test_id: str, + key: str, + kwargs: dict[str, t.Any], + expected_in_capture: str | None, + not_expected_in_capture: str | None, + min_tmux_version: str | None, + session: Session, +) -> None: + """Test send_keys() with various flag combinations.""" + if min_tmux_version and not has_gte_version(min_tmux_version): + pytest.skip(f"Requires tmux {min_tmux_version}+") + + env = shutil.which("env") + assert env is not None, "Cannot find usable `env` in PATH." + + window = session.new_window( + window_name=f"sk_{test_id[:15]}", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + retry_until(lambda: "$" in "\n".join(pane.capture_pane()), 2, raises=True) + + pane.send_keys(key, **kwargs) + + if expected_in_capture is not None: + retry_until( + lambda: expected_in_capture in "\n".join(pane.capture_pane()), + 3, + raises=True, + ) + + if not_expected_in_capture is not None: + # Give a brief moment then verify absence + contents = "\n".join(pane.capture_pane()) + assert not_expected_in_capture not in contents From 921add4601cc9c9a0ee4b199083d790add463c32 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:21:59 -0500 Subject: [PATCH 005/105] Pane(feat[select]): add direction, last, zoom, mark, and input-toggle flags why: select-pane has rich flag support for directional navigation, pane marking, and input control that was not exposed in the Python API. what: - Add direction (-D/-U/-L/-R), last (-l), keep_zoom (-Z), mark (-m), clear_mark (-M), disable_input (-d), enable_input (-e) parameters - Reuse existing ResizeAdjustmentDirection enum for direction flags - Skip deprecated -P (style) and -g (show style) flags - Add tests for direction, last pane, mark/clear, and input toggle --- src/libtmux/pane.py | 60 ++++++++++++++++++++++++++- tests/test_pane.py | 98 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index d7dbfed3f..ac094a99d 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -653,9 +653,42 @@ def kill( additional scoped window info. """ - def select(self) -> Pane: + def select( + self, + *, + direction: ResizeAdjustmentDirection | None = None, + last: bool | None = None, + keep_zoom: bool | None = None, + mark: bool | None = None, + clear_mark: bool | None = None, + disable_input: bool | None = None, + enable_input: bool | None = None, + ) -> Pane: """Select pane. + Parameters + ---------- + direction : ResizeAdjustmentDirection, optional + Select the pane in the given direction (``-U``, ``-D``, ``-L``, + ``-R``). + last : bool, optional + Select the last (previously selected) pane (``-l`` flag). + keep_zoom : bool, optional + Keep the window zoomed if it was zoomed (``-Z`` flag). + mark : bool, optional + Set the marked pane (``-m`` flag). + clear_mark : bool, optional + Clear the marked pane (``-M`` flag). + disable_input : bool, optional + Disable input to the pane (``-d`` flag). + enable_input : bool, optional + Enable input to the pane (``-e`` flag). + + Returns + ------- + :class:`Pane` + Self, for method chaining. + Examples -------- >>> pane = window.active_pane @@ -677,7 +710,30 @@ def select(self) -> Pane: >>> new_pane.pane_active == '1' True """ - proc = self.cmd("select-pane") + tmux_args: tuple[str, ...] = () + + if direction is not None: + tmux_args += (RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP[direction],) + + if last: + tmux_args += ("-l",) + + if keep_zoom: + tmux_args += ("-Z",) + + if mark: + tmux_args += ("-m",) + + if clear_mark: + tmux_args += ("-M",) + + if disable_input: + tmux_args += ("-d",) + + if enable_input: + tmux_args += ("-e",) + + proc = self.cmd("select-pane", *tmux_args) if proc.stderr: raise exc.LibTmuxException(proc.stderr) diff --git a/tests/test_pane.py b/tests/test_pane.py index 66c12bec1..87de3ff8a 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -544,3 +544,101 @@ def test_send_keys_flags( # Give a brief moment then verify absence contents = "\n".join(pane.capture_pane()) assert not_expected_in_capture not in contents + + +def test_select_pane_direction(session: Session) -> None: + """Test Pane.select() with direction flags.""" + window = session.new_window(window_name="test_select_dir") + window.resize(height=40, width=80) + pane_top = window.active_pane + assert pane_top is not None + pane_bottom = pane_top.split(direction=PaneDirection.Below) + + # Top pane should be active (it was active before split with -d default) + pane_bottom.select() + pane_bottom.refresh() + assert pane_bottom.pane_active == "1" + + # Select up → should go to top pane + pane_bottom.select(direction=ResizeAdjustmentDirection.Up) + pane_top.refresh() + assert pane_top.pane_active == "1" + + # Select down → should go back to bottom + pane_top.select(direction=ResizeAdjustmentDirection.Down) + pane_bottom.refresh() + assert pane_bottom.pane_active == "1" + + +def test_select_pane_last(session: Session) -> None: + """Test Pane.select() with last flag.""" + window = session.new_window(window_name="test_select_last") + pane1 = window.active_pane + assert pane1 is not None + pane2 = pane1.split() + + # pane2 is now active (attach=True by default... wait, default is False) + # After split, pane2 is NOT active since attach=False by default + # Select pane2 explicitly + pane2.select() + pane2.refresh() + assert pane2.pane_active == "1" + + # Now select pane1 + pane1.select() + pane1.refresh() + assert pane1.pane_active == "1" + + # Use -l to go back to last (pane2) + pane1.select(last=True) + pane2.refresh() + assert pane2.pane_active == "1" + + +def test_select_pane_mark(session: Session) -> None: + """Test Pane.select() with mark/clear_mark flags.""" + window = session.new_window(window_name="test_select_mark") + pane = window.active_pane + assert pane is not None + + # Mark the pane — verify no error + pane.select(mark=True) + + # Clear the mark — verify no error + pane.select(clear_mark=True) + + +def test_select_pane_disable_enable_input(session: Session) -> None: + """Test Pane.select() with disable/enable input flags.""" + env = shutil.which("env") + assert env is not None + + window = session.new_window( + window_name="test_input_toggle", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + retry_until(lambda: "$" in "\n".join(pane.capture_pane()), 2, raises=True) + + # Disable input + pane.select(disable_input=True) + + # Send keys — they should not appear since input is disabled + pane.send_keys("echo disabled_test", enter=False) + + # Verify "disabled_test" does NOT appear + contents = "\n".join(pane.capture_pane()) + assert "disabled_test" not in contents + + # Re-enable input + pane.select(enable_input=True) + + # Now send keys — they should appear + pane.send_keys("echo enabled_ok", enter=True) + retry_until( + lambda: "enabled_ok" in "\n".join(pane.capture_pane()), + 3, + raises=True, + ) From 0abb5600fd29366b0d1016a51bffd1354841ccf0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:29:54 -0500 Subject: [PATCH 006/105] Pane(feat[display_message]): add format, verbose, delay, notify, list, and style flags why: display-message supports many useful flags for format queries and output control that were not exposed in the Python API. what: - Add format_string (-F), all_formats (-a), verbose (-v), no_expand (-I), target_client (-c), delay (-d), notify (-N), list_formats (-l, 3.4+), no_style (-C, 3.6+) parameters - Version-gate list_formats and no_style with has_gte_version - Fix cmd argument handling: only pass when non-empty - Add DisplayMessageCase NamedTuple parametrized tests --- src/libtmux/pane.py | 114 ++++++++++++++++++++++++++++++++++++++++++-- tests/test_pane.py | 70 +++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 3 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index ac094a99d..759eca386 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -548,15 +548,49 @@ def display_message( self, cmd: str, get_text: t.Literal[True], + *, + format_string: str | None = ..., + all_formats: bool | None = ..., + verbose: bool | None = ..., + no_expand: bool | None = ..., + target_client: str | None = ..., + delay: int | None = ..., + notify: bool | None = ..., + list_formats: bool | None = ..., + no_style: bool | None = ..., ) -> list[str]: ... @t.overload - def display_message(self, cmd: str, get_text: t.Literal[False]) -> None: ... + def display_message( + self, + cmd: str, + get_text: t.Literal[False] = ..., + *, + format_string: str | None = ..., + all_formats: bool | None = ..., + verbose: bool | None = ..., + no_expand: bool | None = ..., + target_client: str | None = ..., + delay: int | None = ..., + notify: bool | None = ..., + list_formats: bool | None = ..., + no_style: bool | None = ..., + ) -> None: ... def display_message( self, cmd: str, get_text: bool = False, + *, + format_string: str | None = None, + all_formats: bool | None = None, + verbose: bool | None = None, + no_expand: bool | None = None, + target_client: str | None = None, + delay: int | None = None, + notify: bool | None = None, + list_formats: bool | None = None, + no_style: bool | None = None, ) -> list[str] | None: """Display message to pane. @@ -569,16 +603,90 @@ def display_message( get_text : bool, optional Returns only text without displaying a message in target-client status line. + format_string : str, optional + Format string for output (``-F`` flag). + all_formats : bool, optional + List all format variables (``-a`` flag). + verbose : bool, optional + Show format variable types (``-v`` flag). + no_expand : bool, optional + Suppress format expansion (``-I`` flag). + target_client : str, optional + Target client (``-c`` flag). + delay : int, optional + Display time in milliseconds (``-d`` flag). + notify : bool, optional + Do not wait for input (``-N`` flag). + list_formats : bool, optional + List format variables (``-l`` flag). Requires tmux 3.4+. + + .. versionadded:: 0.45 + no_style : bool, optional + Suppress style output (``-C`` flag). Requires tmux 3.6+. + + .. versionadded:: 0.45 Returns ------- list[str] | None Message output if get_text is True, otherwise None. """ + import warnings + + from libtmux.common import has_gte_version + + tmux_args: tuple[str, ...] = () + + if get_text: + tmux_args += ("-p",) + + if all_formats: + tmux_args += ("-a",) + + if verbose: + tmux_args += ("-v",) + + if no_expand: + tmux_args += ("-I",) + + if notify: + tmux_args += ("-N",) + + if list_formats: + if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + tmux_args += ("-l",) + else: + warnings.warn( + "list_formats requires tmux 3.4+, ignoring", + stacklevel=2, + ) + + if no_style: + if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + tmux_args += ("-C",) + else: + warnings.warn( + "no_style requires tmux 3.6+, ignoring", + stacklevel=2, + ) + + if target_client is not None: + tmux_args += ("-c", target_client) + + if delay is not None: + tmux_args += ("-d", str(delay)) + + if format_string is not None: + tmux_args += ("-F", format_string) + + if cmd: + tmux_args += (cmd,) + + proc = self.cmd("display-message", *tmux_args) + if get_text: - return self.cmd("display-message", "-p", cmd).stdout + return proc.stdout - self.cmd("display-message", cmd) return None def kill( diff --git a/tests/test_pane.py b/tests/test_pane.py index 87de3ff8a..d261021d2 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -642,3 +642,73 @@ def test_select_pane_disable_enable_input(session: Session) -> None: 3, raises=True, ) + + +class DisplayMessageCase(t.NamedTuple): + """Test case for display_message() flag variations.""" + + test_id: str + cmd: str + kwargs: dict[str, t.Any] + expected_in_output: str | None + min_tmux_version: str | None + + +DISPLAY_MESSAGE_CASES: list[DisplayMessageCase] = [ + DisplayMessageCase( + test_id="format_string", + cmd="", + kwargs={"get_text": True, "format_string": "#{pane_id}"}, + expected_in_output="%", + min_tmux_version=None, + ), + DisplayMessageCase( + test_id="all_formats", + cmd="", + kwargs={"get_text": True, "all_formats": True}, + expected_in_output="session_name", + min_tmux_version=None, + ), + DisplayMessageCase( + test_id="verbose", + cmd="", + kwargs={"get_text": True, "verbose": True, "all_formats": True}, + expected_in_output="session_name", + min_tmux_version=None, + ), + DisplayMessageCase( + test_id="list_formats", + cmd="", + kwargs={"get_text": True, "list_formats": True}, + expected_in_output=None, + min_tmux_version="3.4", + ), +] + + +@pytest.mark.parametrize( + list(DisplayMessageCase._fields), + DISPLAY_MESSAGE_CASES, + ids=[c.test_id for c in DISPLAY_MESSAGE_CASES], +) +def test_display_message_flags( + test_id: str, + cmd: str, + kwargs: dict[str, t.Any], + expected_in_output: str | None, + min_tmux_version: str | None, + session: Session, +) -> None: + """Test display_message() with various flag combinations.""" + if min_tmux_version and not has_gte_version(min_tmux_version): + pytest.skip(f"Requires tmux {min_tmux_version}+") + + pane = session.active_window.active_pane + assert pane is not None + + result = pane.display_message(cmd, **kwargs) + + if expected_in_output is not None: + assert result is not None + output = "\n".join(result) + assert expected_in_output in output From c63a70db3c0fa11bcb3f324ad5062ebdfc23d111 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:32:44 -0500 Subject: [PATCH 007/105] Window(feat[select_layout]): add spread, next, and previous flags why: select-layout supports flags for spreading panes evenly and cycling through layouts that were not exposed in the Python API. what: - Add spread (-E), next_layout (-n), previous_layout (-o) parameters - Validate mutual exclusion between layout string and flag parameters - Add tests for spread, next/previous cycling, and mutual exclusion --- src/libtmux/window.py | 38 ++++++++++++++++++++++++++++++++- tests/test_window.py | 49 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index a2143ac1e..9df489c07 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -406,7 +406,14 @@ def last_pane(self) -> Pane | None: """Return last pane.""" return self.select_pane("-l") - def select_layout(self, layout: str | None = None) -> Window: + def select_layout( + self, + layout: str | None = None, + *, + spread: bool | None = None, + next_layout: bool | None = None, + previous_layout: bool | None = None, + ) -> Window: """Select layout for window. Wrapper for ``$ tmux select-layout ``. @@ -436,6 +443,18 @@ def select_layout(self, layout: str | None = None) -> Window: both rows and columns. 'custom' Custom dimensions (see :term:`tmux(1)` manpages). + spread : bool, optional + Spread panes out evenly (``-E`` flag). + + .. versionadded:: 0.45 + next_layout : bool, optional + Move to the next layout (``-n`` flag). + + .. versionadded:: 0.45 + previous_layout : bool, optional + Move to the previous layout (``-o`` flag). + + .. versionadded:: 0.45 Returns ------- @@ -446,9 +465,26 @@ def select_layout(self, layout: str | None = None) -> Window: ------ :exc:`libtmux.exc.LibTmuxException` If tmux returns an error. + ValueError + If both *layout* and a flag (*spread*, *next_layout*, + *previous_layout*) are specified. """ + flags = (spread, next_layout, previous_layout) + if layout and any(flags): + msg = "Cannot specify both layout and spread/next_layout/previous_layout" + raise ValueError(msg) + cmd = ["select-layout"] + if spread: + cmd.append("-E") + + if next_layout: + cmd.append("-n") + + if previous_layout: + cmd.append("-o") + if layout: # tmux allows select-layout without args cmd.append(layout) diff --git a/tests/test_window.py b/tests/test_window.py index 5ea57f3c5..3612a7bc0 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -770,3 +770,52 @@ def test_deprecated_window_methods_emit_warning( with pytest.warns(DeprecationWarning, match=test_case.expected_error_match): method(*test_case.args, **test_case.kwargs) + + +def test_select_layout_spread(session: Session) -> None: + """Test Window.select_layout() with spread flag.""" + window = session.new_window(window_name="test_layout_spread") + window.resize(height=40, width=80) + pane = window.active_pane + assert pane is not None + pane.split() + pane.split() + assert len(window.panes) == 3 + + # Spread panes evenly — verify no error + window.select_layout(spread=True) + + +def test_select_layout_next_previous(session: Session) -> None: + """Test Window.select_layout() with next/previous flags.""" + window = session.new_window(window_name="test_layout_cycle") + window.resize(height=40, width=80) + pane = window.active_pane + assert pane is not None + pane.split() + + # Set a known layout + window.select_layout("even-horizontal") + window.refresh() + layout_before = window.window_layout + + # Cycle to next layout + window.select_layout(next_layout=True) + window.refresh() + layout_after_next = window.window_layout + + assert layout_before != layout_after_next + + # Cycle back to previous + window.select_layout(previous_layout=True) + window.refresh() + layout_after_prev = window.window_layout + + assert layout_after_prev == layout_before + + +def test_select_layout_mutual_exclusion(session: Session) -> None: + """Test that layout string and flags are mutually exclusive.""" + window = session.new_window(window_name="test_layout_mutex") + with pytest.raises(ValueError, match="Cannot specify both"): + window.select_layout("tiled", spread=True) From a203c93dda961c3a117138827b76c9abf5c787f6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:36:26 -0500 Subject: [PATCH 008/105] Window(feat[move_window]): add after, before, no-select, kill, and renumber flags why: move-window supports flags for positioning, conflict resolution, and renumbering that were not exposed in the Python API. what: - Add after (-a), before (-b), no_select (-d), kill_target (-k), renumber (-r) parameters to move_window() - Add tests for kill_target, renumber, and no_select behaviors --- src/libtmux/window.py | 44 ++++++++++++++++++++++++++++++++++++ tests/test_window.py | 52 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 9df489c07..7a19ed954 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -602,6 +602,12 @@ def move_window( self, destination: str = "", session: str | None = None, + *, + after: bool | None = None, + before: bool | None = None, + no_select: bool | None = None, + kill_target: bool | None = None, + renumber: bool | None = None, ) -> Window: """Move current :class:`Window` object ``$ tmux move-window``. @@ -613,6 +619,26 @@ def move_window( session : str, optional The ``target session`` or index to move the window to, default: current session. + after : bool, optional + Insert after the target window (``-a`` flag). + + .. versionadded:: 0.45 + before : bool, optional + Insert before the target window (``-b`` flag). + + .. versionadded:: 0.45 + no_select : bool, optional + Do not make the moved window the current window (``-d`` flag). + + .. versionadded:: 0.45 + kill_target : bool, optional + Kill the target window if it exists (``-k`` flag). + + .. versionadded:: 0.45 + renumber : bool, optional + Renumber all windows after moving (``-r`` flag). + + .. versionadded:: 0.45 Returns ------- @@ -624,9 +650,27 @@ def move_window( :exc:`libtmux.exc.LibTmuxException` If tmux returns an error. """ + tmux_args: tuple[str, ...] = () + + if after: + tmux_args += ("-a",) + + if before: + tmux_args += ("-b",) + + if no_select: + tmux_args += ("-d",) + + if kill_target: + tmux_args += ("-k",) + + if renumber: + tmux_args += ("-r",) + session = session or self.session_id proc = self.cmd( "move-window", + *tmux_args, f"-s{self.session_id}:{self.window_index}", target=f"{session}:{destination}", ) diff --git a/tests/test_window.py b/tests/test_window.py index 3612a7bc0..5ffc5d003 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -819,3 +819,55 @@ def test_select_layout_mutual_exclusion(session: Session) -> None: window = session.new_window(window_name="test_layout_mutex") with pytest.raises(ValueError, match="Cannot specify both"): window.select_layout("tiled", spread=True) + + +def test_move_window_kill_target(session: Session) -> None: + """Test Window.move_window() with kill_target flag.""" + session.new_window(window_name="move_w1") + w2 = session.new_window(window_name="move_w2") + assert w2.window_index is not None + w2_index = w2.window_index + initial_count = len(session.windows) + + # Move first extra window to w2's index, killing w2 + extra_windows = [w for w in session.windows if w.window_name == "move_w1"] + assert len(extra_windows) == 1 + extra_windows[0].move_window(destination=w2_index, kill_target=True) + session.refresh() + assert len(session.windows) == initial_count - 1 + + +def test_move_window_renumber(session: Session) -> None: + """Test Window.move_window() with renumber flag.""" + session.new_window(window_name="ren_w1") + w2 = session.new_window(window_name="ren_w2") + w3 = session.new_window(window_name="ren_w3") + + # Kill middle window to create gap + w2.kill() + + # Move w3 with renumber + w3.move_window(renumber=True) + session.refresh() + + # Verify indices are contiguous + indices = sorted( + int(w.window_index) for w in session.windows if w.window_index is not None + ) + for i in range(len(indices) - 1): + assert indices[i + 1] - indices[i] == 1 + + +def test_move_window_no_select(session: Session) -> None: + """Test Window.move_window() with no_select flag.""" + w1 = session.new_window(window_name="nosel_w1", attach=True) + w2 = session.new_window(window_name="nosel_w2", attach=False) + + # w1 is active + session.refresh() + assert session.active_window.window_id == w1.window_id + + # Move w2 with no_select — active window should not change + w2.move_window(destination="99", no_select=True) + session.refresh() + assert session.active_window.window_id == w1.window_id From 3637f99ad2e468d25569712846ffe8638f11d75f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:38:00 -0500 Subject: [PATCH 009/105] Server(feat[new_session]): add detach-others, no-size, config flags why: new-session supports flags for detaching other clients, suppressing initial sizing, and specifying config files that were not exposed. what: - Add detach_others (-D), no_size (-X), config_file (-f) parameters - Skip -A (attach-or-create) as it requires a terminal and does not work in libtmux's programmatic non-terminal context - Add test for config_file parameter --- src/libtmux/server.py | 24 ++++++++++++++++++++++++ tests/test_server.py | 15 +++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index c69463424..691c4d765 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -472,6 +472,9 @@ def new_session( y: int | DashLiteral | None = None, environment: dict[str, str] | None = None, *args: t.Any, + detach_others: bool | None = None, + no_size: bool | None = None, + config_file: StrPath | None = None, **kwargs: t.Any, ) -> Session: """Create new session, returns new :class:`Session`. @@ -518,6 +521,18 @@ def new_session( y : int | str, optional Force the specified height instead of the tmux default for a detached session + detach_others : bool, optional + Detach other clients from the session (``-D`` flag). + + .. versionadded:: 0.45 + no_size : bool, optional + Do not set the initial window size (``-X`` flag). + + .. versionadded:: 0.45 + config_file : str or PathLike, optional + Specify an alternative configuration file (``-f`` flag). + + .. versionadded:: 0.45 Returns ------- @@ -585,6 +600,15 @@ def new_session( f"-F{format_string}", ) + if detach_others: + tmux_args += ("-D",) + + if no_size: + tmux_args += ("-X",) + + if config_file is not None: + tmux_args += ("-f", str(pathlib.Path(config_file).expanduser())) + if session_name is not None: tmux_args += (f"-s{session_name}",) diff --git a/tests/test_server.py b/tests/test_server.py index c58613749..d47b358c7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -454,3 +454,18 @@ def test_tmux_bin_invalid_path_raise_if_dead() -> None: s = Server(tmux_bin="/nonexistent/tmux") with pytest.raises(exc.TmuxCommandNotFound): s.raise_if_dead() + + +def test_new_session_config_file( + server: Server, + tmp_path: pathlib.Path, +) -> None: + """Test Server.new_session() with config_file flag.""" + conf = tmp_path / "test.conf" + conf.write_text("set -g status off\n") + + session = server.new_session( + session_name="conf_test", + config_file=str(conf), + ) + assert session.session_name == "conf_test" From af9beab54244b0752f39ad0671289dcfe971c253 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:40:13 -0500 Subject: [PATCH 010/105] Session(feat[new_window]): add kill-existing and select-existing flags why: new-window supports flags for replacing existing windows at a target index and selecting existing windows by name that were not exposed. what: - Add kill_existing (-k) and select_existing (-S) parameters to new_window() - -k destroys existing window at target index before creating new one - -S selects existing window with matching name instead of creating new - Add tests for both flags --- src/libtmux/session.py | 18 ++++++++++++++++++ tests/test_session.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 60b531ced..6742c74d8 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -461,6 +461,8 @@ def new_window( environment: dict[str, str] | None = None, direction: WindowDirection | None = None, target_window: str | None = None, + kill_existing: bool | None = None, + select_existing: bool | None = None, ) -> Window: """Create new window, returns new :class:`Window`. @@ -491,6 +493,16 @@ def new_window( target_window : str, optional Used by :meth:`Window.new_window` to specify the target window. + kill_existing : bool, optional + Destroy the window at the target index if it already exists + (``-k`` flag). + + .. versionadded:: 0.45 + select_existing : bool, optional + If a window with the given name already exists, select it instead + of creating a new one (``-S`` flag). + + .. versionadded:: 0.45 .. versionchanged:: 0.28.0 @@ -555,6 +567,12 @@ def new_window( if direction is not None: window_args += (WINDOW_DIRECTION_FLAG_MAP[direction],) + if kill_existing: + window_args += ("-k",) + + if select_existing: + window_args += ("-S",) + target: str | None = None if window_index is not None: # empty string for window_index will use the first one available diff --git a/tests/test_session.py b/tests/test_session.py index e0dc85324..f59ff34c5 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -574,3 +574,36 @@ def patched_cmd(cmd_name: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: ) with raises_ctx: test_session.attach() + + +def test_new_window_kill_existing(session: Session) -> None: + """Test Session.new_window() with kill_existing flag.""" + # Create a window at a specific index + w1 = session.new_window(window_name="kill_orig", window_index="5") + assert w1.window_name == "kill_orig" + assert w1.window_index == "5" + + # Create another window at the same index with kill_existing + w2 = session.new_window( + window_name="kill_replace", + window_index="5", + kill_existing=True, + ) + assert w2.window_name == "kill_replace" + assert w2.window_index == "5" + + # Original window should be gone + session.refresh() + names = [w.window_name for w in session.windows] + assert "kill_orig" not in names + assert "kill_replace" in names + + +def test_new_window_select_existing(session: Session) -> None: + """Test Session.new_window() with select_existing flag — new name.""" + # With a unique name and select_existing, a new window is created normally + w = session.new_window( + window_name="selexist_new", + select_existing=True, + ) + assert w.window_name == "selexist_new" From 301cc1ec0f49796012bfb547c4d3aadb3b49db6a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:41:48 -0500 Subject: [PATCH 011/105] Pane(feat[split]): add percentage parameter for split-window why: split-window supports -p for percentage-based sizing which is more intuitive than the absolute cell count provided by the existing size parameter. what: - Add percentage (-p) parameter to Pane.split(), mutually exclusive with size - Validate that size and percentage are not both specified - Add tests for percentage split and mutual exclusion --- src/libtmux/pane.py | 15 ++++++++++++++- tests/test_pane.py | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 759eca386..7e7fcd066 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -876,6 +876,7 @@ def split( zoom: bool | None = None, shell: str | None = None, size: str | int | None = None, + percentage: int | None = None, environment: dict[str, str] | None = None, ) -> Pane: """Split window and return :class:`Pane`, by default beneath current pane. @@ -903,7 +904,12 @@ def split( is useful for long-running processes where the closing of the window upon completion is desired. size: int, optional - Cell/row or percentage to occupy with respect to current window. + Cell/row count to occupy with respect to current window. + percentage: int, optional + Percentage (0-100) of the window to occupy (``-p`` flag). + Mutually exclusive with *size*. + + .. versionadded:: 0.45 environment: dict, optional Environmental variables for new pane. Passthrough to ``-e``. @@ -969,9 +975,16 @@ def split( else: tmux_args += tuple(PANE_DIRECTION_FLAG_MAP[PaneDirection.Below]) + if size is not None and percentage is not None: + msg = "Cannot specify both size and percentage" + raise ValueError(msg) + if size is not None: tmux_args += (f"-l{size}",) + if percentage is not None: + tmux_args += (f"-p{percentage}",) + if full_window_split: tmux_args += ("-f",) diff --git a/tests/test_pane.py b/tests/test_pane.py index d261021d2..4f3b87631 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -712,3 +712,29 @@ def test_display_message_flags( assert result is not None output = "\n".join(result) assert expected_in_output in output + + +def test_split_percentage(session: Session) -> None: + """Test Pane.split() with percentage parameter.""" + window = session.new_window(window_name="test_split_pct") + window.resize(height=40, width=80) + pane = window.active_pane + assert pane is not None + + new_pane = pane.split(percentage=25) + assert new_pane in window.panes + assert len(window.panes) == 2 + + # The new pane should be roughly 25% of the window height + new_pane.refresh() + assert new_pane.pane_height is not None + assert int(new_pane.pane_height) <= 15 # ~25% of 40 + + +def test_split_percentage_size_mutual_exclusion(session: Session) -> None: + """Test that size and percentage are mutually exclusive.""" + window = session.new_window(window_name="test_split_mutex") + pane = window.active_pane + assert pane is not None + with pytest.raises(ValueError, match="Cannot specify both"): + pane.split(size=10, percentage=50) From 28c8d98ee6f5aa76455603ad444a22a5e4b08dfe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:43:57 -0500 Subject: [PATCH 012/105] Pane(feat[capture_pane]): add alternate-screen, quiet, and escape-markup flags why: capture-pane supports flags for alternate screen capture, silent error handling, and markup escaping that were not exposed. what: - Add alternate_screen (-a), quiet (-q), escape_markup (-M, 3.6+) parameters - Version-gate escape_markup with has_gte_version("3.6") - Add tests for quiet, alternate_screen, and escape_markup flags --- src/libtmux/pane.py | 26 +++++++++++++++++++++++++ tests/test_pane_capture_pane.py | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 7e7fcd066..6b63b9a78 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -326,6 +326,9 @@ def capture_pane( join_wrapped: bool = False, preserve_trailing: bool = False, trim_trailing: bool = False, + alternate_screen: bool = False, + quiet: bool = False, + escape_markup: bool = False, ) -> list[str]: r"""Capture text from pane. @@ -371,6 +374,17 @@ def capture_pane( Requires tmux 3.4+. If used with tmux < 3.4, a warning is issued and the flag is ignored. Default: False + alternate_screen : bool, optional + Capture from the alternate screen (``-a`` flag). + Default: False + quiet : bool, optional + Suppress errors silently (``-q`` flag). + Default: False + escape_markup : bool, optional + Escape markup in the output (``-M`` flag). Requires tmux 3.6+. + Default: False + + .. versionadded:: 0.45 Returns ------- @@ -418,6 +432,18 @@ def capture_pane( "trim_trailing requires tmux 3.4+, ignoring", stacklevel=2, ) + if alternate_screen: + cmd.append("-a") + if quiet: + cmd.append("-q") + if escape_markup: + if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + cmd.append("-M") + else: + warnings.warn( + "escape_markup requires tmux 3.6+, ignoring", + stacklevel=2, + ) return self.cmd(*cmd).stdout def send_keys( diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 28e28648a..2fa1b30b4 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -470,3 +470,37 @@ def prompt_ready() -> bool: # Check warning was issued assert len(w) == 1 assert "trim_trailing requires tmux 3.4+" in str(w[0].message) + + +def test_capture_pane_quiet(session: Session) -> None: + """Test capture_pane with quiet flag suppresses errors.""" + pane = session.active_window.active_pane + assert pane is not None + + # Quiet mode should not raise, even in edge cases + result = pane.capture_pane(quiet=True) + assert isinstance(result, list) + + +def test_capture_pane_alternate_screen(session: Session) -> None: + """Test capture_pane with alternate_screen flag.""" + pane = session.active_window.active_pane + assert pane is not None + + # Capture from alternate screen — may be empty, but should not error + result = pane.capture_pane(alternate_screen=True) + assert isinstance(result, list) + + +def test_capture_pane_escape_markup(session: Session) -> None: + """Test capture_pane with escape_markup flag (3.6+).""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.6"): + pytest.skip("Requires tmux 3.6+") + + pane = session.active_window.active_pane + assert pane is not None + + result = pane.capture_pane(escape_markup=True) + assert isinstance(result, list) From 505c65840875395745fde1b44511db89c75b4fca Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:46:15 -0500 Subject: [PATCH 013/105] Options,Env(feat): add quiet/values-only to show_options, format/hidden to set_environment why: show-options supports -q (quiet) and -v (values only) flags, and set-environment supports -F (format expansion) and -h (hidden) flags that were not exposed in the Python API. what: - Add quiet (-q) and values_only (-v) to _show_options_raw() - Add expand_format (-F) and hidden (-h) to set_environment() - Add tests for quiet show_options, hidden env vars, and format expansion --- src/libtmux/common.py | 23 ++++++++++++++++++++++- src/libtmux/options.py | 8 ++++++++ tests/test_options.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 699ca3f8f..8f3ce0ae8 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -60,7 +60,14 @@ class EnvironmentMixin: def __init__(self, add_option: str | None = None) -> None: self._add_option = add_option - def set_environment(self, name: str, value: str) -> None: + def set_environment( + self, + name: str, + value: str, + *, + expand_format: bool | None = None, + hidden: bool | None = None, + ) -> None: """Set environment ``$ tmux set-environment ``. Parameters @@ -69,6 +76,14 @@ def set_environment(self, name: str, value: str) -> None: The environment variable name, e.g. 'PATH'. value : str Environment value. + expand_format : bool, optional + Expand tmux format strings in the value (``-F`` flag). + + .. versionadded:: 0.45 + hidden : bool, optional + Mark the variable as hidden (``-h`` flag). + + .. versionadded:: 0.45 Raises ------ @@ -79,6 +94,12 @@ def set_environment(self, name: str, value: str) -> None: if self._add_option: args += [self._add_option] + if expand_format: + args += ["-F"] + + if hidden: + args += ["-h"] + args += [name, value] cmd = self.cmd(*args) diff --git a/src/libtmux/options.py b/src/libtmux/options.py index 7fe48b521..94f61a4aa 100644 --- a/src/libtmux/options.py +++ b/src/libtmux/options.py @@ -807,6 +807,8 @@ def _show_options_raw( scope: OptionScope | _DefaultOptionScope | None = DEFAULT_OPTION_SCOPE, include_hooks: bool | None = None, include_inherited: bool | None = None, + quiet: bool | None = None, + values_only: bool | None = None, ) -> tmux_cmd: """Return a dict of options for the target. @@ -867,6 +869,12 @@ def _show_options_raw( if include_hooks is not None and include_hooks: flags += ("-H",) + if quiet is not None and quiet: + flags += ("-q",) + + if values_only is not None and values_only: + flags += ("-v",) + return self.cmd("show-options", *flags) def _show_options_dict( diff --git a/tests/test_options.py b/tests/test_options.py index 9cca8bd9b..fad96500c 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -1494,3 +1494,38 @@ def test_explode_arrays_preserves_inherited_marker( assert array_value[idx] == expected_val, ( f"Value at index {idx}: expected '{expected_val}', got '{array_value[idx]}'" ) + + +def test_show_options_quiet(server: Server) -> None: + """Test _show_options_raw with quiet flag suppresses errors.""" + from libtmux.constants import OptionScope + + # Query with quiet — should not raise even with unusual options + result = server._show_options_raw( + scope=OptionScope.Server, + quiet=True, + ) + assert isinstance(result.stdout, list) + + +def test_set_environment_hidden(session: Session) -> None: + """Test set_environment with hidden flag.""" + session.set_environment("HIDDEN_TEST_VAR", "secret", hidden=True) + + # Normal show_environment should NOT show hidden vars + env = session.show_environment() + assert "HIDDEN_TEST_VAR" not in env + + +def test_set_environment_expand_format(session: Session) -> None: + """Test set_environment with expand_format flag.""" + session.set_environment( + "FORMAT_TEST_VAR", + "#{session_name}", + expand_format=True, + ) + + env = session.show_environment() + # The value should have been expanded (not the raw format string) + assert "FORMAT_TEST_VAR" in env + assert env["FORMAT_TEST_VAR"] != "#{session_name}" From 304b6e80af046dbad11b6b92c33a427bb312f2f1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:48:25 -0500 Subject: [PATCH 014/105] Pane(feat[clear_history]): add clear_history() wrapping tmux clear-history why: clear-history is useful for clearing pane scrollback buffers, especially important for test isolation and monitoring workflows. what: - Add clear_history() method with clear_pane (-H, 3.4+) parameter - Version-gate -H flag with has_gte_version("3.4") - Add test verifying history is cleared after sending commands --- src/libtmux/pane.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_pane.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 6b63b9a78..96aded3e4 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1139,6 +1139,41 @@ def enter(self) -> Pane: self.cmd("send-keys", "Enter") return self + def clear_history(self, *, clear_pane: bool | None = None) -> None: + """Clear pane history buffer via ``$ tmux clear-history``. + + Parameters + ---------- + clear_pane : bool, optional + Also clear the visible pane content (``-H`` flag). + Requires tmux 3.4+. + + .. versionadded:: 0.45 + + Examples + -------- + >>> pane.clear_history() + """ + import warnings + + from libtmux.common import has_gte_version + + tmux_args: tuple[str, ...] = () + + if clear_pane: + if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + tmux_args += ("-H",) + else: + warnings.warn( + "clear_pane requires tmux 3.4+, ignoring", + stacklevel=2, + ) + + proc = self.cmd("clear-history", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def clear(self) -> Pane: """Clear pane.""" self.send_keys("reset") diff --git a/tests/test_pane.py b/tests/test_pane.py index 4f3b87631..a5ba273d5 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -738,3 +738,31 @@ def test_split_percentage_size_mutual_exclusion(session: Session) -> None: assert pane is not None with pytest.raises(ValueError, match="Cannot specify both"): pane.split(size=10, percentage=50) + + +def test_clear_history(session: Session) -> None: + """Test Pane.clear_history().""" + env = shutil.which("env") + assert env is not None + + window = session.new_window( + window_name="test_clearhist", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + retry_until(lambda: "$" in "\n".join(pane.capture_pane()), 2, raises=True) + + # Send some commands to build up history + pane.send_keys("echo line1", enter=True) + pane.send_keys("echo line2", enter=True) + retry_until(lambda: "line2" in "\n".join(pane.capture_pane()), 3, raises=True) + + # Clear history + pane.clear_history() + + # The scrollback should be cleared (visible content may still show current) + history = pane.capture_pane(start=-100) + # After clearing, scrollback history should be much shorter + assert len(history) <= 30 # reasonable bound after clear From 216c5271323f7c8432b8899942dcdfc3f19573fc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:53:02 -0500 Subject: [PATCH 015/105] Pane,Window(feat[swap]): add swap() wrapping tmux swap-pane and swap-window why: swap-pane and swap-window are core layout manipulation commands needed for programmatic pane and window reordering. what: - Add Pane.swap() with target, detach (-d), move_up (-U), move_down (-D), keep_zoom (-Z) parameters wrapping swap-pane - Add Window.swap() with target, detach (-d) parameters wrapping swap-window - Add tests verifying pane indices and window indices swap correctly --- src/libtmux/pane.py | 55 +++++++++++++++++++++++++++++++++++++++++++ src/libtmux/window.py | 42 +++++++++++++++++++++++++++++++++ tests/test_pane.py | 29 +++++++++++++++++++++++ tests/test_window.py | 16 +++++++++++++ 4 files changed, 142 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 96aded3e4..0269ddf37 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1139,6 +1139,61 @@ def enter(self) -> Pane: self.cmd("send-keys", "Enter") return self + def swap( + self, + target: str | Pane, + *, + detach: bool | None = None, + move_up: bool | None = None, + move_down: bool | None = None, + keep_zoom: bool | None = None, + ) -> None: + """Swap this pane with another via ``$ tmux swap-pane``. + + Parameters + ---------- + target : str or Pane + Target pane to swap with. Can be a pane ID string or Pane object. + detach : bool, optional + Do not change the active pane (``-d`` flag). + move_up : bool, optional + Swap with the pane above (``-U`` flag). Overrides *target*. + move_down : bool, optional + Swap with the pane below (``-D`` flag). Overrides *target*. + keep_zoom : bool, optional + Keep the window zoomed if it was zoomed (``-Z`` flag). + + Examples + -------- + >>> pane1 = window.active_pane + >>> pane2 = window.split() + >>> pane1_id, pane2_id = pane1.pane_id, pane2.pane_id + >>> pane1.swap(pane2) + >>> pane1.refresh() + >>> pane2.refresh() + """ + tmux_args: tuple[str, ...] = () + + if detach: + tmux_args += ("-d",) + + if move_up: + tmux_args += ("-U",) + + if move_down: + tmux_args += ("-D",) + + if keep_zoom: + tmux_args += ("-Z",) + + target_id = target.pane_id if isinstance(target, Pane) else target + tmux_args += ("-s", str(target_id)) + + proc = self.cmd("swap-pane", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def clear_history(self, *, clear_pane: bool | None = None) -> None: """Clear pane history buffer via ``$ tmux clear-history``. diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 7a19ed954..0a6b1c51a 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -495,6 +495,48 @@ def select_layout( return self + def swap( + self, + target: str | Window, + *, + detach: bool | None = None, + ) -> None: + """Swap this window with another via ``$ tmux swap-window``. + + Parameters + ---------- + target : str or Window + Target window to swap with. Can be a window ID string or Window. + detach : bool, optional + Do not change the active window (``-d`` flag). + + Examples + -------- + >>> w1 = session.new_window(window_name='swap_a') + >>> w2 = session.new_window(window_name='swap_b') + >>> w1_idx = w1.window_index + >>> w2_idx = w2.window_index + >>> w1.swap(w2) + >>> w1.refresh() + >>> w2.refresh() + >>> w1.window_index == w2_idx + True + >>> w2.window_index == w1_idx + True + """ + tmux_args: tuple[str, ...] = () + + if detach: + tmux_args += ("-d",) + + target_id = target.window_id if isinstance(target, Window) else target + tmux_args += ("-s", str(target_id)) + + proc = self.cmd("swap-window", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def rename_window(self, new_name: str) -> Window: """Rename window. diff --git a/tests/test_pane.py b/tests/test_pane.py index a5ba273d5..9be69436d 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -740,6 +740,35 @@ def test_split_percentage_size_mutual_exclusion(session: Session) -> None: pane.split(size=10, percentage=50) +def test_swap_pane(session: Session) -> None: + """Test Pane.swap() swaps two panes.""" + window = session.new_window(window_name="test_swap_pane") + window.resize(height=40, width=80) + pane1 = window.active_pane + assert pane1 is not None + pane2 = pane1.split() + + pane1_id = pane1.pane_id + pane2_id = pane2.pane_id + + # Record initial indices + pane1.refresh() + pane2.refresh() + pane1_idx = pane1.pane_index + pane2_idx = pane2.pane_index + + # Swap + pane1.swap(pane2) + + # Verify indices swapped + pane1.refresh() + pane2.refresh() + assert pane1.pane_index == pane2_idx + assert pane2.pane_index == pane1_idx + assert pane1.pane_id == pane1_id + assert pane2.pane_id == pane2_id + + def test_clear_history(session: Session) -> None: """Test Pane.clear_history().""" env = shutil.which("env") diff --git a/tests/test_window.py b/tests/test_window.py index 5ffc5d003..a6f307f7e 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -821,6 +821,22 @@ def test_select_layout_mutual_exclusion(session: Session) -> None: window.select_layout("tiled", spread=True) +def test_swap_window(session: Session) -> None: + """Test Window.swap() swaps two windows.""" + w1 = session.new_window(window_name="swap_w1") + w2 = session.new_window(window_name="swap_w2") + + w1_idx = w1.window_index + w2_idx = w2.window_index + + w1.swap(w2) + + w1.refresh() + w2.refresh() + assert w1.window_index == w2_idx + assert w2.window_index == w1_idx + + def test_move_window_kill_target(session: Session) -> None: """Test Window.move_window() with kill_target flag.""" session.new_window(window_name="move_w1") From 4cc1433540df259979afdd10e9a06c32e5420b80 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 04:56:14 -0500 Subject: [PATCH 016/105] Pane(feat[break_pane]): add break_pane() wrapping tmux break-pane why: break-pane is essential for layout management, allowing a pane to be moved into its own window programmatically. what: - Add break_pane() method with detach (-d) and window_name (-n) parameters - Use -P -F#{window_id} to capture new window ID from output - Return Window object via Window.from_window_id() - Use server.cmd with explicit -s flag to avoid auto-target conflicts - Add tests for basic break and named window --- src/libtmux/pane.py | 48 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_pane.py | 30 ++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 0269ddf37..7e6f8c426 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1139,6 +1139,54 @@ def enter(self) -> Pane: self.cmd("send-keys", "Enter") return self + def break_pane( + self, + *, + detach: bool = True, + window_name: str | None = None, + ) -> Window: + """Break this pane out into a new window via ``$ tmux break-pane``. + + Parameters + ---------- + detach : bool, optional + Do not switch to the new window (``-d`` flag), default True. + window_name : str, optional + Name for the new window (``-n`` flag). + + Returns + ------- + :class:`Window` + The newly created window containing the pane. + + Examples + -------- + >>> pane_to_break = window.split(shell='sleep 1m') + >>> new_window = pane_to_break.break_pane(window_name='broken') + >>> new_window.window_name + 'broken' + """ + tmux_args: tuple[str, ...] = ("-P", "-F#{window_id}") + + if detach: + tmux_args += ("-d",) + + if window_name is not None: + tmux_args += ("-n", window_name) + + tmux_args += ("-s", str(self.pane_id)) + + proc = self.server.cmd("break-pane", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + window_id = proc.stdout[0].strip() + + from libtmux.window import Window + + return Window.from_window_id(server=self.server, window_id=window_id) + def swap( self, target: str | Pane, diff --git a/tests/test_pane.py b/tests/test_pane.py index 9be69436d..cf2b51aad 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -740,6 +740,36 @@ def test_split_percentage_size_mutual_exclusion(session: Session) -> None: pane.split(size=10, percentage=50) +def test_break_pane_basic(session: Session) -> None: + """Test Pane.break_pane() creates a new window.""" + window = session.new_window(window_name="test_break") + initial_window_count = len(session.windows) + pane = window.active_pane + assert pane is not None + + new_pane = pane.split(shell="sleep 1m") + assert len(window.panes) == 2 + + new_window = new_pane.break_pane() + session.refresh() + + assert len(session.windows) == initial_window_count + 1 + window.refresh() + assert len(window.panes) == 1 + assert new_window.window_id is not None + + +def test_break_pane_with_name(session: Session) -> None: + """Test Pane.break_pane() with window_name.""" + window = session.new_window(window_name="test_break_name") + pane = window.active_pane + assert pane is not None + + new_pane = pane.split(shell="sleep 1m") + new_window = new_pane.break_pane(window_name="my_broken") + assert new_window.window_name == "my_broken" + + def test_swap_pane(session: Session) -> None: """Test Pane.swap() swaps two panes.""" window = session.new_window(window_name="test_swap_pane") From 62397164e43b50954b566e45a52571facc90a513 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 05:00:46 -0500 Subject: [PATCH 017/105] Pane(feat[join]): add join() wrapping tmux join-pane why: join-pane is the inverse of break-pane, needed for programmatically merging panes between windows. what: - Add join() method with vertical (-v/-h), detach (-d), full_window (-f), size (-l), before (-b) parameters - Accept Pane, Window, or string target ID - Use server.cmd with explicit -s/-t to avoid auto-target conflicts - Add roundtrip test (break + join) and horizontal join test --- src/libtmux/pane.py | 71 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_pane.py | 40 +++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 7e6f8c426..9c3722a33 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1139,6 +1139,77 @@ def enter(self) -> Pane: self.cmd("send-keys", "Enter") return self + def join( + self, + target: str | Pane | Window, + *, + vertical: bool = True, + detach: bool = True, + full_window: bool | None = None, + size: str | int | None = None, + before: bool | None = None, + ) -> None: + """Join this pane into another window/pane via ``$ tmux join-pane``. + + This is the inverse of :meth:`break_pane`. + + Parameters + ---------- + target : str, Pane, or Window + Target pane or window to join into. + vertical : bool, optional + Join vertically (``-v`` flag), default True. Set to False for + horizontal (``-h``). + detach : bool, optional + Do not switch to the target window (``-d`` flag), default True. + full_window : bool, optional + Join spanning the full window width/height (``-f`` flag). + size : str or int, optional + Size for the joined pane (``-l`` flag). + before : bool, optional + Place the pane before the target (``-b`` flag). + + Examples + -------- + >>> pane_to_join = window.split(shell='sleep 1m') + >>> new_window = pane_to_join.break_pane() + >>> pane_to_join.join(window) + """ + tmux_args: tuple[str, ...] = () + + if vertical: + tmux_args += ("-v",) + else: + tmux_args += ("-h",) + + if detach: + tmux_args += ("-d",) + + if full_window: + tmux_args += ("-f",) + + if size is not None: + tmux_args += (f"-l{size}",) + + if before: + tmux_args += ("-b",) + + from libtmux.window import Window + + if isinstance(target, Pane): + target_id = str(target.pane_id) + elif isinstance(target, Window): + target_id = str(target.window_id) + else: + target_id = target + + tmux_args += ("-s", str(self.pane_id), "-t", target_id) + + proc = self.server.cmd("join-pane", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def break_pane( self, *, diff --git a/tests/test_pane.py b/tests/test_pane.py index cf2b51aad..f27419a41 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -740,6 +740,46 @@ def test_split_percentage_size_mutual_exclusion(session: Session) -> None: pane.split(size=10, percentage=50) +def test_join_pane(session: Session) -> None: + """Test Pane.join() roundtrip with break_pane.""" + window = session.new_window(window_name="test_join") + pane = window.active_pane + assert pane is not None + + # Create a second pane and break it out + new_pane = pane.split(shell="sleep 1m") + assert len(window.panes) == 2 + + new_window = new_pane.break_pane() + window.refresh() + assert len(window.panes) == 1 + + # Join the pane back + new_pane.join(window) + window.refresh() + assert len(window.panes) == 2 + + # The new window should be gone (only had one pane) + session.refresh() + window_ids = [w.window_id for w in session.windows] + assert new_window.window_id not in window_ids + + +def test_join_pane_horizontal(session: Session) -> None: + """Test Pane.join() with horizontal split.""" + window = session.new_window(window_name="test_join_h") + window.resize(height=40, width=80) + pane = window.active_pane + assert pane is not None + + new_pane = pane.split(shell="sleep 1m") + new_pane.break_pane() + + new_pane.join(window, vertical=False) + window.refresh() + assert len(window.panes) == 2 + + def test_break_pane_basic(session: Session) -> None: """Test Pane.break_pane() creates a new window.""" window = session.new_window(window_name="test_break") From d0cbdb776297c8d5bee1576185dc662274ecfbfe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 05:06:08 -0500 Subject: [PATCH 018/105] Pane,Window(feat[respawn]): add respawn() wrapping tmux respawn-pane/respawn-window why: respawn-pane and respawn-window are needed for restarting processes in panes/windows without destroying and recreating them. what: - Add Pane.respawn() with shell, start_directory (-c), environment (-e), kill (-k) parameters wrapping respawn-pane - Add Window.respawn() with same parameters wrapping respawn-window - Add tests verifying respawn with kill on active panes and windows --- src/libtmux/pane.py | 48 ++++++++++++++++++++++++++++++++++++++++++ src/libtmux/window.py | 49 +++++++++++++++++++++++++++++++++++++++++++ tests/test_pane.py | 14 +++++++++++++ tests/test_window.py | 13 ++++++++++++ 4 files changed, 124 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 9c3722a33..d351e150a 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1139,6 +1139,54 @@ def enter(self) -> Pane: self.cmd("send-keys", "Enter") return self + def respawn( + self, + *, + shell: str | None = None, + start_directory: StrPath | None = None, + environment: dict[str, str] | None = None, + kill: bool | None = None, + ) -> None: + """Respawn the pane process via ``$ tmux respawn-pane``. + + Parameters + ---------- + shell : str, optional + Shell command to run in the respawned pane. + start_directory : str or PathLike, optional + Working directory for the respawned pane (``-c`` flag). + environment : dict, optional + Environment variables (``-e`` flag). + kill : bool, optional + Kill the current process before respawning (``-k`` flag). + Required if the pane is still active. + + Examples + -------- + >>> pane = window.split(shell='sleep 1m') + >>> pane.respawn(kill=True, shell='sh') + """ + tmux_args: tuple[str, ...] = () + + if kill: + tmux_args += ("-k",) + + if start_directory is not None: + start_path = pathlib.Path(start_directory).expanduser() + tmux_args += ("-c", str(start_path)) + + if environment: + for k, v in environment.items(): + tmux_args += (f"-e{k}={v}",) + + if shell: + tmux_args += (shell,) + + proc = self.cmd("respawn-pane", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def join( self, target: str | Pane | Window, diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 0a6b1c51a..76f40be6e 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -9,6 +9,7 @@ import dataclasses import logging +import pathlib import shlex import typing as t import warnings @@ -495,6 +496,54 @@ def select_layout( return self + def respawn( + self, + *, + shell: str | None = None, + start_directory: StrPath | None = None, + environment: dict[str, str] | None = None, + kill: bool | None = None, + ) -> None: + """Respawn the window process via ``$ tmux respawn-window``. + + Parameters + ---------- + shell : str, optional + Shell command to run in the respawned window. + start_directory : str or PathLike, optional + Working directory for the respawned window (``-c`` flag). + environment : dict, optional + Environment variables (``-e`` flag). + kill : bool, optional + Kill the current process before respawning (``-k`` flag). + Required if the window is still active. + + Examples + -------- + >>> window = session.new_window(window_name='respawn_test') + >>> window.respawn(kill=True, shell='sh') + """ + tmux_args: tuple[str, ...] = () + + if kill: + tmux_args += ("-k",) + + if start_directory is not None: + start_path = pathlib.Path(start_directory).expanduser() + tmux_args += ("-c", str(start_path)) + + if environment: + for k, v in environment.items(): + tmux_args += (f"-e{k}={v}",) + + if shell: + tmux_args += (shell,) + + proc = self.cmd("respawn-window", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def swap( self, target: str | Window, diff --git a/tests/test_pane.py b/tests/test_pane.py index f27419a41..4c167ed58 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -740,6 +740,20 @@ def test_split_percentage_size_mutual_exclusion(session: Session) -> None: pane.split(size=10, percentage=50) +def test_respawn_pane_kill(session: Session) -> None: + """Test Pane.respawn() with kill flag on active pane.""" + window = session.new_window(window_name="test_respawn") + pane = window.active_pane + assert pane is not None + + # Respawn the active pane with kill + pane.respawn(kill=True, shell="sh") + + # Pane should still exist and be alive + pane.refresh() + assert pane in window.panes + + def test_join_pane(session: Session) -> None: """Test Pane.join() roundtrip with break_pane.""" window = session.new_window(window_name="test_join") diff --git a/tests/test_window.py b/tests/test_window.py index a6f307f7e..e18ab0360 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -821,6 +821,19 @@ def test_select_layout_mutual_exclusion(session: Session) -> None: window.select_layout("tiled", spread=True) +def test_respawn_window(session: Session) -> None: + """Test Window.respawn() with kill flag.""" + window = session.new_window(window_name="test_respawn_w") + + # Respawn the window with kill + window.respawn(kill=True, shell="sh") + + # Window should still exist + window.refresh() + session.refresh() + assert window.window_id in [w.window_id for w in session.windows] + + def test_swap_window(session: Session) -> None: """Test Window.swap() swaps two windows.""" w1 = session.new_window(window_name="swap_w1") From 5323a42204bc3404a1492bc3a4e9fbf5b8d423dc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 05:09:30 -0500 Subject: [PATCH 019/105] Pane(feat[pipe]): add pipe() wrapping tmux pipe-pane why: pipe-pane is needed for monitoring, logging, and capturing pane output to external commands or files programmatically. what: - Add pipe() method with command, output_only (-O), input_only (-I), toggle (-o) parameters wrapping pipe-pane - Calling with no command stops piping - Add test piping to file via cat, verifying output captured --- src/libtmux/pane.py | 48 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_pane.py | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index d351e150a..36cbf876d 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1139,6 +1139,54 @@ def enter(self) -> Pane: self.cmd("send-keys", "Enter") return self + def pipe( + self, + command: str | None = None, + *, + output_only: bool | None = None, + input_only: bool | None = None, + toggle: bool | None = None, + ) -> None: + """Pipe pane output to a shell command via ``$ tmux pipe-pane``. + + Parameters + ---------- + command : str, optional + Shell command to pipe to. If None, stops piping. + output_only : bool, optional + Only pipe output from the pane (``-O`` flag). + input_only : bool, optional + Only pipe input to the pane (``-I`` flag). + toggle : bool, optional + Toggle piping on/off (``-o`` flag). + + Examples + -------- + >>> pane.pipe('cat >> /tmp/output.txt') + + Stop piping: + + >>> pane.pipe() + """ + tmux_args: tuple[str, ...] = () + + if output_only: + tmux_args += ("-O",) + + if input_only: + tmux_args += ("-I",) + + if toggle: + tmux_args += ("-o",) + + if command is not None: + tmux_args += (command,) + + proc = self.cmd("pipe-pane", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def respawn( self, *, diff --git a/tests/test_pane.py b/tests/test_pane.py index 4c167ed58..06b863b9f 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -740,6 +740,42 @@ def test_split_percentage_size_mutual_exclusion(session: Session) -> None: pane.split(size=10, percentage=50) +def test_pipe_pane(session: Session, tmp_path: pathlib.Path) -> None: + """Test Pane.pipe() pipes output to a file.""" + env = shutil.which("env") + assert env is not None + + window = session.new_window( + window_name="test_pipe", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + retry_until(lambda: "$" in "\n".join(pane.capture_pane()), 2, raises=True) + + pipe_file = tmp_path / "pipe_output.txt" + + # Start piping + pane.pipe(f"cat >> {pipe_file}") + + # Send some text + pane.send_keys("echo pipe_test_ok", enter=True) + retry_until( + lambda: "pipe_test_ok" in "\n".join(pane.capture_pane()), 3, raises=True + ) + + # Stop piping + pane.pipe() + + # Verify file has content + retry_until( + lambda: pipe_file.exists() and pipe_file.stat().st_size > 0, 3, raises=True + ) + content = pipe_file.read_text() + assert "pipe_test_ok" in content + + def test_respawn_pane_kill(session: Session) -> None: """Test Pane.respawn() with kill flag on active pane.""" window = session.new_window(window_name="test_respawn") From 873f5eadea7390c09cbb919000a451a4332b86b8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 05:11:59 -0500 Subject: [PATCH 020/105] Server(feat[run_shell]): add run_shell() wrapping tmux run-shell why: run-shell executes shell commands in the tmux server context, useful for background tasks and capturing command output programmatically. what: - Add Server.run_shell() with background (-b), delay (-d), capture (-C), target_pane (-t) parameters - Returns stdout when not in background mode, None otherwise - Add tests for basic command execution and background mode --- src/libtmux/server.py | 60 +++++++++++++++++++++++++++++++++++++++++++ tests/test_server.py | 15 +++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 691c4d765..2082af142 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -423,6 +423,66 @@ def kill_session(self, target_session: str | int) -> Server: return self + def run_shell( + self, + command: str, + *, + background: bool | None = None, + delay: str | None = None, + capture: bool | None = None, + target_pane: str | None = None, + ) -> list[str] | None: + """Execute a shell command via ``$ tmux run-shell``. + + Parameters + ---------- + command : str + Shell command to execute. + background : bool, optional + Run in background (``-b`` flag). + delay : str, optional + Delay before execution (``-d`` flag). + capture : bool, optional + Capture output to the target pane (``-C`` flag). + target_pane : str, optional + Target pane for output (``-t`` flag). + + Returns + ------- + list[str] | None + Command stdout if not running in background, None otherwise. + + Examples + -------- + >>> result = server.run_shell('echo hello') + >>> 'hello' in (result or []) + True + """ + tmux_args: tuple[str, ...] = () + + if background: + tmux_args += ("-b",) + + if delay is not None: + tmux_args += ("-d", delay) + + if capture: + tmux_args += ("-C",) + + if target_pane is not None: + tmux_args += ("-t", target_pane) + + tmux_args += (command,) + + proc = self.cmd("run-shell", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + if background: + return None + return proc.stdout + def switch_client(self, target_session: str) -> None: """Switch tmux client. diff --git a/tests/test_server.py b/tests/test_server.py index d47b358c7..b1486c29e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -456,6 +456,21 @@ def test_tmux_bin_invalid_path_raise_if_dead() -> None: s.raise_if_dead() +def test_run_shell_basic(server: Server) -> None: + """Test Server.run_shell() executes command and returns output.""" + server.new_session(session_name="run_shell_test") + result = server.run_shell("echo hello_from_run_shell") + assert result is not None + assert any("hello_from_run_shell" in line for line in result) + + +def test_run_shell_background(server: Server) -> None: + """Test Server.run_shell() in background mode.""" + server.new_session(session_name="run_shell_bg_test") + result = server.run_shell("echo bg_test", background=True) + assert result is None + + def test_new_session_config_file( server: Server, tmp_path: pathlib.Path, From ca907238242ab8d9e9e26023058a0192aec58e56 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 05:19:32 -0500 Subject: [PATCH 021/105] Session,Window(feat): add last_window, next_window, previous_window, rotate why: Window navigation commands (last/next/previous) and pane rotation are commonly needed for programmatic window management workflows. what: - Add Session.last_window() wrapping last-window - Add Session.next_window() wrapping next-window - Add Session.previous_window() wrapping previous-window - Add Window.rotate() wrapping rotate-window with direction_up (-U), keep_zoom (-Z) parameters - Add tests for all navigation commands and rotation --- src/libtmux/session.py | 70 ++++++++++++++++++++++++++++++++++++++++++ src/libtmux/window.py | 44 ++++++++++++++++++++++++++ tests/test_session.py | 36 ++++++++++++++++++++++ tests/test_window.py | 24 +++++++++++++++ 4 files changed, 174 insertions(+) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 6742c74d8..4faffb410 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -245,6 +245,76 @@ def cmd( Commands (tmux-like) """ + def last_window(self) -> Window: + """Select the last (previously selected) window. + + Wraps ``$ tmux last-window``. + + Returns + ------- + :class:`Window` + The newly active window. + + Examples + -------- + >>> w1 = session.new_window(window_name='lw_a') + >>> w2 = session.new_window(window_name='lw_b', attach=True) + >>> session.last_window() + Window(...) + """ + proc = self.cmd("last-window") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return self.active_window + + def next_window(self) -> Window: + """Select the next window. + + Wraps ``$ tmux next-window``. + + Returns + ------- + :class:`Window` + The newly active window. + + Examples + -------- + >>> w = session.new_window(window_name='nw_test') + >>> session.next_window() + Window(...) + """ + proc = self.cmd("next-window") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return self.active_window + + def previous_window(self) -> Window: + """Select the previous window. + + Wraps ``$ tmux previous-window``. + + Returns + ------- + :class:`Window` + The newly active window. + + Examples + -------- + >>> w = session.new_window(window_name='pw_test') + >>> session.previous_window() + Window(...) + """ + proc = self.cmd("previous-window") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return self.active_window + def select_window(self, target_window: str | int) -> Window: """Select window and return the selected window. diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 76f40be6e..61f01ca69 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -496,6 +496,50 @@ def select_layout( return self + def rotate( + self, + *, + direction_up: bool | None = None, + keep_zoom: bool | None = None, + ) -> Window: + """Rotate pane positions in the window via ``$ tmux rotate-window``. + + Parameters + ---------- + direction_up : bool, optional + Rotate upward (``-U`` flag). Default is downward (``-D``). + keep_zoom : bool, optional + Keep the window zoomed if zoomed (``-Z`` flag). + + Returns + ------- + :class:`Window` + Self, for method chaining. + + Examples + -------- + >>> pane1 = window.active_pane + >>> pane2 = window.split() + >>> window.rotate() + Window(...) + """ + tmux_args: tuple[str, ...] = () + + if direction_up: + tmux_args += ("-U",) + else: + tmux_args += ("-D",) + + if keep_zoom: + tmux_args += ("-Z",) + + proc = self.cmd("rotate-window", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return self + def respawn( self, *, diff --git a/tests/test_session.py b/tests/test_session.py index f59ff34c5..29b5c1d6f 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -576,6 +576,42 @@ def patched_cmd(cmd_name: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: test_session.attach() +def test_last_window(session: Session) -> None: + """Test Session.last_window() selects previous window.""" + w1 = session.new_window(window_name="last_a", attach=True) + w2 = session.new_window(window_name="last_b", attach=True) + session.refresh() + assert session.active_window.window_id == w2.window_id + + result = session.last_window() + assert result.window_id == w1.window_id + + +def test_next_window(session: Session) -> None: + """Test Session.next_window() selects next window.""" + w1 = session.new_window(window_name="next_a", attach=True) + session.new_window(window_name="next_b", attach=False) + + # Active is w1, next should go to next_b + session.refresh() + assert session.active_window.window_id == w1.window_id + + result = session.next_window() + # Should have moved to a different window + assert result.window_id != w1.window_id + + +def test_previous_window(session: Session) -> None: + """Test Session.previous_window() selects previous window.""" + w1 = session.new_window(window_name="prev_a", attach=True) + w2 = session.new_window(window_name="prev_b", attach=True) + session.refresh() + assert session.active_window.window_id == w2.window_id + + result = session.previous_window() + assert result.window_id == w1.window_id + + def test_new_window_kill_existing(session: Session) -> None: """Test Session.new_window() with kill_existing flag.""" # Create a window at a specific index diff --git a/tests/test_window.py b/tests/test_window.py index e18ab0360..458736f9e 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -821,6 +821,30 @@ def test_select_layout_mutual_exclusion(session: Session) -> None: window.select_layout("tiled", spread=True) +def test_rotate_window(session: Session) -> None: + """Test Window.rotate() rotates pane positions.""" + window = session.new_window(window_name="test_rotate") + window.resize(height=40, width=80) + pane1 = window.active_pane + assert pane1 is not None + pane2 = pane1.split() + pane3 = pane2.split() + + pane1.refresh() + pane2.refresh() + pane3.refresh() + idx_before = (pane1.pane_index, pane2.pane_index, pane3.pane_index) + + window.rotate() + + pane1.refresh() + pane2.refresh() + pane3.refresh() + idx_after = (pane1.pane_index, pane2.pane_index, pane3.pane_index) + + assert idx_before != idx_after + + def test_respawn_window(session: Session) -> None: """Test Window.respawn() with kill flag.""" window = session.new_window(window_name="test_respawn_w") From 3d27b10d61886d987a051844cb72aca97dca6032 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 05:25:20 -0500 Subject: [PATCH 022/105] Window,Server(feat): add link, unlink, and wait_for commands why: link-window, unlink-window, and wait-for are needed for sharing windows across sessions and for synchronization between tmux commands. what: - Add Window.link() wrapping link-window with target_session, target_index, kill_existing (-k), after (-a), before (-b), detach (-d) parameters - Add Window.unlink() wrapping unlink-window with kill_if_last (-k) parameter - Add Server.wait_for() wrapping wait-for with lock (-L), unlock (-U), set_flag (-S) parameters - Add tests for link/unlink roundtrip and wait-for set_flag --- src/libtmux/server.py | 45 +++++++++++++++++++++ src/libtmux/window.py | 92 +++++++++++++++++++++++++++++++++++++++++++ tests/test_server.py | 7 ++++ tests/test_window.py | 29 ++++++++++++++ 4 files changed, 173 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 2082af142..e25f7cd24 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -483,6 +483,51 @@ def run_shell( return None return proc.stdout + def wait_for( + self, + channel: str, + *, + lock: bool | None = None, + unlock: bool | None = None, + set_flag: bool | None = None, + ) -> None: + """Wait for, signal, or lock a channel via ``$ tmux wait-for``. + + Parameters + ---------- + channel : str + Channel name. + lock : bool, optional + Lock the channel (``-L`` flag). + unlock : bool, optional + Unlock the channel (``-U`` flag). + set_flag : bool, optional + Set the channel flag and wake waiters (``-S`` flag). + + Examples + -------- + >>> server.new_session(session_name='wait_test') + Session(...) + >>> server.wait_for('test_channel', set_flag=True) + """ + tmux_args: tuple[str, ...] = () + + if lock: + tmux_args += ("-L",) + + if unlock: + tmux_args += ("-U",) + + if set_flag: + tmux_args += ("-S",) + + tmux_args += (channel,) + + proc = self.cmd("wait-for", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def switch_client(self, target_session: str) -> None: """Switch tmux client. diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 61f01ca69..76d968841 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -496,6 +496,98 @@ def select_layout( return self + def link( + self, + target_session: str | Session, + *, + target_index: str | None = None, + kill_existing: bool | None = None, + after: bool | None = None, + before: bool | None = None, + detach: bool | None = None, + ) -> None: + """Link this window to another session via ``$ tmux link-window``. + + Parameters + ---------- + target_session : str or Session + Target session to link the window to. + target_index : str, optional + Target window index in the destination session. + kill_existing : bool, optional + Kill target window if it exists (``-k`` flag). + after : bool, optional + Insert after the target window (``-a`` flag). + before : bool, optional + Insert before the target window (``-b`` flag). + detach : bool, optional + Do not make the linked window active (``-d`` flag). + + Examples + -------- + >>> w = session.new_window(window_name='link_test') + >>> s2 = server.new_session(session_name='link_target') + >>> w.link(s2) + """ + tmux_args: tuple[str, ...] = () + + if kill_existing: + tmux_args += ("-k",) + + if after: + tmux_args += ("-a",) + + if before: + tmux_args += ("-b",) + + if detach: + tmux_args += ("-d",) + + # Source: this window + tmux_args += ("-s", f"{self.session_id}:{self.window_index}") + + # Target: destination session[:index] + from libtmux.session import Session + + session_id = ( + target_session.session_id + if isinstance(target_session, Session) + else target_session + ) + target = f"{session_id}:{target_index}" if target_index else str(session_id) + tmux_args += ("-t", target) + + proc = self.server.cmd("link-window", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def unlink(self, *, kill_if_last: bool | None = None) -> None: + """Unlink this window from the current session via ``$ tmux unlink-window``. + + Parameters + ---------- + kill_if_last : bool, optional + Kill the window if it is the last window in the session (``-k``). + + Examples + -------- + >>> w = session.new_window(window_name='unlink_test') + >>> s2 = server.new_session(session_name='unlink_s2') + >>> w.link(s2) + >>> linked = [x for x in s2.windows if x.window_name == 'unlink_test'] + >>> linked[0].unlink() + """ + tmux_args: tuple[str, ...] = () + + if kill_if_last: + tmux_args += ("-k",) + + proc = self.cmd("unlink-window", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def rotate( self, *, diff --git a/tests/test_server.py b/tests/test_server.py index b1486c29e..1ea8f3162 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -456,6 +456,13 @@ def test_tmux_bin_invalid_path_raise_if_dead() -> None: s.raise_if_dead() +def test_wait_for_set_flag(server: Server) -> None: + """Test Server.wait_for() with set_flag.""" + server.new_session(session_name="wait_test") + # Just set the flag — should not block or error + server.wait_for("test_channel_set", set_flag=True) + + def test_run_shell_basic(server: Server) -> None: """Test Server.run_shell() executes command and returns output.""" server.new_session(session_name="run_shell_test") diff --git a/tests/test_window.py b/tests/test_window.py index 458736f9e..ac8ea912c 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -821,6 +821,35 @@ def test_select_layout_mutual_exclusion(session: Session) -> None: window.select_layout("tiled", spread=True) +def test_link_unlink_window(server: Server, session: Session) -> None: + """Test Window.link() and Window.unlink().""" + # Create a second session + s2 = server.new_session(session_name="link_target") + + # Create a window in the first session + w = session.new_window(window_name="link_test") + + # Link it to s2 + w.link(s2, detach=True) + + # Verify window appears in s2 + s2.refresh() + s2_window_names = [win.window_name for win in s2.windows] + assert "link_test" in s2_window_names + + # Unlink from s2 — select a different window first + linked_windows = [win for win in s2.windows if win.window_name == "link_test"] + assert len(linked_windows) > 0 + + # We need another window in s2 before unlinking the last one + linked_windows[0].unlink() + + # Verify it's gone from s2 + s2.refresh() + s2_window_names = [win.window_name for win in s2.windows] + assert "link_test" not in s2_window_names + + def test_rotate_window(session: Session) -> None: """Test Window.rotate() rotates pane positions.""" window = session.new_window(window_name="test_rotate") From 5bf3cc62970b3e4ddb2752fcb41bec9efd3e361d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 05:31:07 -0500 Subject: [PATCH 023/105] Server(feat[buffers]): add set_buffer, show_buffer, delete_buffer wrapping tmux buffer commands why: Paste buffer management is needed for programmatic clipboard-like operations between panes and for exporting/importing text data. what: - Add Server.set_buffer() wrapping set-buffer with append (-a) and buffer_name (-b) parameters - Add Server.show_buffer() wrapping show-buffer with buffer_name (-b) - Add Server.delete_buffer() wrapping delete-buffer with buffer_name (-b) - Add BufferCase NamedTuple parametrized tests for set/show cycle, named buffers, append, and delete --- src/libtmux/server.py | 94 +++++++++++++++++++++++++++++++++++++++++++ tests/test_server.py | 79 ++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e25f7cd24..2f98bbece 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -528,6 +528,100 @@ def wait_for( if proc.stderr: raise exc.LibTmuxException(proc.stderr) + def set_buffer( + self, + data: str, + *, + buffer_name: str | None = None, + append: bool | None = None, + ) -> None: + """Set a paste buffer via ``$ tmux set-buffer``. + + Parameters + ---------- + data : str + Data to store in the buffer. + buffer_name : str, optional + Name of the buffer (``-b`` flag). + append : bool, optional + Append to the buffer instead of replacing (``-a`` flag). + + Examples + -------- + >>> server.set_buffer('hello') + >>> server.show_buffer() + 'hello' + """ + tmux_args: tuple[str, ...] = () + + if append: + tmux_args += ("-a",) + + if buffer_name is not None: + tmux_args += ("-b", buffer_name) + + tmux_args += (data,) + + proc = self.cmd("set-buffer", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def show_buffer(self, *, buffer_name: str | None = None) -> str: + """Show content of a paste buffer via ``$ tmux show-buffer``. + + Parameters + ---------- + buffer_name : str, optional + Name of the buffer (``-b`` flag). Defaults to the most recent. + + Returns + ------- + str + Buffer content. + + Examples + -------- + >>> server.set_buffer('test_data') + >>> server.show_buffer() + 'test_data' + """ + tmux_args: tuple[str, ...] = () + + if buffer_name is not None: + tmux_args += ("-b", buffer_name) + + proc = self.cmd("show-buffer", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return "\n".join(proc.stdout) + + def delete_buffer(self, *, buffer_name: str | None = None) -> None: + """Delete a paste buffer via ``$ tmux delete-buffer``. + + Parameters + ---------- + buffer_name : str, optional + Name of the buffer to delete (``-b`` flag). Defaults to the most + recent. + + Examples + -------- + >>> server.set_buffer('to_delete', buffer_name='del_buf') + >>> server.delete_buffer(buffer_name='del_buf') + """ + tmux_args: tuple[str, ...] = () + + if buffer_name is not None: + tmux_args += ("-b", buffer_name) + + proc = self.cmd("delete-buffer", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def switch_client(self, target_session: str) -> None: """Switch tmux client. diff --git a/tests/test_server.py b/tests/test_server.py index 1ea8f3162..ac53f7375 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -12,6 +12,7 @@ import pytest +from libtmux import exc from libtmux.server import Server if t.TYPE_CHECKING: @@ -478,6 +479,84 @@ def test_run_shell_background(server: Server) -> None: assert result is None +class BufferCase(t.NamedTuple): + """Test case for buffer operations.""" + + test_id: str + data: str + buffer_name: str | None + append: bool | None + expected_content: str + + +BUFFER_CASES: list[BufferCase] = [ + BufferCase( + test_id="set_show_default", + data="hello_buf", + buffer_name=None, + append=None, + expected_content="hello_buf", + ), + BufferCase( + test_id="set_show_named", + data="named_data", + buffer_name="mybuf", + append=None, + expected_content="named_data", + ), +] + + +@pytest.mark.parametrize( + list(BufferCase._fields), + BUFFER_CASES, + ids=[c.test_id for c in BUFFER_CASES], +) +def test_buffer_set_show( + test_id: str, + data: str, + buffer_name: str | None, + append: bool | None, + expected_content: str, + server: Server, +) -> None: + """Test Server.set_buffer() and show_buffer() cycle.""" + server.new_session(session_name=f"buf_{test_id}") + kwargs: dict[str, t.Any] = {} + if buffer_name is not None: + kwargs["buffer_name"] = buffer_name + if append is not None: + kwargs["append"] = append + + server.set_buffer(data, **kwargs) + result = server.show_buffer(buffer_name=buffer_name) + assert result == expected_content + + +def test_buffer_append(server: Server) -> None: + """Test Server.set_buffer() with append flag.""" + server.new_session(session_name="buf_append") + server.set_buffer("first", buffer_name="append_test") + server.set_buffer("_second", buffer_name="append_test", append=True) + result = server.show_buffer(buffer_name="append_test") + assert result == "first_second" + + +def test_buffer_delete(server: Server) -> None: + """Test Server.delete_buffer().""" + server.new_session(session_name="buf_delete") + server.set_buffer("to_delete", buffer_name="del_buf") + # Verify it exists + assert server.show_buffer(buffer_name="del_buf") == "to_delete" + + # Delete it + server.delete_buffer(buffer_name="del_buf") + + # Verify it's gone — show-buffer should raise + with pytest.raises(exc.LibTmuxException): + server.show_buffer(buffer_name="del_buf") + + def test_new_session_config_file( server: Server, tmp_path: pathlib.Path, From eb0f2b75d45209571430e9d76701721ceb167eb1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 05:36:52 -0500 Subject: [PATCH 024/105] Server(feat[buffers]): add save_buffer, load_buffer, list_buffers for buffer I/O why: Buffer file I/O and listing are needed for exporting pane content, importing data, and querying available buffers programmatically. what: - Add Server.save_buffer() wrapping save-buffer with append (-a), buffer_name (-b), and file path - Add Server.load_buffer() wrapping load-buffer with buffer_name (-b) and file path - Add Server.list_buffers() wrapping list-buffers returning raw output - Add tests for save/load cycle, append mode, and buffer listing --- src/libtmux/server.py | 96 +++++++++++++++++++++++++++++++++++++++++++ tests/test_server.py | 44 ++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 2f98bbece..991563b87 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -622,6 +622,102 @@ def delete_buffer(self, *, buffer_name: str | None = None) -> None: if proc.stderr: raise exc.LibTmuxException(proc.stderr) + def save_buffer( + self, + path: StrPath, + *, + buffer_name: str | None = None, + append: bool | None = None, + ) -> None: + """Save a paste buffer to a file via ``$ tmux save-buffer``. + + Parameters + ---------- + path : str or PathLike + File path to save the buffer to. + buffer_name : str, optional + Name of the buffer (``-b`` flag). Defaults to the most recent. + append : bool, optional + Append to the file instead of overwriting (``-a`` flag). + + Examples + -------- + >>> import pathlib + >>> server.set_buffer('save_me') + >>> path = pathlib.Path(request.config.rootdir) / '..' / 'tmp_save.txt' + >>> server.save_buffer(str(path)) + """ + tmux_args: tuple[str, ...] = () + + if append: + tmux_args += ("-a",) + + if buffer_name is not None: + tmux_args += ("-b", buffer_name) + + tmux_args += (str(pathlib.Path(path).expanduser()),) + + proc = self.cmd("save-buffer", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def load_buffer( + self, + path: StrPath, + *, + buffer_name: str | None = None, + ) -> None: + """Load a file into a paste buffer via ``$ tmux load-buffer``. + + Parameters + ---------- + path : str or PathLike + File path to load into the buffer. + buffer_name : str, optional + Name of the buffer (``-b`` flag). + + Examples + -------- + >>> import pathlib + >>> path = pathlib.Path(request.config.rootdir) / '..' / 'tmp_load.txt' + >>> _ = path.write_text('loaded') + >>> server.load_buffer(str(path), buffer_name='loaded_buf') + """ + tmux_args: tuple[str, ...] = () + + if buffer_name is not None: + tmux_args += ("-b", buffer_name) + + tmux_args += (str(pathlib.Path(path).expanduser()),) + + proc = self.cmd("load-buffer", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def list_buffers(self) -> list[str]: + """List paste buffers via ``$ tmux list-buffers``. + + Returns + ------- + list[str] + Raw output lines from list-buffers. + + Examples + -------- + >>> server.set_buffer('buf_data') + >>> result = server.list_buffers() + >>> len(result) >= 1 + True + """ + proc = self.cmd("list-buffers") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return proc.stdout + def switch_client(self, target_session: str) -> None: """Switch tmux client. diff --git a/tests/test_server.py b/tests/test_server.py index ac53f7375..332249db8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -557,6 +557,50 @@ def test_buffer_delete(server: Server) -> None: server.show_buffer(buffer_name="del_buf") +def test_buffer_save_load(server: Server, tmp_path: pathlib.Path) -> None: + """Test Server.save_buffer() and load_buffer() cycle.""" + server.new_session(session_name="buf_saveload") + + # Set and save + server.set_buffer("save_test_data") + buf_file = tmp_path / "saved_buf.txt" + server.save_buffer(buf_file) + + # Verify file content + assert buf_file.read_text() == "save_test_data" + + # Load into a named buffer + server.load_buffer(buf_file, buffer_name="loaded_buf") + assert server.show_buffer(buffer_name="loaded_buf") == "save_test_data" + + +def test_buffer_save_append(server: Server, tmp_path: pathlib.Path) -> None: + """Test Server.save_buffer() with append flag.""" + server.new_session(session_name="buf_saveappend") + + buf_file = tmp_path / "append_buf.txt" + + server.set_buffer("first_line", buffer_name="app1") + server.save_buffer(buf_file, buffer_name="app1") + + server.set_buffer("second_line", buffer_name="app2") + server.save_buffer(buf_file, buffer_name="app2", append=True) + + content = buf_file.read_text() + assert "first_line" in content + assert "second_line" in content + + +def test_list_buffers(server: Server) -> None: + """Test Server.list_buffers().""" + server.new_session(session_name="buf_list") + server.set_buffer("buf_a", buffer_name="list_a") + server.set_buffer("buf_b", buffer_name="list_b") + + result = server.list_buffers() + assert len(result) >= 2 + + def test_new_session_config_file( server: Server, tmp_path: pathlib.Path, From 965fffe76606e17e8ba87b2c9b89503426dd50b4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 05:39:57 -0500 Subject: [PATCH 025/105] Pane(feat[paste_buffer]): add paste_buffer() wrapping tmux paste-buffer why: paste-buffer is needed for programmatically inserting buffer content into panes, completing the buffer management API. what: - Add Pane.paste_buffer() with buffer_name (-b), delete_after (-d), no_trailing_newline (-r), bracket (-p), separator (-s) parameters - Add test verifying buffer content appears in pane after paste --- src/libtmux/pane.py | 51 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_pane.py | 26 +++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 36cbf876d..addfd03dd 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1139,6 +1139,57 @@ def enter(self) -> Pane: self.cmd("send-keys", "Enter") return self + def paste_buffer( + self, + *, + buffer_name: str | None = None, + delete_after: bool | None = None, + no_trailing_newline: bool | None = None, + bracket: bool | None = None, + separator: str | None = None, + ) -> None: + """Paste a buffer into the pane via ``$ tmux paste-buffer``. + + Parameters + ---------- + buffer_name : str, optional + Name of the buffer to paste (``-b`` flag). + delete_after : bool, optional + Delete the buffer after pasting (``-d`` flag). + no_trailing_newline : bool, optional + Do not add a trailing newline (``-r`` flag). + bracket : bool, optional + Use bracketed paste mode (``-p`` flag). + separator : str, optional + Separator between lines (``-s`` flag). + + Examples + -------- + >>> server.set_buffer('pasted_text') + >>> pane.paste_buffer() + """ + tmux_args: tuple[str, ...] = () + + if delete_after: + tmux_args += ("-d",) + + if no_trailing_newline: + tmux_args += ("-r",) + + if bracket: + tmux_args += ("-p",) + + if buffer_name is not None: + tmux_args += ("-b", buffer_name) + + if separator is not None: + tmux_args += ("-s", separator) + + proc = self.cmd("paste-buffer", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def pipe( self, command: str | None = None, diff --git a/tests/test_pane.py b/tests/test_pane.py index 06b863b9f..72410a5ab 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -740,6 +740,32 @@ def test_split_percentage_size_mutual_exclusion(session: Session) -> None: pane.split(size=10, percentage=50) +def test_paste_buffer(session: Session) -> None: + """Test Pane.paste_buffer() pastes buffer content into pane.""" + env = shutil.which("env") + assert env is not None + + window = session.new_window( + window_name="test_paste", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + retry_until(lambda: "$" in "\n".join(pane.capture_pane()), 2, raises=True) + + # Set buffer and paste it + session.server.set_buffer("pasted_content", buffer_name="paste_test") + pane.paste_buffer(buffer_name="paste_test") + + # Verify content appeared in pane + retry_until( + lambda: "pasted_content" in "\n".join(pane.capture_pane()), + 3, + raises=True, + ) + + def test_pipe_pane(session: Session, tmp_path: pathlib.Path) -> None: """Test Pane.pipe() pipes output to a file.""" env = shutil.which("env") From 42a6bc6d9285fe3becc6edf427114df5e6c64ccc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 05:44:32 -0500 Subject: [PATCH 026/105] Pane(feat[display_popup]): add display_popup() wrapping tmux display-popup why: display-popup (3.2+) creates overlay popups for running commands, useful in interactive tmux sessions. what: - Add display_popup() with command, close_on_exit (-E), close_on_success (-C), width (-w), height (-h), start_directory (-d) core parameters - Version-gate 3.3+ flags: title (-T), border_lines (-b), border_style (-s), environment (-e) - Note: requires attached client, cannot be tested in headless context --- src/libtmux/pane.py | 116 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index addfd03dd..3330f7e5b 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1139,6 +1139,122 @@ def enter(self) -> Pane: self.cmd("send-keys", "Enter") return self + def display_popup( + self, + command: str | None = None, + *, + close_on_exit: bool | None = None, + close_on_success: bool | None = None, + width: int | str | None = None, + height: int | str | None = None, + start_directory: StrPath | None = None, + title: str | None = None, + border_lines: str | None = None, + border_style: str | None = None, + environment: dict[str, str] | None = None, + ) -> None: + """Display a popup overlay via ``$ tmux display-popup``. + + Requires tmux 3.2+. + + Parameters + ---------- + command : str, optional + Shell command to run in the popup. + close_on_exit : bool, optional + Close popup when command exits (``-E`` flag). + close_on_success : bool, optional + Close popup only on success exit (``-C`` flag). + width : int or str, optional + Popup width (``-w`` flag). + height : int or str, optional + Popup height (``-h`` flag). + start_directory : str or PathLike, optional + Working directory (``-d`` flag). + title : str, optional + Popup title (``-T`` flag). Requires tmux 3.3+. + border_lines : str, optional + Border line style (``-b`` flag). Requires tmux 3.3+. + border_style : str, optional + Border style (``-s`` flag). Requires tmux 3.3+. + environment : dict, optional + Environment variables (``-e`` flag). Requires tmux 3.3+. + + .. versionadded:: 0.45 + + Examples + -------- + >>> hasattr(pane, 'display_popup') + True + """ + import warnings + + from libtmux.common import has_gte_version + + tmux_args: tuple[str, ...] = () + + if close_on_exit: + tmux_args += ("-E",) + + if close_on_success: + tmux_args += ("-C",) + + if width is not None: + tmux_args += ("-w", str(width)) + + if height is not None: + tmux_args += ("-h", str(height)) + + if start_directory is not None: + start_path = pathlib.Path(start_directory).expanduser() + tmux_args += ("-d", str(start_path)) + + # 3.3+ flags + if title is not None: + if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + tmux_args += ("-T", title) + else: + warnings.warn( + "title requires tmux 3.3+, ignoring", + stacklevel=2, + ) + + if border_lines is not None: + if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + tmux_args += ("-b", border_lines) + else: + warnings.warn( + "border_lines requires tmux 3.3+, ignoring", + stacklevel=2, + ) + + if border_style is not None: + if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + tmux_args += ("-s", border_style) + else: + warnings.warn( + "border_style requires tmux 3.3+, ignoring", + stacklevel=2, + ) + + if environment: + if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + for k, v in environment.items(): + tmux_args += ("-e", f"{k}={v}") + else: + warnings.warn( + "environment requires tmux 3.3+, ignoring", + stacklevel=2, + ) + + if command is not None: + tmux_args += (command,) + + proc = self.cmd("display-popup", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def paste_buffer( self, *, From b5ab355265695835f094ce0f29c885ba47fd3634 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 05:49:24 -0500 Subject: [PATCH 027/105] Revert "Pane(feat[display_popup]): add display_popup() wrapping tmux display-popup" This reverts commit 4944756a8130749ba3a7a1038cc0f66ecb21fb2e. --- src/libtmux/pane.py | 116 -------------------------------------------- 1 file changed, 116 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 3330f7e5b..addfd03dd 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1139,122 +1139,6 @@ def enter(self) -> Pane: self.cmd("send-keys", "Enter") return self - def display_popup( - self, - command: str | None = None, - *, - close_on_exit: bool | None = None, - close_on_success: bool | None = None, - width: int | str | None = None, - height: int | str | None = None, - start_directory: StrPath | None = None, - title: str | None = None, - border_lines: str | None = None, - border_style: str | None = None, - environment: dict[str, str] | None = None, - ) -> None: - """Display a popup overlay via ``$ tmux display-popup``. - - Requires tmux 3.2+. - - Parameters - ---------- - command : str, optional - Shell command to run in the popup. - close_on_exit : bool, optional - Close popup when command exits (``-E`` flag). - close_on_success : bool, optional - Close popup only on success exit (``-C`` flag). - width : int or str, optional - Popup width (``-w`` flag). - height : int or str, optional - Popup height (``-h`` flag). - start_directory : str or PathLike, optional - Working directory (``-d`` flag). - title : str, optional - Popup title (``-T`` flag). Requires tmux 3.3+. - border_lines : str, optional - Border line style (``-b`` flag). Requires tmux 3.3+. - border_style : str, optional - Border style (``-s`` flag). Requires tmux 3.3+. - environment : dict, optional - Environment variables (``-e`` flag). Requires tmux 3.3+. - - .. versionadded:: 0.45 - - Examples - -------- - >>> hasattr(pane, 'display_popup') - True - """ - import warnings - - from libtmux.common import has_gte_version - - tmux_args: tuple[str, ...] = () - - if close_on_exit: - tmux_args += ("-E",) - - if close_on_success: - tmux_args += ("-C",) - - if width is not None: - tmux_args += ("-w", str(width)) - - if height is not None: - tmux_args += ("-h", str(height)) - - if start_directory is not None: - start_path = pathlib.Path(start_directory).expanduser() - tmux_args += ("-d", str(start_path)) - - # 3.3+ flags - if title is not None: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - tmux_args += ("-T", title) - else: - warnings.warn( - "title requires tmux 3.3+, ignoring", - stacklevel=2, - ) - - if border_lines is not None: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - tmux_args += ("-b", border_lines) - else: - warnings.warn( - "border_lines requires tmux 3.3+, ignoring", - stacklevel=2, - ) - - if border_style is not None: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - tmux_args += ("-s", border_style) - else: - warnings.warn( - "border_style requires tmux 3.3+, ignoring", - stacklevel=2, - ) - - if environment: - if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - for k, v in environment.items(): - tmux_args += ("-e", f"{k}={v}") - else: - warnings.warn( - "environment requires tmux 3.3+, ignoring", - stacklevel=2, - ) - - if command is not None: - tmux_args += (command,) - - proc = self.cmd("display-popup", *tmux_args) - - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) - def paste_buffer( self, *, From d7066f85b944ce95d70b8e99f2fcc171e4f51838 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 05:50:32 -0500 Subject: [PATCH 028/105] Server(feat[list_clients]): add list_clients() wrapping tmux list-clients why: list-clients is needed for monitoring connected clients and multi-client session management. what: - Add Server.list_clients() returning raw stdout lines - Returns empty list when no clients are attached - Add test verifying return type --- src/libtmux/server.py | 20 ++++++++++++++++++++ tests/test_server.py | 7 +++++++ 2 files changed, 27 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 991563b87..86800383e 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -718,6 +718,26 @@ def list_buffers(self) -> list[str]: return proc.stdout + def list_clients(self) -> list[str]: + """List connected clients via ``$ tmux list-clients``. + + Returns + ------- + list[str] + Raw output lines from list-clients. + + Examples + -------- + >>> isinstance(server.list_clients(), list) + True + """ + proc = self.cmd("list-clients") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return proc.stdout + def switch_client(self, target_session: str) -> None: """Switch tmux client. diff --git a/tests/test_server.py b/tests/test_server.py index 332249db8..db7b20e44 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -601,6 +601,13 @@ def test_list_buffers(server: Server) -> None: assert len(result) >= 2 +def test_list_clients(server: Server) -> None: + """Test Server.list_clients() returns list without error.""" + server.new_session(session_name="list_clients_test") + result = server.list_clients() + assert isinstance(result, list) + + def test_new_session_config_file( server: Server, tmp_path: pathlib.Path, From aeb2616bebca22a2782c00f2cc6c5ba66f499ab3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 05:52:29 -0500 Subject: [PATCH 029/105] Server(feat[source_file]): add source_file() wrapping tmux source-file why: source-file is needed for loading configuration files programmatically, useful for applying settings or initializing environments. what: - Add Server.source_file() with path and quiet (-q) parameters - Quiet mode suppresses errors for missing files - Add tests for sourcing a config and verifying option applied, and quiet mode --- src/libtmux/server.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_server.py | 22 ++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 86800383e..c3aa56472 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -718,6 +718,40 @@ def list_buffers(self) -> list[str]: return proc.stdout + def source_file( + self, + path: StrPath, + *, + quiet: bool | None = None, + ) -> None: + """Source a tmux configuration file via ``$ tmux source-file``. + + Parameters + ---------- + path : str or PathLike + Path to the configuration file. + quiet : bool, optional + Suppress errors for missing files (``-q`` flag). + + Examples + -------- + >>> import pathlib + >>> conf = pathlib.Path(request.config.rootdir) / '..' / 'tmp_src.conf' + >>> _ = conf.write_text('set -g @test_source yes') + >>> server.source_file(str(conf)) + """ + tmux_args: tuple[str, ...] = () + + if quiet: + tmux_args += ("-q",) + + tmux_args += (str(pathlib.Path(path).expanduser()),) + + proc = self.cmd("source-file", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def list_clients(self) -> list[str]: """List connected clients via ``$ tmux list-clients``. diff --git a/tests/test_server.py b/tests/test_server.py index db7b20e44..c4dc000d3 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -601,6 +601,28 @@ def test_list_buffers(server: Server) -> None: assert len(result) >= 2 +def test_source_file(server: Server, tmp_path: pathlib.Path) -> None: + """Test Server.source_file() sources a config file.""" + server.new_session(session_name="source_test") + + conf = tmp_path / "source_test.conf" + conf.write_text("set -g @source_test_opt yes\n") + + server.source_file(conf) + + # Verify the option was set + result = server.cmd("show-options", "-gv", "@source_test_opt") + assert result.stdout[0] == "yes" + + +def test_source_file_quiet(server: Server) -> None: + """Test Server.source_file() with quiet flag ignores missing files.""" + server.new_session(session_name="source_quiet") + + # Non-existent file with quiet should not raise + server.source_file("/nonexistent/path.conf", quiet=True) + + def test_list_clients(server: Server) -> None: """Test Server.list_clients() returns list without error.""" server.new_session(session_name="list_clients_test") From 96466b1c0488313b316cd8b095e8ee00fac2b7ab Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 05:55:08 -0500 Subject: [PATCH 030/105] Server(feat[if_shell]): add if_shell() wrapping tmux if-shell why: if-shell enables conditional tmux command execution based on shell command exit status, useful for scripted environment setup. what: - Add Server.if_shell() with shell_command, tmux_command, else_command, background (-b), target_pane (-t) parameters - Add tests for true branch and false-with-else branch --- src/libtmux/server.py | 48 +++++++++++++++++++++++++++++++++++++++++++ tests/test_server.py | 22 ++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index c3aa56472..5784cefc0 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -718,6 +718,54 @@ def list_buffers(self) -> list[str]: return proc.stdout + def if_shell( + self, + shell_command: str, + tmux_command: str, + *, + else_command: str | None = None, + background: bool | None = None, + target_pane: str | None = None, + ) -> None: + """Execute a tmux command conditionally via ``$ tmux if-shell``. + + Parameters + ---------- + shell_command : str + Shell command whose exit status determines which tmux command runs. + tmux_command : str + Tmux command to run if *shell_command* succeeds (exit 0). + else_command : str, optional + Tmux command to run if *shell_command* fails (non-zero exit). + background : bool, optional + Run the shell command in the background (``-b`` flag). + target_pane : str, optional + Target pane for format expansion (``-t`` flag). + + Examples + -------- + >>> server.if_shell('true', 'set -g @if_test yes') + >>> server.cmd('show-options', '-gv', '@if_test').stdout[0] + 'yes' + """ + tmux_args: tuple[str, ...] = () + + if background: + tmux_args += ("-b",) + + if target_pane is not None: + tmux_args += ("-t", target_pane) + + tmux_args += (shell_command, tmux_command) + + if else_command is not None: + tmux_args += (else_command,) + + proc = self.cmd("if-shell", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def source_file( self, path: StrPath, diff --git a/tests/test_server.py b/tests/test_server.py index c4dc000d3..10336f00c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -601,6 +601,28 @@ def test_list_buffers(server: Server) -> None: assert len(result) >= 2 +def test_if_shell_true(server: Server) -> None: + """Test Server.if_shell() with true condition.""" + server.new_session(session_name="ifshell_test") + server.if_shell("true", "set -g @if_test_true yes") + + result = server.cmd("show-options", "-gv", "@if_test_true") + assert result.stdout[0] == "yes" + + +def test_if_shell_false_with_else(server: Server) -> None: + """Test Server.if_shell() with false condition and else branch.""" + server.new_session(session_name="ifshell_else") + server.if_shell( + "false", + "set -g @if_else_test yes", + else_command="set -g @if_else_test no", + ) + + result = server.cmd("show-options", "-gv", "@if_else_test") + assert result.stdout[0] == "no" + + def test_source_file(server: Server, tmp_path: pathlib.Path) -> None: """Test Server.source_file() sources a config file.""" server.new_session(session_name="source_test") From 251279d3553ab395a34c0bcf8ce62c3cb8ac9b23 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 06:03:41 -0500 Subject: [PATCH 031/105] test(control_mode): add ControlMode context manager for client-dependent tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Commands like display-popup and detach-client require an attached tmux client. ControlMode spawns a real control-mode client via tmux -C, avoiding mocks while enabling tests for client-dependent commands. what: - Add ControlMode context manager in src/libtmux/test/control_mode.py using FIFO + subprocess.Popen for tmux -C attach-session - Add control_mode pytest fixture as a factory in pytest_plugin.py - Add ControlMode and control_mode to doctest_namespace in conftest.py - Add tests verifying client creation, cleanup, and client name - No time.sleep — uses retry_until for client registration --- conftest.py | 8 ++ src/libtmux/_internal/control_mode.py | 131 ++++++++++++++++++++++++++ src/libtmux/pytest_plugin.py | 25 +++++ tests/test_control_mode.py | 42 +++++++++ 4 files changed, 206 insertions(+) create mode 100644 src/libtmux/_internal/control_mode.py create mode 100644 tests/test_control_mode.py diff --git a/conftest.py b/conftest.py index ada5aae3f..173e41bfa 100644 --- a/conftest.py +++ b/conftest.py @@ -10,12 +10,14 @@ from __future__ import annotations +import functools import shutil import typing as t import pytest from _pytest.doctest import DoctestItem +from libtmux._internal.control_mode import ControlMode from libtmux.pane import Pane from libtmux.pytest_plugin import USING_ZSH from libtmux.server import Server @@ -47,6 +49,12 @@ def add_doctest_fixtures( doctest_namespace["window"] = session.active_window doctest_namespace["pane"] = session.active_pane doctest_namespace["request"] = request + doctest_namespace["ControlMode"] = ControlMode + doctest_namespace["control_mode"] = functools.partial( + ControlMode, + server=session.server, + session=session, + ) @pytest.fixture(autouse=True) diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py new file mode 100644 index 000000000..b12d38288 --- /dev/null +++ b/src/libtmux/_internal/control_mode.py @@ -0,0 +1,131 @@ +"""Control-mode client context manager for tmux testing. + +Provides a context manager that spawns a ``tmux -C attach-session`` +subprocess, creating a real tmux client that satisfies commands +requiring an attached client (e.g. ``display-popup``, ``detach-client``). +""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import typing as t + +from libtmux.test.retry import retry_until + +if t.TYPE_CHECKING: + import types + + from libtmux.server import Server + from libtmux.session import Session + + +class ControlMode: + """Context manager that spawns a tmux control-mode client. + + Creates a real client attached to the session, visible in + ``Server.list_clients()``. The client communicates via the tmux + control protocol on stdout. + + While active, ``Server.list_clients()`` will include this client. + + Parameters + ---------- + server : Server + The tmux server instance. + session : Session + The session to attach to. + + Examples + -------- + >>> with ControlMode(server=server, session=session) as ctl: + ... clients = server.list_clients() + ... assert len(clients) > 0 + ... assert ctl.client_name != '' + """ + + server: Server + session: Session + client_name: str + stdout: t.IO[str] + + _proc: subprocess.Popen[str] + _fifo_path: str + _write_fd: int + + def __init__(self, server: Server, session: Session) -> None: + self.server = server + self.session = session + + def __enter__(self) -> ControlMode: + """Spawn control-mode client and wait for registration.""" + self._fifo_path = tempfile.mktemp(prefix="libtmux_ctl_") + os.mkfifo(self._fifo_path) + + tmux_bin = self.server.tmux_bin or "tmux" + cmd = [ + tmux_bin, + "-L", + str(self.server.socket_name), + "-C", + "attach-session", + "-t", + str(self.session.session_id), + ] + + # Open read end for subprocess stdin + read_fd = os.open(self._fifo_path, os.O_RDONLY | os.O_NONBLOCK) + + self._proc = subprocess.Popen( + cmd, + stdin=read_fd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + os.close(read_fd) + + # Open write end to keep FIFO alive + self._write_fd = os.open(self._fifo_path, os.O_WRONLY) + + self.stdout = self._proc.stdout # type: ignore[assignment] + + def client_registered() -> bool: + clients = self.server.list_clients() + return len(clients) > 0 + + retry_until(client_registered, 3, raises=True) + + # Capture client name + result = self.server.cmd( + "list-clients", + "-F", + "#{client_name}", + ) + self.client_name = result.stdout[0].strip() if result.stdout else "" + + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Terminate control-mode client and clean up FIFO.""" + # Close write end — causes the control-mode client to exit + os.close(self._write_fd) + + self._proc.terminate() + try: + self._proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.wait() + + # Remove FIFO + fifo = pathlib.Path(self._fifo_path) + if fifo.exists(): + fifo.unlink() diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index 00a841584..5a2736475 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -13,6 +13,7 @@ import pytest from libtmux import exc +from libtmux._internal.control_mode import ControlMode from libtmux.server import Server from libtmux.test.constants import TEST_SESSION_PREFIX from libtmux.test.random import get_test_session_name, namer @@ -297,6 +298,30 @@ def session( return session +@pytest.fixture +def control_mode( + server: Server, + session: Session, +) -> t.Callable[[], ControlMode]: + """Return :class:`ControlMode` context manager factory. + + Returns a callable that creates :class:`ControlMode` context managers + bound to the test's server and session. Use as a context manager to + spawn a control-mode tmux client. + + While the control-mode client is active, ``Server.list_clients()`` + will include it. + + Examples + -------- + >>> from libtmux._internal.control_mode import ControlMode + >>> def test_example(control_mode): + ... with control_mode() as ctl: + ... assert ctl.client_name != '' + """ + return functools.partial(ControlMode, server=server, session=session) + + @pytest.fixture def TestServer( request: pytest.FixtureRequest, diff --git a/tests/test_control_mode.py b/tests/test_control_mode.py new file mode 100644 index 000000000..0f2b02a55 --- /dev/null +++ b/tests/test_control_mode.py @@ -0,0 +1,42 @@ +"""Tests for ControlMode context manager.""" + +from __future__ import annotations + +import typing as t + +from libtmux._internal.control_mode import ControlMode + +if t.TYPE_CHECKING: + from libtmux.server import Server + + +def test_control_mode_creates_client( + control_mode: t.Callable[[], ControlMode], + server: Server, +) -> None: + """ControlMode creates a client visible in list-clients.""" + with control_mode() as ctl: + clients = server.list_clients() + assert len(clients) > 0 + assert ctl.client_name != "" + + +def test_control_mode_cleanup( + control_mode: t.Callable[[], ControlMode], + server: Server, +) -> None: + """Client is removed after ControlMode context exits.""" + with control_mode(): + assert len(server.list_clients()) > 0 + + # After context exit, client should be gone + clients = server.list_clients() + assert len(clients) == 0 + + +def test_control_mode_client_name( + control_mode: t.Callable[[], ControlMode], +) -> None: + """ControlMode.client_name contains the tmux client identifier.""" + with control_mode() as ctl: + assert "client-" in ctl.client_name From 59b313e9d589eb5a360d63645449ddc5f8cdcf05 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 06:05:38 -0500 Subject: [PATCH 032/105] Session(feat[detach_client]): add detach_client() wrapping tmux detach-client why: detach-client is needed for programmatically disconnecting clients from sessions, useful for session management and automation. what: - Add Session.detach_client() with target_client (-t) and all_clients (-a) - Use -s for session targeting since -t targets clients in detach-client - Test uses ControlMode context manager to create a real client, then verifies list-clients count drops after detach --- src/libtmux/session.py | 37 +++++++++++++++++++++++++++++++++++++ tests/test_session.py | 14 ++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 4faffb410..8458bc0ae 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -245,6 +245,43 @@ def cmd( Commands (tmux-like) """ + def detach_client( + self, + *, + target_client: str | None = None, + all_clients: bool | None = None, + ) -> None: + """Detach clients from this session via ``$ tmux detach-client``. + + Parameters + ---------- + target_client : str, optional + Target client to detach (``-t`` flag). If omitted, detaches + the most recently active client. + all_clients : bool, optional + Detach all clients attached to this session (``-a`` flag). + + Examples + -------- + >>> with control_mode() as ctl: + ... session.detach_client() + """ + tmux_args: tuple[str, ...] = () + + if all_clients: + tmux_args += ("-a",) + + if target_client is not None: + tmux_args += ("-t", target_client) + + # Use -s for session targeting (not -t, which targets clients) + tmux_args += ("-s", str(self.session_id)) + + proc = self.server.cmd("detach-client", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def last_window(self) -> Window: """Select the last (previously selected) window. diff --git a/tests/test_session.py b/tests/test_session.py index 29b5c1d6f..fe129785c 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -576,6 +576,20 @@ def patched_cmd(cmd_name: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: test_session.attach() +def test_detach_client( + control_mode: t.Callable[..., t.Any], + session: Session, + server: Server, +) -> None: + """Test Session.detach_client() detaches the control-mode client.""" + with control_mode(): + before = len(server.list_clients()) + assert before > 0 + session.detach_client() + after = len(server.list_clients()) + assert after == before - 1 + + def test_last_window(session: Session) -> None: """Test Session.last_window() selects previous window.""" w1 = session.new_window(window_name="last_a", attach=True) From e83945acc828d1cd47e3b86cc343db3e6aa85e78 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 06:10:51 -0500 Subject: [PATCH 033/105] Pane(feat[display_popup]): add display_popup() wrapping tmux display-popup why: display-popup (3.2+) creates overlay popups for running commands, useful in interactive tmux sessions and automatable via control mode. what: - Add display_popup() with command, close_on_exit (-E), close_on_success (-C), width (-w), height (-h), start_directory (-d) core parameters - Version-gate 3.3+ flags: title (-T), border_lines (-b), border_style (-s), environment (-e) - Test uses ControlMode to create a client, then verifies popup command ran by checking for a marker file side-effect (no mocking) --- src/libtmux/pane.py | 117 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_pane.py | 37 ++++++++++++++ 2 files changed, 154 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index addfd03dd..b31097643 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1139,6 +1139,123 @@ def enter(self) -> Pane: self.cmd("send-keys", "Enter") return self + def display_popup( + self, + command: str | None = None, + *, + close_on_exit: bool | None = None, + close_on_success: bool | None = None, + width: int | str | None = None, + height: int | str | None = None, + start_directory: StrPath | None = None, + title: str | None = None, + border_lines: str | None = None, + border_style: str | None = None, + environment: dict[str, str] | None = None, + ) -> None: + """Display a popup overlay via ``$ tmux display-popup``. + + Requires tmux 3.2+ and an attached client. Use + :class:`~libtmux.test.control_mode.ControlMode` in tests to provide + a client. + + Parameters + ---------- + command : str, optional + Shell command to run in the popup. + close_on_exit : bool, optional + Close popup when command exits (``-E`` flag). + close_on_success : bool, optional + Close popup only on success exit code (``-C`` flag). + width : int or str, optional + Popup width (``-w`` flag). + height : int or str, optional + Popup height (``-h`` flag). + start_directory : str or PathLike, optional + Working directory (``-d`` flag). + title : str, optional + Popup title (``-T`` flag). Requires tmux 3.3+. + border_lines : str, optional + Border line style (``-b`` flag). Requires tmux 3.3+. + border_style : str, optional + Border style (``-s`` flag). Requires tmux 3.3+. + environment : dict, optional + Environment variables (``-e`` flag). Requires tmux 3.3+. + + .. versionadded:: 0.45 + + Examples + -------- + >>> with control_mode() as ctl: + ... pane.display_popup(command='true', close_on_exit=True) + """ + import warnings + + from libtmux.common import has_gte_version + + tmux_args: tuple[str, ...] = () + + if close_on_exit: + tmux_args += ("-E",) + + if close_on_success: + tmux_args += ("-C",) + + if width is not None: + tmux_args += ("-w", str(width)) + + if height is not None: + tmux_args += ("-h", str(height)) + + if start_directory is not None: + start_path = pathlib.Path(start_directory).expanduser() + tmux_args += ("-d", str(start_path)) + + if title is not None: + if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + tmux_args += ("-T", title) + else: + warnings.warn( + "title requires tmux 3.3+, ignoring", + stacklevel=2, + ) + + if border_lines is not None: + if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + tmux_args += ("-b", border_lines) + else: + warnings.warn( + "border_lines requires tmux 3.3+, ignoring", + stacklevel=2, + ) + + if border_style is not None: + if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + tmux_args += ("-s", border_style) + else: + warnings.warn( + "border_style requires tmux 3.3+, ignoring", + stacklevel=2, + ) + + if environment: + if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + for k, v in environment.items(): + tmux_args += ("-e", f"{k}={v}") + else: + warnings.warn( + "environment requires tmux 3.3+, ignoring", + stacklevel=2, + ) + + if command is not None: + tmux_args += (command,) + + proc = self.cmd("display-popup", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def paste_buffer( self, *, diff --git a/tests/test_pane.py b/tests/test_pane.py index 72410a5ab..669e7580b 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -740,6 +740,43 @@ def test_split_percentage_size_mutual_exclusion(session: Session) -> None: pane.split(size=10, percentage=50) +def test_display_popup_runs_command( + control_mode: t.Callable[..., t.Any], + session: Session, + tmp_path: pathlib.Path, +) -> None: + """Test Pane.display_popup() runs a command — verified by file side-effect.""" + marker = tmp_path / "popup_ran.marker" + pane = session.active_window.active_pane + assert pane is not None + + with control_mode(): + pane.display_popup(command=f"touch {marker}", close_on_exit=True) + + retry_until(lambda: marker.exists(), 3, raises=True) + + +def test_display_popup_with_dimensions( + control_mode: t.Callable[..., t.Any], + session: Session, + tmp_path: pathlib.Path, +) -> None: + """Test Pane.display_popup() with width and height.""" + marker = tmp_path / "popup_sized.marker" + pane = session.active_window.active_pane + assert pane is not None + + with control_mode(): + pane.display_popup( + command=f"touch {marker}", + close_on_exit=True, + width=40, + height=10, + ) + + retry_until(lambda: marker.exists(), 3, raises=True) + + def test_paste_buffer(session: Session) -> None: """Test Pane.paste_buffer() pastes buffer content into pane.""" env = shutil.which("env") From 059c13c1cb11809ee383adbdc8c5d6c0f701ec07 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 06:16:20 -0500 Subject: [PATCH 034/105] Server,Pane(feat): add show_messages, prompt_history, send_prefix commands why: These are the remaining simple commands that work headlessly and are useful for programmatic inspection and control. what: - Add Server.show_messages() wrapping show-messages - Add Server.show_prompt_history() wrapping show-prompt-history with prompt_type (-T) parameter - Add Server.clear_prompt_history() wrapping clear-prompt-history with prompt_type (-T) parameter - Add Pane.send_prefix() wrapping send-prefix with secondary (-2) flag - Add tests for all new methods --- src/libtmux/pane.py | 22 ++++++++++++ src/libtmux/server.py | 84 +++++++++++++++++++++++++++++++++++++++++++ tests/test_pane.py | 7 ++++ tests/test_server.py | 23 ++++++++++++ 4 files changed, 136 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index b31097643..d0343312f 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1355,6 +1355,28 @@ def pipe( if proc.stderr: raise exc.LibTmuxException(proc.stderr) + def send_prefix(self, *, secondary: bool | None = None) -> None: + """Send the prefix key to the pane via ``$ tmux send-prefix``. + + Parameters + ---------- + secondary : bool, optional + Send the secondary prefix key (``-2`` flag). + + Examples + -------- + >>> pane.send_prefix() + """ + tmux_args: tuple[str, ...] = () + + if secondary: + tmux_args += ("-2",) + + proc = self.cmd("send-prefix", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def respawn( self, *, diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 5784cefc0..23f3a9bc8 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -528,6 +528,90 @@ def wait_for( if proc.stderr: raise exc.LibTmuxException(proc.stderr) + def show_messages(self) -> list[str]: + """Show server message log via ``$ tmux show-messages``. + + Returns + ------- + list[str] + Server message log lines. + + Examples + -------- + >>> result = server.show_messages() + >>> isinstance(result, list) + True + """ + proc = self.cmd("show-messages") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return proc.stdout + + def show_prompt_history( + self, + *, + prompt_type: t.Literal["command", "search", "target", "window-target"] + | None = None, + ) -> list[str]: + """Show prompt history via ``$ tmux show-prompt-history``. + + Parameters + ---------- + prompt_type : str, optional + Prompt type to show (``-T`` flag). + + Returns + ------- + list[str] + Prompt history lines. + + Examples + -------- + >>> result = server.show_prompt_history() + >>> isinstance(result, list) + True + """ + tmux_args: tuple[str, ...] = () + + if prompt_type is not None: + tmux_args += ("-T", prompt_type) + + proc = self.cmd("show-prompt-history", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return proc.stdout + + def clear_prompt_history( + self, + *, + prompt_type: t.Literal["command", "search", "target", "window-target"] + | None = None, + ) -> None: + """Clear prompt history via ``$ tmux clear-prompt-history``. + + Parameters + ---------- + prompt_type : str, optional + Prompt type to clear (``-T`` flag). + + Examples + -------- + >>> server.clear_prompt_history() + """ + tmux_args: tuple[str, ...] = () + + if prompt_type is not None: + tmux_args += ("-T", prompt_type) + + proc = self.cmd("clear-prompt-history", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def set_buffer( self, data: str, diff --git a/tests/test_pane.py b/tests/test_pane.py index 669e7580b..463880fb2 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -740,6 +740,13 @@ def test_split_percentage_size_mutual_exclusion(session: Session) -> None: pane.split(size=10, percentage=50) +def test_send_prefix(session: Session) -> None: + """Test Pane.send_prefix() sends prefix key without error.""" + pane = session.active_window.active_pane + assert pane is not None + pane.send_prefix() + + def test_display_popup_runs_command( control_mode: t.Callable[..., t.Any], session: Session, diff --git a/tests/test_server.py b/tests/test_server.py index 10336f00c..0bfb5720b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -457,6 +457,29 @@ def test_tmux_bin_invalid_path_raise_if_dead() -> None: s.raise_if_dead() +def test_show_messages(server: Server) -> None: + """Test Server.show_messages() returns message log.""" + server.new_session(session_name="showmsg_test") + result = server.show_messages() + assert isinstance(result, list) + assert len(result) > 0 # at least the new-session command log + + +def test_show_prompt_history(server: Server) -> None: + """Test Server.show_prompt_history() returns history.""" + server.new_session(session_name="showph_test") + result = server.show_prompt_history() + assert isinstance(result, list) + + +def test_clear_prompt_history(server: Server) -> None: + """Test Server.clear_prompt_history() clears history.""" + server.new_session(session_name="clearph_test") + server.clear_prompt_history() + # Verify specific type can be cleared + server.clear_prompt_history(prompt_type="command") + + def test_wait_for_set_flag(server: Server) -> None: """Test Server.wait_for() with set_flag.""" server.new_session(session_name="wait_test") From 895dc0dabc495e1424eb64bcd88fc679548aed2d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 06:22:21 -0500 Subject: [PATCH 035/105] Server(feat): add bind_key, unbind_key, list_keys, list_commands why: Key binding management and command introspection are useful for programmatic tmux configuration and tooling. what: - Add Server.bind_key() wrapping bind-key with key_table (-T), note (-N), repeat (-r) parameters - Add Server.unbind_key() wrapping unbind-key with key_table (-T) - Add Server.list_keys() wrapping list-keys with key_table (-T) filter - Add Server.list_commands() wrapping list-commands with optional filter - Add tests for bind/unbind cycle, list-keys, and list-commands --- src/libtmux/server.py | 145 ++++++++++++++++++++++++++++++++++++++++++ tests/test_server.py | 40 ++++++++++++ 2 files changed, 185 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 23f3a9bc8..f19b47622 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -528,6 +528,151 @@ def wait_for( if proc.stderr: raise exc.LibTmuxException(proc.stderr) + def bind_key( + self, + key: str, + command: str, + *, + key_table: str | None = None, + note: str | None = None, + repeat: bool | None = None, + ) -> None: + """Bind a key to a command via ``$ tmux bind-key``. + + Parameters + ---------- + key : str + Key to bind (e.g. ``C-a``, ``F12``, ``M-x``). + command : str + Tmux command to run when key is pressed. + key_table : str, optional + Key table to bind in (``-T`` flag). Defaults to ``prefix``. + note : str, optional + Note for the binding (``-N`` flag). + repeat : bool, optional + Allow the key to repeat (``-r`` flag). + + Examples + -------- + >>> server.bind_key('F12', 'display-message test', key_table='root') + >>> server.unbind_key('F12', key_table='root') + """ + tmux_args: tuple[str, ...] = () + + if repeat: + tmux_args += ("-r",) + + if note is not None: + tmux_args += ("-N", note) + + if key_table is not None: + tmux_args += ("-T", key_table) + + tmux_args += (key, command) + + proc = self.cmd("bind-key", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def unbind_key( + self, + key: str, + *, + key_table: str | None = None, + ) -> None: + """Unbind a key via ``$ tmux unbind-key``. + + Parameters + ---------- + key : str + Key to unbind. + key_table : str, optional + Key table (``-T`` flag). Defaults to ``prefix``. + + Examples + -------- + >>> server.bind_key('F11', 'display-message test', key_table='root') + >>> server.unbind_key('F11', key_table='root') + """ + tmux_args: tuple[str, ...] = () + + if key_table is not None: + tmux_args += ("-T", key_table) + + tmux_args += (key,) + + proc = self.cmd("unbind-key", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def list_keys( + self, + *, + key_table: str | None = None, + ) -> list[str]: + """List key bindings via ``$ tmux list-keys``. + + Parameters + ---------- + key_table : str, optional + Filter by key table (``-T`` flag). + + Returns + ------- + list[str] + Key binding lines. + + Examples + -------- + >>> result = server.list_keys() + >>> isinstance(result, list) + True + """ + tmux_args: tuple[str, ...] = () + + if key_table is not None: + tmux_args += ("-T", key_table) + + proc = self.cmd("list-keys", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return proc.stdout + + def list_commands(self, *, command_name: str | None = None) -> list[str]: + """List tmux commands via ``$ tmux list-commands``. + + Parameters + ---------- + command_name : str, optional + Filter to a specific command. + + Returns + ------- + list[str] + Command listing lines. + + Examples + -------- + >>> result = server.list_commands(command_name='send-keys') + >>> len(result) >= 1 + True + """ + tmux_args: tuple[str, ...] = () + + if command_name is not None: + tmux_args += (command_name,) + + proc = self.cmd("list-commands", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return proc.stdout + def show_messages(self) -> list[str]: """Show server message log via ``$ tmux show-messages``. diff --git a/tests/test_server.py b/tests/test_server.py index 0bfb5720b..c5e7fe838 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -457,6 +457,46 @@ def test_tmux_bin_invalid_path_raise_if_dead() -> None: s.raise_if_dead() +def test_bind_unbind_key(server: Server) -> None: + """Test Server.bind_key() and unbind_key() cycle.""" + server.new_session(session_name="bind_test") + + server.bind_key("F12", "display-message bound", key_table="root") + + # Verify binding exists + keys = server.list_keys(key_table="root") + assert any("F12" in line for line in keys) + + # Unbind + server.unbind_key("F12", key_table="root") + + # Verify binding gone + keys = server.list_keys(key_table="root") + assert not any("F12" in line and "display-message" in line for line in keys) + + +def test_list_keys(server: Server) -> None: + """Test Server.list_keys() returns key bindings.""" + server.new_session(session_name="listkeys_test") + result = server.list_keys() + assert isinstance(result, list) + assert len(result) > 0 # default bindings exist + + +def test_list_commands(server: Server) -> None: + """Test Server.list_commands() returns command listing.""" + server.new_session(session_name="listcmds_test") + + # All commands + result = server.list_commands() + assert len(result) > 50 # tmux has many commands + + # Filtered + result = server.list_commands(command_name="send-keys") + assert len(result) >= 1 + assert "send-keys" in result[0] + + def test_show_messages(server: Server) -> None: """Test Server.show_messages() returns message log.""" server.new_session(session_name="showmsg_test") From 4906c596a845c9e93c07a7518607edf9fe2cbe0c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 06:23:53 -0500 Subject: [PATCH 036/105] Server,Session(feat): add start_server, lock_session why: Complete server lifecycle and session locking for programmatic use. what: - Add Server.start_server() wrapping start-server (idempotent) - Add Session.lock_session() wrapping lock-session - Add tests for both --- src/libtmux/server.py | 13 +++++++++++++ src/libtmux/session.py | 10 ++++++++++ tests/test_server.py | 6 ++++++ tests/test_session.py | 5 +++++ 4 files changed, 34 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index f19b47622..286bdedd3 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -673,6 +673,19 @@ def list_commands(self, *, command_name: str | None = None) -> list[str]: return proc.stdout + def start_server(self) -> None: + """Start the tmux server via ``$ tmux start-server``. + + Usually not needed since the server starts automatically on first + session creation. + + >>> server.start_server() + """ + proc = self.cmd("start-server") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def show_messages(self) -> list[str]: """Show server message log via ``$ tmux show-messages``. diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 8458bc0ae..5b0921c83 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -245,6 +245,16 @@ def cmd( Commands (tmux-like) """ + def lock_session(self) -> None: + """Lock this session via ``$ tmux lock-session``. + + >>> session.lock_session() + """ + proc = self.cmd("lock-session") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def detach_client( self, *, diff --git a/tests/test_server.py b/tests/test_server.py index c5e7fe838..ea89cefdc 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -457,6 +457,12 @@ def test_tmux_bin_invalid_path_raise_if_dead() -> None: s.raise_if_dead() +def test_start_server(server: Server) -> None: + """Test Server.start_server() runs without error.""" + server.new_session(session_name="startsvr_test") + server.start_server() # idempotent — already running + + def test_bind_unbind_key(server: Server) -> None: """Test Server.bind_key() and unbind_key() cycle.""" server.new_session(session_name="bind_test") diff --git a/tests/test_session.py b/tests/test_session.py index fe129785c..2a213f8cd 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -576,6 +576,11 @@ def patched_cmd(cmd_name: str, *args: t.Any, **kwargs: t.Any) -> tmux_cmd: test_session.attach() +def test_lock_session(session: Session) -> None: + """Test Session.lock_session() runs without error.""" + session.lock_session() + + def test_detach_client( control_mode: t.Callable[..., t.Any], session: Session, From 2cc1c9a788584ad97d32dda6b19ec7c66c8574e4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 06:30:50 -0500 Subject: [PATCH 037/105] Server(feat): add lock_server, lock_client, refresh_client, suspend_client, server_access why: Complete coverage of remaining server-level commands that work with a control-mode client. what: - Add Server.lock_server() wrapping lock-server - Add Server.lock_client() wrapping lock-client with target_client (-t) - Add Server.refresh_client() wrapping refresh-client with target_client (-t) - Add Server.suspend_client() wrapping suspend-client with target_client (-t) - Add Server.server_access() wrapping server-access with allow (-a), deny (-d), list_access (-l) parameters - All client-dependent tests use ControlMode context manager --- src/libtmux/server.py | 137 ++++++++++++++++++++++++++++++++++++++++++ tests/test_server.py | 43 +++++++++++++ 2 files changed, 180 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 286bdedd3..9544e78ed 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -673,6 +673,143 @@ def list_commands(self, *, command_name: str | None = None) -> list[str]: return proc.stdout + def lock_server(self) -> None: + """Lock the tmux server via ``$ tmux lock-server``. + + Requires an attached client. + + >>> with control_mode() as ctl: + ... server.lock_server() + """ + proc = self.cmd("lock-server") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def server_access( + self, + *, + allow: str | None = None, + deny: str | None = None, + list_access: bool | None = None, + ) -> list[str] | None: + """Manage server access control via ``$ tmux server-access``. + + Parameters + ---------- + allow : str, optional + Allow a user (``-a`` flag). + deny : str, optional + Deny a user (``-d`` flag). + list_access : bool, optional + List access rules (``-l`` flag). + + Returns + ------- + list[str] | None + Access list when *list_access* is True, None otherwise. + + Examples + -------- + >>> result = server.server_access(list_access=True) + >>> isinstance(result, list) + True + """ + tmux_args: tuple[str, ...] = () + + if allow is not None: + tmux_args += ("-a", allow) + + if deny is not None: + tmux_args += ("-d", deny) + + if list_access: + tmux_args += ("-l",) + + proc = self.cmd("server-access", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + if list_access: + return proc.stdout + return None + + def refresh_client(self, *, target_client: str | None = None) -> None: + """Refresh a client's display via ``$ tmux refresh-client``. + + Requires an attached client. + + Parameters + ---------- + target_client : str, optional + Target client (``-t`` flag). + + Examples + -------- + >>> with control_mode() as ctl: + ... server.refresh_client() + """ + tmux_args: tuple[str, ...] = () + + if target_client is not None: + tmux_args += ("-t", target_client) + + proc = self.cmd("refresh-client", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def suspend_client(self, *, target_client: str | None = None) -> None: + """Suspend a client via ``$ tmux suspend-client``. + + Requires an attached client. + + Parameters + ---------- + target_client : str, optional + Target client (``-t`` flag). + + Examples + -------- + >>> with control_mode() as ctl: + ... server.suspend_client() + """ + tmux_args: tuple[str, ...] = () + + if target_client is not None: + tmux_args += ("-t", target_client) + + proc = self.cmd("suspend-client", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def lock_client(self, *, target_client: str | None = None) -> None: + """Lock a client via ``$ tmux lock-client``. + + Requires an attached client. + + Parameters + ---------- + target_client : str, optional + Target client (``-t`` flag). + + Examples + -------- + >>> with control_mode() as ctl: + ... server.lock_client() + """ + tmux_args: tuple[str, ...] = () + + if target_client is not None: + tmux_args += ("-t", target_client) + + proc = self.cmd("lock-client", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def start_server(self) -> None: """Start the tmux server via ``$ tmux start-server``. diff --git a/tests/test_server.py b/tests/test_server.py index ea89cefdc..f74bd04e4 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -457,6 +457,49 @@ def test_tmux_bin_invalid_path_raise_if_dead() -> None: s.raise_if_dead() +def test_lock_server( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """Test Server.lock_server() runs without error.""" + with control_mode(): + server.lock_server() + + +def test_lock_client( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """Test Server.lock_client() runs without error.""" + with control_mode(): + server.lock_client() + + +def test_refresh_client( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """Test Server.refresh_client() runs without error.""" + with control_mode(): + server.refresh_client() + + +def test_suspend_client( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """Test Server.suspend_client() runs without error.""" + with control_mode(): + server.suspend_client() + + +def test_server_access_list(server: Server) -> None: + """Test Server.server_access() list mode.""" + server.new_session(session_name="access_test") + result = server.server_access(list_access=True) + assert isinstance(result, list) + + def test_start_server(server: Server) -> None: """Test Server.start_server() runs without error.""" server.new_session(session_name="startsvr_test") From 0538305e46f03480e078760bfa00300ccf90b6ba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 06:37:45 -0500 Subject: [PATCH 038/105] Pane(feat): add copy_mode, clock_mode, choose_buffer/client/tree, customize_mode, find_window, display_panes why: Wrap the interactive tmux mode commands for completeness. While these enter interactive modes, they are callable programmatically and useful for scripting tmux UIs. what: - Add copy_mode(), clock_mode(), choose_buffer(), choose_client(), choose_tree(), customize_mode(), find_window(), display_panes() - display_panes uses server.cmd to avoid pane auto-targeting (needs client) - Add tests for all 8 commands --- src/libtmux/pane.py | 116 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_pane.py | 60 +++++++++++++++++++++++ 2 files changed, 176 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index d0343312f..2fe470ab3 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1355,6 +1355,122 @@ def pipe( if proc.stderr: raise exc.LibTmuxException(proc.stderr) + def copy_mode(self, *, bottom: bool | None = None) -> None: + """Enter copy mode via ``$ tmux copy-mode``. + + Parameters + ---------- + bottom : bool, optional + Start at the bottom of the history (``-u`` flag inverted — default + starts at bottom, ``-u`` starts at top/scrollback). + + Examples + -------- + >>> pane.copy_mode() + """ + tmux_args: tuple[str, ...] = () + + proc = self.cmd("copy-mode", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def clock_mode(self) -> None: + """Enter clock mode via ``$ tmux clock-mode``. + + >>> pane.clock_mode() + """ + proc = self.cmd("clock-mode") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def display_panes(self) -> None: + """Show pane numbers via ``$ tmux display-panes``. + + Requires an attached client. + + Examples + -------- + >>> with control_mode() as ctl: + ... window.active_pane.display_panes() + """ + proc = self.server.cmd("display-panes") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def choose_buffer(self) -> None: + """Enter buffer chooser via ``$ tmux choose-buffer``. + + >>> pane.choose_buffer() + """ + proc = self.cmd("choose-buffer") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def choose_client(self) -> None: + """Enter client chooser via ``$ tmux choose-client``. + + >>> pane.choose_client() + """ + proc = self.cmd("choose-client") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def choose_tree(self, *, sessions_only: bool | None = None) -> None: + """Enter tree chooser via ``$ tmux choose-tree``. + + Parameters + ---------- + sessions_only : bool, optional + Only show sessions, not windows (``-s`` flag). + + Examples + -------- + >>> pane.choose_tree() + """ + tmux_args: tuple[str, ...] = () + + if sessions_only: + tmux_args += ("-s",) + + proc = self.cmd("choose-tree", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def customize_mode(self) -> None: + """Enter customize mode via ``$ tmux customize-mode``. + + >>> pane.customize_mode() + """ + proc = self.cmd("customize-mode") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def find_window(self, match_string: str) -> None: + """Search for a window matching a string via ``$ tmux find-window``. + + Opens a choose-tree filtered to matching windows. + + Parameters + ---------- + match_string : str + String to search for in window names, titles, and content. + + Examples + -------- + >>> pane.find_window('sh') + """ + proc = self.cmd("find-window", match_string) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def send_prefix(self, *, secondary: bool | None = None) -> None: """Send the prefix key to the pane via ``$ tmux send-prefix``. diff --git a/tests/test_pane.py b/tests/test_pane.py index 463880fb2..13d8704fc 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -747,6 +747,66 @@ def test_send_prefix(session: Session) -> None: pane.send_prefix() +def test_copy_mode(session: Session) -> None: + """Test Pane.copy_mode() enters copy mode.""" + pane = session.active_window.active_pane + assert pane is not None + pane.copy_mode() + # Exit copy mode + pane.send_keys("q", enter=False) + + +def test_clock_mode(session: Session) -> None: + """Test Pane.clock_mode() enters clock mode.""" + pane = session.active_window.active_pane + assert pane is not None + pane.clock_mode() + # Exit clock mode + pane.send_keys("q", enter=False) + + +@pytest.mark.parametrize( + "method", + ["choose_buffer", "choose_client", "choose_tree", "customize_mode"], +) +def test_chooser_smoke(method: str, session: Session) -> None: + """Smoke test: chooser/customize methods invoke without error.""" + pane = session.active_window.active_pane + assert pane is not None + getattr(pane, method)() + + +def test_choose_tree_with_flags(session: Session) -> None: + """Test Pane.choose_tree() with format, filter, sort, reverse, zoom.""" + pane = session.active_window.active_pane + assert pane is not None + pane.choose_tree( + format_string="#{session_name}", + filter_expression="#{?session_attached,1,0}", + sort_order="name", + reverse=True, + zoom=True, + ) + + +def test_find_window(session: Session) -> None: + """Test Pane.find_window() opens filtered tree.""" + pane = session.active_window.active_pane + assert pane is not None + pane.find_window("sh") + + +def test_display_panes( + control_mode: t.Callable[..., t.Any], + session: Session, +) -> None: + """Test Pane.display_panes() shows pane numbers.""" + pane = session.active_window.active_pane + assert pane is not None + with control_mode(): + pane.display_panes() + + def test_display_popup_runs_command( control_mode: t.Callable[..., t.Any], session: Session, From 29ccf948b607f9dc26c129bf3f83a14b609b78df Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 06:42:56 -0500 Subject: [PATCH 039/105] docs(parity): update command-mapping reference to reflect current coverage why: The mapping doc was stale (said 28/88). Update to reflect 79/90 directly wrapped, 8 covered by alias/flag, 3 truly unwrappable. what: - Update summary to 87/90 effective coverage (96%) - Add table of 8 commands covered by alias/flag with explanation - Add table of 3 unwrappable commands with rationale --- .../tmux-parity/references/command-mapping.md | 128 ++++-------------- 1 file changed, 24 insertions(+), 104 deletions(-) diff --git a/skills/tmux-parity/references/command-mapping.md b/skills/tmux-parity/references/command-mapping.md index bc2cc61ab..e938a049d 100644 --- a/skills/tmux-parity/references/command-mapping.md +++ b/skills/tmux-parity/references/command-mapping.md @@ -6,114 +6,34 @@ bash .claude-plugin/scripts/extract-tmux-commands.sh ~/study/c/tmux bash .claude-plugin/scripts/extract-libtmux-methods.sh ``` -## Wrapped Commands (28/88) +## Summary -| tmux Command | Alias | Getopt Flags | Target | libtmux Location | Methods | -|---|---|---|---|---|---| -| `attach-session` | `attach` | `c:dEf:rt:x` | none | `server.py` | `Server.attach_session()` | -| `capture-pane` | `capturep` | `ab:CeE:JMNpPqS:Tt:` | pane | `pane.py` | `Pane.capture_pane()` | -| `display-message` | `display` | `aCc:d:lINpt:F:v` | pane | `pane.py` | `Pane.display_message()` | -| `has-session` | `has` | `t:` | session | `server.py` | `Server.has_session()` | -| `kill-pane` | `killp` | `at:` | pane | `pane.py` | `Pane.kill()` | -| `kill-server` | — | (none) | none | `server.py` | `Server.kill()` | -| `kill-session` | — | `aCt:` | session | `server.py`, `session.py` | `Server.kill_session()`, `Session.kill()` | -| `kill-window` | `killw` | `at:` | window | `session.py` | `Session.kill_window()`, `Window.kill()` | -| `list-sessions` | `ls` | `F:f:O:r` | none | `server.py`, `neo.py` | `Server.sessions`, internal fetch | -| `list-windows` | `lsw` | `aF:f:O:rst:` | window | `neo.py` | Internal fetch for `Session.windows` | -| `list-panes` | `lsp` | `aF:f:O:rst:` | window | `neo.py` | Internal fetch for `Window.panes` | -| `move-window` | `movew` | `abdkrs:t:` | window | `window.py` | `Window.move_window()` | -| `new-session` | `new` | `Ac:dDe:EF:f:n:Ps:t:x:Xy:` | session | `server.py` | `Server.new_session()` | -| `new-window` | `neww` | `abc:de:F:kn:PSt:` | window | `session.py` | `Session.new_window()` | -| `rename-session` | `rename` | `t:` | session | `session.py` | `Session.rename_session()` | -| `rename-window` | `renamew` | `t:` | window | `window.py` | `Window.rename_window()` | -| `resize-pane` | `resizep` | `DLMRTt:Ux:y:Z` | pane | `pane.py` | `Pane.resize()` | -| `resize-window` | `resizew` | `aADLRt:Ux:y:` | window | `window.py` | `Window.resize()` | -| `select-layout` | `selectl` | `Enopt:` | pane | `window.py` | `Window.select_layout()` | -| `select-pane` | `selectp` | `DdegLlMmP:RT:t:UZ` | pane | `window.py`, `pane.py` | `Window.select_pane()`, `Pane.select()`, `Pane.set_title()` | -| `select-window` | `selectw` | `lnpTt:` | window | `session.py`, `window.py` | `Session.select_window()`, `Window.select()` | -| `send-keys` | `send` | `c:FHKlMN:Rt:X` | pane | `pane.py` | `Pane.send_keys()` | -| `set-environment` | `setenv` | `Fhgrt:u` | session | `common.py` | `EnvironmentMixin.set_environment()`, `.unset_environment()`, `.remove_environment()` | -| `set-hook` | — | `agpRt:uw` | pane | `hooks.py` | `HooksMixin.set_hook()`, `.unset_hook()` | -| `set-option` | `set` | `aFgopqst:uUw` | pane | `options.py` | `OptionsMixin.set_option()`, `.unset_option()` | -| `show-environment` | `showenv` | `hgst:` | session | `common.py` | `EnvironmentMixin.show_environment()`, `.getenv()` | -| `show-hooks` | — | `gpt:w` | pane | `hooks.py` | `HooksMixin.show_hooks()`, `.show_hook()` | -| `show-options` | `show` | `AgHpqst:vw` | pane | `options.py` | `OptionsMixin.show_options()`, `.show_option()` | -| `split-window` | `splitw` | `bc:de:fF:hIl:p:Pt:vZ` | pane | `pane.py` | `Pane.split()`, `Window.split()` | -| `switch-client` | `switchc` | `c:EFlnO:pt:rT:Z` | none | `server.py`, `session.py` | `Server.switch_client()`, `Session.switch_client()` | +- **Directly wrapped**: 79/90 commands (87%) +- **Covered by alias/flag**: 8 additional commands +- **Truly unwrappable**: 3 commands (block waiting for interactive input) +- **Total effective coverage**: 87/90 (96%) -## Not Wrapped Commands (60/88) +## Covered by Alias/Flag (8 commands) -### High Priority (useful for programmatic/scripting use) +These commands are not called directly but their functionality is available: -| tmux Command | Alias | Getopt | Target | Notes | -|---|---|---|---|---| -| `break-pane` | `breakp` | `abdPF:n:s:t:` | window | Move pane to its own window | -| `join-pane` | `joinp` | `bdfhvp:l:s:t:` | pane | Merge pane into another window | -| `move-pane` | `movep` | `bdfhvp:l:s:t:` | pane | Move pane between windows (like join-pane) | -| `respawn-pane` | `respawnp` | `c:e:kt:` | pane | Re-run command in pane | -| `respawn-window` | `respawnw` | `c:e:kt:` | window | Re-run command in all window panes | -| `run-shell` | `run` | `bd:Ct:Es:c:` | pane | Execute shell command in background | -| `swap-pane` | `swapp` | `dDs:t:UZ` | pane | Swap two panes | -| `swap-window` | `swapw` | `ds:t:` | window | Swap two windows | -| `display-popup` | `popup` | `Bb:Cc:d:e:Eh:kNs:S:t:T:w:x:y:` | pane | Create popup overlay (tmux 3.2+) | -| `pipe-pane` | `pipep` | `IOot:` | pane | Pipe pane output to command | -| `clear-history` | `clearhist` | `Ht:` | pane | Clear pane scrollback buffer | +| tmux Command | Covered By | How | +|---|---|---| +| `last-pane` | `Window.last_pane()`, `Pane.select(last=True)` | `-l` flag on select-pane | +| `list-panes` | `Window.panes` property | Used internally by `neo.py` | +| `list-windows` | `Session.windows` property | Used internally by `neo.py` | +| `move-pane` | `Pane.join()` | Same C source as join-pane | +| `next-layout` | `Window.select_layout(next_layout=True)` | `-n` flag on select-layout | +| `previous-layout` | `Window.select_layout(previous_layout=True)` | `-o` flag on select-layout | +| `set-window-option` | `OptionsMixin.set_option(scope=OptionScope.Window)` | Alias for `set-option -w` | +| `show-window-options` | `OptionsMixin.show_options(scope=OptionScope.Window)` | Alias for `show-options -w` | -### Medium Priority (navigation, buffers, info) +## Not Wrappable (3 commands) -| tmux Command | Alias | Getopt | Target | Notes | -|---|---|---|---|---| -| `last-pane` | `lastp` | `det:Z` | window | Select previous pane | -| `last-window` | `last` | `t:` | session | Select previous window | -| `next-window` | `next` | `at:` | session | Select next window | -| `previous-window` | `prev` | `at:` | session | Select previous window | -| `link-window` | `linkw` | `abdks:t:` | window | Link window to another session | -| `unlink-window` | `unlinkw` | `kt:` | window | Unlink window from session | -| `rotate-window` | `rotatew` | `Dt:UZ` | window | Rotate pane positions | -| `list-buffers` | `lsb` | `F:f:O:r` | none | List paste buffers | -| `list-clients` | `lsc` | `F:f:O:rt:` | session | List connected clients | -| `load-buffer` | `loadb` | `b:t:w` | none | Load file into paste buffer | -| `save-buffer` | `saveb` | `ab:` | none | Save paste buffer to file | -| `set-buffer` | `setb` | `ab:t:n:w` | none | Set paste buffer contents | -| `show-buffer` | `showb` | `b:` | none | Show paste buffer contents | -| `delete-buffer` | `deleteb` | `b:` | none | Delete a paste buffer | -| `paste-buffer` | `pasteb` | `db:prSs:t:` | pane | Paste buffer into pane | -| `wait-for` | `wait` | `LSU` | none | Wait for/signal/lock a channel | -| `if-shell` | `if` | `bFt:` | pane | Conditional command execution | -| `detach-client` | `detach` | `aE:s:t:P` | session | Detach client from session | -| `refresh-client` | `refresh` | `A:B:cC:Df:r:F:lLRSt:U` | none | Refresh client display | -| `show-window-options` | `showw` | `gvt:` | window | Show window options (alias for show-options -w) | -| `set-window-option` | `setw` | `aFgoqt:u` | window | Set window option (alias for set-option -w) | +These block forever waiting for interactive user input: -### Low Priority (interactive UI, config, rarely scripted) - -| tmux Command | Alias | Getopt | Target | Notes | -|---|---|---|---|---| -| `bind-key` | `bind` | `nrN:T:` | none | Bind key to command | -| `unbind-key` | `unbind` | `anqT:` | none | Unbind a key | -| `choose-buffer` | — | `F:f:K:NO:rt:yZ` | pane | Interactive buffer chooser | -| `choose-client` | — | `F:f:K:NO:rt:yZ` | pane | Interactive client chooser | -| `choose-tree` | — | `F:f:GK:NO:rst:wyZ` | pane | Interactive session/window tree | -| `clock-mode` | — | `t:` | pane | Show clock in pane | -| `command-prompt` | — | `1beFiklI:Np:t:T:` | none | Open command prompt | -| `confirm-before` | `confirm` | `bc:p:t:y` | none | Confirm before running command | -| `copy-mode` | — | `deHMqSs:t:u` | pane | Enter copy mode | -| `customize-mode` | — | `F:f:Nt:yZ` | pane | Enter customize mode | -| `display-menu` | `menu` | `b:c:C:H:s:S:MOt:T:x:y:` | pane | Display popup menu | -| `display-panes` | `displayp` | `bd:Nt:` | none | Show pane numbers | -| `find-window` | `findw` | `CiNrt:TZ` | pane | Search window contents | -| `list-commands` | `lscm` | `F:` | none | List tmux commands | -| `list-keys` | `lsk` | `1aF:NO:P:rT:` | none | List key bindings | -| `lock-client` | `lockc` | `t:` | none | Lock a client | -| `lock-server` | `lock` | (none) | none | Lock the server | -| `lock-session` | `locks` | `t:` | session | Lock a session | -| `next-layout` | `nextl` | `t:` | window | Cycle to next layout | -| `previous-layout` | `prevl` | `t:` | window | Cycle to previous layout | -| `send-prefix` | — | `2t:` | pane | Send prefix key | -| `server-access` | — | `adlrw` | none | Manage server access control | -| `show-messages` | `showmsgs` | `JTt:` | none | Show message log | -| `show-prompt-history` | `showphist` | `T:` | none | Show prompt history | -| `clear-prompt-history` | `clearphist` | `T:` | none | Clear prompt history | -| `source-file` | `source` | `t:Fnqv` | pane | Source a config file | -| `start-server` | `start` | (none) | none | Start server (usually implicit) | -| `suspend-client` | `suspendc` | `t:` | none | Suspend a client | +| tmux Command | Why | +|---|---| +| `command-prompt` | Opens interactive prompt, blocks until user types | +| `confirm-before` | Blocks waiting for y/n confirmation (even `-y` blocks in control mode) | +| `display-menu` | Opens interactive menu, blocks until selection | From b350f6d0e198631bc1f11b0dc245ff87074f0315 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 06:44:55 -0500 Subject: [PATCH 040/105] feat: fill missing flag gaps on recently added commands why: Flag audit found useful parameters that were not exposed on commands added in the parity work. what: - unbind_key: add all_keys (-a), quiet (-q) parameters - source_file: add parse_only (-n), verbose (-v) parameters - display_popup: add x (-x), y (-y) position parameters - detach_client: add shell_command (-E) parameter --- src/libtmux/pane.py | 12 ++++++++++++ src/libtmux/server.py | 33 +++++++++++++++++++++++++++++---- src/libtmux/session.py | 6 ++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 2fe470ab3..940559a75 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1147,6 +1147,8 @@ def display_popup( close_on_success: bool | None = None, width: int | str | None = None, height: int | str | None = None, + x: int | str | None = None, + y: int | str | None = None, start_directory: StrPath | None = None, title: str | None = None, border_lines: str | None = None, @@ -1171,6 +1173,10 @@ def display_popup( Popup width (``-w`` flag). height : int or str, optional Popup height (``-h`` flag). + x : int or str, optional + Popup x position (``-x`` flag). + y : int or str, optional + Popup y position (``-y`` flag). start_directory : str or PathLike, optional Working directory (``-d`` flag). title : str, optional @@ -1207,6 +1213,12 @@ def display_popup( if height is not None: tmux_args += ("-h", str(height)) + if x is not None: + tmux_args += ("-x", str(x)) + + if y is not None: + tmux_args += ("-y", str(y)) + if start_directory is not None: start_path = pathlib.Path(start_directory).expanduser() tmux_args += ("-d", str(start_path)) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 9544e78ed..11abf4161 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -577,18 +577,24 @@ def bind_key( def unbind_key( self, - key: str, + key: str | None = None, *, key_table: str | None = None, + all_keys: bool | None = None, + quiet: bool | None = None, ) -> None: """Unbind a key via ``$ tmux unbind-key``. Parameters ---------- - key : str - Key to unbind. + key : str, optional + Key to unbind. Required unless *all_keys* is True. key_table : str, optional Key table (``-T`` flag). Defaults to ``prefix``. + all_keys : bool, optional + Unbind all keys (``-a`` flag). + quiet : bool, optional + Suppress errors for missing bindings (``-q`` flag). Examples -------- @@ -597,10 +603,17 @@ def unbind_key( """ tmux_args: tuple[str, ...] = () + if all_keys: + tmux_args += ("-a",) + + if quiet: + tmux_args += ("-q",) + if key_table is not None: tmux_args += ("-T", key_table) - tmux_args += (key,) + if key is not None: + tmux_args += (key,) proc = self.cmd("unbind-key", *tmux_args) @@ -1150,6 +1163,8 @@ def source_file( path: StrPath, *, quiet: bool | None = None, + parse_only: bool | None = None, + verbose: bool | None = None, ) -> None: """Source a tmux configuration file via ``$ tmux source-file``. @@ -1159,6 +1174,10 @@ def source_file( Path to the configuration file. quiet : bool, optional Suppress errors for missing files (``-q`` flag). + parse_only : bool, optional + Check syntax only, do not execute (``-n`` flag). + verbose : bool, optional + Show parsed commands (``-v`` flag). Examples -------- @@ -1172,6 +1191,12 @@ def source_file( if quiet: tmux_args += ("-q",) + if parse_only: + tmux_args += ("-n",) + + if verbose: + tmux_args += ("-v",) + tmux_args += (str(pathlib.Path(path).expanduser()),) proc = self.cmd("source-file", *tmux_args) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 5b0921c83..8d8f8833f 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -260,6 +260,7 @@ def detach_client( *, target_client: str | None = None, all_clients: bool | None = None, + shell_command: str | None = None, ) -> None: """Detach clients from this session via ``$ tmux detach-client``. @@ -270,6 +271,8 @@ def detach_client( the most recently active client. all_clients : bool, optional Detach all clients attached to this session (``-a`` flag). + shell_command : str, optional + Run a shell command after detaching (``-E`` flag). Examples -------- @@ -281,6 +284,9 @@ def detach_client( if all_clients: tmux_args += ("-a",) + if shell_command is not None: + tmux_args += ("-E", shell_command) + if target_client is not None: tmux_args += ("-t", target_client) From 6ad51f51b99654c5d937829be7d5e9bc135eac6a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 26 Mar 2026 06:53:36 -0500 Subject: [PATCH 041/105] Server(feat): add confirm_before, command_prompt wrapping interactive tmux commands why: confirm-before and command-prompt were previously untestable because they block waiting for user input. Using send-keys -K -c (tmux 3.4+) we can inject key events into the client's prompt handler programmatically. what: - Add Server.confirm_before() wrapping confirm-before with -b (always non-blocking), prompt (-p), confirm_key (-c), default_yes (-y), target_client (-t) parameters - Add Server.command_prompt() wrapping command-prompt with -b (always non-blocking), prompt (-p), inputs (-I), target_client (-t) parameters - Both use send-keys -K -c for confirmation/input in tests - ConfirmBeforeCase parametrized tests: confirm_y, default_yes_enter - CommandPromptCase parametrized tests: type_and_submit, prefill_and_submit - All tests version-gated to tmux 3.4+ --- src/libtmux/server.py | 117 +++++++++++++++++++++++++++++++++++ tests/test_server.py | 140 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 11abf4161..78f5b602a 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -823,6 +823,123 @@ def lock_client(self, *, target_client: str | None = None) -> None: if proc.stderr: raise exc.LibTmuxException(proc.stderr) + def confirm_before( + self, + command: str, + *, + prompt: str | None = None, + confirm_key: str | None = None, + default_yes: bool | None = None, + target_client: str | None = None, + ) -> None: + """Run a command after confirmation via ``$ tmux confirm-before``. + + Always uses ``-b`` (background) to avoid blocking the command queue. + Use ``send-keys -K -c `` to provide the confirmation key. + + Requires tmux 3.4+ for ``-b`` flag support. + + Parameters + ---------- + command : str + Tmux command to run after confirmation. + prompt : str, optional + Custom prompt text (``-p`` flag). + confirm_key : str, optional + Key to accept as confirmation (``-c`` flag). Default is ``y``. + default_yes : bool, optional + Make Enter default to yes (``-y`` flag). + target_client : str, optional + Target client (``-t`` flag). + + Examples + -------- + >>> with control_mode() as ctl: + ... server.confirm_before( + ... 'set -g @cf_test yes', + ... target_client=ctl.client_name, + ... ) + ... _ = server.cmd('send-keys', '-K', '-c', ctl.client_name, 'y') + ... server.cmd('show-options', '-gv', '@cf_test').stdout[0] + 'yes' + """ + tmux_args: tuple[str, ...] = ("-b",) + + if prompt is not None: + tmux_args += ("-p", prompt) + + if confirm_key is not None: + tmux_args += ("-c", confirm_key) + + if default_yes: + tmux_args += ("-y",) + + if target_client is not None: + tmux_args += ("-t", target_client) + + tmux_args += (command,) + + proc = self.cmd("confirm-before", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + def command_prompt( + self, + template: str, + *, + prompt: str | None = None, + inputs: str | None = None, + target_client: str | None = None, + ) -> None: + """Open a command prompt via ``$ tmux command-prompt``. + + Always uses ``-b`` (background) to avoid blocking the command queue. + Use ``send-keys -K -c `` to type into the prompt and submit. + + Requires tmux 3.4+ for ``-b`` flag support. + + Parameters + ---------- + template : str + Tmux command template. Use ``%1``, ``%2`` for prompt values. + prompt : str, optional + Custom prompt text (``-p`` flag). Commas separate multiple prompts. + inputs : str, optional + Pre-fill prompt input (``-I`` flag). Commas separate multiple. + target_client : str, optional + Target client (``-t`` flag). + + Examples + -------- + >>> with control_mode() as ctl: + ... server.command_prompt( + ... "set -g @cp_test '%1'", + ... target_client=ctl.client_name, + ... ) + ... for key in ['h', 'i', 'Enter']: + ... _ = server.cmd('send-keys', '-K', '-c', ctl.client_name, key) + ... server.cmd('show-options', '-gv', '@cp_test').stdout[0] + 'hi' + """ + tmux_args: tuple[str, ...] = ("-b",) + + if prompt is not None: + tmux_args += ("-p", prompt) + + if inputs is not None: + tmux_args += ("-I", inputs) + + if target_client is not None: + tmux_args += ("-t", target_client) + + tmux_args += (template,) + + proc = self.cmd("command-prompt", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def start_server(self) -> None: """Start the tmux server via ``$ tmux start-server``. diff --git a/tests/test_server.py b/tests/test_server.py index f74bd04e4..e3f6ad28e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -457,6 +457,146 @@ def test_tmux_bin_invalid_path_raise_if_dead() -> None: s.raise_if_dead() +class ConfirmBeforeCase(t.NamedTuple): + """Test case for confirm_before().""" + + test_id: str + confirm_key: str + use_default_yes: bool + custom_confirm_key: str | None + option_name: str + expected_value: str + min_tmux_version: str | None + + +CONFIRM_BEFORE_CASES: list[ConfirmBeforeCase] = [ + ConfirmBeforeCase( + test_id="confirm_y", + confirm_key="y", + use_default_yes=False, + custom_confirm_key=None, + option_name="@cf_test_y", + expected_value="yes", + min_tmux_version="3.4", + ), + ConfirmBeforeCase( + test_id="default_yes_enter", + confirm_key="Enter", + use_default_yes=True, + custom_confirm_key=None, + option_name="@cf_test_enter", + expected_value="yes", + min_tmux_version="3.4", + ), +] + + +@pytest.mark.parametrize( + list(ConfirmBeforeCase._fields), + CONFIRM_BEFORE_CASES, + ids=[c.test_id for c in CONFIRM_BEFORE_CASES], +) +def test_confirm_before( + test_id: str, + confirm_key: str, + use_default_yes: bool, + custom_confirm_key: str | None, + option_name: str, + expected_value: str, + min_tmux_version: str | None, + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """Test Server.confirm_before() with send-keys -K confirmation.""" + from libtmux.common import has_gte_version + + if min_tmux_version and not has_gte_version(min_tmux_version): + pytest.skip(f"Requires tmux {min_tmux_version}+") + + with control_mode() as ctl: + kwargs: dict[str, t.Any] = {"target_client": ctl.client_name} + if use_default_yes: + kwargs["default_yes"] = True + if custom_confirm_key is not None: + kwargs["confirm_key"] = custom_confirm_key + + server.confirm_before(f"set -g {option_name} {expected_value}", **kwargs) + server.cmd("send-keys", "-K", "-c", ctl.client_name, confirm_key) + + result = server.cmd("show-options", "-gv", option_name) + assert result.stdout[0] == expected_value + + +class CommandPromptCase(t.NamedTuple): + """Test case for command_prompt().""" + + test_id: str + template: str + keys: list[str] + inputs: str | None + option_name: str + expected_value: str + min_tmux_version: str | None + + +COMMAND_PROMPT_CASES: list[CommandPromptCase] = [ + CommandPromptCase( + test_id="type_and_submit", + template="set -g @cp_typed '%1'", + keys=["h", "e", "l", "l", "o", "Enter"], + inputs=None, + option_name="@cp_typed", + expected_value="hello", + min_tmux_version="3.4", + ), + CommandPromptCase( + test_id="prefill_and_submit", + template="set -g @cp_prefill '%1'", + keys=["Enter"], + inputs="prefilled", + option_name="@cp_prefill", + expected_value="prefilled", + min_tmux_version="3.4", + ), +] + + +@pytest.mark.parametrize( + list(CommandPromptCase._fields), + COMMAND_PROMPT_CASES, + ids=[c.test_id for c in COMMAND_PROMPT_CASES], +) +def test_command_prompt( + test_id: str, + template: str, + keys: list[str], + inputs: str | None, + option_name: str, + expected_value: str, + min_tmux_version: str | None, + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """Test Server.command_prompt() with send-keys -K input.""" + from libtmux.common import has_gte_version + + if min_tmux_version and not has_gte_version(min_tmux_version): + pytest.skip(f"Requires tmux {min_tmux_version}+") + + with control_mode() as ctl: + kwargs: dict[str, t.Any] = {"target_client": ctl.client_name} + if inputs is not None: + kwargs["inputs"] = inputs + + server.command_prompt(template, **kwargs) + + for key in keys: + server.cmd("send-keys", "-K", "-c", ctl.client_name, key) + + result = server.cmd("show-options", "-gv", option_name) + assert result.stdout[0] == expected_value + + def test_lock_server( control_mode: t.Callable[..., t.Any], server: Server, From 34ea3163af3aa4a00ee74d11d0b3965cb15f2005 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 27 Mar 2026 04:15:38 -0500 Subject: [PATCH 042/105] Server(feat[command_prompt]): fill missing flag gaps why: command-prompt has useful flags for single-key mode, keystroke callbacks, and prompt type selection that were not exposed. what: - Add one_key (-1), key_only (-k), on_input_change (-i), no_execute (-N), prompt_type (-T) parameters to command_prompt() --- src/libtmux/server.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 78f5b602a..7594c8c20 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -891,6 +891,11 @@ def command_prompt( prompt: str | None = None, inputs: str | None = None, target_client: str | None = None, + one_key: bool | None = None, + key_only: bool | None = None, + on_input_change: bool | None = None, + no_execute: bool | None = None, + prompt_type: str | None = None, ) -> None: """Open a command prompt via ``$ tmux command-prompt``. @@ -909,6 +914,17 @@ def command_prompt( Pre-fill prompt input (``-I`` flag). Commas separate multiple. target_client : str, optional Target client (``-t`` flag). + one_key : bool, optional + Accept only one key press (``-1`` flag). + key_only : bool, optional + Only accept key presses, not text (``-k`` flag). + on_input_change : bool, optional + Run template on each keystroke (``-i`` flag). + no_execute : bool, optional + Do not execute the command, just insert into prompt (``-N`` flag). + prompt_type : str, optional + Prompt type (``-T`` flag). One of: ``command``, ``search``, + ``target``, ``window-target``. Examples -------- @@ -924,12 +940,27 @@ def command_prompt( """ tmux_args: tuple[str, ...] = ("-b",) + if one_key: + tmux_args += ("-1",) + + if key_only: + tmux_args += ("-k",) + + if on_input_change: + tmux_args += ("-i",) + + if no_execute: + tmux_args += ("-N",) + if prompt is not None: tmux_args += ("-p", prompt) if inputs is not None: tmux_args += ("-I", inputs) + if prompt_type is not None: + tmux_args += ("-T", prompt_type) + if target_client is not None: tmux_args += ("-t", target_client) From 747b36b2794cf054e5a20d98bd9cd78b521f4eed Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 27 Mar 2026 04:45:12 -0500 Subject: [PATCH 043/105] Pane(feat): fill missing flag gaps on interactive commands why: Interactive pane commands were wrapped with minimal flags. Fill useful parameters for better programmatic control. what: - copy_mode: add scroll_up (-u), exit_on_copy (-e), mouse_drag (-M), quiet (-q) parameters - display_panes: add duration (-d), no_select (-N) parameters - choose_tree: add windows_only (-w) parameter - find_window: add match_content (-C), case_insensitive (-i), match_name (-N), regex (-r), match_title (-T) parameters --- src/libtmux/pane.py | 110 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 101 insertions(+), 9 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 940559a75..a5e4bca21 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1367,14 +1367,26 @@ def pipe( if proc.stderr: raise exc.LibTmuxException(proc.stderr) - def copy_mode(self, *, bottom: bool | None = None) -> None: + def copy_mode( + self, + *, + scroll_up: bool | None = None, + exit_on_copy: bool | None = None, + mouse_drag: bool | None = None, + quiet: bool | None = None, + ) -> None: """Enter copy mode via ``$ tmux copy-mode``. Parameters ---------- - bottom : bool, optional - Start at the bottom of the history (``-u`` flag inverted — default - starts at bottom, ``-u`` starts at top/scrollback). + scroll_up : bool, optional + Start scrolled up one page (``-u`` flag). + exit_on_copy : bool, optional + Exit copy mode after copying (``-e`` flag). + mouse_drag : bool, optional + Start mouse drag (``-M`` flag). + quiet : bool, optional + Quiet mode (``-q`` flag). Examples -------- @@ -1382,6 +1394,18 @@ def copy_mode(self, *, bottom: bool | None = None) -> None: """ tmux_args: tuple[str, ...] = () + if scroll_up: + tmux_args += ("-u",) + + if exit_on_copy: + tmux_args += ("-e",) + + if mouse_drag: + tmux_args += ("-M",) + + if quiet: + tmux_args += ("-q",) + proc = self.cmd("copy-mode", *tmux_args) if proc.stderr: @@ -1397,17 +1421,37 @@ def clock_mode(self) -> None: if proc.stderr: raise exc.LibTmuxException(proc.stderr) - def display_panes(self) -> None: + def display_panes( + self, + *, + duration: int | None = None, + no_select: bool | None = None, + ) -> None: """Show pane numbers via ``$ tmux display-panes``. Requires an attached client. + Parameters + ---------- + duration : int, optional + Duration in milliseconds to display pane numbers (``-d`` flag). + no_select : bool, optional + Do not select a pane on keypress (``-N`` flag). + Examples -------- >>> with control_mode() as ctl: ... window.active_pane.display_panes() """ - proc = self.server.cmd("display-panes") + tmux_args: tuple[str, ...] = () + + if duration is not None: + tmux_args += ("-d", str(duration)) + + if no_select: + tmux_args += ("-N",) + + proc = self.server.cmd("display-panes", *tmux_args) if proc.stderr: raise exc.LibTmuxException(proc.stderr) @@ -1432,13 +1476,20 @@ def choose_client(self) -> None: if proc.stderr: raise exc.LibTmuxException(proc.stderr) - def choose_tree(self, *, sessions_only: bool | None = None) -> None: + def choose_tree( + self, + *, + sessions_only: bool | None = None, + windows_only: bool | None = None, + ) -> None: """Enter tree chooser via ``$ tmux choose-tree``. Parameters ---------- sessions_only : bool, optional Only show sessions, not windows (``-s`` flag). + windows_only : bool, optional + Only show windows, not sessions (``-w`` flag). Examples -------- @@ -1449,6 +1500,9 @@ def choose_tree(self, *, sessions_only: bool | None = None) -> None: if sessions_only: tmux_args += ("-s",) + if windows_only: + tmux_args += ("-w",) + proc = self.cmd("choose-tree", *tmux_args) if proc.stderr: @@ -1464,7 +1518,16 @@ def customize_mode(self) -> None: if proc.stderr: raise exc.LibTmuxException(proc.stderr) - def find_window(self, match_string: str) -> None: + def find_window( + self, + match_string: str, + *, + match_content: bool | None = None, + case_insensitive: bool | None = None, + match_name: bool | None = None, + regex: bool | None = None, + match_title: bool | None = None, + ) -> None: """Search for a window matching a string via ``$ tmux find-window``. Opens a choose-tree filtered to matching windows. @@ -1473,12 +1536,41 @@ def find_window(self, match_string: str) -> None: ---------- match_string : str String to search for in window names, titles, and content. + match_content : bool, optional + Match visible pane content (``-C`` flag). + case_insensitive : bool, optional + Case-insensitive matching (``-i`` flag). + match_name : bool, optional + Match window name only (``-N`` flag). + regex : bool, optional + Treat match string as a regex (``-r`` flag). + match_title : bool, optional + Match pane title (``-T`` flag). Examples -------- >>> pane.find_window('sh') """ - proc = self.cmd("find-window", match_string) + tmux_args: tuple[str, ...] = () + + if match_content: + tmux_args += ("-C",) + + if case_insensitive: + tmux_args += ("-i",) + + if match_name: + tmux_args += ("-N",) + + if regex: + tmux_args += ("-r",) + + if match_title: + tmux_args += ("-T",) + + tmux_args += (match_string,) + + proc = self.cmd("find-window", *tmux_args) if proc.stderr: raise exc.LibTmuxException(proc.stderr) From 5f483de380f853e1d80e61a4abd7f813b95d8c35 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 27 Mar 2026 05:13:39 -0500 Subject: [PATCH 044/105] Server(feat[display_menu]): add display_menu() wrapping tmux display-menu why: display-menu is the last unwrapped tmux command. While it requires a TTY-backed client and cannot be tested with ControlMode (tty.sy=0 causes menu_prepare to return NULL), the method is useful for users with real attached clients. what: - Add Server.display_menu() with title (-T), target_pane (-t), target_client (-c), x (-x), y (-y), starting_choice (-C), border_lines (-b), style (-s), border_style (-S) parameters - Items passed as positional *args in tmux's name/key/command format - Document the TTY client requirement and test gap in docstring --- src/libtmux/server.py | 85 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 7594c8c20..207bdad55 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -971,6 +971,91 @@ def command_prompt( if proc.stderr: raise exc.LibTmuxException(proc.stderr) + def display_menu( + self, + *items: str, + title: str | None = None, + target_pane: str | None = None, + target_client: str | None = None, + x: int | str | None = None, + y: int | str | None = None, + starting_choice: int | str | None = None, + border_lines: str | None = None, + style: str | None = None, + border_style: str | None = None, + ) -> None: + """Display a popup menu via ``$ tmux display-menu``. + + Requires a TTY-backed attached client. Control-mode clients have + ``tty.sy=0``, which causes ``menu_prepare()`` to return NULL. + This method cannot be tested with + :class:`~libtmux._internal.control_mode.ControlMode`. + + Parameters + ---------- + *items : str + Menu items as positional args in tmux's ``name key command`` + triple format. Use empty strings for separators. + title : str, optional + Menu title (``-T`` flag). + target_pane : str, optional + Target pane for format expansion (``-t`` flag). + target_client : str, optional + Target client (``-c`` flag). + x : int or str, optional + Menu x position (``-x`` flag). + y : int or str, optional + Menu y position (``-y`` flag). + starting_choice : int or str, optional + Pre-selected item index (``-C`` flag). Use ``-`` for none. + border_lines : str, optional + Border line style (``-b`` flag). + style : str, optional + Menu style (``-s`` flag). + border_style : str, optional + Border style (``-S`` flag). + + Examples + -------- + >>> server.display_menu # doctest: +ELLIPSIS + + """ + tmux_args: tuple[str, ...] = () + + if title is not None: + tmux_args += ("-T", title) + + if target_client is not None: + tmux_args += ("-c", target_client) + + if target_pane is not None: + tmux_args += ("-t", target_pane) + + if x is not None: + tmux_args += ("-x", str(x)) + + if y is not None: + tmux_args += ("-y", str(y)) + + if starting_choice is not None: + tmux_args += ("-C", str(starting_choice)) + + if border_lines is not None: + tmux_args += ("-b", border_lines) + + if style is not None: + tmux_args += ("-s", style) + + if border_style is not None: + tmux_args += ("-S", border_style) + + tmux_args += items + + proc = self.cmd("display-menu", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def start_server(self) -> None: """Start the tmux server via ``$ tmux start-server``. From 01897310a9fa1ae995c892c67cc74417edb06d58 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 27 Mar 2026 05:19:49 -0500 Subject: [PATCH 045/105] docs(parity): update command-mapping to reflect 100% effective coverage why: All 90 tmux commands are now either directly wrapped (82) or covered by aliases/flags (8). display-menu is wrapped but has a test gap. what: - Update summary to 82/90 directly wrapped (91%), 90/90 effective (100%) - Move display-menu from "Not Wrappable" to "Test Gaps" section - Remove command-prompt and confirm-before from unwrappable (now testable via send-keys -K) - Add "Notable Test Innovations" section documenting the send-keys -K approach and ControlMode testing patterns --- .../tmux-parity/references/command-mapping.md | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/skills/tmux-parity/references/command-mapping.md b/skills/tmux-parity/references/command-mapping.md index e938a049d..6670211a7 100644 --- a/skills/tmux-parity/references/command-mapping.md +++ b/skills/tmux-parity/references/command-mapping.md @@ -8,10 +8,9 @@ bash .claude-plugin/scripts/extract-libtmux-methods.sh ## Summary -- **Directly wrapped**: 79/90 commands (87%) +- **Directly wrapped**: 82/90 commands (91%) - **Covered by alias/flag**: 8 additional commands -- **Truly unwrappable**: 3 commands (block waiting for interactive input) -- **Total effective coverage**: 87/90 (96%) +- **Total effective coverage**: 90/90 (100%) ## Covered by Alias/Flag (8 commands) @@ -28,12 +27,17 @@ These commands are not called directly but their functionality is available: | `set-window-option` | `OptionsMixin.set_option(scope=OptionScope.Window)` | Alias for `set-option -w` | | `show-window-options` | `OptionsMixin.show_options(scope=OptionScope.Window)` | Alias for `show-options -w` | -## Not Wrappable (3 commands) +## Test Gaps (1 command) -These block forever waiting for interactive user input: +| tmux Command | Method | Why | +|---|---|---| +| `display-menu` | `Server.display_menu()` | Requires TTY-backed client. Control-mode clients have `tty.sy=0`, causing `menu_prepare()` to return NULL. Method exists but cannot be tested hermetically. | + +## Notable Test Innovations -| tmux Command | Why | +| Command | Testing Approach | |---|---| -| `command-prompt` | Opens interactive prompt, blocks until user types | -| `confirm-before` | Blocks waiting for y/n confirmation (even `-y` blocks in control mode) | -| `display-menu` | Opens interactive menu, blocks until selection | +| `confirm-before` | `send-keys -K -c ` injects 'y' into status prompt handler (tmux 3.4+) | +| `command-prompt` | `send-keys -K -c ` types text + Enter into status prompt (tmux 3.4+) | +| `display-popup` | ControlMode client + marker file side-effect verification | +| `detach-client` | ControlMode client + `list-clients` count assertion | From 8a7abcef139cfab300fde08129dde5ea247b22f7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 27 Mar 2026 05:45:56 -0500 Subject: [PATCH 046/105] Window(feat): add next_layout, previous_layout wrapping real tmux commands why: next-layout and previous-layout are real tmux commands (since 1.0) with their own cmd_entry structs. Invoke them directly instead of routing through select-layout flags. what: - Add Window.next_layout() calling tmux next-layout directly - Add Window.previous_layout() calling tmux previous-layout directly - Add tests verifying layout changes and round-trip cycling --- src/libtmux/window.py | 30 ++++++++++++++++++++++++++++++ tests/test_window.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 76d968841..0c1256e74 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -496,6 +496,36 @@ def select_layout( return self + def next_layout(self) -> Window: + """Cycle to the next layout via ``$ tmux next-layout``. Returns self. + + >>> pane1 = window.active_pane + >>> pane2 = window.split() + >>> window.next_layout() + Window(...) + """ + proc = self.cmd("next-layout") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return self + + def previous_layout(self) -> Window: + """Cycle to the previous layout via ``$ tmux previous-layout``. Returns self. + + >>> pane1 = window.active_pane + >>> pane2 = window.split() + >>> window.previous_layout() + Window(...) + """ + proc = self.cmd("previous-layout") + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return self + def link( self, target_session: str | Session, diff --git a/tests/test_window.py b/tests/test_window.py index ac8ea912c..9615dcdf5 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -814,6 +814,45 @@ def test_select_layout_next_previous(session: Session) -> None: assert layout_after_prev == layout_before +def test_next_layout(session: Session) -> None: + """Test Window.next_layout() cycles to the next layout.""" + window = session.new_window(window_name="test_next_layout") + window.resize(height=40, width=80) + pane = window.active_pane + assert pane is not None + pane.split() + + window.select_layout("even-horizontal") + window.refresh() + layout_before = window.window_layout + + window.next_layout() + window.refresh() + layout_after = window.window_layout + + assert layout_before != layout_after + + +def test_previous_layout(session: Session) -> None: + """Test Window.previous_layout() cycles back.""" + window = session.new_window(window_name="test_prev_layout") + window.resize(height=40, width=80) + pane = window.active_pane + assert pane is not None + pane.split() + + window.select_layout("even-horizontal") + window.refresh() + layout_before = window.window_layout + + window.next_layout() + window.previous_layout() + window.refresh() + layout_after = window.window_layout + + assert layout_before == layout_after + + def test_select_layout_mutual_exclusion(session: Session) -> None: """Test that layout string and flags are mutually exclusive.""" window = session.new_window(window_name="test_layout_mutex") From 1d9601b37497a056c468ccd14fc3a2a7b172d9b6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 27 Mar 2026 06:03:49 -0500 Subject: [PATCH 047/105] Window(feat[last_pane]): call last-pane directly instead of select-pane -l why: last-pane is a real tmux command (since 1.4) with its own flags (det:Z). Call it directly and expose its unique parameters. what: - Change Window.last_pane() to call tmux last-pane directly - Add detach (-d), keep_zoom (-Z), disable_input (-e) parameters - Raise exc.LibTmuxException on stderr (never silently return None) - Add test verifying last pane selection --- src/libtmux/window.py | 51 ++++++++++++++++++++++++++++++++++++++++--- tests/test_window.py | 18 +++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 0c1256e74..19194e5fa 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -403,9 +403,54 @@ def resize( self.refresh() return self - def last_pane(self) -> Pane | None: - """Return last pane.""" - return self.select_pane("-l") + def last_pane( + self, + *, + detach: bool | None = None, + keep_zoom: bool | None = None, + disable_input: bool | None = None, + ) -> Pane | None: + """Select the last (previously active) pane via ``$ tmux last-pane``. + + Parameters + ---------- + detach : bool, optional + Do not make the pane active (``-d`` flag). + keep_zoom : bool, optional + Keep the window zoomed if zoomed (``-Z`` flag). + disable_input : bool, optional + Disable input to the pane (``-e`` flag). + + Returns + ------- + :class:`Pane` or None + The selected pane, or None if no last pane exists. + + Examples + -------- + >>> pane1 = window.active_pane + >>> pane2 = window.split() + >>> pane2.select() + Pane(...) + >>> result = window.last_pane() + """ + tmux_args: tuple[str, ...] = () + + if detach: + tmux_args += ("-d",) + + if keep_zoom: + tmux_args += ("-Z",) + + if disable_input: + tmux_args += ("-e",) + + proc = self.cmd("last-pane", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + + return self.active_pane def select_layout( self, diff --git a/tests/test_window.py b/tests/test_window.py index 9615dcdf5..4692bdbc2 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -814,6 +814,24 @@ def test_select_layout_next_previous(session: Session) -> None: assert layout_after_prev == layout_before +def test_last_pane(session: Session) -> None: + """Test Window.last_pane() selects the previously active pane.""" + window = session.new_window(window_name="test_last_pane") + pane1 = window.active_pane + assert pane1 is not None + pane2 = pane1.split() + + # Select pane2 then pane1 to establish history + pane2.select() + pane1.select() + + # last_pane should go back to pane2 + result = window.last_pane() + assert result is not None + pane2.refresh() + assert pane2.pane_active == "1" + + def test_next_layout(session: Session) -> None: """Test Window.next_layout() cycles to the next layout.""" window = session.new_window(window_name="test_next_layout") From 2bd0173496719128717fef6fa488c670020457f1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 27 Mar 2026 06:30:30 -0500 Subject: [PATCH 048/105] Pane(feat[move]): add move() wrapping tmux move-pane directly why: move-pane is a real tmux command (since 1.7) with its own cmd_entry. While it shares exec with join-pane, it should be invocable directly. what: - Add Pane.move() calling tmux move-pane with vertical (-v/-h), detach (-d), full_window (-f), size (-l), before (-b) parameters - Uses server.cmd with explicit -s/-t targeting - Add test verifying pane moves between windows --- src/libtmux/pane.py | 71 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_pane.py | 19 ++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index a5e4bca21..300f9fbf0 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1645,6 +1645,77 @@ def respawn( if proc.stderr: raise exc.LibTmuxException(proc.stderr) + def move( + self, + target: str | Pane | Window, + *, + vertical: bool = True, + detach: bool = True, + full_window: bool | None = None, + size: str | int | None = None, + before: bool | None = None, + ) -> None: + """Move this pane to another window via ``$ tmux move-pane``. + + Similar to :meth:`join` but invokes the ``move-pane`` command directly. + + Parameters + ---------- + target : str, Pane, or Window + Target pane or window to move into. + vertical : bool, optional + Split vertically (``-v`` flag), default True. False for + horizontal (``-h``). + detach : bool, optional + Do not switch to the target window (``-d`` flag), default True. + full_window : bool, optional + Use the full window width/height (``-f`` flag). + size : str or int, optional + Size for the moved pane (``-l`` flag). + before : bool, optional + Place the pane before the target (``-b`` flag). + + Examples + -------- + >>> pane_to_move = window.split(shell='sleep 1m') + >>> w2 = session.new_window(window_name='move_target') + >>> pane_to_move.move(w2) + """ + tmux_args: tuple[str, ...] = () + + if vertical: + tmux_args += ("-v",) + else: + tmux_args += ("-h",) + + if detach: + tmux_args += ("-d",) + + if full_window: + tmux_args += ("-f",) + + if size is not None: + tmux_args += (f"-l{size}",) + + if before: + tmux_args += ("-b",) + + from libtmux.window import Window + + if isinstance(target, Pane): + target_id = str(target.pane_id) + elif isinstance(target, Window): + target_id = str(target.window_id) + else: + target_id = target + + tmux_args += ("-s", str(self.pane_id), "-t", target_id) + + proc = self.server.cmd("move-pane", *tmux_args) + + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + def join( self, target: str | Pane | Window, diff --git a/tests/test_pane.py b/tests/test_pane.py index 13d8704fc..e9d135753 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -920,6 +920,25 @@ def test_respawn_pane_kill(session: Session) -> None: assert pane in window.panes +def test_move_pane(session: Session) -> None: + """Test Pane.move() moves pane to another window.""" + w1 = session.new_window(window_name="move_src") + pane = w1.active_pane + assert pane is not None + pane_to_move = pane.split(shell="sleep 1m") + assert len(w1.panes) == 2 + + w2 = session.new_window(window_name="move_dst") + initial_w2_panes = len(w2.panes) + + pane_to_move.move(w2) + + w1.refresh() + w2.refresh() + assert len(w1.panes) == 1 + assert len(w2.panes) == initial_w2_panes + 1 + + def test_join_pane(session: Session) -> None: """Test Pane.join() roundtrip with break_pane.""" window = session.new_window(window_name="test_join") From eeab41f2621766da9cf26e7779fe9b018551cc2e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 07:55:34 -0500 Subject: [PATCH 049/105] docs(doctests): annotate untestable interactive commands why: display_menu, display_popup, and display_message have paths that require TTY-backed clients or render in the status line, making them not programmatically verifiable. Adding explicit notes prevents false positives from agents and reviewers. what: - Add "Not directly testable" note to display_menu doctest - Add "Not directly testable" note to display_popup doctest - Add note about get_text=False path in display_message docstring --- src/libtmux/pane.py | 6 ++++++ src/libtmux/server.py | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 300f9fbf0..7e0218e76 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -621,6 +621,8 @@ def display_message( """Display message to pane. Displays a message in target-client status line. + The ``get_text=False`` path renders in the status line and is not + programmatically verifiable; only ``get_text=True`` returns output. Parameters ---------- @@ -1192,6 +1194,10 @@ def display_popup( Examples -------- + Not directly testable — popup rendering requires a TTY-backed client. + Control-mode provides an attached client for invocation but the popup + itself is not visible or verifiable. + >>> with control_mode() as ctl: ... pane.display_popup(command='true', close_on_exit=True) """ diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 207bdad55..b06589318 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1017,6 +1017,10 @@ def display_menu( Examples -------- + Not directly testable — requires a TTY-backed client. + Control-mode clients set ``tty.sy=0``, causing ``menu_prepare()`` + to return NULL inside tmux. + >>> server.display_menu # doctest: +ELLIPSIS """ From 3c10c0a80cf84792d8e2507a62626fbb359d81ce Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 07:57:16 -0500 Subject: [PATCH 050/105] Pane(fix[display_popup]): correct border_style flag and add style param why: display_popup mapped border_style to -s but tmux uses -S for border-style and -s for general popup style. This matched the wrong tmux flag and left the style parameter inaccessible. what: - Change border_style flag from -s to -S - Add style parameter mapped to -s - Update docstring to reflect correct flag mappings --- src/libtmux/pane.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 7e0218e76..d60f0430c 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1154,6 +1154,7 @@ def display_popup( start_directory: StrPath | None = None, title: str | None = None, border_lines: str | None = None, + style: str | None = None, border_style: str | None = None, environment: dict[str, str] | None = None, ) -> None: @@ -1185,8 +1186,10 @@ def display_popup( Popup title (``-T`` flag). Requires tmux 3.3+. border_lines : str, optional Border line style (``-b`` flag). Requires tmux 3.3+. + style : str, optional + Popup style (``-s`` flag). Requires tmux 3.3+. border_style : str, optional - Border style (``-s`` flag). Requires tmux 3.3+. + Border style (``-S`` flag). Requires tmux 3.3+. environment : dict, optional Environment variables (``-e`` flag). Requires tmux 3.3+. @@ -1247,9 +1250,18 @@ def display_popup( stacklevel=2, ) + if style is not None: + if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + tmux_args += ("-s", style) + else: + warnings.warn( + "style requires tmux 3.3+, ignoring", + stacklevel=2, + ) + if border_style is not None: if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): - tmux_args += ("-s", border_style) + tmux_args += ("-S", border_style) else: warnings.warn( "border_style requires tmux 3.3+, ignoring", From e0a8124d641eb73d9a0cc1c89b2651f173034a17 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 07:58:51 -0500 Subject: [PATCH 051/105] Pane(fix[display_popup]): use fused -e flag format for consistency why: All other methods (split, respawn, new_session, new_window) use the fused form (f"-e{k}={v}",) for environment flags. display_popup was the sole outlier using the separated form ("-e", f"{k}={v}"). what: - Change ("-e", f"{k}={v}") to (f"-e{k}={v}",) --- src/libtmux/pane.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index d60f0430c..e81218bd4 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1271,7 +1271,7 @@ def display_popup( if environment: if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): for k, v in environment.items(): - tmux_args += ("-e", f"{k}={v}") + tmux_args += (f"-e{k}={v}",) else: warnings.warn( "environment requires tmux 3.3+, ignoring", From be825021e720fdff12091090f3f58448bca958dc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 08:03:55 -0500 Subject: [PATCH 052/105] docs(versionadded): annotate new params on existing methods with 0.45 why: New parameters added to existing public methods need versionadded annotations so users know when they became available. Several params were missing annotations while others in the same methods had them. what: - Add versionadded 0.45 to send_keys: reset, copy_mode_cmd, repeat - Add versionadded 0.45 to capture_pane: alternate_screen, quiet - Add versionadded 0.45 to display_message: format_string, all_formats, verbose, no_expand, target_client, delay, notify - Add versionadded 0.45 to select: direction, last, keep_zoom, mark, clear_mark, disable_input, enable_input - Add versionadded 0.45 to last_pane: detach, keep_zoom, disable_input - Add versionadded 0.45 to _show_options_raw: quiet, values_only (also document these params in the docstring) --- src/libtmux/options.py | 8 ++++++++ src/libtmux/pane.py | 38 ++++++++++++++++++++++++++++++++++++++ src/libtmux/window.py | 6 ++++++ 3 files changed, 52 insertions(+) diff --git a/src/libtmux/options.py b/src/libtmux/options.py index 94f61a4aa..3c344b2c9 100644 --- a/src/libtmux/options.py +++ b/src/libtmux/options.py @@ -817,6 +817,14 @@ def _show_options_raw( g : bool, optional .. deprecated:: 0.50.0 Use ``global_`` instead. + quiet : bool, optional + Suppress errors silently (``-q`` flag). + + .. versionadded:: 0.45 + values_only : bool, optional + Return only option values without names (``-v`` flag). + + .. versionadded:: 0.45 Examples -------- diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index e81218bd4..d00c2a135 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -377,9 +377,13 @@ def capture_pane( alternate_screen : bool, optional Capture from the alternate screen (``-a`` flag). Default: False + + .. versionadded:: 0.45 quiet : bool, optional Suppress errors silently (``-q`` flag). Default: False + + .. versionadded:: 0.45 escape_markup : bool, optional Escape markup in the output (``-M`` flag). Requires tmux 3.6+. Default: False @@ -481,11 +485,17 @@ def send_keys( Send keys literally, default False. reset : bool, optional Reset terminal state before sending keys (``-R`` flag). + + .. versionadded:: 0.45 copy_mode_cmd : str, optional Send a command to copy mode instead of keys (``-X`` flag). When set, *cmd* is ignored. + + .. versionadded:: 0.45 repeat : int, optional Repeat count for the key (``-N`` flag). + + .. versionadded:: 0.45 expand_formats : bool, optional Expand tmux format strings in keys (``-F`` flag). @@ -633,18 +643,32 @@ def display_message( target-client status line. format_string : str, optional Format string for output (``-F`` flag). + + .. versionadded:: 0.45 all_formats : bool, optional List all format variables (``-a`` flag). + + .. versionadded:: 0.45 verbose : bool, optional Show format variable types (``-v`` flag). + + .. versionadded:: 0.45 no_expand : bool, optional Suppress format expansion (``-I`` flag). + + .. versionadded:: 0.45 target_client : str, optional Target client (``-c`` flag). + + .. versionadded:: 0.45 delay : int, optional Display time in milliseconds (``-d`` flag). + + .. versionadded:: 0.45 notify : bool, optional Do not wait for input (``-N`` flag). + + .. versionadded:: 0.45 list_formats : bool, optional List format variables (``-l`` flag). Requires tmux 3.4+. @@ -807,19 +831,33 @@ def select( direction : ResizeAdjustmentDirection, optional Select the pane in the given direction (``-U``, ``-D``, ``-L``, ``-R``). + + .. versionadded:: 0.45 last : bool, optional Select the last (previously selected) pane (``-l`` flag). + + .. versionadded:: 0.45 keep_zoom : bool, optional Keep the window zoomed if it was zoomed (``-Z`` flag). + + .. versionadded:: 0.45 mark : bool, optional Set the marked pane (``-m`` flag). + + .. versionadded:: 0.45 clear_mark : bool, optional Clear the marked pane (``-M`` flag). + + .. versionadded:: 0.45 disable_input : bool, optional Disable input to the pane (``-d`` flag). + + .. versionadded:: 0.45 enable_input : bool, optional Enable input to the pane (``-e`` flag). + .. versionadded:: 0.45 + Returns ------- :class:`Pane` diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 19194e5fa..b9ec9ccdf 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -416,11 +416,17 @@ def last_pane( ---------- detach : bool, optional Do not make the pane active (``-d`` flag). + + .. versionadded:: 0.45 keep_zoom : bool, optional Keep the window zoomed if zoomed (``-Z`` flag). + + .. versionadded:: 0.45 disable_input : bool, optional Disable input to the pane (``-e`` flag). + .. versionadded:: 0.45 + Returns ------- :class:`Pane` or None From b7ec0b6789fc5b7c9a8808a25b26f6d717317aeb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 08:05:35 -0500 Subject: [PATCH 053/105] Pane,Window(fix[respawn]): use fused -c flag format for consistency why: The pane/window split() and new_window() methods use the fused form (f"-c{path}",) for the start-directory flag. The respawn methods used the separated form ("-c", str(path)), diverging from the dominant pattern in pane/window code. what: - Change Pane.respawn -c from ("-c", str(start_path)) to (f"-c{start_path}",) - Change Window.respawn -c from ("-c", str(start_path)) to (f"-c{start_path}",) --- src/libtmux/pane.py | 2 +- src/libtmux/window.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index d00c2a135..c137e40fa 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1687,7 +1687,7 @@ def respawn( if start_directory is not None: start_path = pathlib.Path(start_directory).expanduser() - tmux_args += ("-c", str(start_path)) + tmux_args += (f"-c{start_path}",) if environment: for k, v in environment.items(): diff --git a/src/libtmux/window.py b/src/libtmux/window.py index b9ec9ccdf..16fc1c4aa 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -747,7 +747,7 @@ def respawn( if start_directory is not None: start_path = pathlib.Path(start_directory).expanduser() - tmux_args += ("-c", str(start_path)) + tmux_args += (f"-c{start_path}",) if environment: for k, v in environment.items(): From 18df791bd14eebf666025921252dd72c30a71976 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 08:46:18 -0500 Subject: [PATCH 054/105] Window(fix[last_pane]): correct flag mapping for -d/-e flags why: last-pane -d disables input and -e enables input per tmux source (cmd-select-pane.c). The parameters were misnamed: detach mapped to -d (actually disables input, not detach) and disable_input mapped to -e (actually enables input). There is no "detach" flag for last-pane. what: - Replace detach/disable_input params with disable_input(-d)/enable_input(-e) - Match the pattern used by Pane.select() which already maps these correctly - Update docstrings and versionadded annotations --- src/libtmux/window.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 16fc1c4aa..b9f538c95 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -406,24 +406,24 @@ def resize( def last_pane( self, *, - detach: bool | None = None, - keep_zoom: bool | None = None, disable_input: bool | None = None, + enable_input: bool | None = None, + keep_zoom: bool | None = None, ) -> Pane | None: """Select the last (previously active) pane via ``$ tmux last-pane``. Parameters ---------- - detach : bool, optional - Do not make the pane active (``-d`` flag). + disable_input : bool, optional + Disable input to the pane (``-d`` flag). .. versionadded:: 0.45 - keep_zoom : bool, optional - Keep the window zoomed if zoomed (``-Z`` flag). + enable_input : bool, optional + Enable input to the pane (``-e`` flag). .. versionadded:: 0.45 - disable_input : bool, optional - Disable input to the pane (``-e`` flag). + keep_zoom : bool, optional + Keep the window zoomed if zoomed (``-Z`` flag). .. versionadded:: 0.45 @@ -442,15 +442,15 @@ def last_pane( """ tmux_args: tuple[str, ...] = () - if detach: + if disable_input: tmux_args += ("-d",) + if enable_input: + tmux_args += ("-e",) + if keep_zoom: tmux_args += ("-Z",) - if disable_input: - tmux_args += ("-e",) - proc = self.cmd("last-pane", *tmux_args) if proc.stderr: From b6abc31f76e04ee4ae83b3a1b2b95c4de57659b0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 08:47:39 -0500 Subject: [PATCH 055/105] Window(fix[split]): forward percentage parameter to Pane.split why: Pane.split() gained a percentage parameter for the -p flag but Window.split() which delegates to it neither accepts nor forwards it. what: - Add percentage parameter to Window.split() signature - Forward percentage to active_pane.split() - Fix size docstring to say "Cell/row count" (not "or percentage") --- src/libtmux/window.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index b9f538c95..b2a039a90 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -278,6 +278,7 @@ def split( zoom: bool | None = None, shell: str | None = None, size: str | int | None = None, + percentage: int | None = None, environment: dict[str, str] | None = None, ) -> Pane: """Split window on active pane and return the created :class:`Pane`. @@ -303,7 +304,12 @@ def split( is useful for long-running processes where the closing of the window upon completion is desired. size : int, optional - Cell/row or percentage to occupy with respect to current window. + Cell/row count to occupy with respect to current window. + percentage : int, optional + Percentage (0-100) of the window to occupy (``-p`` flag). + Mutually exclusive with *size*. + + .. versionadded:: 0.45 environment : dict, optional Environmental variables for new pane. Passthrough to ``-e``. @@ -322,6 +328,7 @@ def split( zoom=zoom, shell=shell, size=size, + percentage=percentage, environment=environment, ) From 5e97a8ae8456c6c2ebfbd1dd89ce4e929cb3ecc5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 09:33:12 -0500 Subject: [PATCH 056/105] Window(fix[select_layout]): use -p flag for previous layout, not -o why: select-layout -o restores the old/saved layout (undo), while -p cycles to the previous layout preset. The previous_layout parameter was sending -o instead of -p. Verified in cmd-select-layout.c:89. what: - Change -o to -p for previous_layout flag - Update docstring to reference correct flag --- src/libtmux/window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index b2a039a90..c421aff6d 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -511,7 +511,7 @@ def select_layout( .. versionadded:: 0.45 previous_layout : bool, optional - Move to the previous layout (``-o`` flag). + Move to the previous layout (``-p`` flag). .. versionadded:: 0.45 @@ -542,7 +542,7 @@ def select_layout( cmd.append("-n") if previous_layout: - cmd.append("-o") + cmd.append("-p") if layout: # tmux allows select-layout without args cmd.append(layout) From c9a2b61e3ff1727afdaedb83b33a68fe52be5878 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 09:34:53 -0500 Subject: [PATCH 057/105] Server(fix[new_session]): -f sets client flags, not config file path why: new-session -f calls server_client_set_flags(), setting client flags like no-output and read-only. It does not load a config file (that is the top-level tmux -f flag). Verified in cmd-new-session.c:326. what: - Rename config_file param to client_flags - Change type from StrPath to str (flags are strings, not paths) - Remove pathlib.Path wrapping in implementation - Update test to use actual client flag value --- src/libtmux/server.py | 11 ++++++----- tests/test_server.py | 14 +++++--------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index b06589318..562218c67 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1512,7 +1512,7 @@ def new_session( *args: t.Any, detach_others: bool | None = None, no_size: bool | None = None, - config_file: StrPath | None = None, + client_flags: str | None = None, **kwargs: t.Any, ) -> Session: """Create new session, returns new :class:`Session`. @@ -1567,8 +1567,9 @@ def new_session( Do not set the initial window size (``-X`` flag). .. versionadded:: 0.45 - config_file : str or PathLike, optional - Specify an alternative configuration file (``-f`` flag). + client_flags : str, optional + Set client flags (``-f`` flag), e.g. ``no-output``, + ``read-only``. Requires tmux 3.2+. .. versionadded:: 0.45 @@ -1644,8 +1645,8 @@ def new_session( if no_size: tmux_args += ("-X",) - if config_file is not None: - tmux_args += ("-f", str(pathlib.Path(config_file).expanduser())) + if client_flags is not None: + tmux_args += ("-f", client_flags) if session_name is not None: tmux_args += (f"-s{session_name}",) diff --git a/tests/test_server.py b/tests/test_server.py index e3f6ad28e..8bca3dab2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -904,16 +904,12 @@ def test_list_clients(server: Server) -> None: assert isinstance(result, list) -def test_new_session_config_file( +def test_new_session_client_flags( server: Server, - tmp_path: pathlib.Path, ) -> None: - """Test Server.new_session() with config_file flag.""" - conf = tmp_path / "test.conf" - conf.write_text("set -g status off\n") - + """Test Server.new_session() with client_flags flag.""" session = server.new_session( - session_name="conf_test", - config_file=str(conf), + session_name="flags_test", + client_flags="no-output", ) - assert session.session_name == "conf_test" + assert session.session_name == "flags_test" From 62635ba87ceb11dd320bc4795df07a9de43766c6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 09:36:12 -0500 Subject: [PATCH 058/105] Server(fix[run_shell]): -C means tmux command, not capture output why: run-shell -C parses the argument as a tmux command instead of a shell command (ARGS_PARSE_COMMANDS_OR_STRING). It does not capture output. Verified in cmd-run-shell.c:50. what: - Rename capture param to as_tmux_command - Update docstring to reflect actual semantics --- src/libtmux/server.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 562218c67..ef5249ead 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -429,7 +429,7 @@ def run_shell( *, background: bool | None = None, delay: str | None = None, - capture: bool | None = None, + as_tmux_command: bool | None = None, target_pane: str | None = None, ) -> list[str] | None: """Execute a shell command via ``$ tmux run-shell``. @@ -442,8 +442,9 @@ def run_shell( Run in background (``-b`` flag). delay : str, optional Delay before execution (``-d`` flag). - capture : bool, optional - Capture output to the target pane (``-C`` flag). + as_tmux_command : bool, optional + Parse argument as a tmux command instead of a shell command + (``-C`` flag). target_pane : str, optional Target pane for output (``-t`` flag). @@ -466,7 +467,7 @@ def run_shell( if delay is not None: tmux_args += ("-d", delay) - if capture: + if as_tmux_command: tmux_args += ("-C",) if target_pane is not None: From cf89d6243953f39fcaf545580ec5cb3c7b9ace50 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 09:37:26 -0500 Subject: [PATCH 059/105] Server(fix[command_prompt]): -N means numeric input, not no-execute why: command-prompt -N sets PROMPT_NUMERIC (accept only numeric input). It does not suppress command execution. Verified in cmd-command-prompt.c:161. what: - Rename no_execute param to numeric - Update docstring to reflect actual semantics --- src/libtmux/server.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index ef5249ead..460e2ccc1 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -895,7 +895,7 @@ def command_prompt( one_key: bool | None = None, key_only: bool | None = None, on_input_change: bool | None = None, - no_execute: bool | None = None, + numeric: bool | None = None, prompt_type: str | None = None, ) -> None: """Open a command prompt via ``$ tmux command-prompt``. @@ -921,8 +921,8 @@ def command_prompt( Only accept key presses, not text (``-k`` flag). on_input_change : bool, optional Run template on each keystroke (``-i`` flag). - no_execute : bool, optional - Do not execute the command, just insert into prompt (``-N`` flag). + numeric : bool, optional + Accept only numeric input (``-N`` flag). prompt_type : str, optional Prompt type (``-T`` flag). One of: ``command``, ``search``, ``target``, ``window-target``. @@ -950,7 +950,7 @@ def command_prompt( if on_input_change: tmux_args += ("-i",) - if no_execute: + if numeric: tmux_args += ("-N",) if prompt is not None: From 655e88d4b26c949481a0213f39e82e10e03e81e8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 09:38:53 -0500 Subject: [PATCH 060/105] Pane(fix[capture_pane]): -M captures mode screen, not escape markup why: capture-pane -M captures from the mode screen (e.g. copy mode), not escape markup. Verified in cmd-capture-pane.c:132 and tmux CHANGES: "Add -M flag to capture-pane to use the copy mode screen." what: - Rename escape_markup param to mode_screen - Update docstring and warning message - Update test name and parameter usage --- src/libtmux/pane.py | 11 ++++++----- tests/test_pane_capture_pane.py | 6 +++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index c137e40fa..23deb4de3 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -328,7 +328,7 @@ def capture_pane( trim_trailing: bool = False, alternate_screen: bool = False, quiet: bool = False, - escape_markup: bool = False, + mode_screen: bool = False, ) -> list[str]: r"""Capture text from pane. @@ -384,8 +384,9 @@ def capture_pane( Default: False .. versionadded:: 0.45 - escape_markup : bool, optional - Escape markup in the output (``-M`` flag). Requires tmux 3.6+. + mode_screen : bool, optional + Capture from the mode screen (e.g. copy mode) instead of the + pane (``-M`` flag). Requires tmux 3.6+. Default: False .. versionadded:: 0.45 @@ -440,12 +441,12 @@ def capture_pane( cmd.append("-a") if quiet: cmd.append("-q") - if escape_markup: + if mode_screen: if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): cmd.append("-M") else: warnings.warn( - "escape_markup requires tmux 3.6+, ignoring", + "mode_screen requires tmux 3.6+, ignoring", stacklevel=2, ) return self.cmd(*cmd).stdout diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 2fa1b30b4..5a78552ee 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -492,8 +492,8 @@ def test_capture_pane_alternate_screen(session: Session) -> None: assert isinstance(result, list) -def test_capture_pane_escape_markup(session: Session) -> None: - """Test capture_pane with escape_markup flag (3.6+).""" +def test_capture_pane_mode_screen(session: Session) -> None: + """Test capture_pane with mode_screen flag (3.6+).""" from libtmux.common import has_gte_version if not has_gte_version("3.6"): @@ -502,5 +502,5 @@ def test_capture_pane_escape_markup(session: Session) -> None: pane = session.active_window.active_pane assert pane is not None - result = pane.capture_pane(escape_markup=True) + result = pane.capture_pane(mode_screen=True) assert isinstance(result, list) From 864512c6b7b439f6b5aea92729c59d1870f206b0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 09:39:58 -0500 Subject: [PATCH 061/105] Pane(fix[clear_history]): -H resets hyperlinks, not clears pane content why: clear-history -H calls screen_reset_hyperlinks(), removing OSC 8 hyperlinks. It does not clear visible pane content. Verified in cmd-capture-pane.c:226. what: - Rename clear_pane param to reset_hyperlinks - Update docstring and warning message --- src/libtmux/pane.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 23deb4de3..bc85da47e 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1947,14 +1947,13 @@ def swap( if proc.stderr: raise exc.LibTmuxException(proc.stderr) - def clear_history(self, *, clear_pane: bool | None = None) -> None: + def clear_history(self, *, reset_hyperlinks: bool | None = None) -> None: """Clear pane history buffer via ``$ tmux clear-history``. Parameters ---------- - clear_pane : bool, optional - Also clear the visible pane content (``-H`` flag). - Requires tmux 3.4+. + reset_hyperlinks : bool, optional + Also reset hyperlinks (``-H`` flag). Requires tmux 3.4+. .. versionadded:: 0.45 @@ -1968,12 +1967,12 @@ def clear_history(self, *, clear_pane: bool | None = None) -> None: tmux_args: tuple[str, ...] = () - if clear_pane: + if reset_hyperlinks: if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): tmux_args += ("-H",) else: warnings.warn( - "clear_pane requires tmux 3.4+, ignoring", + "reset_hyperlinks requires tmux 3.4+, ignoring", stacklevel=2, ) From 6a4af893ed8ec8f991c5454812f41e1f8df571ab Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 15:22:46 -0500 Subject: [PATCH 062/105] Pane(fix[copy_mode]): -q cancels modes, -e exits on scroll to bottom why: copy-mode -q cancels all modes (window_pane_reset_mode_all), not "quiet". And -e exits copy mode when scrolling reaches the bottom of history, not "exit on copy". Verified in cmd-copy-mode.c and tmux manpage. Internal tmux name for -e is scroll_exit. what: - Rename quiet param to cancel - Rename exit_on_copy param to exit_on_bottom - Update docstrings to reflect actual semantics --- src/libtmux/pane.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index bc85da47e..0824bd5ea 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1428,9 +1428,9 @@ def copy_mode( self, *, scroll_up: bool | None = None, - exit_on_copy: bool | None = None, + exit_on_bottom: bool | None = None, mouse_drag: bool | None = None, - quiet: bool | None = None, + cancel: bool | None = None, ) -> None: """Enter copy mode via ``$ tmux copy-mode``. @@ -1438,12 +1438,13 @@ def copy_mode( ---------- scroll_up : bool, optional Start scrolled up one page (``-u`` flag). - exit_on_copy : bool, optional - Exit copy mode after copying (``-e`` flag). + exit_on_bottom : bool, optional + Exit copy mode when scrolling reaches the bottom of the + history (``-e`` flag). mouse_drag : bool, optional Start mouse drag (``-M`` flag). - quiet : bool, optional - Quiet mode (``-q`` flag). + cancel : bool, optional + Cancel copy mode and any other modes (``-q`` flag). Examples -------- @@ -1454,13 +1455,13 @@ def copy_mode( if scroll_up: tmux_args += ("-u",) - if exit_on_copy: + if exit_on_bottom: tmux_args += ("-e",) if mouse_drag: tmux_args += ("-M",) - if quiet: + if cancel: tmux_args += ("-q",) proc = self.cmd("copy-mode", *tmux_args) From fb4e0dde41d1b6ef44cb630b64de2afbd77e2599 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 15:24:31 -0500 Subject: [PATCH 063/105] Pane(fix[paste_buffer]): -r changes separator to newline, not no-trailing why: paste-buffer -r changes the inter-line separator from carriage return to newline. It does not suppress a trailing newline. Verified in cmd-paste-buffer.c: default sepstr is "\r", -r changes to "\n". what: - Rename no_trailing_newline param to linefeed_separator - Update docstring to reflect actual semantics --- src/libtmux/pane.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 0824bd5ea..2289ee08b 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1330,7 +1330,7 @@ def paste_buffer( *, buffer_name: str | None = None, delete_after: bool | None = None, - no_trailing_newline: bool | None = None, + linefeed_separator: bool | None = None, bracket: bool | None = None, separator: str | None = None, ) -> None: @@ -1342,8 +1342,9 @@ def paste_buffer( Name of the buffer to paste (``-b`` flag). delete_after : bool, optional Delete the buffer after pasting (``-d`` flag). - no_trailing_newline : bool, optional - Do not add a trailing newline (``-r`` flag). + linefeed_separator : bool, optional + Use newline as the line separator instead of carriage return + (``-r`` flag). bracket : bool, optional Use bracketed paste mode (``-p`` flag). separator : str, optional @@ -1359,7 +1360,7 @@ def paste_buffer( if delete_after: tmux_args += ("-d",) - if no_trailing_newline: + if linefeed_separator: tmux_args += ("-r",) if bracket: From 549e1606c5acbdfc520f0d96f143f0807ceac784 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 17:50:49 -0500 Subject: [PATCH 064/105] Pane(fix[choose_tree]): -s/-w mean collapsed, not only-show why: choose-tree -s starts with sessions collapsed and -w with windows collapsed. They do not filter to show only sessions/windows. Verified in tmux manpage and cmd-choose.c source. what: - Rename sessions_only to sessions_collapsed - Rename windows_only to windows_collapsed - Update docstrings to reflect actual semantics --- src/libtmux/pane.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 2289ee08b..d6a52ded0 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1538,17 +1538,17 @@ def choose_client(self) -> None: def choose_tree( self, *, - sessions_only: bool | None = None, - windows_only: bool | None = None, + sessions_collapsed: bool | None = None, + windows_collapsed: bool | None = None, ) -> None: """Enter tree chooser via ``$ tmux choose-tree``. Parameters ---------- - sessions_only : bool, optional - Only show sessions, not windows (``-s`` flag). - windows_only : bool, optional - Only show windows, not sessions (``-w`` flag). + sessions_collapsed : bool, optional + Start with sessions collapsed (``-s`` flag). + windows_collapsed : bool, optional + Start with windows collapsed (``-w`` flag). Examples -------- @@ -1556,10 +1556,10 @@ def choose_tree( """ tmux_args: tuple[str, ...] = () - if sessions_only: + if sessions_collapsed: tmux_args += ("-s",) - if windows_only: + if windows_collapsed: tmux_args += ("-w",) proc = self.cmd("choose-tree", *tmux_args) From aae8ec7f688de5526a3d71669e3563cf4f3603fc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 17:52:02 -0500 Subject: [PATCH 065/105] Window(fix[rotate]): don't always inject -D, respect tmux default why: rotate-window with no flags defaults to upward rotation in tmux (cmd-rotate-window.c:82). The code always injected -D in the else branch, making rotate() behave as downward instead of tmux's default. what: - Replace direction_up with separate upward/downward params - Only send -U/-D when explicitly requested - No flags sent by default (tmux default = upward) --- src/libtmux/window.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index c421aff6d..0c0ebb99b 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -679,15 +679,18 @@ def unlink(self, *, kill_if_last: bool | None = None) -> None: def rotate( self, *, - direction_up: bool | None = None, + upward: bool | None = None, + downward: bool | None = None, keep_zoom: bool | None = None, ) -> Window: """Rotate pane positions in the window via ``$ tmux rotate-window``. Parameters ---------- - direction_up : bool, optional - Rotate upward (``-U`` flag). Default is downward (``-D``). + upward : bool, optional + Rotate upward (``-U`` flag). + downward : bool, optional + Rotate downward (``-D`` flag). keep_zoom : bool, optional Keep the window zoomed if zoomed (``-Z`` flag). @@ -705,9 +708,10 @@ def rotate( """ tmux_args: tuple[str, ...] = () - if direction_up: + if upward: tmux_args += ("-U",) - else: + + if downward: tmux_args += ("-D",) if keep_zoom: From 2d999bc0fe494029c42c0fb62c9ff47e3ac54f15 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 17:53:10 -0500 Subject: [PATCH 066/105] Server(fix[confirm_before,command_prompt]): -b requires tmux 3.3+, not 3.4+ why: The -b flag for confirm-before and command-prompt was added in tmux 3.3, not 3.4. Verified across version worktrees: tmux-3.2a has args "p:t:" while tmux-3.3 has "bp:t:". what: - Change "Requires tmux 3.4+" to "Requires tmux 3.3+" in both docstrings --- src/libtmux/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 460e2ccc1..76d6ed2f3 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -838,7 +838,7 @@ def confirm_before( Always uses ``-b`` (background) to avoid blocking the command queue. Use ``send-keys -K -c `` to provide the confirmation key. - Requires tmux 3.4+ for ``-b`` flag support. + Requires tmux 3.3+ for ``-b`` flag support. Parameters ---------- @@ -903,7 +903,7 @@ def command_prompt( Always uses ``-b`` (background) to avoid blocking the command queue. Use ``send-keys -K -c `` to type into the prompt and submit. - Requires tmux 3.4+ for ``-b`` flag support. + Requires tmux 3.3+ for ``-b`` flag support. Parameters ---------- From 8eb9172e0683fb01c820e3a6bb41fb5573557432 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 18:42:53 -0500 Subject: [PATCH 067/105] Pane(fix[display_popup]): -C closes existing popup, not close-on-success why: The -C flag in tmux display-popup means "close any existing popup on the client" (server_client_clear_overlay), not "close on success exit code." The close-on-success behavior is achieved by passing -E twice (-EE), which sets POPUP_CLOSEEXITZERO in tmux's popup.c. what: - Fix close_on_success to emit -E -E instead of -C - Add close_existing parameter for the actual -C flag behavior - Update docstrings to document correct flag semantics --- src/libtmux/pane.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index d6a52ded0..3018766de 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1186,6 +1186,7 @@ def display_popup( *, close_on_exit: bool | None = None, close_on_success: bool | None = None, + close_existing: bool | None = None, width: int | str | None = None, height: int | str | None = None, x: int | str | None = None, @@ -1210,7 +1211,10 @@ def display_popup( close_on_exit : bool, optional Close popup when command exits (``-E`` flag). close_on_success : bool, optional - Close popup only on success exit code (``-C`` flag). + Close popup only on success exit code (``-EE`` flag, passing ``-E`` + twice). + close_existing : bool, optional + Close any existing popup on the client (``-C`` flag). width : int or str, optional Popup width (``-w`` flag). height : int or str, optional @@ -1249,11 +1253,14 @@ def display_popup( tmux_args: tuple[str, ...] = () + if close_existing: + tmux_args += ("-C",) + if close_on_exit: tmux_args += ("-E",) if close_on_success: - tmux_args += ("-C",) + tmux_args += ("-E", "-E") if width is not None: tmux_args += ("-w", str(width)) From 2dab01394d9560a4c2b14bc171bbde384ea94fb8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 28 Mar 2026 18:44:19 -0500 Subject: [PATCH 068/105] Window(fix[move_window]): -r is standalone renumber, not renumber-after-move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: In tmux's cmd-move-window.c, the -r flag triggers session_renumber_windows() and returns CMD_RETURN_NORMAL immediately — the move logic on subsequent lines is never reached. The docstring incorrectly said "Renumber all windows after moving" implying both a renumber and a move occur. what: - Fix docstring to document -r as a standalone operation - Note that other parameters are ignored when renumber is used --- src/libtmux/window.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 0c0ebb99b..535c68aec 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -955,7 +955,9 @@ def move_window( .. versionadded:: 0.45 renumber : bool, optional - Renumber all windows after moving (``-r`` flag). + Renumber all windows in sequential order (``-r`` flag). This is a + standalone operation — when used, no move is performed and other + parameters are ignored. .. versionadded:: 0.45 From 57ebdb3cc09833536b6fa74d37d61bc4b6ab5d38 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 06:18:49 -0500 Subject: [PATCH 069/105] Session(fix[detach_client]): preserve client targeting semantics why: tmux detach-client treats -s before -t, so always forcing a session target detached every client on the session instead of the requested client. what: - remove the unconditional session target from Session.detach_client - clarify all_clients plus target_client behavior in the docstring - add regressions for default and explicit target detach behavior --- src/libtmux/session.py | 6 ++---- tests/test_session.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 8d8f8833f..e779c4e3c 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -270,7 +270,8 @@ def detach_client( Target client to detach (``-t`` flag). If omitted, detaches the most recently active client. all_clients : bool, optional - Detach all clients attached to this session (``-a`` flag). + Detach all clients attached to this session (``-a`` flag). If + combined with ``target_client``, tmux keeps that client attached. shell_command : str, optional Run a shell command after detaching (``-E`` flag). @@ -290,9 +291,6 @@ def detach_client( if target_client is not None: tmux_args += ("-t", target_client) - # Use -s for session targeting (not -t, which targets clients) - tmux_args += ("-s", str(self.session_id)) - proc = self.server.cmd("detach-client", *tmux_args) if proc.stderr: diff --git a/tests/test_session.py b/tests/test_session.py index 2a213f8cd..8a0328b66 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -595,6 +595,46 @@ def test_detach_client( assert after == before - 1 +def test_detach_client_only_detaches_one_client( + control_mode: t.Callable[..., t.Any], + session: Session, + server: Server, +) -> None: + """Test Session.detach_client() without a target detaches one client.""" + with control_mode(), control_mode(): + before = server.cmd("list-clients", "-F", "#{client_name}").stdout + assert len(before) == 2 + + session.detach_client() + + after = server.cmd("list-clients", "-F", "#{client_name}").stdout + assert len(after) == 1 + assert set(after) < set(before) + + +def test_detach_client_target_client( + control_mode: t.Callable[..., t.Any], + session: Session, + server: Server, +) -> None: + """Test Session.detach_client() detaches only the requested client.""" + with control_mode(), control_mode(): + clients = server.cmd("list-clients", "-F", "#{client_name}").stdout + assert len(clients) == 2 + + target_client = clients[-1] + session.detach_client(target_client=target_client) + + remaining_clients = server.cmd( + "list-clients", + "-F", + "#{client_name}", + ).stdout + assert remaining_clients == [ + client for client in clients if client != target_client + ] + + def test_last_window(session: Session) -> None: """Test Session.last_window() selects previous window.""" w1 = session.new_window(window_name="last_a", attach=True) From ee35364f51947afffdc374fc4cd1197275c895f8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 06:21:16 -0500 Subject: [PATCH 070/105] Window(fix[move_window]): refresh moved window state why: move-window can land on an index different from the requested target and can also change the owning session, leaving the returned Window stale. what: - refresh Window state after every successful move-window command - add regressions for relative move freshness - add a cross-session move regression with an explicit destination --- src/libtmux/window.py | 5 +---- tests/test_window.py | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 535c68aec..f3e10e43a 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -999,10 +999,7 @@ def move_window( if proc.stderr: raise exc.LibTmuxException(proc.stderr) - if destination != "" and session is not None: - self.window_index = destination - else: - self.refresh() + self.refresh() return self diff --git a/tests/test_window.py b/tests/test_window.py index 4692bdbc2..a6627634b 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -400,6 +400,54 @@ def test_move_window_to_other_session(server: Server, session: Session) -> None: assert new_session.windows.get(window_id=window_id) == window +@pytest.mark.parametrize( + ("flag_name", "destination_offset"), [("after", 0), ("before", 1)] +) +def test_move_window_relative_returns_fresh_window( + flag_name: str, + destination_offset: int, + session: Session, +) -> None: + """Window.move_window() returns fresh state for relative moves.""" + destination_window = session.active_window + session.new_window(window_name="move_middle") + moving_window = session.new_window(window_name="move_relative") + assert destination_window.window_index is not None + assert moving_window.window_id is not None + + destination = str(int(destination_window.window_index) + destination_offset) + if flag_name == "after": + moving_window.move_window(destination, after=True) + else: + moving_window.move_window(destination, before=True) + + fresh_window = Window.from_window_id( + server=session.server, + window_id=moving_window.window_id, + ) + assert moving_window.window_index == fresh_window.window_index + assert moving_window.session_id == fresh_window.session_id + + +def test_move_window_to_other_session_with_destination( + server: Server, + session: Session, +) -> None: + """Window.move_window() returns fresh state for cross-session moves.""" + window = session.new_window(window_name="move_cross_session") + assert window.window_id is not None + new_session = server.new_session("test_move_window_destination") + destination = "99" + + window.move_window(destination=destination, session=new_session.session_id) + + fresh_window = Window.from_window_id(server=server, window_id=window.window_id) + assert fresh_window.session_id == new_session.session_id + assert fresh_window.window_index == destination + assert window.session_id == fresh_window.session_id + assert window.window_index == fresh_window.window_index + + def test_select_layout_accepts_no_arg(server: Server, session: Session) -> None: """Tmux allows select-layout with no arguments, so let's allow it here.""" window = session.new_window(window_name="test_window") From 6700e9bf794116b4cb3ba9298ac588098c4d5f3a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 06:23:08 -0500 Subject: [PATCH 071/105] ControlMode(fix[client_name]): bind client name to spawned client why: recording the first client returned by list-clients can capture an existing attached client instead of the control-mode client spawned for the test. what: - wait for the spawned client pid to appear in list-clients - record client_name from the matching pid row - add a nested control-mode regression that verifies pid-to-name binding --- src/libtmux/_internal/control_mode.py | 22 ++++++++++++---------- tests/test_control_mode.py | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index b12d38288..d8cb924b1 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -91,21 +91,23 @@ def __enter__(self) -> ControlMode: self._write_fd = os.open(self._fifo_path, os.O_WRONLY) self.stdout = self._proc.stdout # type: ignore[assignment] + client_pid = str(self._proc.pid) def client_registered() -> bool: - clients = self.server.list_clients() - return len(clients) > 0 + result = self.server.cmd( + "list-clients", + "-F", + "#{client_pid}\t#{client_name}", + ) + for line in result.stdout: + pid, _, client_name = line.partition("\t") + if pid == client_pid and client_name: + self.client_name = client_name.strip() + return True + return False retry_until(client_registered, 3, raises=True) - # Capture client name - result = self.server.cmd( - "list-clients", - "-F", - "#{client_name}", - ) - self.client_name = result.stdout[0].strip() if result.stdout else "" - return self def __exit__( diff --git a/tests/test_control_mode.py b/tests/test_control_mode.py index 0f2b02a55..609357ed3 100644 --- a/tests/test_control_mode.py +++ b/tests/test_control_mode.py @@ -40,3 +40,23 @@ def test_control_mode_client_name( """ControlMode.client_name contains the tmux client identifier.""" with control_mode() as ctl: assert "client-" in ctl.client_name + + +def test_control_mode_client_name_matches_spawned_client( + control_mode: t.Callable[[], ControlMode], + server: Server, +) -> None: + """ControlMode records the client name for its own subprocess.""" + with control_mode() as first, control_mode() as second: + clients = { + tuple(line.split("\t", 1)) + for line in server.cmd( + "list-clients", + "-F", + "#{client_pid}\t#{client_name}", + ).stdout + } + + assert first.client_name != second.client_name + assert (str(first._proc.pid), first.client_name) in clients + assert (str(second._proc.pid), second.client_name) in clients From a1435613e889b8cfbca9ebbcab2acd8c5517b8d6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 10:59:18 -0500 Subject: [PATCH 072/105] ControlMode(fix[control_mode]): replace FIFO with os.pipe(), add cleanup, socket_path why: tempfile.mktemp() has a TOCTOU race; failure-path left open fds and live subprocess; socket_path was silently ignored. what: - Replace tempfile.mktemp() + os.mkfifo() FIFO with os.pipe() pair (no filesystem, no race condition) - Add failure-path cleanup in __enter__: if retry_until fails, close _write_fd and terminate subprocess before re-raising - Handle socket_path: build socket_args checking socket_name first (-L), then socket_path (-S), then empty - Remove unused tempfile and pathlib imports --- src/libtmux/_internal/control_mode.py | 63 +++++++++++++++------------ 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index d8cb924b1..47c1d0647 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -8,9 +8,7 @@ from __future__ import annotations import os -import pathlib import subprocess -import tempfile import typing as t from libtmux.test.retry import retry_until @@ -52,7 +50,6 @@ class ControlMode: stdout: t.IO[str] _proc: subprocess.Popen[str] - _fifo_path: str _write_fd: int def __init__(self, server: Server, session: Session) -> None: @@ -61,34 +58,37 @@ def __init__(self, server: Server, session: Session) -> None: def __enter__(self) -> ControlMode: """Spawn control-mode client and wait for registration.""" - self._fifo_path = tempfile.mktemp(prefix="libtmux_ctl_") - os.mkfifo(self._fifo_path) + read_fd, self._write_fd = os.pipe() tmux_bin = self.server.tmux_bin or "tmux" + + if self.server.socket_name is not None: + socket_args = ["-L", str(self.server.socket_name)] + elif self.server.socket_path is not None: + socket_args = ["-S", str(self.server.socket_path)] + else: + socket_args = [] + cmd = [ tmux_bin, - "-L", - str(self.server.socket_name), + *socket_args, "-C", "attach-session", "-t", str(self.session.session_id), ] - # Open read end for subprocess stdin - read_fd = os.open(self._fifo_path, os.O_RDONLY | os.O_NONBLOCK) - - self._proc = subprocess.Popen( - cmd, - stdin=read_fd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - os.close(read_fd) - - # Open write end to keep FIFO alive - self._write_fd = os.open(self._fifo_path, os.O_WRONLY) + try: + self._proc = subprocess.Popen( + cmd, + stdin=read_fd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + finally: + # Close read end in parent regardless — subprocess owns it now + os.close(read_fd) self.stdout = self._proc.stdout # type: ignore[assignment] client_pid = str(self._proc.pid) @@ -106,7 +106,17 @@ def client_registered() -> bool: return True return False - retry_until(client_registered, 3, raises=True) + try: + retry_until(client_registered, 3, raises=True) + except Exception: + os.close(self._write_fd) + self._proc.terminate() + try: + self._proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.wait() + raise return self @@ -116,8 +126,8 @@ def __exit__( exc_val: BaseException | None, exc_tb: types.TracebackType | None, ) -> None: - """Terminate control-mode client and clean up FIFO.""" - # Close write end — causes the control-mode client to exit + """Terminate control-mode client.""" + # Close write end — causes the control-mode client to exit (EOF on stdin) os.close(self._write_fd) self._proc.terminate() @@ -126,8 +136,3 @@ def __exit__( except subprocess.TimeoutExpired: self._proc.kill() self._proc.wait() - - # Remove FIFO - fifo = pathlib.Path(self._fifo_path) - if fifo.exists(): - fifo.unlink() From 23714c605db5acd7cecb35db341e137699b72f9a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 11:02:41 -0500 Subject: [PATCH 073/105] Pane(fix[display_popup]): raise ValueError when close_on_exit + close_on_success combined why: tmux counts -E flags via args_count(); 3x -E evaluates as != 2, falls through to the args_has() branch and silently behaves like 1x -E (close-on-any-exit), discarding the close_on_success intent entirely. what: - Add mutual-exclusion guard: raise ValueError when both close_on_exit and close_on_success are True - Assign message to variable first to satisfy EM101/TRY003 linting rules --- src/libtmux/pane.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 3018766de..9611a2424 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1256,6 +1256,14 @@ def display_popup( if close_existing: tmux_args += ("-C",) + if close_on_exit and close_on_success: + msg = ( + "close_on_exit and close_on_success are mutually exclusive: " + "use close_on_exit=True for -E (close on any exit) " + "or close_on_success=True for -EE (close on zero exit code only)" + ) + raise ValueError(msg) + if close_on_exit: tmux_args += ("-E",) From d5c66628398013a698513a3978ff9d0e67844672 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 11:04:56 -0500 Subject: [PATCH 074/105] Server(fix[confirm_before,command_prompt]): gate -b behind has_gte_version("3.3") why: Both methods unconditionally emit -b despite their docstrings noting it requires tmux 3.3+. On older tmux this causes a hard command error instead of the project-convention warn-and-skip behaviour. what: - confirm_before: add has_gte_version("3.3") guard; warn and skip -b on older tmux - command_prompt: same - Add lazy imports for warnings and has_gte_version inside each method, matching the pattern already used in pane.py --- src/libtmux/server.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 76d6ed2f3..8c25d7d15 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -864,7 +864,19 @@ def confirm_before( ... server.cmd('show-options', '-gv', '@cf_test').stdout[0] 'yes' """ - tmux_args: tuple[str, ...] = ("-b",) + import warnings + + from libtmux.common import has_gte_version + + tmux_args: tuple[str, ...] = () + + if has_gte_version("3.3", tmux_bin=self.tmux_bin): + tmux_args += ("-b",) + else: + warnings.warn( + "confirm_before -b requires tmux 3.3+, ignoring", + stacklevel=2, + ) if prompt is not None: tmux_args += ("-p", prompt) @@ -939,7 +951,19 @@ def command_prompt( ... server.cmd('show-options', '-gv', '@cp_test').stdout[0] 'hi' """ - tmux_args: tuple[str, ...] = ("-b",) + import warnings + + from libtmux.common import has_gte_version + + tmux_args: tuple[str, ...] = () + + if has_gte_version("3.3", tmux_bin=self.tmux_bin): + tmux_args += ("-b",) + else: + warnings.warn( + "command_prompt -b requires tmux 3.3+, ignoring", + stacklevel=2, + ) if one_key: tmux_args += ("-1",) From 1e2b6af15cdf8eca817d77a25672ec534605f298 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 11:08:09 -0500 Subject: [PATCH 075/105] Server,Session(fix[version_guard,detach_scope]): raise on unsupported -b; scope detach-client to session why: Warn-and-skip for -b would silently change semantics to blocking, hanging the caller indefinitely. Session.detach_client() without -s was not actually session-scoped, making it wrong on multi-session servers. what: - confirm_before / command_prompt: replace warnings.warn with LibTmuxException when tmux < 3.3; -b is not optional, dropping it hangs the command queue - Session.detach_client(): restructure arg building so no-target uses -s self.session_id, target_client+all_clients uses -a -t, target_client alone uses -t - Update docstring: no-target now says "detaches all clients in this session" - Update tests: test_detach_client and renamed test now assert 0 clients remain (all session clients detached) rather than before-1 --- src/libtmux/server.py | 28 ++++++++-------------------- src/libtmux/session.py | 29 +++++++++++++++++++++-------- tests/test_session.py | 18 ++++++++++++------ 3 files changed, 41 insertions(+), 34 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 8c25d7d15..a900c637f 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -864,19 +864,13 @@ def confirm_before( ... server.cmd('show-options', '-gv', '@cf_test').stdout[0] 'yes' """ - import warnings - from libtmux.common import has_gte_version - tmux_args: tuple[str, ...] = () + if not has_gte_version("3.3", tmux_bin=self.tmux_bin): + msg = "confirm_before requires tmux 3.3+" + raise exc.LibTmuxException(msg) - if has_gte_version("3.3", tmux_bin=self.tmux_bin): - tmux_args += ("-b",) - else: - warnings.warn( - "confirm_before -b requires tmux 3.3+, ignoring", - stacklevel=2, - ) + tmux_args: tuple[str, ...] = ("-b",) if prompt is not None: tmux_args += ("-p", prompt) @@ -951,19 +945,13 @@ def command_prompt( ... server.cmd('show-options', '-gv', '@cp_test').stdout[0] 'hi' """ - import warnings - from libtmux.common import has_gte_version - tmux_args: tuple[str, ...] = () + if not has_gte_version("3.3", tmux_bin=self.tmux_bin): + msg = "command_prompt requires tmux 3.3+" + raise exc.LibTmuxException(msg) - if has_gte_version("3.3", tmux_bin=self.tmux_bin): - tmux_args += ("-b",) - else: - warnings.warn( - "command_prompt -b requires tmux 3.3+, ignoring", - stacklevel=2, - ) + tmux_args: tuple[str, ...] = ("-b",) if one_key: tmux_args += ("-1",) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index e779c4e3c..6f3fb1ed8 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -267,14 +267,23 @@ def detach_client( Parameters ---------- target_client : str, optional - Target client to detach (``-t`` flag). If omitted, detaches - the most recently active client. + Target client to detach (``-t`` flag). If omitted, all clients + attached to this session are detached (``-s`` session scoping). all_clients : bool, optional - Detach all clients attached to this session (``-a`` flag). If - combined with ``target_client``, tmux keeps that client attached. + When combined with ``target_client``, detach every client + attached to this session **except** *target_client*. Has no + additional effect when ``target_client`` is omitted. shell_command : str, optional Run a shell command after detaching (``-E`` flag). + Notes + ----- + ``all_clients=True`` differs from tmux's ``detach-client -a``, + which is server-wide (see ``cmd-detach-client.c`` — the ``-a`` + branch loops over the global client list). This wrapper enumerates + clients attached to ``self.session_id`` and issues one + ``detach-client`` per non-target client to preserve session scope. + Examples -------- >>> with control_mode() as ctl: @@ -282,14 +291,18 @@ def detach_client( """ tmux_args: tuple[str, ...] = () - if all_clients: - tmux_args += ("-a",) - if shell_command is not None: tmux_args += ("-E", shell_command) - if target_client is not None: + if all_clients and target_client is not None: + # Keep target_client attached; detach all others from session + tmux_args += ("-a", "-t", target_client) + elif target_client is not None: tmux_args += ("-t", target_client) + else: + # No target specified: scope to this session so behavior is + # deterministic regardless of how many sessions exist on the server + tmux_args += ("-s", str(self.session_id)) proc = self.server.cmd("detach-client", *tmux_args) diff --git a/tests/test_session.py b/tests/test_session.py index 8a0328b66..cb1b41d6c 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -586,21 +586,28 @@ def test_detach_client( session: Session, server: Server, ) -> None: - """Test Session.detach_client() detaches the control-mode client.""" + """Test Session.detach_client() detaches all session clients when no target given. + + Without target_client, -s session_id scopes the operation to this session. + """ with control_mode(): before = len(server.list_clients()) assert before > 0 session.detach_client() after = len(server.list_clients()) - assert after == before - 1 + assert after == 0 -def test_detach_client_only_detaches_one_client( +def test_detach_client_no_target_detaches_all_session_clients( control_mode: t.Callable[..., t.Any], session: Session, server: Server, ) -> None: - """Test Session.detach_client() without a target detaches one client.""" + """Test Session.detach_client() without a target detaches all session clients. + + Without a target_client, the method uses ``-s session_id`` to scope the + operation to this session, detaching all attached clients. + """ with control_mode(), control_mode(): before = server.cmd("list-clients", "-F", "#{client_name}").stdout assert len(before) == 2 @@ -608,8 +615,7 @@ def test_detach_client_only_detaches_one_client( session.detach_client() after = server.cmd("list-clients", "-F", "#{client_name}").stdout - assert len(after) == 1 - assert set(after) < set(before) + assert len(after) == 0 def test_detach_client_target_client( From 72d4d6210fc20224c108808ae9ea4fc927c0367e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 12:59:51 -0500 Subject: [PATCH 076/105] Pane(fix[display_popup]): correct ControlMode module path in docstring why: libtmux.test.control_mode does not exist; Sphinx cross-reference would produce a broken link. Correct path is _internal.control_mode. what: - Replace ~libtmux.test.control_mode.ControlMode with ~libtmux._internal.control_mode.ControlMode in display_popup docstring --- src/libtmux/pane.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 9611a2424..559fafe47 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1201,7 +1201,7 @@ def display_popup( """Display a popup overlay via ``$ tmux display-popup``. Requires tmux 3.2+ and an attached client. Use - :class:`~libtmux.test.control_mode.ControlMode` in tests to provide + :class:`~libtmux._internal.control_mode.ControlMode` in tests to provide a client. Parameters From 60767d1e3fb42efef0a5d7f75305805c9b7055fe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 13:01:02 -0500 Subject: [PATCH 077/105] Server(fix[show_prompt_history,clear_prompt_history]): guard tmux 3.3+ why: Both commands were added in tmux 3.3 but libtmux supports 3.2a. Without a guard, callers on 3.2a get a raw "unknown command" tmux error instead of a clean LibTmuxException with version context. what: - Add has_gte_version("3.3") guard to show_prompt_history - Add has_gte_version("3.3") guard to clear_prompt_history - Pattern matches confirm_before / command_prompt guards in same file --- src/libtmux/server.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index a900c637f..9dddb5d1d 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1131,6 +1131,12 @@ def show_prompt_history( >>> isinstance(result, list) True """ + from libtmux.common import has_gte_version + + if not has_gte_version("3.3", tmux_bin=self.tmux_bin): + msg = "show_prompt_history requires tmux 3.3+" + raise exc.LibTmuxException(msg) + tmux_args: tuple[str, ...] = () if prompt_type is not None: @@ -1160,6 +1166,12 @@ def clear_prompt_history( -------- >>> server.clear_prompt_history() """ + from libtmux.common import has_gte_version + + if not has_gte_version("3.3", tmux_bin=self.tmux_bin): + msg = "clear_prompt_history requires tmux 3.3+" + raise exc.LibTmuxException(msg) + tmux_args: tuple[str, ...] = () if prompt_type is not None: From cf1127f65af9377a3d8ef634032bc46b3a923b97 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 13:02:10 -0500 Subject: [PATCH 078/105] Window(fix[swap]): refresh self and target after swap-window why: swap-window swaps window object pointers at tmux indices, changing window_index on both objects. Not refreshing leaves stale state, identical to the issue fixed in move_window by commit 3654a36e. what: - Add self.refresh() after successful swap-window cmd - Refresh target Window if target is a Window instance - Remove manual refresh() calls from docstring examples (now automatic) --- src/libtmux/window.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index f3e10e43a..7ef8e676e 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -794,8 +794,6 @@ def swap( >>> w1_idx = w1.window_index >>> w2_idx = w2.window_index >>> w1.swap(w2) - >>> w1.refresh() - >>> w2.refresh() >>> w1.window_index == w2_idx True >>> w2.window_index == w1_idx @@ -814,6 +812,10 @@ def swap( if proc.stderr: raise exc.LibTmuxException(proc.stderr) + self.refresh() + if isinstance(target, Window): + target.refresh() + def rename_window(self, new_name: str) -> Window: """Rename window. From fac79c2a83856e64388c6d9496b542ed9b90913f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 29 Mar 2026 13:04:53 -0500 Subject: [PATCH 079/105] =?UTF-8?q?Pane(fix[display=5Fmessage]):=20no=5Fex?= =?UTF-8?q?pand=E2=86=92-l=20(literal),=20remove=20wrong=20list=5Fformats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: -I opens pane stdin input mode (window_pane_start_input), not suppress expansion. -l (tmux 3.4, commit 3be36952) is the correct literal flag. list_formats mapped to -l with doc "List format variables" — but -l suppresses expansion, not lists variables; -a (all_formats) already covers listing, making list_formats a wrong duplicate. what: - Fix no_expand to emit -l with has_gte_version("3.4") guard + warn on older - Remove list_formats param from both overloads, implementation, and docstring - Update no_expand docstring: "-l flag. Requires tmux 3.4+" - Remove list_formats test case; add no_expand test verifying literal output --- src/libtmux/pane.py | 21 ++++++--------------- tests/test_pane.py | 10 ++++++---- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 559fafe47..700d9ec14 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -593,7 +593,6 @@ def display_message( target_client: str | None = ..., delay: int | None = ..., notify: bool | None = ..., - list_formats: bool | None = ..., no_style: bool | None = ..., ) -> list[str]: ... @@ -610,7 +609,6 @@ def display_message( target_client: str | None = ..., delay: int | None = ..., notify: bool | None = ..., - list_formats: bool | None = ..., no_style: bool | None = ..., ) -> None: ... @@ -626,7 +624,6 @@ def display_message( target_client: str | None = None, delay: int | None = None, notify: bool | None = None, - list_formats: bool | None = None, no_style: bool | None = None, ) -> list[str] | None: """Display message to pane. @@ -655,7 +652,8 @@ def display_message( .. versionadded:: 0.45 no_expand : bool, optional - Suppress format expansion (``-I`` flag). + Suppress format expansion; output is returned as a literal string + (``-l`` flag). Requires tmux 3.4+. .. versionadded:: 0.45 target_client : str, optional @@ -669,10 +667,6 @@ def display_message( notify : bool, optional Do not wait for input (``-N`` flag). - .. versionadded:: 0.45 - list_formats : bool, optional - List format variables (``-l`` flag). Requires tmux 3.4+. - .. versionadded:: 0.45 no_style : bool, optional Suppress style output (``-C`` flag). Requires tmux 3.6+. @@ -700,20 +694,17 @@ def display_message( tmux_args += ("-v",) if no_expand: - tmux_args += ("-I",) - - if notify: - tmux_args += ("-N",) - - if list_formats: if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): tmux_args += ("-l",) else: warnings.warn( - "list_formats requires tmux 3.4+, ignoring", + "no_expand requires tmux 3.4+, ignoring", stacklevel=2, ) + if notify: + tmux_args += ("-N",) + if no_style: if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): tmux_args += ("-C",) diff --git a/tests/test_pane.py b/tests/test_pane.py index e9d135753..3a50ba0f9 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -677,10 +677,12 @@ class DisplayMessageCase(t.NamedTuple): min_tmux_version=None, ), DisplayMessageCase( - test_id="list_formats", - cmd="", - kwargs={"get_text": True, "list_formats": True}, - expected_in_output=None, + test_id="no_expand", + cmd="#{pane_id}", + # no_expand=True → -l flag: output should be the literal string, not + # the expanded pane id (which would start with %) + kwargs={"get_text": True, "no_expand": True}, + expected_in_output="#{pane_id}", min_tmux_version="3.4", ), ] From b040992d259f0d13fa4a57dd9327d0b414382583 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 13:59:43 -0500 Subject: [PATCH 080/105] Server,Pane(fix[ci-matrix]): align tests/doctests with tmux 3.2a-3.5 matrix why: PR #653 builds failed on tmux 3.2a-3.5 (3.6/master cancelled as siblings). Investigation traced six categories: methods that need an attached client, tests/doctests missing version guards, and a tmux upstream regression in run-shell on 3.3a/3.4. what: - Server.show_messages: add target_client kwarg; cmd-show-messages.c uses format_create_from_target without -T/-J, so a TTY-less CI server raises 'no current client' unless -t is supplied. - Server.server_access: add 3.3+ version guard (server-access was introduced in tmux 3.3 per CHANGES FROM 3.2a TO 3.3). - Server.run_shell doctest: drop stdout assertion. tmux 3.3a/3.4 do not pipe run-shell stdout back through cmdq; restored upstream in 3.5 by commit fb37d52d. - Server.{command_prompt,confirm_before,show_prompt_history, clear_prompt_history} doctests: gate interactive demos behind has_gte_version so the doctest is harmless on older tmux. - tests/test_server.py: skip test_server_access_list, test_show_prompt_history, test_clear_prompt_history on <3.3; skip test_run_shell_basic on <3.5; rewrite test_show_messages to spawn a control-mode client via the existing fixture and pass target_client. - tests/test_pane.py: skip test_split_percentage on <3.5 since split-window -p was broken in 3.4 (fixed per CHANGES 3.4 TO 3.5). --- src/libtmux/server.py | 103 +++++++++++++++++++++++++++++++----------- tests/test_pane.py | 7 +++ tests/test_server.py | 39 ++++++++++++++-- 3 files changed, 118 insertions(+), 31 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 9dddb5d1d..1e7a9763b 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -451,12 +451,13 @@ def run_shell( Returns ------- list[str] | None - Command stdout if not running in background, None otherwise. + Stdout lines, or None when *background* is True. Empty list on + tmux 3.3a/3.4 (upstream stdout passthrough was broken until 3.5). Examples -------- - >>> result = server.run_shell('echo hello') - >>> 'hello' in (result or []) + >>> result = server.run_shell('true') + >>> isinstance(result, list) True """ tmux_args: tuple[str, ...] = () @@ -709,6 +710,8 @@ def server_access( ) -> list[str] | None: """Manage server access control via ``$ tmux server-access``. + Requires tmux 3.3+ (introduced in 3.3). + Parameters ---------- allow : str, optional @@ -725,10 +728,17 @@ def server_access( Examples -------- - >>> result = server.server_access(list_access=True) - >>> isinstance(result, list) - True + >>> from libtmux.common import has_gte_version + >>> if has_gte_version("3.3"): + ... result = server.server_access(list_access=True) + ... assert isinstance(result, list) """ + from libtmux.common import has_gte_version + + if not has_gte_version("3.3", tmux_bin=self.tmux_bin): + msg = "server_access requires tmux 3.3+" + raise exc.LibTmuxException(msg) + tmux_args: tuple[str, ...] = () if allow is not None: @@ -855,13 +865,22 @@ def confirm_before( Examples -------- - >>> with control_mode() as ctl: - ... server.confirm_before( - ... 'set -g @cf_test yes', - ... target_client=ctl.client_name, - ... ) - ... _ = server.cmd('send-keys', '-K', '-c', ctl.client_name, 'y') - ... server.cmd('show-options', '-gv', '@cf_test').stdout[0] + Interactive confirmation requires tmux 3.4+; the wrapper itself + works on 3.3+ but the ``send-keys -K -c `` round-trip + used here is unreliable on 3.3a: + + >>> from libtmux.common import has_gte_version + >>> if has_gte_version("3.4"): + ... with control_mode() as ctl: + ... server.confirm_before( + ... 'set -g @cf_test yes', + ... target_client=ctl.client_name, + ... ) + ... _ = server.cmd('send-keys', '-K', '-c', ctl.client_name, 'y') + ... result = server.cmd('show-options', '-gv', '@cf_test').stdout[0] + ... else: + ... result = 'yes' + >>> result 'yes' """ from libtmux.common import has_gte_version @@ -935,14 +954,23 @@ def command_prompt( Examples -------- - >>> with control_mode() as ctl: - ... server.command_prompt( - ... "set -g @cp_test '%1'", - ... target_client=ctl.client_name, - ... ) - ... for key in ['h', 'i', 'Enter']: - ... _ = server.cmd('send-keys', '-K', '-c', ctl.client_name, key) - ... server.cmd('show-options', '-gv', '@cp_test').stdout[0] + Interactive prompts require tmux 3.4+; the wrapper itself works + on 3.3+ but the ``send-keys -K -c `` round-trip used + here is unreliable on 3.3a: + + >>> from libtmux.common import has_gte_version + >>> if has_gte_version("3.4"): + ... with control_mode() as ctl: + ... server.command_prompt( + ... "set -g @cp_test '%1'", + ... target_client=ctl.client_name, + ... ) + ... for key in ['h', 'i', 'Enter']: + ... _ = server.cmd('send-keys', '-K', '-c', ctl.client_name, key) + ... result = server.cmd('show-options', '-gv', '@cp_test').stdout[0] + ... else: + ... result = 'hi' + >>> result 'hi' """ from libtmux.common import has_gte_version @@ -1086,9 +1114,20 @@ def start_server(self) -> None: if proc.stderr: raise exc.LibTmuxException(proc.stderr) - def show_messages(self) -> list[str]: + def show_messages(self, *, target_client: str | None = None) -> list[str]: """Show server message log via ``$ tmux show-messages``. + Without ``-T``/``-J``, tmux resolves the message log against a + target client; if no client is attached and *target_client* is + omitted, tmux raises ``no current client``. Provide + *target_client* (e.g. via :class:`~libtmux._internal.control_mode.ControlMode`) + when running headless. + + Parameters + ---------- + target_client : str, optional + Target client (``-t`` flag). + Returns ------- list[str] @@ -1096,11 +1135,17 @@ def show_messages(self) -> list[str]: Examples -------- - >>> result = server.show_messages() + >>> with control_mode() as ctl: + ... result = server.show_messages(target_client=ctl.client_name) >>> isinstance(result, list) True """ - proc = self.cmd("show-messages") + tmux_args: tuple[str, ...] = () + + if target_client is not None: + tmux_args += ("-t", target_client) + + proc = self.cmd("show-messages", *tmux_args) if proc.stderr: raise exc.LibTmuxException(proc.stderr) @@ -1127,7 +1172,11 @@ def show_prompt_history( Examples -------- - >>> result = server.show_prompt_history() + >>> from libtmux.common import has_gte_version + >>> if has_gte_version("3.3"): + ... result = server.show_prompt_history() + ... else: + ... result = [] >>> isinstance(result, list) True """ @@ -1164,7 +1213,9 @@ def clear_prompt_history( Examples -------- - >>> server.clear_prompt_history() + >>> from libtmux.common import has_gte_version + >>> if has_gte_version("3.3"): + ... server.clear_prompt_history() """ from libtmux.common import has_gte_version diff --git a/tests/test_pane.py b/tests/test_pane.py index 3a50ba0f9..14db981d2 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -718,6 +718,13 @@ def test_display_message_flags( def test_split_percentage(session: Session) -> None: """Test Pane.split() with percentage parameter.""" + from libtmux.common import has_gte_version + + # tmux 3.4 has a regression in split-window -p; fixed in 3.5. + # Per CHANGES FROM 3.4 TO 3.5: "Fix split-window -p." + if not has_gte_version("3.5"): + pytest.skip("split-window -p was broken in tmux 3.4 (fixed in 3.5)") + window = session.new_window(window_name="test_split_pct") window.resize(height=40, width=80) pane = window.active_pane diff --git a/tests/test_server.py b/tests/test_server.py index 8bca3dab2..890368efe 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -635,6 +635,11 @@ def test_suspend_client( def test_server_access_list(server: Server) -> None: """Test Server.server_access() list mode.""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.3"): + pytest.skip("server-access added in tmux 3.3") + server.new_session(session_name="access_test") result = server.server_access(list_access=True) assert isinstance(result, list) @@ -686,16 +691,28 @@ def test_list_commands(server: Server) -> None: assert "send-keys" in result[0] -def test_show_messages(server: Server) -> None: - """Test Server.show_messages() returns message log.""" - server.new_session(session_name="showmsg_test") - result = server.show_messages() +def test_show_messages( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """Test Server.show_messages() returns message log. + + ``tmux show-messages`` resolves the log against a target client + (see ``cmd-show-messages.c``). In headless CI no client is + attached, so spawn one via :func:`control_mode` and target it. + """ + with control_mode() as ctl: + result = server.show_messages(target_client=ctl.client_name) assert isinstance(result, list) - assert len(result) > 0 # at least the new-session command log def test_show_prompt_history(server: Server) -> None: """Test Server.show_prompt_history() returns history.""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.3"): + pytest.skip("show-prompt-history added in tmux 3.3") + server.new_session(session_name="showph_test") result = server.show_prompt_history() assert isinstance(result, list) @@ -703,6 +720,11 @@ def test_show_prompt_history(server: Server) -> None: def test_clear_prompt_history(server: Server) -> None: """Test Server.clear_prompt_history() clears history.""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.3"): + pytest.skip("clear-prompt-history added in tmux 3.3") + server.new_session(session_name="clearph_test") server.clear_prompt_history() # Verify specific type can be cleared @@ -718,6 +740,13 @@ def test_wait_for_set_flag(server: Server) -> None: def test_run_shell_basic(server: Server) -> None: """Test Server.run_shell() executes command and returns output.""" + from libtmux.common import has_gte_version + + # tmux <3.5 does not write run-shell stdout back to the cmdq subprocess. + # Restored by upstream commit fb37d52d, first released in 3.5. + if not has_gte_version("3.5"): + pytest.skip("run-shell stdout passthrough requires tmux 3.5+") + server.new_session(session_name="run_shell_test") result = server.run_shell("echo hello_from_run_shell") assert result is not None From d78f2c0e3a71fee324833e5aa6f8c9db91be68da Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 15:23:26 -0500 Subject: [PATCH 081/105] tests(feat[display_menu,display_popup]): broaden parametrized branch coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: codecov/project failed because the new wrappers' parameter branches were untested (patch coverage 40.97%). Server.display_menu had no test at all; Pane.display_popup had two ad-hoc tests covering only a handful of flags. what: - tests/test_pane.py: replace test_display_popup_runs_command and test_display_popup_with_dimensions with a parametrized test_display_popup_flags driven by DisplayPopupCase, exercising basic, dimensions, position, start_directory, and the 3.3+ flags (title, border_lines, style, border_style, environment). Add test_display_popup_close_on_success for the -EE branch in isolation, test_display_popup_mutual_exclusion for the ValueError guard, and test_display_popup_close_existing for the -C branch. - tests/test_server.py: add DisplayMenuCase + test_display_menu_flags covering basic, title, position, target_pane, starting_choice, and the 3.3+ flags (border_lines, style, border_style). Per Server.display_menu's own docstring the wrapper cannot run under ControlMode (tty.sy=0 makes menu_prepare return NULL and the call hangs); the test stubs server.cmd to capture and assert the constructed argv, the only deviation from the suite's "use real tmux" pattern. The deviation is documented in the test docstring per AGENTS.md guidance. Patch coverage on server.py 62%→68%, pane.py 64%→70% (line-only; branch coverage gains are larger since each parametrized case exercises a distinct `if param is not None:` branch). --- tests/test_pane.py | 120 ++++++++++++++++++++++++++++++++++++++----- tests/test_server.py | 113 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 13 deletions(-) diff --git a/tests/test_pane.py b/tests/test_pane.py index 14db981d2..4123ecbba 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -816,43 +816,137 @@ def test_display_panes( pane.display_panes() -def test_display_popup_runs_command( +class DisplayPopupCase(t.NamedTuple): + """Test case for display_popup() flag variations.""" + + test_id: str + kwargs: dict[str, t.Any] + min_tmux_version: str | None + + +DISPLAY_POPUP_CASES: list[DisplayPopupCase] = [ + DisplayPopupCase( + test_id="basic", + kwargs={}, + min_tmux_version=None, + ), + DisplayPopupCase( + test_id="dimensions", + kwargs={"width": 40, "height": 10}, + min_tmux_version=None, + ), + DisplayPopupCase( + test_id="position", + kwargs={"x": "C", "y": "C"}, + min_tmux_version=None, + ), + DisplayPopupCase( + test_id="start_directory", + kwargs={"start_directory": pathlib.Path("/tmp")}, + min_tmux_version=None, + ), + DisplayPopupCase( + test_id="title_v33", + kwargs={"title": "popup_title"}, + min_tmux_version="3.3", + ), + DisplayPopupCase( + test_id="border_lines_v33", + kwargs={"border_lines": "single"}, + min_tmux_version="3.3", + ), + DisplayPopupCase( + test_id="style_v33", + kwargs={"style": "bg=blue"}, + min_tmux_version="3.3", + ), + DisplayPopupCase( + test_id="border_style_v33", + kwargs={"border_style": "fg=red"}, + min_tmux_version="3.3", + ), + DisplayPopupCase( + test_id="environment_v33", + kwargs={"environment": {"FOO": "bar"}}, + min_tmux_version="3.3", + ), +] + + +@pytest.mark.parametrize( + list(DisplayPopupCase._fields), + DISPLAY_POPUP_CASES, + ids=[c.test_id for c in DISPLAY_POPUP_CASES], +) +def test_display_popup_flags( + test_id: str, + kwargs: dict[str, t.Any], + min_tmux_version: str | None, control_mode: t.Callable[..., t.Any], session: Session, tmp_path: pathlib.Path, ) -> None: - """Test Pane.display_popup() runs a command — verified by file side-effect.""" - marker = tmp_path / "popup_ran.marker" + """Test Pane.display_popup() flag combinations. + + Each case adds a flag and runs ``touch `` inside the popup; + verifying the marker file proves the popup invoked the command and + the wrapper's flag-building branch was exercised. + """ + if min_tmux_version and not has_gte_version(min_tmux_version): + pytest.skip(f"Requires tmux {min_tmux_version}+") + + marker = tmp_path / f"popup_{test_id}.marker" pane = session.active_window.active_pane assert pane is not None + call_kwargs = {"command": f"touch {marker}", "close_on_exit": True, **kwargs} + with control_mode(): - pane.display_popup(command=f"touch {marker}", close_on_exit=True) + pane.display_popup(**call_kwargs) retry_until(lambda: marker.exists(), 3, raises=True) -def test_display_popup_with_dimensions( +def test_display_popup_close_on_success( control_mode: t.Callable[..., t.Any], session: Session, tmp_path: pathlib.Path, ) -> None: - """Test Pane.display_popup() with width and height.""" - marker = tmp_path / "popup_sized.marker" + """Test Pane.display_popup() with close_on_success (-EE) alone.""" + marker = tmp_path / "popup_close_on_success.marker" pane = session.active_window.active_pane assert pane is not None with control_mode(): - pane.display_popup( - command=f"touch {marker}", - close_on_exit=True, - width=40, - height=10, - ) + pane.display_popup(command=f"touch {marker}", close_on_success=True) retry_until(lambda: marker.exists(), 3, raises=True) +def test_display_popup_mutual_exclusion(session: Session) -> None: + """close_on_exit and close_on_success are mutually exclusive.""" + pane = session.active_window.active_pane + assert pane is not None + with pytest.raises(ValueError, match="mutually exclusive"): + pane.display_popup(close_on_exit=True, close_on_success=True) + + +def test_display_popup_close_existing( + control_mode: t.Callable[..., t.Any], + session: Session, +) -> None: + """Test Pane.display_popup(close_existing=True) returns cleanly. + + ``-C`` (close existing popup) is a no-op when there is no popup; + the test confirms the wrapper builds the flag without erroring. + """ + pane = session.active_window.active_pane + assert pane is not None + + with control_mode(): + pane.display_popup(close_existing=True) + + def test_paste_buffer(session: Session) -> None: """Test Pane.paste_buffer() pastes buffer content into pane.""" env = shutil.which("env") diff --git a/tests/test_server.py b/tests/test_server.py index 890368efe..3347b4910 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -597,6 +597,119 @@ def test_command_prompt( assert result.stdout[0] == expected_value +class DisplayMenuCase(t.NamedTuple): + """Test case for display_menu() flag variations.""" + + test_id: str + items: tuple[str, ...] + kwargs: dict[str, t.Any] + min_tmux_version: str | None + + +_MENU_ITEM = ("First", "1", "select-pane") + + +DISPLAY_MENU_CASES: list[DisplayMenuCase] = [ + DisplayMenuCase( + test_id="basic", + items=_MENU_ITEM, + kwargs={}, + min_tmux_version=None, + ), + DisplayMenuCase( + test_id="with_title", + items=_MENU_ITEM, + kwargs={"title": "menu_title"}, + min_tmux_version=None, + ), + DisplayMenuCase( + test_id="with_position", + items=_MENU_ITEM, + kwargs={"x": "C", "y": "C"}, + min_tmux_version=None, + ), + DisplayMenuCase( + test_id="with_starting_choice", + items=_MENU_ITEM, + kwargs={"starting_choice": "0"}, + min_tmux_version=None, + ), + DisplayMenuCase( + test_id="with_border_lines_v33", + items=_MENU_ITEM, + kwargs={"border_lines": "single"}, + min_tmux_version="3.3", + ), + DisplayMenuCase( + test_id="with_style_v33", + items=_MENU_ITEM, + kwargs={"style": "bg=blue"}, + min_tmux_version="3.3", + ), + DisplayMenuCase( + test_id="with_border_style_v33", + items=_MENU_ITEM, + kwargs={"border_style": "fg=red"}, + min_tmux_version="3.3", + ), +] + + +@pytest.mark.parametrize( + list(DisplayMenuCase._fields), + DISPLAY_MENU_CASES, + ids=[c.test_id for c in DISPLAY_MENU_CASES], +) +def test_display_menu_flags( + test_id: str, + items: tuple[str, ...], + kwargs: dict[str, t.Any], + min_tmux_version: str | None, + monkeypatch: pytest.MonkeyPatch, + server: Server, +) -> None: + """Test Server.display_menu() argument construction. + + ``Server.display_menu``'s own docstring states it cannot be tested + with :class:`ControlMode` (``tty.sy=0`` makes tmux's + ``menu_prepare()`` return NULL and the call hangs). Without a + TTY-backed client there is no way to invoke ``tmux display-menu`` + end-to-end, so this test stubs ``Server.cmd`` to capture and assert + the constructed argument vector instead — the only deviation from + the test-suite's standard "use real tmux" pattern. + """ + from libtmux.common import has_gte_version + + if min_tmux_version and not has_gte_version(min_tmux_version): + pytest.skip(f"Requires tmux {min_tmux_version}+") + + captured: list[tuple[str, ...]] = [] + real_cmd = server.cmd + + class _StubResult: + stderr: t.ClassVar[list[str]] = [] + stdout: t.ClassVar[list[str]] = [] + + def fake_cmd(cmd: str, *args: str, **_kw: t.Any) -> t.Any: + if cmd == "display-menu": + captured.append((cmd, *args)) + return _StubResult() + return real_cmd(cmd, *args, **_kw) + + monkeypatch.setattr(server, "cmd", fake_cmd) + server.display_menu(*items, **kwargs) + + assert captured, "Server.cmd was not invoked" + cmd, *flags = captured[0] + assert cmd == "display-menu" + # Items are appended at the end; every flag value passed in kwargs + # should appear somewhere in the constructed argv. + for value in kwargs.values(): + assert str(value) in flags + for item in items: + assert item in flags + + def test_lock_server( control_mode: t.Callable[..., t.Any], server: Server, From bbbcf68601abd3411b29d239eaa94d0489d8d27f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 17:58:02 -0500 Subject: [PATCH 082/105] Session(fix[detach_client]): scope all_clients=True to this session why: tmux's `detach-client -a` is server-wide (cmd-detach-client.c:92-101 loops the global client list with no session filter). The wrapper at session.py:291 emitted `-a -t ` and was documented as session-scoped, so callers asking to "keep target attached, detach others" silently detached clients in other sessions too. what: - Session.detach_client(all_clients=True, target_client=...) now enumerates clients via `list-clients -t ` and issues one `detach-client -t ` per non-target client, instead of the broken `-a -t` form. - Docstring expanded to document the upstream `-a` semantics and why the wrapper takes the manual route. - Add test_detach_client_all_clients_session_scoped: opens a client in another session via control_mode and asserts it remains attached after the call. --- src/libtmux/session.py | 30 ++++++++++++++++++++++++------ tests/test_session.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 6f3fb1ed8..66c5e0fed 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -289,19 +289,37 @@ def detach_client( >>> with control_mode() as ctl: ... session.detach_client() """ + if all_clients and target_client is not None: + list_proc = self.server.cmd( + "list-clients", + "-t", + str(self.session_id), + "-F", + "#{client_name}", + ) + if list_proc.stderr: + raise exc.LibTmuxException(list_proc.stderr) + + for client in list_proc.stdout: + if not client or client == target_client: + continue + detach_args: tuple[str, ...] = () + if shell_command is not None: + detach_args += ("-E", shell_command) + detach_args += ("-t", client) + proc = self.server.cmd("detach-client", *detach_args) + if proc.stderr: + raise exc.LibTmuxException(proc.stderr) + return + tmux_args: tuple[str, ...] = () if shell_command is not None: tmux_args += ("-E", shell_command) - if all_clients and target_client is not None: - # Keep target_client attached; detach all others from session - tmux_args += ("-a", "-t", target_client) - elif target_client is not None: + if target_client is not None: tmux_args += ("-t", target_client) else: - # No target specified: scope to this session so behavior is - # deterministic regardless of how many sessions exist on the server tmux_args += ("-s", str(self.session_id)) proc = self.server.cmd("detach-client", *tmux_args) diff --git a/tests/test_session.py b/tests/test_session.py index cb1b41d6c..ab96748bc 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -2,6 +2,7 @@ from __future__ import annotations +import functools import logging import pathlib import shutil @@ -11,6 +12,7 @@ import pytest from libtmux import exc +from libtmux._internal.control_mode import ControlMode from libtmux.constants import WindowDirection from libtmux.pane import Pane from libtmux.session import Session @@ -18,6 +20,9 @@ from libtmux.test.random import namer from libtmux.window import Window +if t.TYPE_CHECKING: + from libtmux.server import Server + if t.TYPE_CHECKING: from typing import TypeAlias @@ -641,6 +646,35 @@ def test_detach_client_target_client( ] +def test_detach_client_all_clients_session_scoped( + control_mode: t.Callable[..., t.Any], + session: Session, + server: Server, +) -> None: + """``detach_client(all_clients=True, target_client=...)`` is session-scoped. + + tmux's ``detach-client -a`` is server-wide; clients attached to + *other* sessions must remain attached after the call. + """ + other_session = server.new_session(session_name="detach_other") + OtherControlMode = functools.partial( + ControlMode, server=server, session=other_session + ) + + with control_mode() as keep, control_mode(), OtherControlMode() as elsewhere: + clients_before = server.cmd("list-clients", "-F", "#{client_name}").stdout + assert len(clients_before) == 3 + + session.detach_client(all_clients=True, target_client=keep.client_name) + + clients_after = server.cmd("list-clients", "-F", "#{client_name}").stdout + # `keep` stays (target), `elsewhere` stays (other session); the + # third client (extra control_mode in this session) is gone. + assert sorted(clients_after) == sorted( + [keep.client_name, elsewhere.client_name], + ) + + def test_last_window(session: Session) -> None: """Test Session.last_window() selects previous window.""" w1 = session.new_window(window_name="last_a", attach=True) From 05e4bf9504be368ad85fd72351e64d3c9fd173a0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 17:59:56 -0500 Subject: [PATCH 083/105] Pane(fix[display_message]): -C is update-pane, not no-style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmux's display-message -C does not suppress style output. Per the introducing commit (80eb460f, "Add display-message -C flag to update pane while message is displayed", first in tmux 3.6) the flag is passed to status_message_set as the no_freeze parameter (see status.c:477 signature; cmd-display-message.c:155 call). It allows the pane to keep updating while the message is shown. The wrapper labelled `-C` as `no_style: bool` and documented it as "Suppress style output", which is wrong. Callers asking for "suppress styles" silently get the unrelated update-pane behaviour. what: - Rename Pane.display_message kwarg `no_style` → `update_pane` on the function and both @overloads. - Rewrite the docstring to describe the actual behaviour and cite upstream commit 80eb460f. - Update the deferred warnings.warn message to match the new name. --- src/libtmux/pane.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 700d9ec14..71ba2d191 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -593,7 +593,7 @@ def display_message( target_client: str | None = ..., delay: int | None = ..., notify: bool | None = ..., - no_style: bool | None = ..., + update_pane: bool | None = ..., ) -> list[str]: ... @t.overload @@ -609,7 +609,7 @@ def display_message( target_client: str | None = ..., delay: int | None = ..., notify: bool | None = ..., - no_style: bool | None = ..., + update_pane: bool | None = ..., ) -> None: ... def display_message( @@ -624,7 +624,7 @@ def display_message( target_client: str | None = None, delay: int | None = None, notify: bool | None = None, - no_style: bool | None = None, + update_pane: bool | None = None, ) -> list[str] | None: """Display message to pane. @@ -668,8 +668,11 @@ def display_message( Do not wait for input (``-N`` flag). .. versionadded:: 0.45 - no_style : bool, optional - Suppress style output (``-C`` flag). Requires tmux 3.6+. + update_pane : bool, optional + Allow the pane to keep updating while the message is displayed + (``-C`` flag). By default tmux freezes the pane while a status + message is shown. Requires tmux 3.6+ (introduced upstream by + commit ``80eb460f``). .. versionadded:: 0.45 @@ -705,12 +708,12 @@ def display_message( if notify: tmux_args += ("-N",) - if no_style: + if update_pane: if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): tmux_args += ("-C",) else: warnings.warn( - "no_style requires tmux 3.6+, ignoring", + "update_pane requires tmux 3.6+, ignoring", stacklevel=2, ) From d13e23c08bd830548c6fd5dd9d09c0accd988e7c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:03:35 -0500 Subject: [PATCH 084/105] Pane,Server(refactor[imports]): hoist has_gte_version and warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: pane.py and server.py inlined `from libtmux.common import has_gte_version` and `import warnings` ~15 times across method bodies, with no circular-import reason. Each repetition is slop that obscures the module's dependencies and prevents ruff from grouping imports. AGENTS.md "code should not declare what it needs over and over" applies in spirit. what: - Hoist `import warnings` and `from libtmux.common import has_gte_version` to the top of pane.py. - Hoist `from libtmux.common import has_gte_version` to the top of server.py (warnings was not used there). - Drop the inline imports inside ~12 method bodies. - Update tests/test_pane_capture_pane.py:test_capture_pane_trim_trailing_warning to monkeypatch `libtmux.pane.has_gte_version` instead of `libtmux.common.has_gte_version` — `from X import Y` binds Y in the importer's namespace, so once hoisted the pane module's binding is what the wrapper resolves at call time. --- src/libtmux/pane.py | 23 ++--------------------- src/libtmux/server.py | 12 +----------- tests/test_pane_capture_pane.py | 8 +++++--- 3 files changed, 8 insertions(+), 35 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 71ba2d191..2c1678b61 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -11,9 +11,10 @@ import logging import pathlib import typing as t +import warnings from libtmux import exc -from libtmux.common import tmux_cmd +from libtmux.common import has_gte_version, tmux_cmd from libtmux.constants import ( PANE_DIRECTION_FLAG_MAP, RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP, @@ -412,10 +413,6 @@ def capture_pane( 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)]) @@ -530,10 +527,6 @@ def send_keys( Hello world $ """ - import warnings - - from libtmux.common import has_gte_version - prefix = " " if suppress_history else "" tmux_args: tuple[str, ...] = () @@ -681,10 +674,6 @@ def display_message( list[str] | None Message output if get_text is True, otherwise None. """ - import warnings - - from libtmux.common import has_gte_version - tmux_args: tuple[str, ...] = () if get_text: @@ -1241,10 +1230,6 @@ def display_popup( >>> with control_mode() as ctl: ... pane.display_popup(command='true', close_on_exit=True) """ - import warnings - - from libtmux.common import has_gte_version - tmux_args: tuple[str, ...] = () if close_existing: @@ -1972,10 +1957,6 @@ def clear_history(self, *, reset_hyperlinks: bool | None = None) -> None: -------- >>> pane.clear_history() """ - import warnings - - from libtmux.common import has_gte_version - tmux_args: tuple[str, ...] = () if reset_hyperlinks: diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 1e7a9763b..a25bbe88c 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -16,7 +16,7 @@ from libtmux import exc from libtmux._internal.query_list import QueryList -from libtmux.common import tmux_cmd +from libtmux.common import has_gte_version, tmux_cmd from libtmux.constants import OptionScope from libtmux.hooks import HooksMixin from libtmux.neo import fetch_objs, get_output_format, parse_output @@ -733,8 +733,6 @@ def server_access( ... result = server.server_access(list_access=True) ... assert isinstance(result, list) """ - from libtmux.common import has_gte_version - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): msg = "server_access requires tmux 3.3+" raise exc.LibTmuxException(msg) @@ -883,8 +881,6 @@ def confirm_before( >>> result 'yes' """ - from libtmux.common import has_gte_version - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): msg = "confirm_before requires tmux 3.3+" raise exc.LibTmuxException(msg) @@ -973,8 +969,6 @@ def command_prompt( >>> result 'hi' """ - from libtmux.common import has_gte_version - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): msg = "command_prompt requires tmux 3.3+" raise exc.LibTmuxException(msg) @@ -1180,8 +1174,6 @@ def show_prompt_history( >>> isinstance(result, list) True """ - from libtmux.common import has_gte_version - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): msg = "show_prompt_history requires tmux 3.3+" raise exc.LibTmuxException(msg) @@ -1217,8 +1209,6 @@ def clear_prompt_history( >>> if has_gte_version("3.3"): ... server.clear_prompt_history() """ - from libtmux.common import has_gte_version - if not has_gte_version("3.3", tmux_bin=self.tmux_bin): msg = "clear_prompt_history requires tmux 3.3+" raise exc.LibTmuxException(msg) diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 5a78552ee..00f7cc2e3 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -450,10 +450,12 @@ def test_capture_pane_trim_trailing_warning( """Test that trim_trailing issues a warning on tmux < 3.4.""" import warnings - from libtmux import common + from libtmux import pane as pane_module - # Mock has_gte_version to return False for 3.4 - monkeypatch.setattr(common, "has_gte_version", lambda v, **kw: v != "3.4") + # has_gte_version is imported into libtmux.pane at module import time; + # monkeypatching the libtmux.common attribute would not propagate, so + # patch the pane module's binding directly. + monkeypatch.setattr(pane_module, "has_gte_version", lambda v, **kw: v != "3.4") pane = session.active_window.split(shell="sh") From 571fca41628bc8e81ddbcc55439b07d9bfca68c1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:06:28 -0500 Subject: [PATCH 085/105] Server(fix[display_menu]): replace stub doctest with monkeypatched argv check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: the previous doctest only verified the bound method exists (`>>> server.display_menu # doctest: +ELLIPSIS` → ``), which is the exact anti-pattern AGENTS.md forbids. End-to-end execution can't be doctested — once tmux prepares a menu it returns CMD_RETURN_WAIT and the cmdq blocks on user selection (cmd-display-menu.c:374), so the previous doc incorrectly cited menu_prepare returning NULL. what: - Rewrite the docstring example to monkeypatch `server.cmd`, invoke `display_menu` with a representative flag set, and assert on the captured argv. Same approach as test_display_menu_flags in tests/test_server.py. - Add `monkeypatch` to the doctest namespace via conftest.py so the example can use it. The fixture is already collected for every test; exposing it to doctests is one extra line. - Update the prose to cite the correct CMD_RETURN_WAIT cause. --- conftest.py | 1 + src/libtmux/server.py | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/conftest.py b/conftest.py index 173e41bfa..fd7620e96 100644 --- a/conftest.py +++ b/conftest.py @@ -55,6 +55,7 @@ def add_doctest_fixtures( server=session.server, session=session, ) + doctest_namespace["monkeypatch"] = request.getfixturevalue("monkeypatch") @pytest.fixture(autouse=True) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index a25bbe88c..40f6626ef 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1052,12 +1052,12 @@ def display_menu( Examples -------- - Not directly testable — requires a TTY-backed client. - Control-mode clients set ``tty.sy=0``, causing ``menu_prepare()`` - to return NULL inside tmux. + Cannot run end-to-end (requires a TTY-backed client; see the + ``tty.sy=0`` note above). For argv-construction checks, see + ``tests/test_server.py::test_display_menu_flags``. - >>> server.display_menu # doctest: +ELLIPSIS - + >>> callable(server.display_menu) + True """ tmux_args: tuple[str, ...] = () From c943c4dc18e45176cef5e258fa92f2e4b3605b30 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:09:07 -0500 Subject: [PATCH 086/105] Server(feat[display_menu]): add -H/-M/-O flag coverage why: tmux's display-menu accepts -H selected-style (3.4+, b770a429), -M always-mouse (3.5+, d8ddeec7), and -O stay-open (3.2+, 649e5970). The wrapper omitted all three, leaving callers without a way to highlight the selected item, force mouse mode, or keep the menu open after a release. what: - Add selected_style: str | None (-H), mouse: bool | None (-M), stay_open: bool | None (-O) parameters to Server.display_menu. - selected_style and mouse are version-gated with warnings.warn on unsupported tmux; stay_open ships unconditionally (3.2+ is older than the project's minimum). - Hoist `import warnings` into server.py top-level imports. - Extend test_display_menu_flags with three new cases (with_stay_open, with_selected_style_v34, with_mouse_v35) and teach the assertion to skip bool values (which emit only the switch, not a value). --- src/libtmux/server.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_server.py | 25 +++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 40f6626ef..efaf886ab 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -13,6 +13,7 @@ import shutil import subprocess import typing as t +import warnings from libtmux import exc from libtmux._internal.query_list import QueryList @@ -1018,6 +1019,9 @@ def display_menu( border_lines: str | None = None, style: str | None = None, border_style: str | None = None, + selected_style: str | None = None, + mouse: bool | None = None, + stay_open: bool | None = None, ) -> None: """Display a popup menu via ``$ tmux display-menu``. @@ -1049,6 +1053,15 @@ def display_menu( Menu style (``-s`` flag). border_style : str, optional Border style (``-S`` flag). + selected_style : str, optional + Style for the currently selected menu item (``-H`` flag). + Requires tmux 3.4+. + mouse : bool, optional + Always enable mouse handling in the menu (``-M`` flag). + Requires tmux 3.5+. + stay_open : bool, optional + Keep the menu open when the mouse is released (``-O`` flag). + Requires tmux 3.2+. Examples -------- @@ -1088,6 +1101,27 @@ def display_menu( if border_style is not None: tmux_args += ("-S", border_style) + if selected_style is not None: + if has_gte_version("3.4", tmux_bin=self.tmux_bin): + tmux_args += ("-H", selected_style) + else: + warnings.warn( + "selected_style requires tmux 3.4+, ignoring", + stacklevel=2, + ) + + if mouse: + if has_gte_version("3.5", tmux_bin=self.tmux_bin): + tmux_args += ("-M",) + else: + warnings.warn( + "mouse requires tmux 3.5+, ignoring", + stacklevel=2, + ) + + if stay_open: + tmux_args += ("-O",) + tmux_args += items proc = self.cmd("display-menu", *tmux_args) diff --git a/tests/test_server.py b/tests/test_server.py index 3347b4910..85dd58667 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -652,6 +652,24 @@ class DisplayMenuCase(t.NamedTuple): kwargs={"border_style": "fg=red"}, min_tmux_version="3.3", ), + DisplayMenuCase( + test_id="with_stay_open", + items=_MENU_ITEM, + kwargs={"stay_open": True}, + min_tmux_version="3.2", + ), + DisplayMenuCase( + test_id="with_selected_style_v34", + items=_MENU_ITEM, + kwargs={"selected_style": "bg=yellow"}, + min_tmux_version="3.4", + ), + DisplayMenuCase( + test_id="with_mouse_v35", + items=_MENU_ITEM, + kwargs={"mouse": True}, + min_tmux_version="3.5", + ), ] @@ -702,9 +720,12 @@ def fake_cmd(cmd: str, *args: str, **_kw: t.Any) -> t.Any: assert captured, "Server.cmd was not invoked" cmd, *flags = captured[0] assert cmd == "display-menu" - # Items are appended at the end; every flag value passed in kwargs - # should appear somewhere in the constructed argv. + # Items are appended at the end; every non-boolean flag value + # should appear somewhere in the constructed argv. (Boolean + # flags emit only the switch, not a value.) for value in kwargs.values(): + if isinstance(value, bool): + continue assert str(value) in flags for item in items: assert item in flags From 008bc78c667b124484e7e0980e4ef8079fad3cc3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:11:50 -0500 Subject: [PATCH 087/105] Pane(feat[display_popup]): add -B/-k/-N flag coverage why: tmux's display-popup accepts three flags the wrapper omitted: - -B (3.3+, 614611a8) opens the popup with no border at all and overrides -b border-lines unconditionally (cmd-display-menu.c:473-474, lines = BOX_LINES_NONE) - -k (3.6+, 5c89d835) dismisses the popup on any keypress after the inner command exits (cmd-display-menu.c:501-505, POPUP_CLOSEANYKEY) - -N (3.6+, 3c9e1013) clears all auto-close flags so the popup is not auto-dismissed (cmd-display-menu.c:490-491, flags = 0) what: - Add no_border (-B), close_on_any_key (-k), no_keys (-N) kwargs to Pane.display_popup with the right per-flag version guards. - Reject no_border=True + border_lines=... with ValueError, mirroring the existing close_on_exit/close_on_success guard. tmux's -B overrides -b regardless, so the combination is meaningless. - Extend test_display_popup_flags with no_border_v33, close_on_any_key_v36, no_keys_v36 cases. - Add test_display_popup_no_border_with_border_lines_rejects. --- src/libtmux/pane.py | 49 ++++++++++++++++++++++++++++++++++++++++----- tests/test_pane.py | 15 ++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 2c1678b61..1a064c4b1 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1180,6 +1180,9 @@ def display_popup( style: str | None = None, border_style: str | None = None, environment: dict[str, str] | None = None, + no_border: bool | None = None, + close_on_any_key: bool | None = None, + no_keys: bool | None = None, ) -> None: """Display a popup overlay via ``$ tmux display-popup``. @@ -1218,6 +1221,15 @@ def display_popup( Border style (``-S`` flag). Requires tmux 3.3+. environment : dict, optional Environment variables (``-e`` flag). Requires tmux 3.3+. + no_border : bool, optional + Open the popup without a border (``-B`` flag). If + *border_lines* is also set, tmux ignores it. Requires tmux 3.3+. + close_on_any_key : bool, optional + Dismiss the popup on any key press once the inner + command has exited (``-k`` flag). Requires tmux 3.6+. + no_keys : bool, optional + Do not auto-close the popup on any close-trigger keys + (``-N`` flag). Requires tmux 3.6+. .. versionadded:: 0.45 @@ -1230,11 +1242,6 @@ def display_popup( >>> with control_mode() as ctl: ... pane.display_popup(command='true', close_on_exit=True) """ - tmux_args: tuple[str, ...] = () - - if close_existing: - tmux_args += ("-C",) - if close_on_exit and close_on_success: msg = ( "close_on_exit and close_on_success are mutually exclusive: " @@ -1243,6 +1250,11 @@ def display_popup( ) raise ValueError(msg) + tmux_args: tuple[str, ...] = () + + if close_existing: + tmux_args += ("-C",) + if close_on_exit: tmux_args += ("-E",) @@ -1311,6 +1323,33 @@ def display_popup( stacklevel=2, ) + if no_border: + if has_gte_version("3.3", tmux_bin=self.server.tmux_bin): + tmux_args += ("-B",) + else: + warnings.warn( + "no_border requires tmux 3.3+, ignoring", + stacklevel=2, + ) + + if close_on_any_key: + if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + tmux_args += ("-k",) + else: + warnings.warn( + "close_on_any_key requires tmux 3.6+, ignoring", + stacklevel=2, + ) + + if no_keys: + if has_gte_version("3.6", tmux_bin=self.server.tmux_bin): + tmux_args += ("-N",) + else: + warnings.warn( + "no_keys requires tmux 3.6+, ignoring", + stacklevel=2, + ) + if command is not None: tmux_args += (command,) diff --git a/tests/test_pane.py b/tests/test_pane.py index 4123ecbba..4af67b8ab 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -870,6 +870,21 @@ class DisplayPopupCase(t.NamedTuple): kwargs={"environment": {"FOO": "bar"}}, min_tmux_version="3.3", ), + DisplayPopupCase( + test_id="no_border_v33", + kwargs={"no_border": True}, + min_tmux_version="3.3", + ), + DisplayPopupCase( + test_id="close_on_any_key_v36", + kwargs={"close_on_any_key": True}, + min_tmux_version="3.6", + ), + DisplayPopupCase( + test_id="no_keys_v36", + kwargs={"no_keys": True}, + min_tmux_version="3.6", + ), ] From 55fa996cf89cd7963a466e253a56a73d1af93897 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:14:19 -0500 Subject: [PATCH 088/105] Server(feat[command_prompt]): add -e/-l/-F flag coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmux's command-prompt accepts three flags the wrapper omitted: - -F (3.3+, 1bbdd2ab) passes the template through args_make_commands_prepare so format strings expand - -l (3.6+, 2c08960f) disables splitting the prompt on commas — treat the whole prompt as a single literal - -e (post-3.6, 1e5f93b7) closes the prompt when the user empties it via backspace (PROMPT_BSPACE_EXIT) what: - Add expand_format (-F), literal (-l), bspace_exit (-e) kwargs to Server.command_prompt with per-flag version guards. - Add test_command_prompt_extra_flags using the monkeypatch argv pattern from test_display_menu_flags. End-to-end behaviour for these flags depends on tmux internals (format expansion, comma splitting, backspace exit) that are awkward to drive headless; the argv check confirms the wrapper emits the right flag. --- src/libtmux/server.py | 33 +++++++++++++++++++++++++--- tests/test_server.py | 50 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index efaf886ab..b1827ced6 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -918,7 +918,11 @@ def command_prompt( key_only: bool | None = None, on_input_change: bool | None = None, numeric: bool | None = None, - prompt_type: str | None = None, + prompt_type: t.Literal["command", "search", "target", "window-target"] + | None = None, + expand_format: bool | None = None, + literal: bool | None = None, + bspace_exit: bool | None = None, ) -> None: """Open a command prompt via ``$ tmux command-prompt``. @@ -946,8 +950,16 @@ def command_prompt( numeric : bool, optional Accept only numeric input (``-N`` flag). prompt_type : str, optional - Prompt type (``-T`` flag). One of: ``command``, ``search``, - ``target``, ``window-target``. + Prompt type (``-T`` flag). + expand_format : bool, optional + Pass the template through ``args_make_commands_prepare`` + (``-F`` flag) so format strings expand. Requires tmux 3.3+. + literal : bool, optional + Disable splitting *prompt* on commas — treat it as a single + prompt (``-l`` flag). Requires tmux 3.6+. + bspace_exit : bool, optional + Close the prompt when the user empties it with backspace + (``-e`` flag, ``PROMPT_BSPACE_EXIT``). Requires tmux 3.7+ (master). Examples -------- @@ -988,6 +1000,21 @@ def command_prompt( if numeric: tmux_args += ("-N",) + if expand_format: + tmux_args += ("-F",) + + if literal: + if has_gte_version("3.6", tmux_bin=self.tmux_bin): + tmux_args += ("-l",) + else: + warnings.warn( + "literal requires tmux 3.6+, ignoring", + stacklevel=2, + ) + + if bspace_exit: + tmux_args += ("-e",) + if prompt is not None: tmux_args += ("-p", prompt) diff --git a/tests/test_server.py b/tests/test_server.py index 85dd58667..cef37bfed 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -597,6 +597,56 @@ def test_command_prompt( assert result.stdout[0] == expected_value +@pytest.mark.parametrize( + ("kwargs", "expected_flag", "min_tmux_version"), + [ + ({"expand_format": True}, "-F", "3.3"), + ({"literal": True}, "-l", "3.6"), + ({"bspace_exit": True}, "-e", None), + ], + ids=["expand_format_v33", "literal_v36", "bspace_exit"], +) +def test_command_prompt_extra_flags( + kwargs: dict[str, t.Any], + expected_flag: str, + min_tmux_version: str | None, + monkeypatch: pytest.MonkeyPatch, + server: Server, +) -> None: + """``command_prompt`` exposes -F/-l/-e flags. + + End-to-end behaviour for these flags depends on tmux internals + that are awkward to drive from a headless test (format + expansion, comma-split disabling, backspace-exit). Verify the + constructed argv instead, matching the test_display_menu_flags + monkeypatch pattern. + """ + from libtmux.common import has_gte_version + + if min_tmux_version and not has_gte_version(min_tmux_version): + pytest.skip(f"Requires tmux {min_tmux_version}+") + + captured: list[tuple[str, ...]] = [] + real_cmd = server.cmd + + class _StubResult: + stderr: t.ClassVar[list[str]] = [] + stdout: t.ClassVar[list[str]] = [] + + def fake_cmd(cmd: str, *args: str, **_kw: t.Any) -> t.Any: + if cmd == "command-prompt": + captured.append((cmd, *args)) + return _StubResult() + return real_cmd(cmd, *args, **_kw) + + monkeypatch.setattr(server, "cmd", fake_cmd) + server.command_prompt("set -g @x '%1'", **kwargs) + + assert captured, "Server.cmd was not invoked" + _name, *flags = captured[0] + assert expected_flag in flags + + class DisplayMenuCase(t.NamedTuple): """Test case for display_menu() flag variations.""" From 6638f1338c5d04d50f9cd074bd3b4e6d4fe648cb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:16:20 -0500 Subject: [PATCH 089/105] Pane(feat[copy_mode]): add page_down (-d) and source_pane (-s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmux's copy-mode accepts two flags the wrapper omitted: - -s source-pane (3.2+, c0602f35) lets a pane display another pane's history in copy mode — useful for scrolling/copying from one pane into an editor in another - -d (3.5+, 4823acca) page-down on entry if already in copy mode what: - Add page_down (-d, version-gated to 3.5+) and source_pane (-s, unconditional since 3.2 is older than the project minimum) to Pane.copy_mode. - Add test_copy_mode_source_pane (cross-pane history exercise) and test_copy_mode_page_down (3.5+ skipif). --- src/libtmux/pane.py | 21 +++++++++++++++++++++ tests/test_pane.py | 24 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 1a064c4b1..5a476fb1c 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1465,6 +1465,8 @@ def copy_mode( exit_on_bottom: bool | None = None, mouse_drag: bool | None = None, cancel: bool | None = None, + page_down: bool | None = None, + source_pane: str | None = None, ) -> None: """Enter copy mode via ``$ tmux copy-mode``. @@ -1479,6 +1481,13 @@ def copy_mode( Start mouse drag (``-M`` flag). cancel : bool, optional Cancel copy mode and any other modes (``-q`` flag). + page_down : bool, optional + Scroll a page down if already in copy mode (``-d`` flag). + Requires tmux 3.5+. + source_pane : str, optional + Source pane whose contents should be displayed in copy + mode (``-s`` flag). Lets you scroll/copy from another + pane's history. Requires tmux 3.2+. Examples -------- @@ -1495,6 +1504,18 @@ def copy_mode( if mouse_drag: tmux_args += ("-M",) + if page_down: + if has_gte_version("3.5", tmux_bin=self.server.tmux_bin): + tmux_args += ("-d",) + else: + warnings.warn( + "page_down requires tmux 3.5+, ignoring", + stacklevel=2, + ) + + if source_pane is not None: + tmux_args += ("-s", source_pane) + if cancel: tmux_args += ("-q",) diff --git a/tests/test_pane.py b/tests/test_pane.py index 4af67b8ab..051bc49e0 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -765,6 +765,30 @@ def test_copy_mode(session: Session) -> None: pane.send_keys("q", enter=False) +def test_copy_mode_source_pane(session: Session) -> None: + """Test Pane.copy_mode(source_pane=...) reads another pane's history.""" + window = session.new_window(window_name="copy_src") + src = window.active_pane + assert src is not None + dest = window.split() + src.send_keys("echo SOURCE_TEXT", enter=True) + + dest.copy_mode(source_pane=str(src.pane_id)) + dest.send_keys("q", enter=False) + + +def test_copy_mode_page_down(session: Session) -> None: + """Test Pane.copy_mode(page_down=True) on tmux 3.5+.""" + if not has_gte_version("3.5"): + pytest.skip("page_down requires tmux 3.5+") + + pane = session.active_window.active_pane + assert pane is not None + pane.copy_mode() + pane.copy_mode(page_down=True) + pane.send_keys("q", enter=False) + + def test_clock_mode(session: Session) -> None: """Test Pane.clock_mode() enters clock mode.""" pane = session.active_window.active_pane From f32ddd04a1cb067d36b2c99c5f0a85b5a4a6a50e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:19:32 -0500 Subject: [PATCH 090/105] Pane(feat[choose_tree]): expose -F/-f/-O/-r/-Z flags why: tmux's choose-tree accepts a much larger surface than the wrapper exposed (cmd-choose-tree.c:36, args="F:f:GK:NO:rstwyZ"). Real users need at least format/filter/sort to drive the chooser programmatically, plus -Z to zoom while picking. what: - Add format_string (-F), filter_expression (-f), sort_order (-O), reverse (-r), zoom (-Z) kwargs to Pane.choose_tree. - Use filter_expression rather than filter to avoid shadowing the Python builtin (ruff A002). - Add test_choose_tree_with_flags exercising all five. --- src/libtmux/pane.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 5a476fb1c..599fd1dfb 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -1594,6 +1594,11 @@ def choose_tree( *, sessions_collapsed: bool | None = None, windows_collapsed: bool | None = None, + format_string: str | None = None, + filter_expression: str | None = None, + sort_order: t.Literal["index", "name", "time", "size"] | None = None, + reverse: bool | None = None, + zoom: bool | None = None, ) -> None: """Enter tree chooser via ``$ tmux choose-tree``. @@ -1603,6 +1608,17 @@ def choose_tree( Start with sessions collapsed (``-s`` flag). windows_collapsed : bool, optional Start with windows collapsed (``-w`` flag). + format_string : str, optional + Format for each item shown in the chooser (``-F`` flag). + filter_expression : str, optional + Filter expression evaluated per item; only items whose + filter expands non-zero are shown (``-f`` flag). + sort_order : str, optional + Sort field (``-O`` flag). + reverse : bool, optional + Reverse the sort order (``-r`` flag). + zoom : bool, optional + Zoom the pane while the chooser is active (``-Z`` flag). Examples -------- @@ -1616,6 +1632,21 @@ def choose_tree( if windows_collapsed: tmux_args += ("-w",) + if zoom: + tmux_args += ("-Z",) + + if reverse: + tmux_args += ("-r",) + + if format_string is not None: + tmux_args += ("-F", format_string) + + if filter_expression is not None: + tmux_args += ("-f", filter_expression) + + if sort_order is not None: + tmux_args += ("-O", sort_order) + proc = self.cmd("choose-tree", *tmux_args) if proc.stderr: From 6f7aeb3436466299ea73b22bdcef1c44647997cc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:22:02 -0500 Subject: [PATCH 091/105] Pane(feat[capture_pane]): support -b to write into a tmux buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: capture_pane hardcoded -p (pipe to caller's stdout) and offered no way to send the capture into a named tmux buffer for later cross- pane consumption — a real feature of cmd-capture-pane.c (args="ab:CeE:JMNpPqS:Tt:", -b at line 256). what: - Add to_buffer: str | None kwarg. When set, the wrapper omits -p, emits -b and returns None instead of stdout. - Use @t.overload to keep the existing list[str] return type for the default path; only the to_buffer-set call returns None. - Add test_capture_pane_to_buffer that captures into a named buffer and verifies the marker survived via show-buffer / delete-buffer. --- src/libtmux/pane.py | 58 ++++++++++++++++++++++++++++++--- tests/test_pane_capture_pane.py | 20 ++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 599fd1dfb..6a63286fb 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -317,6 +317,40 @@ def resize( self.refresh() return self + @t.overload + def capture_pane( + self, + start: t.Literal["-"] | int | None = ..., + end: t.Literal["-"] | int | None = ..., + *, + escape_sequences: bool = ..., + escape_non_printable: bool = ..., + join_wrapped: bool = ..., + preserve_trailing: bool = ..., + trim_trailing: bool = ..., + alternate_screen: bool = ..., + quiet: bool = ..., + mode_screen: bool = ..., + to_buffer: str, + ) -> None: ... + + @t.overload + def capture_pane( + self, + start: t.Literal["-"] | int | None = ..., + end: t.Literal["-"] | int | None = ..., + *, + escape_sequences: bool = ..., + escape_non_printable: bool = ..., + join_wrapped: bool = ..., + preserve_trailing: bool = ..., + trim_trailing: bool = ..., + alternate_screen: bool = ..., + quiet: bool = ..., + mode_screen: bool = ..., + to_buffer: None = ..., + ) -> list[str]: ... + def capture_pane( self, start: t.Literal["-"] | int | None = None, @@ -330,7 +364,8 @@ def capture_pane( alternate_screen: bool = False, quiet: bool = False, mode_screen: bool = False, - ) -> list[str]: + to_buffer: str | None = None, + ) -> list[str] | None: r"""Capture text from pane. ``$ tmux capture-pane`` to pane. @@ -390,12 +425,18 @@ def capture_pane( pane (``-M`` flag). Requires tmux 3.6+. Default: False + .. versionadded:: 0.45 + to_buffer : str, optional + Write the capture into the named tmux buffer (``-b`` flag) + instead of returning it. When set, ``-p`` is omitted and + the wrapper returns ``None``. + .. versionadded:: 0.45 Returns ------- - list[str] - Captured pane content. + list[str] or None + Captured pane content, or ``None`` when *to_buffer* is set. Examples -------- @@ -413,7 +454,11 @@ def capture_pane( Hello world $ """ - cmd = ["capture-pane", "-p"] + cmd = ["capture-pane"] + if to_buffer is not None: + cmd.extend(["-b", to_buffer]) + else: + cmd.append("-p") if start is not None: cmd.extend(["-S", str(start)]) if end is not None: @@ -446,7 +491,10 @@ def capture_pane( "mode_screen requires tmux 3.6+, ignoring", stacklevel=2, ) - return self.cmd(*cmd).stdout + proc = self.cmd(*cmd) + if to_buffer is not None: + return None + return proc.stdout def send_keys( self, diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 00f7cc2e3..7e093f84c 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -506,3 +506,23 @@ def test_capture_pane_mode_screen(session: Session) -> None: result = pane.capture_pane(mode_screen=True) assert isinstance(result, list) + + +def test_capture_pane_to_buffer(session: Session) -> None: + """Test capture_pane(to_buffer=...) writes to a tmux buffer.""" + pane = session.active_window.active_pane + assert pane is not None + + pane.send_keys('echo "BUFFER_CAPTURE_MARKER"', enter=True) + retry_until( + lambda: "BUFFER_CAPTURE_MARKER" in "\n".join(pane.capture_pane()), + 2, + raises=True, + ) + + result = pane.capture_pane(to_buffer="cap_test_buf") + assert result is None + + contents = session.server.cmd("show-buffer", "-b", "cap_test_buf").stdout + assert any("BUFFER_CAPTURE_MARKER" in line for line in contents) + session.server.cmd("delete-buffer", "-b", "cap_test_buf") From f4865ad192d5835f9ec00b4b8b907c8687ef10c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:23:54 -0500 Subject: [PATCH 092/105] Server(feat[show_messages]): add -T (terminals) and -J (jobs) flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmux's show-messages takes -T (list terminal capabilities) and -J (print job server summary) in addition to the message-log default (cmd-show-messages.c:41 args="JTt:"). The wrapper docstring already referenced both, but only -t was exposed. -T and -J are early-return paths in cmd-show-messages.c that don't go through format_create_from_target, so they work clientless — handy for debugging tmux from a headless test run. what: - Add terminals (-T) and jobs (-J) bool kwargs to Server.show_messages. - Update the docstring to note the clientless modes. - Add test_show_messages_terminals_jobs which exercises both modes without spinning up a control_mode client. --- src/libtmux/server.py | 26 +++++++++++++++++++++++--- tests/test_server.py | 16 ++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index b1827ced6..5b98af44d 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1169,24 +1169,38 @@ def start_server(self) -> None: if proc.stderr: raise exc.LibTmuxException(proc.stderr) - def show_messages(self, *, target_client: str | None = None) -> list[str]: + def show_messages( + self, + *, + target_client: str | None = None, + terminals: bool | None = None, + jobs: bool | None = None, + ) -> list[str]: """Show server message log via ``$ tmux show-messages``. Without ``-T``/``-J``, tmux resolves the message log against a target client; if no client is attached and *target_client* is omitted, tmux raises ``no current client``. Provide *target_client* (e.g. via :class:`~libtmux._internal.control_mode.ControlMode`) - when running headless. + when running headless, or use *terminals*/*jobs* — those modes + don't require a client. Parameters ---------- target_client : str, optional Target client (``-t`` flag). + terminals : bool, optional + List terminal capabilities and flags instead of the message + log (``-T`` flag). + jobs : bool, optional + Print the tmux job server summary instead of the message + log (``-J`` flag). Returns ------- list[str] - Server message log lines. + Server message log lines (or terminal/job summary when + *terminals*/*jobs* is set). Examples -------- @@ -1197,6 +1211,12 @@ def show_messages(self, *, target_client: str | None = None) -> list[str]: """ tmux_args: tuple[str, ...] = () + if terminals: + tmux_args += ("-T",) + + if jobs: + tmux_args += ("-J",) + if target_client is not None: tmux_args += ("-t", target_client) diff --git a/tests/test_server.py b/tests/test_server.py index cef37bfed..86680576e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -890,6 +890,22 @@ def test_show_messages( assert isinstance(result, list) +def test_show_messages_terminals_jobs(server: Server) -> None: + """Test Server.show_messages(terminals=...) and (jobs=...) work clientless. + + ``-T`` and ``-J`` are early-return paths in cmd-show-messages.c that + don't reach ``format_create_from_target``, so they don't require a + client. Verify both modes return a list without raising. + """ + server.new_session(session_name="showmsg_alt_test") + + terminals = server.show_messages(terminals=True) + assert isinstance(terminals, list) + + jobs = server.show_messages(jobs=True) + assert isinstance(jobs, list) + + def test_show_prompt_history(server: Server) -> None: """Test Server.show_prompt_history() returns history.""" from libtmux.common import has_gte_version From ac6a874952be3b76b0e908a78299e4aa35f0b153 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:25:56 -0500 Subject: [PATCH 093/105] Server(feat[server_access]): add -r and -w flags for read-only/read-write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmux's server-access supports forcing a user to attach read-only (-r) or allowing read-write attach (-w) — both implicitly allow the user if not yet in the ACL (cmd-server-access.c:108-145). The wrapper omitted both, leaving callers without the controls that make this command useful. what: - Add read_only (-r) and write (-w) bool kwargs to Server.server_access. Mutually exclusive — tmux rejects -r -w with "cannot be used together", and the wrapper raises ValueError early to give a clearer Python-side trace. - Add test_server_access_read_only_write_mutex for the guard and test_server_access_argv (monkeypatch capture) for the emitted flags. server-access's actual side effect requires real OS users and ACL state, so end-to-end testing is out of scope. --- src/libtmux/server.py | 20 ++++++++++++++++++ tests/test_server.py | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 5b98af44d..a1e7e9939 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -708,6 +708,8 @@ def server_access( allow: str | None = None, deny: str | None = None, list_access: bool | None = None, + read_only: bool | None = None, + write: bool | None = None, ) -> list[str] | None: """Manage server access control via ``$ tmux server-access``. @@ -721,6 +723,14 @@ def server_access( Deny a user (``-d`` flag). list_access : bool, optional List access rules (``-l`` flag). + read_only : bool, optional + Force the user to attach read-only (``-r`` flag). Implies + allow if the user is not already in the ACL. Mutually + exclusive with *write* — tmux rejects ``-r -w``. + write : bool, optional + Allow the user to attach read-write (``-w`` flag). Implies + allow if the user is not already in the ACL. Mutually + exclusive with *read_only*. Returns ------- @@ -738,6 +748,10 @@ def server_access( msg = "server_access requires tmux 3.3+" raise exc.LibTmuxException(msg) + if read_only and write: + msg = "read_only and write are mutually exclusive (tmux rejects -r -w)" + raise ValueError(msg) + tmux_args: tuple[str, ...] = () if allow is not None: @@ -749,6 +763,12 @@ def server_access( if list_access: tmux_args += ("-l",) + if read_only: + tmux_args += ("-r",) + + if write: + tmux_args += ("-w",) + proc = self.cmd("server-access", *tmux_args) if proc.stderr: diff --git a/tests/test_server.py b/tests/test_server.py index 86680576e..1d112036c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -829,6 +829,54 @@ def test_server_access_list(server: Server) -> None: assert isinstance(result, list) +def test_server_access_read_only_write_mutex(server: Server) -> None: + """``read_only`` and ``write`` are mutually exclusive.""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.3"): + pytest.skip("server-access added in tmux 3.3") + + with pytest.raises(ValueError, match="mutually exclusive"): + server.server_access(allow="someuser", read_only=True, write=True) + + +def test_server_access_argv( + monkeypatch: pytest.MonkeyPatch, + server: Server, +) -> None: + """``server_access`` emits -r/-w when read_only/write are set. + + server-access requires real OS users and ACL state, so end-to-end + testing of the side-effect would tie the suite to system config. + Verify the constructed argv instead. + """ + from libtmux.common import has_gte_version + + if not has_gte_version("3.3"): + pytest.skip("server-access added in tmux 3.3") + + captured: list[tuple[str, ...]] = [] + real_cmd = server.cmd + + class _StubResult: + stderr: t.ClassVar[list[str]] = [] + stdout: t.ClassVar[list[str]] = [] + + def fake_cmd(cmd: str, *args: str, **_kw: t.Any) -> t.Any: + if cmd == "server-access": + captured.append((cmd, *args)) + return _StubResult() + return real_cmd(cmd, *args, **_kw) + + monkeypatch.setattr(server, "cmd", fake_cmd) + + server.server_access(allow="alice", read_only=True) + assert captured[-1][1:] == ("-a", "alice", "-r") + + server.server_access(allow="bob", write=True) + assert captured[-1][1:] == ("-a", "bob", "-w") + + def test_start_server(server: Server) -> None: """Test Server.start_server() runs without error.""" server.new_session(session_name="startsvr_test") From 8b15f0baa79c1e9e64c7e7a9cfa0e20746a44456 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:28:13 -0500 Subject: [PATCH 094/105] docs(tmux-parity[skill]): update stale coverage claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: SKILL.md said "~28 of ~88 tmux commands wrapped" and listed join-pane, swap-pane, run-shell, display-popup as high-priority unwrapped — all of which the tmux-parity branch wraps. command-mapping.md likewise listed last-pane, next-layout, previous-layout, move-pane as alias/flag-covered, despite the branch adding direct wrappers for each (commits dd8c65f0, 2ab4c65f, aa00c45d). Static numbers in generated docs go stale fast; static "unwrapped" lists go stale faster. what: - Rewrite SKILL.md "Current Coverage Summary" to point at the extraction scripts rather than baking in a number/list that immediately rots. Note that effective coverage is 100%. - Rewrite command-mapping.md "Covered by Alias/Flag" — there are now exactly four indirect cases (list-panes, list-windows, set-window-option, show-window-options), all reached through internal queries / option scoping. Updated count from 8/82 to 4/86. - Convert ```bash command blocks to ```console with $ prefix per AGENTS.md. --- skills/tmux-parity/SKILL.md | 20 ++++++---- .../tmux-parity/references/command-mapping.md | 40 ++++++++++--------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/skills/tmux-parity/SKILL.md b/skills/tmux-parity/SKILL.md index 069480d14..c695cf286 100644 --- a/skills/tmux-parity/SKILL.md +++ b/skills/tmux-parity/SKILL.md @@ -57,18 +57,24 @@ Class hierarchy mapping from tmux target types: ## Current Coverage Summary -**~28 of ~88 tmux commands wrapped** (~32% coverage, approximate — run extraction scripts for current data). High-priority unwrapped commands include: `join-pane`, `swap-pane`, `swap-window`, `respawn-pane`, `respawn-window`, `run-shell`, `break-pane`, `move-pane`, `pipe-pane`, `display-popup`. +Coverage is effectively 100% — every tmux command is reachable from +the Python API, either directly or via internal queries / option +scoping. The four indirect cases are listed in +`references/command-mapping.md`. + +Static numbers go stale fast. **Run the extraction scripts** when you +need current counts before making coverage claims. ## Extraction Scripts Run these for up-to-date data: -```bash -# All tmux commands with flags and target types -bash .claude-plugin/scripts/extract-tmux-commands.sh ~/study/c/tmux +```console +$ bash .claude-plugin/scripts/extract-tmux-commands.sh ~/study/c/tmux +``` -# All tmux commands libtmux currently wraps -bash .claude-plugin/scripts/extract-libtmux-methods.sh +```console +$ bash .claude-plugin/scripts/extract-libtmux-methods.sh ``` ## Additional Resources @@ -76,6 +82,6 @@ bash .claude-plugin/scripts/extract-libtmux-methods.sh ### Reference Files For detailed data, consult: -- **`references/command-mapping.md`** — Complete mapping of all ~88 tmux commands to libtmux methods, with flag coverage +- **`references/command-mapping.md`** — Mapping of every tmux command to its libtmux entrypoint, including the four reached indirectly - **`references/libtmux-patterns.md`** — Implementation patterns for wrapping new commands (method signatures, doctests, logging, error handling) - **`references/tmux-command-table.md`** — Guide to navigating tmux C source: cmd_entry fields, getopt format, target types, options-table.c, format.c diff --git a/skills/tmux-parity/references/command-mapping.md b/skills/tmux-parity/references/command-mapping.md index 6670211a7..ec281a72e 100644 --- a/skills/tmux-parity/references/command-mapping.md +++ b/skills/tmux-parity/references/command-mapping.md @@ -1,31 +1,33 @@ # tmux Command → libtmux Method Mapping -Generated from tmux HEAD and libtmux source. Re-generate with: -```bash -bash .claude-plugin/scripts/extract-tmux-commands.sh ~/study/c/tmux -bash .claude-plugin/scripts/extract-libtmux-methods.sh +Run the extraction scripts for current data — these numbers shift as +tmux and libtmux evolve: + +```console +$ bash .claude-plugin/scripts/extract-tmux-commands.sh ~/study/c/tmux +``` + +```console +$ bash .claude-plugin/scripts/extract-libtmux-methods.sh ``` ## Summary -- **Directly wrapped**: 82/90 commands (91%) -- **Covered by alias/flag**: 8 additional commands -- **Total effective coverage**: 90/90 (100%) +- **Directly invoked**: 86 of 90 tmux commands +- **Covered indirectly via internal queries / option scoping**: 4 +- **Total effective coverage**: 90 / 90 (100%) -## Covered by Alias/Flag (8 commands) +## Covered Indirectly (4 commands) -These commands are not called directly but their functionality is available: +These four tmux commands aren't called by name in `libtmux` source but +their functionality is reachable through other primitives: -| tmux Command | Covered By | How | -|---|---|---| -| `last-pane` | `Window.last_pane()`, `Pane.select(last=True)` | `-l` flag on select-pane | -| `list-panes` | `Window.panes` property | Used internally by `neo.py` | -| `list-windows` | `Session.windows` property | Used internally by `neo.py` | -| `move-pane` | `Pane.join()` | Same C source as join-pane | -| `next-layout` | `Window.select_layout(next_layout=True)` | `-n` flag on select-layout | -| `previous-layout` | `Window.select_layout(previous_layout=True)` | `-o` flag on select-layout | -| `set-window-option` | `OptionsMixin.set_option(scope=OptionScope.Window)` | Alias for `set-option -w` | -| `show-window-options` | `OptionsMixin.show_options(scope=OptionScope.Window)` | Alias for `show-options -w` | +| tmux Command | Reached Through | +|---|---| +| `list-panes` | `Window.panes` property (issued internally by `neo.py` queries) | +| `list-windows` | `Session.windows` property (issued internally by `neo.py` queries) | +| `set-window-option` | `OptionsMixin.set_option(scope=OptionScope.Window)` — `set-option -w` | +| `show-window-options` | `OptionsMixin.show_options(scope=OptionScope.Window)` — `show-options -w` | ## Test Gaps (1 command) From 8642fa244a26f729d387453b3eb1a6efb534bfbb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:30:35 -0500 Subject: [PATCH 095/105] docs(tmux-parity[plugin]): use console fences with $ prefix per AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: AGENTS.md "Shell Command Formatting" requires command examples in docs to use ```console with a "$ " prefix (not ```bash) and to keep one command per code block so the prompt is unambiguous and copy-pastable. The plugin docs added in this branch had multi-command ```bash blocks scattered across .claude/commands and skills/tmux-parity references. what: - .claude/agents/parity-analyzer.md: 3 blocks → console+$. - .claude/commands/version-diff.md: 2 blocks → console+$. - .claude/commands/implement-command.md: split the 6-command verification block into 6 console+$ blocks. - skills/tmux-parity/references/tmux-command-table.md: 2 blocks → console+$ (one of which had two ls invocations — split per AGENTS.md "one command per block"). --- .claude/agents/parity-analyzer.md | 19 +++++++----- .claude/commands/implement-command.md | 30 +++++++++++-------- .claude/commands/version-diff.md | 11 ++++--- .../references/tmux-command-table.md | 17 +++++++---- 4 files changed, 47 insertions(+), 30 deletions(-) diff --git a/.claude/agents/parity-analyzer.md b/.claude/agents/parity-analyzer.md index 10dc9fe3b..ba0b6872a 100644 --- a/.claude/agents/parity-analyzer.md +++ b/.claude/agents/parity-analyzer.md @@ -52,22 +52,27 @@ You are a tmux/libtmux feature parity analysis specialist. Analyze the gap betwe ### Step 1: Extract tmux commands Run the extraction script for current data: -```bash -bash .claude-plugin/scripts/extract-tmux-commands.sh ~/study/c/tmux + +```console +$ bash .claude-plugin/scripts/extract-tmux-commands.sh ~/study/c/tmux ``` -This outputs `command|alias|getopt|target` for all ~88 tmux commands. + +This outputs `command|alias|getopt|target` for every tmux command. ### Step 2: Extract libtmux coverage Run the libtmux extraction: -```bash -bash .claude-plugin/scripts/extract-libtmux-methods.sh + +```console +$ bash .claude-plugin/scripts/extract-libtmux-methods.sh ``` + This outputs the unique tmux command strings that libtmux invokes. Additionally, check mixin files for commands invoked via `tmux_cmd()`: -```bash -grep -rn '"set-environment"\|"show-environment"\|"set-hook"\|"set-option"\|"show-option"\|"capture-pane"\|"move-window"\|"select-layout"\|"kill-pane"' src/libtmux/*.py | grep -oP '"([a-z]+-[a-z-]+)"' | sort -u | tr -d '"' + +```console +$ grep -rn '"set-environment"\|"show-environment"\|"set-hook"\|"set-option"\|"show-option"\|"capture-pane"\|"move-window"\|"select-layout"\|"kill-pane"' src/libtmux/*.py | grep -oP '"([a-z]+-[a-z-]+)"' | sort -u | tr -d '"' ``` ### Step 3: Cross-reference diff --git a/.claude/commands/implement-command.md b/.claude/commands/implement-command.md index 9e70407c9..4c39cfd18 100644 --- a/.claude/commands/implement-command.md +++ b/.claude/commands/implement-command.md @@ -104,24 +104,28 @@ Add tests in `tests/test_{class}.py` (or a new file if warranted): Run the full verification workflow: -```bash -# Format -uv run ruff format . +```console +$ uv run ruff format . +``` -# Lint -uv run ruff check . --fix --show-fixes +```console +$ uv run ruff check . --fix --show-fixes +``` -# Type check -uv run mypy src tests +```console +$ uv run mypy src tests +``` -# Test the specific file -uv run pytest tests/test_{class}.py -x -v +```console +$ uv run pytest tests/test_{class}.py -x -v +``` -# Run doctests -uv run pytest --doctest-modules src/libtmux/{class}.py -v +```console +$ uv run pytest --doctest-modules src/libtmux/{class}.py -v +``` -# Full test suite -uv run pytest +```console +$ uv run pytest ``` All must pass before considering the implementation complete. diff --git a/.claude/commands/version-diff.md b/.claude/commands/version-diff.md index 53db7d711..c3f4ae6cb 100644 --- a/.claude/commands/version-diff.md +++ b/.claude/commands/version-diff.md @@ -17,16 +17,19 @@ Compare tmux features between two versions using the source worktrees at `~/stud Extract `version1`, `version2`, and optional `command-name` from `$ARGUMENTS`. If no arguments provided, list available versions: -```bash -ls -d ~/study/c/tmux-*/ | sed 's|.*/tmux-||;s|/$||' | sort -V + +```console +$ ls -d ~/study/c/tmux-*/ | sed 's|.*/tmux-||;s|/$||' | sort -V ``` + Then ask the user which two versions to compare. ## Validate Worktrees Verify both worktrees exist: -```bash -ls -d ~/study/c/tmux-{version1}/ ~/study/c/tmux-{version2}/ 2>/dev/null + +```console +$ ls -d ~/study/c/tmux-{version1}/ ~/study/c/tmux-{version2}/ 2>/dev/null ``` ## Single Command Comparison (when command-name given) diff --git a/skills/tmux-parity/references/tmux-command-table.md b/skills/tmux-parity/references/tmux-command-table.md index 4fb7ff3fc..b810bb0ff 100644 --- a/skills/tmux-parity/references/tmux-command-table.md +++ b/skills/tmux-parity/references/tmux-command-table.md @@ -89,13 +89,18 @@ Compare against libtmux: `src/libtmux/formats.py` - 2.0 through 2.9, 2.9a - 3.0, 3.0a, 3.1 through 3.1c, 3.2, 3.2a, 3.3, 3.3a, 3.4, 3.5, 3.5a, 3.6, 3.6a -To check if a command exists in a version: -```bash -ls ~/study/c/tmux-3.0/cmd-display-popup.c 2>/dev/null # Not found = added later -ls ~/study/c/tmux-3.3/cmd-display-popup.c 2>/dev/null # Found = exists in 3.3 +To check if a command exists in a version (not-found = added later): + +```console +$ ls ~/study/c/tmux-3.0/cmd-display-popup.c 2>/dev/null +``` + +```console +$ ls ~/study/c/tmux-3.3/cmd-display-popup.c 2>/dev/null ``` To diff a command across versions: -```bash -diff ~/study/c/tmux-3.0/cmd-send-keys.c ~/study/c/tmux-3.6a/cmd-send-keys.c + +```console +$ diff ~/study/c/tmux-3.0/cmd-send-keys.c ~/study/c/tmux-3.6a/cmd-send-keys.c ``` From 05b323bdfe1fa27238fb10460f9a461aac348223 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:32:00 -0500 Subject: [PATCH 096/105] test(session): drop duplicate test_detach_client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: test_detach_client and test_detach_client_no_target_detaches_all_session_clients covered the same path (no target_client → -s session_id scoping) with identical fixture shape; the latter is strictly stronger because it asserts on two attached clients rather than one. Keeping both adds maintenance cost without adding signal. what: - Remove test_detach_client. The remaining tests (no_target_*, target_client, all_clients_session_scoped) cover the three real branches of Session.detach_client. --- tests/test_session.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tests/test_session.py b/tests/test_session.py index ab96748bc..88d0a7bdc 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -586,23 +586,6 @@ def test_lock_session(session: Session) -> None: session.lock_session() -def test_detach_client( - control_mode: t.Callable[..., t.Any], - session: Session, - server: Server, -) -> None: - """Test Session.detach_client() detaches all session clients when no target given. - - Without target_client, -s session_id scopes the operation to this session. - """ - with control_mode(): - before = len(server.list_clients()) - assert before > 0 - session.detach_client() - after = len(server.list_clients()) - assert after == 0 - - def test_detach_client_no_target_detaches_all_session_clients( control_mode: t.Callable[..., t.Any], session: Session, From 3e481fff04dd517cba927dab8e593a1e5a796024 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:33:58 -0500 Subject: [PATCH 097/105] test(server): fold test_buffer_append into BufferCase parametrisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: BufferCase already had an `append: bool | None` field but no parametrisation case actually exercised it — the append behaviour lived in a duplicative test_buffer_append function. The remaining buffer tests (delete, save_load, save_append, list_buffers) test genuinely different operations than set+show variations and stay as their own test functions. what: - Add a `set_show_append` BufferCase that seeds the buffer with "first" and appends "_second" via append=True, asserting the concatenated content. - Update test_buffer_set_show to interpret the existing `append` field by seeding the buffer and passing append=True. - Remove the standalone test_buffer_append function. --- tests/test_server.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/test_server.py b/tests/test_server.py index 1d112036c..4bfdc4901 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1033,6 +1033,15 @@ class BufferCase(t.NamedTuple): append=None, expected_content="named_data", ), + BufferCase( + test_id="set_show_append", + # set_buffer() called twice in the test body for this case; + # the first sets "first", the second appends "_second". + data="_second", + buffer_name="append_test", + append=True, + expected_content="first_second", + ), ] @@ -1054,23 +1063,17 @@ def test_buffer_set_show( kwargs: dict[str, t.Any] = {} if buffer_name is not None: kwargs["buffer_name"] = buffer_name - if append is not None: - kwargs["append"] = append + + if append: + # Seed the buffer with "first" so the appended *data* concatenates. + server.set_buffer("first", buffer_name=buffer_name) + kwargs["append"] = True server.set_buffer(data, **kwargs) result = server.show_buffer(buffer_name=buffer_name) assert result == expected_content -def test_buffer_append(server: Server) -> None: - """Test Server.set_buffer() with append flag.""" - server.new_session(session_name="buf_append") - server.set_buffer("first", buffer_name="append_test") - server.set_buffer("_second", buffer_name="append_test", append=True) - result = server.show_buffer(buffer_name="append_test") - assert result == "first_second" - - def test_buffer_delete(server: Server) -> None: """Test Server.delete_buffer().""" server.new_session(session_name="buf_delete") From 0385b90a0261db29a47e7f46fe8b920bb0f950ee Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:36:00 -0500 Subject: [PATCH 098/105] test(pane): consolidate capture_pane flag-smoke tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: test_capture_pane_quiet, test_capture_pane_alternate_screen, and test_capture_pane_mode_screen each had the same "call with one flag, assert isinstance(result, list)" shape. Three almost-identical 3-line functions are exactly the case parametrised tests are for. The existing CAPTURE_PANE_CASES harness is intentionally focused on output-content assertions (run a command, check pattern in output) — folding flag smoke-tests into it would muddle that purpose. A small dedicated parametrise is the right fit. what: - Replace the three smoke functions with one parametrised test_capture_pane_flag_smoke driving (kwargs, min_tmux_version) cases for quiet, alternate_screen, and mode_screen (3.6+). --- tests/test_pane_capture_pane.py | 53 ++++++++++++++++----------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 7e093f84c..5858cbd45 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -474,37 +474,36 @@ def prompt_ready() -> bool: assert "trim_trailing requires tmux 3.4+" in str(w[0].message) -def test_capture_pane_quiet(session: Session) -> None: - """Test capture_pane with quiet flag suppresses errors.""" - pane = session.active_window.active_pane - assert pane is not None - - # Quiet mode should not raise, even in edge cases - result = pane.capture_pane(quiet=True) - assert isinstance(result, list) - - -def test_capture_pane_alternate_screen(session: Session) -> None: - """Test capture_pane with alternate_screen flag.""" - pane = session.active_window.active_pane - assert pane is not None - - # Capture from alternate screen — may be empty, but should not error - result = pane.capture_pane(alternate_screen=True) - assert isinstance(result, list) - - -def test_capture_pane_mode_screen(session: Session) -> None: - """Test capture_pane with mode_screen flag (3.6+).""" - from libtmux.common import has_gte_version - - if not has_gte_version("3.6"): - pytest.skip("Requires tmux 3.6+") +@pytest.mark.parametrize( + ("kwargs", "min_tmux_version"), + [ + ({"quiet": True}, None), + ({"alternate_screen": True}, None), + ({"mode_screen": True}, "3.6"), + ], + ids=["quiet", "alternate_screen", "mode_screen_v36"], +) +def test_capture_pane_flag_smoke( + kwargs: dict[str, t.Any], + min_tmux_version: str | None, + session: Session, +) -> None: + """Smoke-test capture_pane flags that aren't tied to output content. + + These flags either suppress errors (quiet), select a screen + (alternate_screen), or read from a non-default source + (mode_screen). The runtime side-effects depend on tmux internal + state that's awkward to drive headless; assert that the call + returns a list without raising. Output-pattern assertions live in + CAPTURE_PANE_CASES for the flags whose behaviour is observable. + """ + if min_tmux_version and not has_gte_version(min_tmux_version): + pytest.skip(f"Requires tmux {min_tmux_version}+") pane = session.active_window.active_pane assert pane is not None - result = pane.capture_pane(mode_screen=True) + result = pane.capture_pane(**kwargs) assert isinstance(result, list) From d62c2cb5b965c70ca46fddad039c8d61ce99e205 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 2 May 2026 18:38:20 -0500 Subject: [PATCH 099/105] docs(versionadded): annotate 55 new methods with 0.45 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: a previous commit (3b83234e) annotated new params on existing methods but left the entirely-new methods unmarked. After the parity branch, ~55 brand-new public wrappers ship without a "versionadded" hint, so users can't tell from the docs which API landed in 0.45 vs an older release. The Weave review's three reviewers all flagged this (consensus: Suggestion → Important). what: - Insert `.. versionadded:: 0.45` into the docstring of every new public method on Server, Session, Window, and Pane. - 27 methods on server.py, 5 on session.py, 7 on window.py, 16 on pane.py. - Existing methods (capture_pane, display_message, Pane.select, Window.select, …) are left alone — they predate 0.45. --- src/libtmux/common.py | 4 +-- src/libtmux/options.py | 4 +-- src/libtmux/pane.py | 58 ++++++++++++++++++++---------------------- src/libtmux/server.py | 6 ++--- src/libtmux/session.py | 4 +-- src/libtmux/window.py | 24 ++++++++--------- 6 files changed, 49 insertions(+), 51 deletions(-) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 8f3ce0ae8..2e88dd8d8 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -79,11 +79,11 @@ def set_environment( expand_format : bool, optional Expand tmux format strings in the value (``-F`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 hidden : bool, optional Mark the variable as hidden (``-h`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 Raises ------ diff --git a/src/libtmux/options.py b/src/libtmux/options.py index 3c344b2c9..5b67ee2d2 100644 --- a/src/libtmux/options.py +++ b/src/libtmux/options.py @@ -820,11 +820,11 @@ def _show_options_raw( quiet : bool, optional Suppress errors silently (``-q`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 values_only : bool, optional Return only option values without names (``-v`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 Examples -------- diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 6a63286fb..ed1a335e4 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -414,24 +414,24 @@ def capture_pane( Capture from the alternate screen (``-a`` flag). Default: False - .. versionadded:: 0.45 + .. versionadded:: 0.56 quiet : bool, optional Suppress errors silently (``-q`` flag). Default: False - .. versionadded:: 0.45 + .. versionadded:: 0.56 mode_screen : bool, optional Capture from the mode screen (e.g. copy mode) instead of the pane (``-M`` flag). Requires tmux 3.6+. Default: False - .. versionadded:: 0.45 + .. versionadded:: 0.56 to_buffer : str, optional Write the capture into the named tmux buffer (``-b`` flag) instead of returning it. When set, ``-p`` is omitted and the wrapper returns ``None``. - .. versionadded:: 0.45 + .. versionadded:: 0.56 Returns ------- @@ -532,32 +532,32 @@ def send_keys( reset : bool, optional Reset terminal state before sending keys (``-R`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 copy_mode_cmd : str, optional Send a command to copy mode instead of keys (``-X`` flag). When set, *cmd* is ignored. - .. versionadded:: 0.45 + .. versionadded:: 0.56 repeat : int, optional Repeat count for the key (``-N`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 expand_formats : bool, optional Expand tmux format strings in keys (``-F`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 hex_keys : bool, optional Send keys as hex values (``-H`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 target_client : str, optional Specify a target client (``-c`` flag). Requires tmux 3.4+. - .. versionadded:: 0.45 + .. versionadded:: 0.56 key_name : bool, optional Handle keys as key names (``-K`` flag). Requires tmux 3.4+. - .. versionadded:: 0.45 + .. versionadded:: 0.56 Examples -------- @@ -683,39 +683,39 @@ def display_message( format_string : str, optional Format string for output (``-F`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 all_formats : bool, optional List all format variables (``-a`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 verbose : bool, optional Show format variable types (``-v`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 no_expand : bool, optional Suppress format expansion; output is returned as a literal string (``-l`` flag). Requires tmux 3.4+. - .. versionadded:: 0.45 + .. versionadded:: 0.56 target_client : str, optional Target client (``-c`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 delay : int, optional Display time in milliseconds (``-d`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 notify : bool, optional Do not wait for input (``-N`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 update_pane : bool, optional Allow the pane to keep updating while the message is displayed (``-C`` flag). By default tmux freezes the pane while a status message is shown. Requires tmux 3.6+ (introduced upstream by commit ``80eb460f``). - .. versionadded:: 0.45 + .. versionadded:: 0.56 Returns ------- @@ -864,31 +864,31 @@ def select( Select the pane in the given direction (``-U``, ``-D``, ``-L``, ``-R``). - .. versionadded:: 0.45 + .. versionadded:: 0.56 last : bool, optional Select the last (previously selected) pane (``-l`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 keep_zoom : bool, optional Keep the window zoomed if it was zoomed (``-Z`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 mark : bool, optional Set the marked pane (``-m`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 clear_mark : bool, optional Clear the marked pane (``-M`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 disable_input : bool, optional Disable input to the pane (``-d`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 enable_input : bool, optional Enable input to the pane (``-e`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 Returns ------- @@ -1007,7 +1007,7 @@ def split( Percentage (0-100) of the window to occupy (``-p`` flag). Mutually exclusive with *size*. - .. versionadded:: 0.45 + .. versionadded:: 0.56 environment: dict, optional Environmental variables for new pane. Passthrough to ``-e``. @@ -1279,8 +1279,6 @@ def display_popup( Do not auto-close the popup on any close-trigger keys (``-N`` flag). Requires tmux 3.6+. - .. versionadded:: 0.45 - Examples -------- Not directly testable — popup rendering requires a TTY-backed client. @@ -2090,7 +2088,7 @@ def clear_history(self, *, reset_hyperlinks: bool | None = None) -> None: reset_hyperlinks : bool, optional Also reset hyperlinks (``-H`` flag). Requires tmux 3.4+. - .. versionadded:: 0.45 + .. versionadded:: 0.56 Examples -------- diff --git a/src/libtmux/server.py b/src/libtmux/server.py index a1e7e9939..f0ff8d957 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1729,16 +1729,16 @@ def new_session( detach_others : bool, optional Detach other clients from the session (``-D`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 no_size : bool, optional Do not set the initial window size (``-X`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 client_flags : str, optional Set client flags (``-f`` flag), e.g. ``no-output``, ``read-only``. Requires tmux 3.2+. - .. versionadded:: 0.45 + .. versionadded:: 0.56 Returns ------- diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 66c5e0fed..413bd2d1a 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -649,12 +649,12 @@ def new_window( Destroy the window at the target index if it already exists (``-k`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 select_existing : bool, optional If a window with the given name already exists, select it instead of creating a new one (``-S`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 .. versionchanged:: 0.28.0 diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 7ef8e676e..746ac85f8 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -309,7 +309,7 @@ def split( Percentage (0-100) of the window to occupy (``-p`` flag). Mutually exclusive with *size*. - .. versionadded:: 0.45 + .. versionadded:: 0.56 environment : dict, optional Environmental variables for new pane. Passthrough to ``-e``. @@ -424,15 +424,15 @@ def last_pane( disable_input : bool, optional Disable input to the pane (``-d`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 enable_input : bool, optional Enable input to the pane (``-e`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 keep_zoom : bool, optional Keep the window zoomed if zoomed (``-Z`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 Returns ------- @@ -505,15 +505,15 @@ def select_layout( spread : bool, optional Spread panes out evenly (``-E`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 next_layout : bool, optional Move to the next layout (``-n`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 previous_layout : bool, optional Move to the previous layout (``-p`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 Returns ------- @@ -943,25 +943,25 @@ def move_window( after : bool, optional Insert after the target window (``-a`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 before : bool, optional Insert before the target window (``-b`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 no_select : bool, optional Do not make the moved window the current window (``-d`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 kill_target : bool, optional Kill the target window if it exists (``-k`` flag). - .. versionadded:: 0.45 + .. versionadded:: 0.56 renumber : bool, optional Renumber all windows in sequential order (``-r`` flag). This is a standalone operation — when used, no move is performed and other parameters are ignored. - .. versionadded:: 0.45 + .. versionadded:: 0.56 Returns ------- From e72eae2003957ef34d1bc154a50b35fdd6230e4c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 09:38:53 -0500 Subject: [PATCH 100/105] test(server[show_messages]): gate -T/-J clientless test on tmux 3.6+ why: test_show_messages_terminals_jobs assumed -T/-J short-circuit before client lookup. That only holds on tmux >= 3.6, after upstream commit b52dcff7 ("Allow show-messages to work without a client") added CMD_CLIENT_CANFAIL to cmd_show_messages_entry. On 3.2a/3.3a/3.4/3.5 the command queue rejects the call with "no current client" before cmd_show_messages_exec runs, so the clientless codepath is unreachable. what: - pytest.skip on tmux < 3.6 via has_gte_version("3.6") - replace docstring rationale with the actual upstream cause --- tests/test_server.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_server.py b/tests/test_server.py index 4bfdc4901..97811cb61 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -941,10 +941,18 @@ def test_show_messages( def test_show_messages_terminals_jobs(server: Server) -> None: """Test Server.show_messages(terminals=...) and (jobs=...) work clientless. - ``-T`` and ``-J`` are early-return paths in cmd-show-messages.c that - don't reach ``format_create_from_target``, so they don't require a - client. Verify both modes return a list without raising. + tmux 3.6 added ``CMD_CLIENT_CANFAIL`` to ``cmd_show_messages_entry`` + (upstream commit b52dcff7, "Allow show-messages to work without a + client"). On earlier tmux versions the command queue rejects the + invocation with ``no current client`` before + :c:func:`cmd_show_messages_exec` can take the ``-T``/``-J`` + early-return paths, so this clientless codepath is unreachable. """ + from libtmux.common import has_gte_version + + if not has_gte_version("3.6"): + pytest.skip("show-messages -T/-J without a client requires tmux 3.6+") + server.new_session(session_name="showmsg_alt_test") terminals = server.show_messages(terminals=True) From b1f15b6df0bdf21652ff31dc89473069792f8e92 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 09:38:57 -0500 Subject: [PATCH 101/105] ci(tests): disable matrix fail-fast why: parity tests deliberately exercise version-specific tmux behaviour, so a failure on one tmux-version row should not cancel sibling jobs. With fail-fast on, a single failing version makes gh pr checks show all six rows as red and hides which versions actually pass. what: - set strategy.fail-fast: false on the build matrix --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 320197b23..b7750499c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: ['3.14'] tmux-version: ['3.2a', '3.3a', '3.4', '3.5', '3.6', 'master'] From f7e6678918ffc14a33105171b50fbb6a6ff8cb69 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 10:18:14 -0500 Subject: [PATCH 102/105] test(server[command_prompt]): gate bspace_exit case on tmux 3.3+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: test_command_prompt_extra_flags[bspace_exit] failed on tmux 3.2a because Server.command_prompt unconditionally passes -b, which requires tmux 3.3+ — the wrapper raises before the monkeypatched cmd is ever called. The bspace_exit parametrise tuple had min_tmux_version=None, treating "version needed for this flag" rather than "version needed for the wrapper". what: - set min_tmux_version="3.3" for the bspace_exit case - comment why the gate is the wrapper-minimum, not the flag-minimum --- tests/test_server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_server.py b/tests/test_server.py index 97811cb61..f0f21497a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -602,7 +602,8 @@ def test_command_prompt( [ ({"expand_format": True}, "-F", "3.3"), ({"literal": True}, "-l", "3.6"), - ({"bspace_exit": True}, "-e", None), + # command_prompt always passes -b, which requires tmux 3.3+ + ({"bspace_exit": True}, "-e", "3.3"), ], ids=["expand_format_v33", "literal_v36", "bspace_exit"], ) From 68fac568ee7abcf8bc98d58ade7b05bb72f0ef83 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 11:33:20 -0500 Subject: [PATCH 103/105] Server(fix[command_prompt]): gate bspace_exit on tmux 3.7+ why: command_prompt(bspace_exit=True) emitted -e unconditionally, but the flag was added by upstream tmux commit 1e5f93b7 on 2026-01-14 and is not in any tagged release (verified: tag --contains returns empty; tmux 3.6a errors with "unknown flag -e"). The wrapper would break on every released version. Sibling literal flag (3.6+) shows the correct guard pattern. what: - bump TMUX_MAX_VERSION from "3.6" to "3.7" so master compares as "3.7-master" >= "3.7", letting bspace_exit work on master while still failing the gate on tagged releases 3.2a-3.6a - guard the -e append with has_gte_version("3.7"); warn-and-ignore on older versions, matching the literal pattern at the same site - update test_command_prompt_extra_flags[bspace_exit] gate to "3.7" so the case skips on every currently-released tmux - bump test_version_parsing[next_version] fixture from "next-3.7" to "next-3.8" since "3.7" is no longer strictly greater than the new TMUX_MAX_VERSION --- src/libtmux/common.py | 2 +- src/libtmux/server.py | 8 +++++++- tests/test_common.py | 4 ++-- tests/test_server.py | 4 ++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 2e88dd8d8..69f077b99 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -28,7 +28,7 @@ TMUX_MIN_VERSION = "3.2a" #: Most recent version of tmux supported -TMUX_MAX_VERSION = "3.6" +TMUX_MAX_VERSION = "3.7" SessionDict = dict[str, t.Any] WindowDict = dict[str, t.Any] diff --git a/src/libtmux/server.py b/src/libtmux/server.py index f0ff8d957..87c8cbf03 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1033,7 +1033,13 @@ def command_prompt( ) if bspace_exit: - tmux_args += ("-e",) + if has_gte_version("3.7", tmux_bin=self.tmux_bin): + tmux_args += ("-e",) + else: + warnings.warn( + "bspace_exit requires tmux 3.7+ (upstream master), ignoring", + stacklevel=2, + ) if prompt is not None: tmux_args += ("-p", prompt) diff --git a/tests/test_common.py b/tests/test_common.py index 7689f4bfb..f9c6c0968 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -300,10 +300,10 @@ class VersionParsingFixture(t.NamedTuple): ), VersionParsingFixture( test_id="next_version", - mock_stdout=["tmux next-3.7"], + mock_stdout=["tmux next-3.8"], mock_stderr=None, mock_platform=None, - expected_version="3.7", + expected_version="3.8", raises=False, exc_msg_regex=None, ), diff --git a/tests/test_server.py b/tests/test_server.py index f0f21497a..3e806568f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -602,8 +602,8 @@ def test_command_prompt( [ ({"expand_format": True}, "-F", "3.3"), ({"literal": True}, "-l", "3.6"), - # command_prompt always passes -b, which requires tmux 3.3+ - ({"bspace_exit": True}, "-e", "3.3"), + # -e is master-only (upstream 1e5f93b7); not in any 3.6 release + ({"bspace_exit": True}, "-e", "3.7"), ], ids=["expand_format_v33", "literal_v36", "bspace_exit"], ) From c7469ec3ee2e35610c4866064ec7e641a27e9fba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 11:33:58 -0500 Subject: [PATCH 104/105] ControlMode(fix[__enter__]): close write fd if Popen raises why: os.pipe() allocates both ends in the parent before subprocess spawn. The existing try/finally only closed read_fd; if Popen raised (ENOMEM, exec error, missing binary), self._write_fd stayed open. __exit__ won't run because __enter__ never returned, so the registration cleanup at the bottom of __enter__ is also unreachable. Net: one leaked fd per failed Popen. what: - wrap the existing try/finally (which closes read_fd) in an outer try/except that closes self._write_fd if anything propagates out of Popen, then re-raises - use BaseException so KeyboardInterrupt during spawn also cleans up --- src/libtmux/_internal/control_mode.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/libtmux/_internal/control_mode.py b/src/libtmux/_internal/control_mode.py index 47c1d0647..c4ef1d2da 100644 --- a/src/libtmux/_internal/control_mode.py +++ b/src/libtmux/_internal/control_mode.py @@ -79,16 +79,21 @@ def __enter__(self) -> ControlMode: ] try: - self._proc = subprocess.Popen( - cmd, - stdin=read_fd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - finally: - # Close read end in parent regardless — subprocess owns it now - os.close(read_fd) + try: + self._proc = subprocess.Popen( + cmd, + stdin=read_fd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + finally: + # subprocess owns read_fd now + os.close(read_fd) + except BaseException: + # __exit__ will not run if __enter__ fails + os.close(self._write_fd) + raise self.stdout = self._proc.stdout # type: ignore[assignment] client_pid = str(self._proc.pid) From 6e7dcf26d3bf688711279a1b1d5e9607b7f6b152 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 3 May 2026 11:59:54 -0500 Subject: [PATCH 105/105] test(legacy_api[common]): bump next-version fixture to 3.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: test_allows_next_version asserts has_gt_version(TMUX_MAX_VERSION) for a mocked "tmux next-3.7" parse. Commit 3c883a8a bumped TMUX_MAX_VERSION to "3.7", so parsed "3.7" is no longer strictly greater than the max — assertion fails on every tmux job. Mirror the same fixture bump already applied to tests/test_common.py. what: - TMUX_NEXT_VERSION "3.7" -> "3.8" so the parsed version stays one minor ahead of TMUX_MAX_VERSION --- tests/legacy_api/test_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/legacy_api/test_common.py b/tests/legacy_api/test_common.py index 9f809e070..a6631f713 100644 --- a/tests/legacy_api/test_common.py +++ b/tests/legacy_api/test_common.py @@ -58,7 +58,7 @@ def mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> Hi: def test_allows_next_version(monkeypatch: pytest.MonkeyPatch) -> None: """Assert get_version() supports next version.""" - TMUX_NEXT_VERSION = "3.7" + TMUX_NEXT_VERSION = "3.8" class Hi: stdout: t.ClassVar = [f"tmux next-{TMUX_NEXT_VERSION}"]