Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dcf36f2
docs(fix[README]): Test the capture example
tony Oct 4, 2026
ff99a47
docs(style[quickstart]): Fit examples to 80
tony Oct 4, 2026
37f2e4c
docs(style[automation_patterns]): Fit examples
tony Oct 4, 2026
9f47882
docs(style[context_managers]): Shorten comment
tony Oct 4, 2026
2621673
docs(style[filtering]): Bind the link target once
tony Oct 4, 2026
c7ac282
docs(style[options_and_hooks]): Fit hook examples
tony Oct 4, 2026
fce4256
docs(style[pane_interaction]): Fit resize examples
tony Oct 4, 2026
1ca8c52
docs(style[self_location]): Fit holders example
tony Oct 4, 2026
e3c064d
docs(style[traversal]): List matching window names
tony Oct 4, 2026
8f906ce
docs(style[workspace_setup]): Fit examples to 80
tony Oct 4, 2026
561338f
docs(style[query_list]): Name lookup and rows
tony Oct 4, 2026
ff6ec9b
docs(style[common]): Fit tmux_cmd doctest
tony Oct 4, 2026
f72ba10
docs(style[neo]): Bind stderr before each check
tony Oct 4, 2026
606456f
docs(style[pane]): Bind the above direction
tony Oct 4, 2026
2c6180a
docs(style[pytest_plugin]): Fit fixture doctests
tony Oct 4, 2026
905b67d
docs(style[server]): Bind the new id first
tony Oct 4, 2026
d4da485
docs(style[session]): Bind the search pattern
tony Oct 4, 2026
3e2dcfa
docs(style[window]): Pass the id by position
tony Oct 4, 2026
9f6fd7f
docs(style[options]): Rebuild status doctest
tony Oct 4, 2026
2984b44
docs(style[MIGRATION]): Shorten the Live comment
tony Oct 4, 2026
63733fb
docs(feat[WRITING]): Add example style rules
tony Oct 4, 2026
8378a2e
ai(rules[AGENTS]): Route code examples to WRITING
tony Oct 4, 2026
887481f
ci(feat[example-width]): Check example width
tony Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ Lint:
$ uv run ruff check . --fix --show-fixes
```

Example width:

```console
$ python3 scripts/check_example_width.py
```

Type-check:

```console
Expand Down
86 changes: 86 additions & 0 deletions .github/WRITING.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,92 @@ available for a case that ever needs an explicitly marked block, rather
than a plain prompted fence. This section is where any additional
executed-block format is documented when one is adopted.

<!-- shared:examples -->

An example is code written for a reader: a program under `examples/`, code in
a doc comment or docstring, and every fenced block in a README or docs page.
Shell blocks also follow [Code blocks](#code-blocks).

The text between the shared markers is the same in every libtmux port.
Change it in all of them together.

### Width

- **Examples stay within 80 columns.** They render in fixed-width boxes that
scroll sideways, and 80 columns fits a libtmux.org code block in a
laptop-width window. Comments inside examples wrap at 80 too.
- **The width check enforces it.** It reads the tracked files that
`.github/example-width.toml` names and fails on a wider line. It measures
the whole source line, so code in a doc comment counts its indent and
comment marker. It skips output (a fence tagged `text`, and what a
`console` block prints), hidden setup lines, and a line that is only a URL;
an untagged fence counts as code.
- **A line that must stay wider is listed there with its reason.** An entry
that no longer matches a line fails the check, so no stale entry stays.
- **The formatter's width is the hard limit for all other source.** Example
directories set their formatter to 80 where the formatter takes a width.

### Reaching 80

- **Change the code, not the line breaks.** A formatter rejoins any line that
fits its width. Name a sub-expression, use a short example name, hide setup
the reader does not need, or print less.
- **Break at the outermost level when a break is still needed:** after an
opening parenthesis with one argument per line, one call per line in a
chain, one field per line in a literal.
- **Put a comment on its own line above the code it explains.** Never trail
one after code in an example, unless the repository's example runner reads
it there, as with an assertion marker.
- **Break a long string at a word boundary,** never inside a tmux format
(`#{...}`) or an escape sequence; the joined text stays the same.
- **Continue a long command in a `console` block the way its shell does:**
`\` after a `$ ` prompt, a backtick after `PS> `, one flag per continuation
line.

### What never breaks

- **Output a test compares.** Wrapping it changes what the test expects.
- **A block copied from a source file.** Fix the width in the source, then
refresh the copy with the sync command the port block names, or by hand
where it names none; never fix the copy alone.
- **Marker lines and URLs,** which tools and readers take whole.

<!-- /shared:examples -->

### In this repository

- **Hard limit:** `ruff format` and ruff's E501 (on through
`extend-select`) at ruff's default 88, because `[tool.ruff]` in
`pyproject.toml` sets no `line-length`; both read `.py` files only. The
width check, `python3 scripts/check_example_width.py`
(`just check-example-width`), holds examples to 80.
- **Not formatted:** doctests in docstrings, fenced code in Markdown pages and
`docs/*.md` doctests. `ruff format` leaves docstring code alone and E501 stops
at 88, so fit these lines to 80 by hand, as Reaching 80 says.
`just format-markdown` runs Prettier, which leaves Python fences alone and,
with `proseWrap` unset, does not wrap prose.
- **Runs, compiles, exempt:** the doctest rules above decide what runs.
An `[[allow]]` entry in `.github/example-width.toml` exempts a line from
the width check, which also skips a line `# doctest: +HIDE` hides from
the rendered page.
- **Compared output and copied blocks:** expected output is never
wrapped; `ELLIPSIS` is global, so trim a long repr with `...`. No
example is copied from another file here, so there is no sync command;
edit the docstring or page itself.

Bad, over 80:

```python
>>> Window.from_window_id(window_id=session.cmd('new-window', '-P', '-F#{window_id}').stdout[0], server=session.server)
```

Good, naming the sub-expression:

```python
>>> new_window_id = session.cmd('new-window', '-P', '-F#{window_id}').stdout[0]
>>> Window.from_window_id(window_id=new_window_id, server=session.server)
```

## Documentation pages

Warm the framing, never the facts. Resolution-order lists, value
Expand Down
11 changes: 11 additions & 0 deletions .github/example-width.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
width = 80
markdown = ["**/*.md", "MIGRATION", "CHANGES"]
doc_comments = ["src/**/*.py"]
allow_ceiling = 1
shared_digest = "28b74bae1f7a06731fea5a150cbde4430179b7788b7564ea1644c3951df8531b"
checker_digest = "eb87afceb3fc8f11ef5a478cd38f901ef3950e89ff94fbe0ca281c0cfc1a0e2e"

[[allow]]
path = ".github/WRITING.md"
line = """>>> Window.from_window_id(window_id=session.cmd('new-window', '-P', '-F#{window_id}').stdout[0], server=session.server)"""
reason = "the Bad example in the port block; it shows the width problem the Good example fixes"
5 changes: 5 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ jobs:
- name: Format with ruff format
run: uv run ruff format . --check

- name: Check example width
run: |
python3 scripts/check_example_width.py --self-test
python3 scripts/check_example_width.py

- name: Lint with mypy
run: uv run mypy .

Expand Down
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ what was asked for.

## Which policy applies

- Documentation, user-facing text, `CHANGES`, release notes, commit
messages, docstrings, and source comments:
[.github/WRITING.md](.github/WRITING.md)
- Documentation, user-facing text, `CHANGES`, release notes, commit messages,
docstrings, source comments, and any code example (example programs, doctests,
Markdown code blocks): [.github/WRITING.md](.github/WRITING.md), and its
[Documented examples that
run](.github/WRITING.md#documented-examples-that-run) section for code a
reader sees
- Environment, the gates, tests, documentation builds, releases, and
pull requests: [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md)

Expand Down
12 changes: 9 additions & 3 deletions MIGRATION
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ For typed access that reflects the client's *live* attachment, use
client = server.clients.get(client_name=ctl.client_name)
session_id = client.session_id # str captured when the object was read

# Live (re-reads list-clients; returns None if tmux no longer reports the client)
# Live (re-reads list-clients; None if tmux no longer reports the client)
attached = client.attached_session # libtmux.Session | None
window = client.attached_window # libtmux.Window | None
pane = client.attached_pane # libtmux.Pane | None
Expand Down Expand Up @@ -449,7 +449,11 @@ from libtmux.test import (
# New (0.45.0+)
from libtmux.test.constants import TEST_SESSION_PREFIX
from libtmux.test.environment import EnvironmentVarGuard
from libtmux.test.random import get_test_session_name, get_test_window_name, namer
from libtmux.test.random import (
get_test_session_name,
get_test_window_name,
namer,
)
from libtmux.test.temporary import temp_session, temp_window
```

Expand Down Expand Up @@ -526,7 +530,9 @@ from libtmux.test.temporary import temp_session, temp_window

```python
from libtmux.constants import ResizeAdjustmentDirection
pane.resize_pane(adjustment_direction=ResizeAdjustmentDirection.Down, adjustment=25)
pane.resize_pane(
adjustment_direction=ResizeAdjustmentDirection.Down, adjustment=25
)
pane.resize_pane(
adjustment_direction=ResizeAdjustmentDirection.Right, adjustment=25
)
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,15 @@ Pane(%... ...)
>>> pane.clear()
Pane(%... ...)
>>> pane.send_keys("echo 'hello world'", enter=True)
>>> pane.cmd('capture-pane', '-p').stdout # doctest: +SKIP
>>> from libtmux.test.retry import retry_until
>>> retry_until(lambda: 'hello world' in pane.capture_pane())
True
```

`pane.capture_pane()` returns the pane's lines, prompt included, so the
capture above reads like this (the prompt depends on your shell):

```text
["$ echo 'hello world'", 'hello world', '$']
```

Expand Down
15 changes: 10 additions & 5 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ via trunk (can break easily):
- [pip]\:

```console
$ pip install --user -e git+https://github.com/tmux-python/libtmux.git#egg=libtmux
$ pip install --user -e \
'git+https://github.com/tmux-python/libtmux.git#egg=libtmux'
```

- [pipx]\:
Expand All @@ -92,7 +93,8 @@ via trunk (can break easily):
- [uv]\:

```console
$ uv tool install libtmux --from git+https://github.com/tmux-python/libtmux.git
$ uv tool install libtmux \
--from git+https://github.com/tmux-python/libtmux.git
```

[pip]: https://pip.pypa.io/en/stable/
Expand Down Expand Up @@ -190,7 +192,8 @@ and as shown later, you'd use
{meth}`Session.new_window() <libtmux.Session.new_window>`):

```python
>>> Window.from_window_id(window_id=session.cmd('new-window', '-P', '-F#{window_id}').stdout[0], server=session.server)
>>> new_window_id = session.cmd('new-window', '-P', '-F#{window_id}').stdout[0]
>>> Window.from_window_id(window_id=new_window_id, server=session.server)
Window(@2 2:..., Session($1 libtmux_...))
```

Expand All @@ -205,7 +208,8 @@ Raw output directly to a {class}`~libtmux.Pane` (in practice, you'd use
{meth}`Window.split() <libtmux.Window.split>`):

```python
>>> Pane.from_pane_id(pane_id=window.cmd('split-window', '-P', '-F#{pane_id}').stdout[0], server=window.server)
>>> new_pane_id = window.cmd('split-window', '-P', '-F#{pane_id}').stdout[0]
>>> Pane.from_pane_id(pane_id=new_pane_id, server=window.server)
Pane(%... Window(@1 1:..., Session($1 libtmux_...)))
```

Expand Down Expand Up @@ -429,7 +433,8 @@ Pane(%1 Window(@1 ...:..., Session($1 ...)))
```

```{eval-rst}
.. todo:: have a ``.kill()`` and ``.select()`` proxy for Server, Session, Window and Pane objects.
.. todo:: have a ``.kill()`` and ``.select()`` proxy for Server, Session,
Window and Pane objects.
```

## Sending commands to tmux panes remotely
Expand Down
8 changes: 4 additions & 4 deletions docs/topics/automation_patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ catch instead of a script that quietly stalls.
```python
>>> import time

>>> timeout_window = session.new_window(window_name='timeout-demo', attach=False)
>>> timeout_window = session.new_window('timeout-demo', attach=False)
>>> timeout_pane = timeout_window.active_pane

>>> class CommandTimeout(Exception):
Expand Down Expand Up @@ -401,13 +401,13 @@ up for. Tune both for how expensive the command is and how patient you can be.
>>> retry_window = session.new_window(window_name='retry-demo', attach=False)
>>> retry_pane = retry_window.active_pane

>>> def retry_until_success(pane, command, success_marker, max_retries=3, delay=0.5):
>>> def retry_until_success(pane, command, marker, max_retries=3, delay=0.5):
... """Retry command until success marker appears."""
... for attempt in range(max_retries):
... pane.send_keys(command)
... time.sleep(delay)
... output = '\\n'.join(pane.capture_pane())
... if success_marker in output:
... if marker in output:
... return True, attempt + 1
... return False, max_retries

Expand Down Expand Up @@ -550,7 +550,7 @@ it. Tear down what you opened when you're done, so a long-running automation pro
doesn't accumulate orphaned objects.

```python
>>> cleanup_window = session.new_window(window_name='cleanup-demo', attach=False)
>>> cleanup_window = session.new_window('cleanup-demo', attach=False)
>>> cleanup_window # doctest: +ELLIPSIS
Window(@... ...)

Expand Down
2 changes: 1 addition & 1 deletion docs/topics/context_managers.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ once and have every layer torn down for you:
... with window.split() as pane:
... pane.send_keys('echo "Hello"')
... # Do work with the pane
... # Everything is cleaned up automatically when exiting contexts
... # Exiting the contexts cleans everything up
```

This ensures that:
Expand Down
38 changes: 19 additions & 19 deletions docs/topics/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ When you pass a bare keyword like `session_name=...`, the default lookup is

```python
>>> # These are equivalent
>>> server.sessions.filter(session_name=session.session_name) # doctest: +ELLIPSIS
>>> name = session.session_name
>>> server.sessions.filter(session_name=name)
[Session($... ...)]
>>> server.sessions.filter(session_name__exact=session.session_name) # doctest: +ELLIPSIS
>>> server.sessions.filter(session_name__exact=name)
[Session($... ...)]
```

Expand Down Expand Up @@ -153,9 +154,8 @@ edge:
>>> home = server.new_session(session_name="home")
>>> shared = home.new_window(window_name="shared", attach=False)
>>> guest = server.new_session(session_name="guest")
>>> _ = server.cmd(
... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:"
... )
>>> target = f"{guest.session_id}:"
>>> _ = server.cmd("link-window", "-d", "-s", shared.window_id, "-t", target)

>>> len(server.windows.filter(window_id=shared.window_id))
2
Expand All @@ -173,9 +173,8 @@ ambiguous, and says so rather than guessing:
>>> home = server.new_session(session_name="home")
>>> shared = home.new_window(window_name="shared", attach=False)
>>> guest = server.new_session(session_name="guest")
>>> _ = server.cmd(
... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:"
... )
>>> target = f"{guest.session_id}:"
>>> _ = server.cmd("link-window", "-d", "-s", shared.window_id, "-t", target)

>>> try:
... server.windows.get(window_id=shared.window_id)
Expand All @@ -193,9 +192,8 @@ listing each holding session once however many indexes it links the window at:
>>> home = server.new_session(session_name="home")
>>> shared = home.new_window(window_name="shared", attach=False)
>>> guest = server.new_session(session_name="guest")
>>> _ = server.cmd(
... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:"
... )
>>> target = f"{guest.session_id}:"
>>> _ = server.cmd("link-window", "-d", "-s", shared.window_id, "-t", target)

>>> sorted(s.session_name for s in shared.linked_sessions)
['guest', 'home']
Expand All @@ -215,11 +213,11 @@ so they are the right tool for a lookup by id:
>>> home = server.new_session(session_name="home")
>>> shared = home.new_window(window_name="shared", attach=False)
>>> guest = server.new_session(session_name="guest")
>>> _ = server.cmd(
... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:"
... )
>>> target = f"{guest.session_id}:"
>>> _ = server.cmd("link-window", "-d", "-s", shared.window_id, "-t", target)

>>> Window.from_window_id(server, shared.window_id).window_id == shared.window_id
>>> same = Window.from_window_id(server, shared.window_id)
>>> same.window_id == shared.window_id
True
```

Expand Down Expand Up @@ -275,8 +273,9 @@ Reach for the `i`-prefixed variants when the casing of a name shouldn't matter:
True

>>> # Case-insensitive startswith
>>> session.windows.filter(window_name__istartswith='myapp') # doctest: +ELLIPSIS
[Window(@... ...:MyApp-Server, Session($... ...)), Window(@... ...:myapp-worker, Session($... ...))]
>>> found = session.windows.filter(window_name__istartswith='myapp')
>>> [window.window_name for window in found]
['MyApp-Server', 'myapp-worker']

>>> # Clean up
>>> w1.kill()
Expand Down Expand Up @@ -322,8 +321,9 @@ When you already have a set of names in hand, `in` keeps the matches and `nin`

>>> # Filter windows in a list of names
>>> target_envs = ["dev", "prod"]
>>> session.windows.filter(window_name__in=target_envs) # doctest: +ELLIPSIS
[Window(@... ...:dev, Session($... ...)), Window(@... ...:prod, Session($... ...))]
>>> found = session.windows.filter(window_name__in=target_envs)
>>> [window.window_name for window in found]
['dev', 'prod']

>>> # Filter windows NOT in a list
>>> non_prod = session.windows.filter(window_name__nin=["prod"])
Expand Down
Loading
Loading