Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 28 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,34 @@ $ uvx --from 'libtmux' --prerelease allow python
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### What's new

#### Read only what a pane wrote since you last looked (#740)

{meth}`Pane.capture_since() <libtmux.Pane.capture_since>` returns the rows a
pane has written since a {class}`~libtmux.capture.CaptureCursor`, along with a
fresh cursor to resume from. Watching a pane over time — tailing a build,
following a long-running command — no longer means re-reading the whole screen
each tick and diffing it yourself.

Cursors are immutable and the call never advances the one it was given, so
replaying a cursor always returns the same rows. They serialize through `str()`
and {meth}`~libtmux.capture.CaptureCursor.from_str` for callers that carry them
across a process or wire boundary.

Naive screen-diffing goes wrong in ways tmux makes easy to hit, and a cursor
accounts for each: output that scrolled past the visible region between reads,
`clear-history` and `history-limit` trims renumbering the grid under a stored
offset, and a respawned pane reusing its `pane_id` while running a different
program. Where the anchored rows are genuinely gone, the result reports
`lines_missed` and falls back to the visible screen rather than returning a
delta that quietly omits them. Where continuing would mean reading another
process's output, it raises {exc}`~libtmux.exc.PaneLifecycleChanged` or
{exc}`~libtmux.exc.InvalidCaptureCursor` — both under a shared
{exc}`~libtmux.exc.CaptureCursorError` base.

See {ref}`capture-since` for a walkthrough.

### Documentation

#### Cleaner `from_env` examples (#719)
Expand Down
10 changes: 9 additions & 1 deletion docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ Use {meth}`pane.send_keys() <libtmux.Pane.send_keys>` and
:::{grid-item-card} Capture output from a pane?
:link: libtmux.pane
:link-type: doc
Use {meth}`pane.capture_pane() <libtmux.Pane.capture_pane>`.
Use {meth}`pane.capture_pane() <libtmux.Pane.capture_pane>` for a snapshot, or
{meth}`pane.capture_since() <libtmux.Pane.capture_since>` for only what is new.
:::

:::{grid-item-card} Write tests against tmux?
Expand Down Expand Up @@ -108,6 +109,12 @@ tmux option get/set.
tmux hook management.
:::

:::{grid-item-card} Capture
:link: libtmux.capture
:link-type: doc
Cursors for incremental pane reads.
:::

:::{grid-item-card} Constants
:link: libtmux.constants
:link-type: doc
Expand Down Expand Up @@ -176,6 +183,7 @@ Common <libtmux.common>
Neo <libtmux.neo>
Options <libtmux.options>
Hooks <libtmux.hooks>
Capture <libtmux.capture>
Constants <libtmux.constants>
Exceptions <libtmux.exc>
```
23 changes: 23 additions & 0 deletions docs/api/libtmux.capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
(capture)=

# Capture

Incremental pane reading for {meth}`~libtmux.Pane.capture_since`.

Where {meth}`~libtmux.Pane.capture_pane` returns a snapshot of a pane,
{meth}`~libtmux.Pane.capture_since` returns a *delta* — the rows written since a
{class}`~libtmux.capture.CaptureCursor` — plus a fresh cursor to resume from.

Cursors are immutable, so a call never advances the cursor it was handed, and
serialize through `str()` / {meth}`~libtmux.capture.CaptureCursor.from_str` for
callers that carry them across a process or wire boundary.

See {ref}`capture-since` for a worked walkthrough.

```{eval-rst}
.. automodule:: libtmux.capture
:members:
:private-members:
:show-inheritance:
:member-order: bysource
```
83 changes: 83 additions & 0 deletions docs/topics/pane_interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,89 @@ True
flags (`start`, `end`, `escape_sequences`, etc.) — tmux ignores them when
`-P` is set.

(capture-since)=

### Capturing only what is new

{meth}`~libtmux.Pane.capture_pane` answers "what is on screen right now?". When
you watch a pane over time — tailing a build, following a long-running command —
the question becomes "what changed since I last looked?", and re-capturing the
whole screen every tick makes you answer it yourself.

{meth}`~libtmux.Pane.capture_since` answers it directly. It returns the rows
written since a {class}`~libtmux.capture.CaptureCursor`, plus a fresh cursor to
resume from:

```python
>>> from libtmux.test.retry import retry_until

>>> first = pane.capture_since()
>>> pane.send_keys('echo watching')

>>> retry_until(
... lambda: any(
... 'watching' in line
... for line in pane.capture_since(first.cursor).lines
... ),
... 2,
... )
True
```

The cursor is immutable and the call never advances it, so replaying one is
always safe. Assign the returned cursor to move forward:

```python
>>> latest = pane.capture_since()
>>> pane.capture_since(latest.cursor).lines
[]
```

#### When output is genuinely gone

tmux keeps a bounded scrollback. If `clear-history` runs, or output floods past
`history-limit`, the rows a cursor pointed at stop existing. Rather than return
a delta that quietly omits them, `capture_since` falls back to the current
visible screen and sets `lines_missed`:

```python
>>> from libtmux.test.retry import retry_until

>>> pane.send_keys('printf "scroll %s\\n" $(seq 1 60)')
>>> retry_until(lambda: pane.capture_since().cursor.history_size > 0, 3)
True

>>> before_clear = pane.capture_since()
>>> pane.cmd('clear-history')
<libtmux...>

>>> pane.send_keys('echo after')
>>> retry_until(
... lambda: pane.capture_since(before_clear.cursor).lines_missed is True, 3
... )
True
```

Treat `lines_missed=True` as "some output was lost" — the returned rows are
still real, they are just not the complete delta.

Two conditions raise instead of degrading, because continuing would mean reading
a different program's output through a cursor that looks valid:
{exc}`~libtmux.exc.PaneLifecycleChanged` when the pane died or was respawned,
and {exc}`~libtmux.exc.InvalidCaptureCursor` when a cursor is replayed against
another pane. Both derive from {exc}`~libtmux.exc.CaptureCursorError`, so one
`except` clause covers every way a cursor stops being usable.

Cursors serialize for callers that hand them across a process or wire boundary:

```python
>>> from libtmux.capture import CaptureCursor

>>> cursor = pane.capture_since().cursor
>>> CaptureCursor.from_str(str(cursor)) == cursor
True
```

## Waiting for output

tmux runs commands asynchronously: {meth}`~libtmux.Pane.send_keys` returns the
Expand Down
Loading
Loading