tmux-wrapper 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tmux_wrapper-0.1.0.dist-info/METADATA +194 -0
- tmux_wrapper-0.1.0.dist-info/RECORD +7 -0
- tmux_wrapper-0.1.0.dist-info/WHEEL +5 -0
- tmux_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
- tmux_wrapper-0.1.0.dist-info/licenses/LICENSE +21 -0
- tmux_wrapper-0.1.0.dist-info/top_level.txt +1 -0
- tmux_wrapper.py +1114 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tmux-wrapper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight tmux automation wrapper and renderer.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Randomizez/TmuxWrapper
|
|
7
|
+
Project-URL: Repository, https://github.com/Randomizez/TmuxWrapper
|
|
8
|
+
Project-URL: Issues, https://github.com/Randomizez/TmuxWrapper/issues
|
|
9
|
+
Keywords: tmux,terminal,automation,cli
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Terminals
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: fire>=0.7.1
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# tmux-wrapper
|
|
26
|
+
|
|
27
|
+
`tmux-wrapper` is a small Python module and CLI for driving a tmux session like
|
|
28
|
+
a human: type text, press key chords, inspect what changed, and scroll through
|
|
29
|
+
history.
|
|
30
|
+
|
|
31
|
+
It is designed for agent workflows, test automation, and other cases where you
|
|
32
|
+
want a simple tmux control surface instead of shelling out to a large stack of
|
|
33
|
+
custom tmux commands.
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- `type(text)` sends literal text to the active pane.
|
|
38
|
+
- `press(chords)` sends key chords such as `Enter`, `Ctrl+C`, or `Ctrl+B Z`.
|
|
39
|
+
- `snapshot()` captures the full current screen and resets the diff baseline.
|
|
40
|
+
- `view()` is the recommended inspection API. It returns a contextual,
|
|
41
|
+
line-oriented delta against the previous capture.
|
|
42
|
+
- `glance()` returns incremental additions plus collapsed
|
|
43
|
+
`...[N unchanged lines]` markers for unchanged regions.
|
|
44
|
+
- `scroll_up(lines=3)` and `scroll_down(lines=3)` emulate mouse-wheel style
|
|
45
|
+
scrolling via tmux copy mode.
|
|
46
|
+
- `tmux-c` provides the same workflow from the command line.
|
|
47
|
+
|
|
48
|
+
## Requirements
|
|
49
|
+
|
|
50
|
+
- Python 3.9+
|
|
51
|
+
- `tmux` installed and available on `PATH`
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
Install from PyPI:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install tmux-wrapper
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
After installation, the CLI command `tmux-c` is available:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
tmux-c 1 glance
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Quick Start
|
|
68
|
+
|
|
69
|
+
### Python API
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from tmux_wrapper import Keys, TMUXWrapper
|
|
73
|
+
|
|
74
|
+
tmux = TMUXWrapper(session="demo")
|
|
75
|
+
|
|
76
|
+
# Establish a baseline for future view()/glance() calls.
|
|
77
|
+
tmux.snapshot()
|
|
78
|
+
|
|
79
|
+
tmux.type("echo hello")
|
|
80
|
+
tmux.press([(Keys.Enter,)])
|
|
81
|
+
print(tmux.view())
|
|
82
|
+
|
|
83
|
+
# For a compact "only what was newly added" report:
|
|
84
|
+
print(tmux.glance())
|
|
85
|
+
|
|
86
|
+
tmux.scroll_up(5)
|
|
87
|
+
print(tmux.view())
|
|
88
|
+
|
|
89
|
+
tmux.scroll_down(999)
|
|
90
|
+
tmux.delete()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### CLI
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
tmux-c demo snapshot
|
|
97
|
+
tmux-c demo type "ls /data"
|
|
98
|
+
tmux-c demo press Enter
|
|
99
|
+
tmux-c demo view
|
|
100
|
+
tmux-c demo glance
|
|
101
|
+
tmux-c demo scroll_up 5
|
|
102
|
+
tmux-c demo view
|
|
103
|
+
tmux-c demo scroll_down 999
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## How Inspection Works
|
|
107
|
+
|
|
108
|
+
`view()` is the default inspection method.
|
|
109
|
+
|
|
110
|
+
- `snapshot()` captures the full screen and stores it as the new baseline.
|
|
111
|
+
- `view()` compares the current screen against the previous capture.
|
|
112
|
+
- `glance()` uses the same diff basis, but returns only added lines plus
|
|
113
|
+
`...[N unchanged lines]` markers for unchanged regions.
|
|
114
|
+
- Added lines are marked with `!!`.
|
|
115
|
+
- Removed lines are hidden.
|
|
116
|
+
- `?` helper lines from `difflib.ndiff` are also hidden.
|
|
117
|
+
- If there are no new additions, `glance()` returns `[Nothing Changed]`.
|
|
118
|
+
|
|
119
|
+
Example:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
!!new output line
|
|
123
|
+
existing prompt context
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
For compact incremental output, `glance()` returns abbreviated output such as:
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
...[12 unchanged lines]
|
|
130
|
+
!!new output line
|
|
131
|
+
...[3 unchanged lines]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Press Syntax
|
|
135
|
+
|
|
136
|
+
In Python, `press()` accepts a list of chords:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
tmux.press([(Keys.Enter,)])
|
|
140
|
+
tmux.press([(Keys.Ctrl, Keys.C)])
|
|
141
|
+
tmux.press([(Keys.Ctrl, Keys.B), (Keys.Z,)])
|
|
142
|
+
tmux.press([(Keys.Ctrl, Keys.B), (Keys.Left,)])
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
In the CLI, each chord is passed as an argument:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
tmux-c demo press Enter
|
|
149
|
+
tmux-c demo press Ctrl+C
|
|
150
|
+
tmux-c demo press Ctrl+B Z
|
|
151
|
+
tmux-c demo press Ctrl+B Left
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Scrolling
|
|
155
|
+
|
|
156
|
+
`scroll_up()` and `scroll_down()` are line-based helpers built on tmux copy
|
|
157
|
+
mode.
|
|
158
|
+
|
|
159
|
+
- `scroll_up(lines)` enters copy mode and scrolls up by `lines`.
|
|
160
|
+
- `scroll_down(lines)` scrolls down by `lines`.
|
|
161
|
+
- When `scroll_down()` reaches the bottom, it exits copy mode automatically.
|
|
162
|
+
|
|
163
|
+
This matches the intended "mouse wheel with `set -g mouse on`" feel more closely
|
|
164
|
+
than page-based movement.
|
|
165
|
+
|
|
166
|
+
## Session Behavior
|
|
167
|
+
|
|
168
|
+
- `TMUXWrapper(session="name")` creates the session if it does not already
|
|
169
|
+
exist.
|
|
170
|
+
- If the wrapper created the session, object cleanup will delete it by default.
|
|
171
|
+
- Calling `delete()` always deletes the session immediately.
|
|
172
|
+
- CLI snapshot/view/glance state is persisted per session so repeated
|
|
173
|
+
`tmux-c ...` calls can diff across separate invocations.
|
|
174
|
+
|
|
175
|
+
## Development
|
|
176
|
+
|
|
177
|
+
Install development dependencies with uv:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
uv sync --dev
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Run tests:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
env -u VIRTUAL_ENV uv run pytest -q
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Notes
|
|
190
|
+
|
|
191
|
+
- The package focuses on a practical tmux-driving workflow, not a full tmux
|
|
192
|
+
abstraction layer.
|
|
193
|
+
- The renderer captures the full tmux window, not just a single pane.
|
|
194
|
+
- The cursor is rendered visibly in full-screen snapshots.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
tmux_wrapper.py,sha256=bw0ATlWeEMD1q2wuMOoqO-PkHxSMmSmyr6NWB0qh1kg,37815
|
|
2
|
+
tmux_wrapper-0.1.0.dist-info/licenses/LICENSE,sha256=T8SKa8cTXPI1j_tHhdLJL8dSxv2KLB1LMcPRYspeVcI,1067
|
|
3
|
+
tmux_wrapper-0.1.0.dist-info/METADATA,sha256=SIn8R9Ihy3NFbnDMDATHHHLF7pD9iyY8S5Rc0WRhkvQ,5112
|
|
4
|
+
tmux_wrapper-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
tmux_wrapper-0.1.0.dist-info/entry_points.txt,sha256=Y54a40P9SiyYY3OR7pa9erOw4xgkgsSxzGDzbio9dlw,45
|
|
6
|
+
tmux_wrapper-0.1.0.dist-info/top_level.txt,sha256=NzSqhGF_sAX7ougooDymOaey0VEjnACfiHgL26z_Eds,13
|
|
7
|
+
tmux_wrapper-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Randomizez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tmux_wrapper
|
tmux_wrapper.py
ADDED
|
@@ -0,0 +1,1114 @@
|
|
|
1
|
+
#!/usr/bin/env -S -u VIRTUAL_ENV uv run python
|
|
2
|
+
|
|
3
|
+
"""Keyboard-driven tmux automation helpers.
|
|
4
|
+
|
|
5
|
+
This module exposes a small wrapper around tmux plus a renderer that turns
|
|
6
|
+
``tmux attach`` output into a printable text snapshot. It is meant for tests
|
|
7
|
+
and agents that must drive tmux the same way a human would:
|
|
8
|
+
|
|
9
|
+
1) create or attach to a session,
|
|
10
|
+
2) send literal text or key chords,
|
|
11
|
+
3) inspect the whole tmux window after each action.
|
|
12
|
+
|
|
13
|
+
Public entry points:
|
|
14
|
+
1) ``Keys`` enumerates the supported modifier, character, navigation, and
|
|
15
|
+
function keys used by ``TMUXWrapper.press()``.
|
|
16
|
+
2) ``TMUXWrapper.type(text)`` sends literal text to the active pane without
|
|
17
|
+
adding a trailing newline.
|
|
18
|
+
3) ``TMUXWrapper.press(chords)`` sends one or more key chords, including tmux
|
|
19
|
+
prefix sequences such as ``(Keys.Ctrl, Keys.B)`` followed by another key.
|
|
20
|
+
4) ``TMUXWrapper.snapshot()`` captures the whole current window and resets the
|
|
21
|
+
diff baseline.
|
|
22
|
+
5) ``TMUXWrapper.view()`` is the recommended inspection API. It compares the
|
|
23
|
+
current window against the previous capture and keeps unchanged context.
|
|
24
|
+
6) ``TMUXWrapper.glance()`` shows only the incremental additions since the
|
|
25
|
+
previous capture.
|
|
26
|
+
7) ``TMUXWrapper.scroll_up(lines=3)`` and ``scroll_down(lines=3)`` emulate
|
|
27
|
+
mouse-wheel scrolling by operating tmux copy mode in line increments.
|
|
28
|
+
|
|
29
|
+
Behavior notes:
|
|
30
|
+
1) ``TMUXWrapper`` creates the target session on demand.
|
|
31
|
+
2) If this wrapper created the session, object cleanup deletes it by default.
|
|
32
|
+
3) Common tmux prefix bindings such as pane navigation and page scrolling are
|
|
33
|
+
translated through tmux commands when direct key injection is unreliable.
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> from tmux_wrapper import Keys, TMUXWrapper
|
|
37
|
+
>>> tmux = TMUXWrapper(session="demo")
|
|
38
|
+
>>> tmux.snapshot() # establish an initial baseline
|
|
39
|
+
>>> tmux.type("echo hello")
|
|
40
|
+
>>> tmux.press([(Keys.Enter,)])
|
|
41
|
+
>>> print(tmux.view())
|
|
42
|
+
...
|
|
43
|
+
>>> tmux.delete()
|
|
44
|
+
|
|
45
|
+
CLI:
|
|
46
|
+
$ tmux-c demo snapshot
|
|
47
|
+
$ tmux-c demo type "ls"
|
|
48
|
+
$ tmux-c demo press Enter
|
|
49
|
+
$ tmux-c demo view
|
|
50
|
+
$ tmux-c demo glance
|
|
51
|
+
$ tmux-c demo press Ctrl+B Z
|
|
52
|
+
$ tmux-c demo scroll_up 5
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
from enum import Enum
|
|
58
|
+
from functools import lru_cache
|
|
59
|
+
import hashlib
|
|
60
|
+
import json
|
|
61
|
+
import os
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
import pty
|
|
64
|
+
import select
|
|
65
|
+
import fcntl
|
|
66
|
+
import struct
|
|
67
|
+
import subprocess
|
|
68
|
+
import tempfile
|
|
69
|
+
import time
|
|
70
|
+
from typing import Iterable, Optional
|
|
71
|
+
import difflib
|
|
72
|
+
|
|
73
|
+
class literal(str):
|
|
74
|
+
"""String subclass whose repr is the raw text block itself."""
|
|
75
|
+
|
|
76
|
+
def __repr__(self):
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
class Keys(str, Enum):
|
|
80
|
+
"""Keyboard keys accepted by :meth:`TMUXWrapper.press`."""
|
|
81
|
+
# Modifiers
|
|
82
|
+
Ctrl = "Ctrl"
|
|
83
|
+
Alt = "Alt"
|
|
84
|
+
Shift = "Shift"
|
|
85
|
+
# Letters
|
|
86
|
+
A = "A"
|
|
87
|
+
B = "B"
|
|
88
|
+
C = "C"
|
|
89
|
+
D = "D"
|
|
90
|
+
E = "E"
|
|
91
|
+
F = "F"
|
|
92
|
+
G = "G"
|
|
93
|
+
H = "H"
|
|
94
|
+
I = "I"
|
|
95
|
+
J = "J"
|
|
96
|
+
K = "K"
|
|
97
|
+
L = "L"
|
|
98
|
+
M = "M"
|
|
99
|
+
N = "N"
|
|
100
|
+
O = "O"
|
|
101
|
+
P = "P"
|
|
102
|
+
Q = "Q"
|
|
103
|
+
R = "R"
|
|
104
|
+
S = "S"
|
|
105
|
+
T = "T"
|
|
106
|
+
U = "U"
|
|
107
|
+
V = "V"
|
|
108
|
+
W = "W"
|
|
109
|
+
X = "X"
|
|
110
|
+
Y = "Y"
|
|
111
|
+
Z = "Z"
|
|
112
|
+
# Digits
|
|
113
|
+
Digit0 = "Digit0"
|
|
114
|
+
Digit1 = "Digit1"
|
|
115
|
+
Digit2 = "Digit2"
|
|
116
|
+
Digit3 = "Digit3"
|
|
117
|
+
Digit4 = "Digit4"
|
|
118
|
+
Digit5 = "Digit5"
|
|
119
|
+
Digit6 = "Digit6"
|
|
120
|
+
Digit7 = "Digit7"
|
|
121
|
+
Digit8 = "Digit8"
|
|
122
|
+
Digit9 = "Digit9"
|
|
123
|
+
# Punctuation (ANSI US)
|
|
124
|
+
Backtick = "Backtick"
|
|
125
|
+
Minus = "Minus"
|
|
126
|
+
Equal = "Equal"
|
|
127
|
+
LeftBracket = "LeftBracket"
|
|
128
|
+
RightBracket = "RightBracket"
|
|
129
|
+
Backslash = "Backslash"
|
|
130
|
+
Semicolon = "Semicolon"
|
|
131
|
+
Quote = "Quote"
|
|
132
|
+
Comma = "Comma"
|
|
133
|
+
Period = "Period"
|
|
134
|
+
Slash = "Slash"
|
|
135
|
+
Space = "Space"
|
|
136
|
+
# Control keys
|
|
137
|
+
Enter = "Enter"
|
|
138
|
+
Tab = "Tab"
|
|
139
|
+
Escape = "Escape"
|
|
140
|
+
Backspace = "Backspace"
|
|
141
|
+
CapsLock = "CapsLock"
|
|
142
|
+
# Navigation
|
|
143
|
+
Up = "Up"
|
|
144
|
+
Down = "Down"
|
|
145
|
+
Left = "Left"
|
|
146
|
+
Right = "Right"
|
|
147
|
+
Home = "Home"
|
|
148
|
+
End = "End"
|
|
149
|
+
PageUp = "PageUp"
|
|
150
|
+
PageDown = "PageDown"
|
|
151
|
+
Insert = "Insert"
|
|
152
|
+
Delete = "Delete"
|
|
153
|
+
# Function keys
|
|
154
|
+
F1 = "F1"
|
|
155
|
+
F2 = "F2"
|
|
156
|
+
F3 = "F3"
|
|
157
|
+
F4 = "F4"
|
|
158
|
+
F5 = "F5"
|
|
159
|
+
F6 = "F6"
|
|
160
|
+
F7 = "F7"
|
|
161
|
+
F8 = "F8"
|
|
162
|
+
F9 = "F9"
|
|
163
|
+
F10 = "F10"
|
|
164
|
+
F11 = "F11"
|
|
165
|
+
F12 = "F12"
|
|
166
|
+
# System keys
|
|
167
|
+
PrintScreen = "PrintScreen"
|
|
168
|
+
ScrollLock = "ScrollLock"
|
|
169
|
+
Pause = "Pause"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TMUXRenderer:
|
|
173
|
+
"""Render tmux attach output into a fixed-size text buffer."""
|
|
174
|
+
|
|
175
|
+
_ALT_CHARSET_MAP = {
|
|
176
|
+
"q": "─",
|
|
177
|
+
"x": "│",
|
|
178
|
+
"n": "┼",
|
|
179
|
+
"l": "┌",
|
|
180
|
+
"k": "┐",
|
|
181
|
+
"m": "└",
|
|
182
|
+
"j": "┘",
|
|
183
|
+
"t": "├",
|
|
184
|
+
"u": "┤",
|
|
185
|
+
"w": "┬",
|
|
186
|
+
"v": "┴",
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
def render(
|
|
190
|
+
self,
|
|
191
|
+
text: str,
|
|
192
|
+
width: int,
|
|
193
|
+
height: int,
|
|
194
|
+
) -> list[str]:
|
|
195
|
+
"""Render a captured tmux screen and overlay the cursor position."""
|
|
196
|
+
lines, cursor_pos = self._render_pty(text, width, height)
|
|
197
|
+
if cursor_pos is not None:
|
|
198
|
+
row, col = cursor_pos
|
|
199
|
+
if 0 <= row < len(lines):
|
|
200
|
+
line = lines[row]
|
|
201
|
+
if 0 <= col < len(line):
|
|
202
|
+
lines[row] = line[:col] + "▁" + line[col + 1 :]
|
|
203
|
+
if height is not None and len(lines) != height:
|
|
204
|
+
if len(lines) > height:
|
|
205
|
+
lines = lines[-height:]
|
|
206
|
+
else:
|
|
207
|
+
lines = [" " * width for _ in range(height - len(lines))] + lines
|
|
208
|
+
return lines
|
|
209
|
+
|
|
210
|
+
def _render_pty(self, text: str, width: int, height: int) -> tuple[list[str], Optional[tuple[int, int]]]:
|
|
211
|
+
screen = [[" " for _ in range(width)] for _ in range(height)]
|
|
212
|
+
row = 0
|
|
213
|
+
col = 0
|
|
214
|
+
g0_line = False
|
|
215
|
+
g1_line = True
|
|
216
|
+
use_g1 = False
|
|
217
|
+
saved_row = 0
|
|
218
|
+
saved_col = 0
|
|
219
|
+
scroll_top = 0
|
|
220
|
+
scroll_bottom = height - 1
|
|
221
|
+
cursor_visible = True
|
|
222
|
+
i = 0
|
|
223
|
+
while i < len(text):
|
|
224
|
+
ch = text[i]
|
|
225
|
+
if ch == "\x0e": # SO
|
|
226
|
+
use_g1 = True
|
|
227
|
+
i += 1
|
|
228
|
+
continue
|
|
229
|
+
if ch == "\x0f": # SI
|
|
230
|
+
use_g1 = False
|
|
231
|
+
i += 1
|
|
232
|
+
continue
|
|
233
|
+
if ch == "\x1b":
|
|
234
|
+
i += 1
|
|
235
|
+
if i >= len(text):
|
|
236
|
+
break
|
|
237
|
+
if text[i] == "7":
|
|
238
|
+
saved_row, saved_col = row, col
|
|
239
|
+
i += 1
|
|
240
|
+
continue
|
|
241
|
+
if text[i] == "8":
|
|
242
|
+
row, col = saved_row, saved_col
|
|
243
|
+
i += 1
|
|
244
|
+
continue
|
|
245
|
+
if text[i] in ("(", ")"):
|
|
246
|
+
if i + 1 < len(text):
|
|
247
|
+
set_g1 = text[i] == ")"
|
|
248
|
+
mode = text[i + 1]
|
|
249
|
+
if set_g1:
|
|
250
|
+
g1_line = mode == "0"
|
|
251
|
+
else:
|
|
252
|
+
g0_line = mode == "0"
|
|
253
|
+
i += 2
|
|
254
|
+
continue
|
|
255
|
+
if text[i] == "[":
|
|
256
|
+
i += 1
|
|
257
|
+
params, final, private, i = self._parse_csi(text, i)
|
|
258
|
+
if private and final in ("h", "l") and (params[0] if params else 0) == 25:
|
|
259
|
+
cursor_visible = final == "h"
|
|
260
|
+
else:
|
|
261
|
+
row, col, saved_row, saved_col, scroll_top, scroll_bottom = self._apply_csi(
|
|
262
|
+
params,
|
|
263
|
+
final,
|
|
264
|
+
row,
|
|
265
|
+
col,
|
|
266
|
+
screen,
|
|
267
|
+
saved_row,
|
|
268
|
+
saved_col,
|
|
269
|
+
scroll_top,
|
|
270
|
+
scroll_bottom,
|
|
271
|
+
)
|
|
272
|
+
continue
|
|
273
|
+
if text[i] == "]":
|
|
274
|
+
i = self._skip_osc(text, i + 1)
|
|
275
|
+
continue
|
|
276
|
+
i += 1
|
|
277
|
+
continue
|
|
278
|
+
if ch == "\r":
|
|
279
|
+
col = 0
|
|
280
|
+
elif ch == "\n":
|
|
281
|
+
row += 1
|
|
282
|
+
if row > scroll_bottom:
|
|
283
|
+
del screen[scroll_top]
|
|
284
|
+
screen.insert(scroll_bottom, [" " for _ in range(width)])
|
|
285
|
+
row = scroll_bottom
|
|
286
|
+
elif ch == "\b":
|
|
287
|
+
col = max(0, col - 1)
|
|
288
|
+
elif ch == "\t":
|
|
289
|
+
col = min(width - 1, (col // 8 + 1) * 8)
|
|
290
|
+
elif ch >= " " and ch != "\x7f":
|
|
291
|
+
if col >= width:
|
|
292
|
+
row += 1
|
|
293
|
+
col = 0
|
|
294
|
+
if row >= height:
|
|
295
|
+
screen.pop(0)
|
|
296
|
+
screen.append([" " for _ in range(width)])
|
|
297
|
+
row = height - 1
|
|
298
|
+
line_drawing = (use_g1 and g1_line) or ((not use_g1) and g0_line)
|
|
299
|
+
if line_drawing:
|
|
300
|
+
ch = self._ALT_CHARSET_MAP.get(ch, ch)
|
|
301
|
+
if 0 <= row < height and 0 <= col < width:
|
|
302
|
+
screen[row][col] = ch
|
|
303
|
+
col += 1
|
|
304
|
+
i += 1
|
|
305
|
+
|
|
306
|
+
lines = [self._strip_control_chars("".join(line)) for line in screen]
|
|
307
|
+
if not cursor_visible:
|
|
308
|
+
return lines, None
|
|
309
|
+
return lines, (row, min(max(col, 0), max(width - 1, 0)))
|
|
310
|
+
|
|
311
|
+
@staticmethod
|
|
312
|
+
def _parse_csi(text: str, i: int) -> tuple[list[int], str, bool, int]:
|
|
313
|
+
params: list[int] = []
|
|
314
|
+
current = ""
|
|
315
|
+
private = False
|
|
316
|
+
while i < len(text):
|
|
317
|
+
ch = text[i]
|
|
318
|
+
if ch.isdigit():
|
|
319
|
+
current += ch
|
|
320
|
+
elif ch == ";":
|
|
321
|
+
params.append(int(current) if current else 0)
|
|
322
|
+
current = ""
|
|
323
|
+
elif ch == "?":
|
|
324
|
+
private = True
|
|
325
|
+
current = ""
|
|
326
|
+
else:
|
|
327
|
+
if current or params:
|
|
328
|
+
params.append(int(current) if current else 0)
|
|
329
|
+
return params, ch, private, i + 1
|
|
330
|
+
i += 1
|
|
331
|
+
return params, "m", private, i
|
|
332
|
+
|
|
333
|
+
@staticmethod
|
|
334
|
+
def _skip_osc(text: str, i: int) -> int:
|
|
335
|
+
while i < len(text):
|
|
336
|
+
if text[i] == "\x07":
|
|
337
|
+
return i + 1
|
|
338
|
+
if text[i] == "\x1b" and i + 1 < len(text) and text[i + 1] == "\\":
|
|
339
|
+
return i + 2
|
|
340
|
+
i += 1
|
|
341
|
+
return i
|
|
342
|
+
|
|
343
|
+
def _apply_csi(
|
|
344
|
+
self,
|
|
345
|
+
params: list[int],
|
|
346
|
+
final: str,
|
|
347
|
+
row: int,
|
|
348
|
+
col: int,
|
|
349
|
+
screen: list[list[str]],
|
|
350
|
+
saved_row: int,
|
|
351
|
+
saved_col: int,
|
|
352
|
+
scroll_top: int,
|
|
353
|
+
scroll_bottom: int,
|
|
354
|
+
) -> tuple[int, int, int, int, int, int]:
|
|
355
|
+
height = len(screen)
|
|
356
|
+
width = len(screen[0]) if height else 0
|
|
357
|
+
param = params[0] if params else 0
|
|
358
|
+
if final in ("H", "f"):
|
|
359
|
+
r = (params[0] - 1) if len(params) >= 1 and params[0] else 0
|
|
360
|
+
c = (params[1] - 1) if len(params) >= 2 and params[1] else 0
|
|
361
|
+
return max(0, min(height - 1, r)), max(0, min(width - 1, c)), saved_row, saved_col, scroll_top, scroll_bottom
|
|
362
|
+
if final == "A":
|
|
363
|
+
return max(0, row - (param or 1)), col, saved_row, saved_col, scroll_top, scroll_bottom
|
|
364
|
+
if final == "B":
|
|
365
|
+
return min(height - 1, row + (param or 1)), col, saved_row, saved_col, scroll_top, scroll_bottom
|
|
366
|
+
if final == "C":
|
|
367
|
+
return row, min(width - 1, col + (param or 1)), saved_row, saved_col, scroll_top, scroll_bottom
|
|
368
|
+
if final == "D":
|
|
369
|
+
return row, max(0, col - (param or 1)), saved_row, saved_col, scroll_top, scroll_bottom
|
|
370
|
+
if final == "G":
|
|
371
|
+
c = (param - 1) if param else 0
|
|
372
|
+
return row, max(0, min(width - 1, c)), saved_row, saved_col, scroll_top, scroll_bottom
|
|
373
|
+
if final == "E":
|
|
374
|
+
r = min(height - 1, row + (param or 1))
|
|
375
|
+
return r, 0, saved_row, saved_col, scroll_top, scroll_bottom
|
|
376
|
+
if final == "F":
|
|
377
|
+
r = max(0, row - (param or 1))
|
|
378
|
+
return r, 0, saved_row, saved_col, scroll_top, scroll_bottom
|
|
379
|
+
if final == "s":
|
|
380
|
+
return row, col, row, col, scroll_top, scroll_bottom
|
|
381
|
+
if final == "u":
|
|
382
|
+
return saved_row, saved_col, saved_row, saved_col, scroll_top, scroll_bottom
|
|
383
|
+
if final == "r":
|
|
384
|
+
top = (params[0] - 1) if len(params) >= 1 and params[0] else 0
|
|
385
|
+
bottom = (params[1] - 1) if len(params) >= 2 and params[1] else height - 1
|
|
386
|
+
top = max(0, min(height - 1, top))
|
|
387
|
+
bottom = max(top, min(height - 1, bottom))
|
|
388
|
+
return row, col, saved_row, saved_col, top, bottom
|
|
389
|
+
if final == "J":
|
|
390
|
+
mode = param or 0
|
|
391
|
+
if mode == 2:
|
|
392
|
+
for r in range(height):
|
|
393
|
+
screen[r] = [" " for _ in range(width)]
|
|
394
|
+
elif mode == 0:
|
|
395
|
+
for r in range(row, height):
|
|
396
|
+
start = col if r == row else 0
|
|
397
|
+
for c in range(start, width):
|
|
398
|
+
screen[r][c] = " "
|
|
399
|
+
return row, col, saved_row, saved_col, scroll_top, scroll_bottom
|
|
400
|
+
if final == "K":
|
|
401
|
+
mode = param or 0
|
|
402
|
+
if mode == 2:
|
|
403
|
+
for c in range(width):
|
|
404
|
+
screen[row][c] = " "
|
|
405
|
+
elif mode == 0:
|
|
406
|
+
for c in range(col, width):
|
|
407
|
+
screen[row][c] = " "
|
|
408
|
+
elif mode == 1:
|
|
409
|
+
for c in range(0, col + 1):
|
|
410
|
+
screen[row][c] = " "
|
|
411
|
+
return row, col, saved_row, saved_col, scroll_top, scroll_bottom
|
|
412
|
+
if final == "L":
|
|
413
|
+
count = param or 1
|
|
414
|
+
count = min(count, scroll_bottom - row + 1)
|
|
415
|
+
for _ in range(count):
|
|
416
|
+
screen.insert(row, [" " for _ in range(width)])
|
|
417
|
+
del screen[scroll_bottom + 1]
|
|
418
|
+
return row, col, saved_row, saved_col, scroll_top, scroll_bottom
|
|
419
|
+
if final == "M":
|
|
420
|
+
count = param or 1
|
|
421
|
+
count = min(count, scroll_bottom - row + 1)
|
|
422
|
+
for _ in range(count):
|
|
423
|
+
del screen[row]
|
|
424
|
+
screen.insert(scroll_bottom, [" " for _ in range(width)])
|
|
425
|
+
return row, col, saved_row, saved_col, scroll_top, scroll_bottom
|
|
426
|
+
if final == "@":
|
|
427
|
+
count = param or 1
|
|
428
|
+
for _ in range(count):
|
|
429
|
+
screen[row].insert(col, " ")
|
|
430
|
+
screen[row].pop()
|
|
431
|
+
return row, col, saved_row, saved_col, scroll_top, scroll_bottom
|
|
432
|
+
if final == "P":
|
|
433
|
+
count = param or 1
|
|
434
|
+
for _ in range(count):
|
|
435
|
+
if col < width:
|
|
436
|
+
del screen[row][col]
|
|
437
|
+
screen[row].append(" ")
|
|
438
|
+
return row, col, saved_row, saved_col, scroll_top, scroll_bottom
|
|
439
|
+
return row, col, saved_row, saved_col, scroll_top, scroll_bottom
|
|
440
|
+
|
|
441
|
+
@staticmethod
|
|
442
|
+
def _strip_control_chars(line: str) -> str:
|
|
443
|
+
return "".join(ch for ch in line if ch >= " " and ch != "\x7f")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class TMUXWrapper:
|
|
447
|
+
"""Drive a tmux session through text entry, key chords, and window capture."""
|
|
448
|
+
|
|
449
|
+
def __init__(
|
|
450
|
+
self,
|
|
451
|
+
session: str,
|
|
452
|
+
tmux_bin: str = "tmux",
|
|
453
|
+
renderer: Optional[TMUXRenderer] = None,
|
|
454
|
+
) -> None:
|
|
455
|
+
"""Attach to ``session``, creating it if needed."""
|
|
456
|
+
self.session = session
|
|
457
|
+
self.tmux_bin = tmux_bin
|
|
458
|
+
self.renderer = renderer or TMUXRenderer()
|
|
459
|
+
self._default_size = (200, 40)
|
|
460
|
+
self._prefix_pending = False
|
|
461
|
+
self._owns_session = False
|
|
462
|
+
self._ensure_session()
|
|
463
|
+
self._afterimage = []
|
|
464
|
+
|
|
465
|
+
def __del__(self) -> None:
|
|
466
|
+
self._safe_delete()
|
|
467
|
+
|
|
468
|
+
def type(self, type_str: str) -> None:
|
|
469
|
+
"""Send literal text to the active pane without pressing Enter."""
|
|
470
|
+
if not type_str:
|
|
471
|
+
return
|
|
472
|
+
self._run_tmux(["send-keys", "-t", self._target(), "-l", type_str])
|
|
473
|
+
|
|
474
|
+
def press(self, keys: list[tuple[Keys, ...]]) -> None:
|
|
475
|
+
"""Send key chords to tmux.
|
|
476
|
+
|
|
477
|
+
Each chord is a tuple containing zero or more modifiers plus exactly
|
|
478
|
+
one base key. To issue a tmux prefix binding, send ``Ctrl+B`` as one
|
|
479
|
+
chord and the bound key as the next chord.
|
|
480
|
+
"""
|
|
481
|
+
if not keys:
|
|
482
|
+
return
|
|
483
|
+
for chord in keys:
|
|
484
|
+
if not chord:
|
|
485
|
+
continue
|
|
486
|
+
if self._is_prefix_chord(chord):
|
|
487
|
+
self._prefix_pending = True
|
|
488
|
+
continue
|
|
489
|
+
if self._prefix_pending and self._handle_tmux_binding(chord):
|
|
490
|
+
self._prefix_pending = False
|
|
491
|
+
continue
|
|
492
|
+
self._prefix_pending = False
|
|
493
|
+
encoded = self._encode_chord(chord)
|
|
494
|
+
self._run_tmux(["send-keys", "-t", self._target(), encoded])
|
|
495
|
+
|
|
496
|
+
def snapshot(self) -> literal:
|
|
497
|
+
"""Capture the whole tmux window and reset the diff baseline."""
|
|
498
|
+
content = self._attach_capture()
|
|
499
|
+
self._afterimage = content
|
|
500
|
+
return literal("\n".join(content))
|
|
501
|
+
|
|
502
|
+
def glance(self) -> literal:
|
|
503
|
+
"""Return additions plus counted collapsed markers for unchanged regions."""
|
|
504
|
+
afterimage = self._afterimage
|
|
505
|
+
content = self._attach_capture()
|
|
506
|
+
self._afterimage = content
|
|
507
|
+
diff = self._glance_lines(afterimage, content)
|
|
508
|
+
if not diff:
|
|
509
|
+
return literal("[Nothing Changed]")
|
|
510
|
+
return literal("\n".join(diff))
|
|
511
|
+
|
|
512
|
+
def view(self) -> literal:
|
|
513
|
+
"""Return a contextual diff against the previous capture."""
|
|
514
|
+
afterimage = self._afterimage
|
|
515
|
+
content = self._attach_capture()
|
|
516
|
+
self._afterimage = content
|
|
517
|
+
diff = self._diff_lines(afterimage, content, include_context=True)
|
|
518
|
+
return literal("\n".join(diff))
|
|
519
|
+
|
|
520
|
+
def scroll_up(self, lines: int = 3) -> None:
|
|
521
|
+
"""Enter copy mode and scroll the viewport up by ``lines``."""
|
|
522
|
+
repeat = self._normalize_scroll_lines(lines)
|
|
523
|
+
if repeat == 0:
|
|
524
|
+
return
|
|
525
|
+
self._enter_copy_mode()
|
|
526
|
+
if not self._try_copy_mode_action(["scroll-up"], repeat=repeat):
|
|
527
|
+
self._run_tmux(["send-keys", "-t", self._target(), "PageUp"])
|
|
528
|
+
|
|
529
|
+
def scroll_down(self, lines: int = 3) -> None:
|
|
530
|
+
"""Enter copy mode and scroll down; exit copy mode at the bottom."""
|
|
531
|
+
repeat = self._normalize_scroll_lines(lines)
|
|
532
|
+
if repeat == 0:
|
|
533
|
+
return
|
|
534
|
+
self._enter_copy_mode()
|
|
535
|
+
if not self._try_copy_mode_action(["scroll-down"], repeat=repeat):
|
|
536
|
+
self._run_tmux(["send-keys", "-t", self._target(), "PageDown"])
|
|
537
|
+
if self._in_copy_mode() and self._scroll_position() == 0:
|
|
538
|
+
self._try_copy_mode_action(["cancel"])
|
|
539
|
+
|
|
540
|
+
def delete(self) -> None:
|
|
541
|
+
"""Delete the tmux session immediately."""
|
|
542
|
+
try:
|
|
543
|
+
self._run_tmux(["kill-session", "-t", self.session])
|
|
544
|
+
except RuntimeError as exc:
|
|
545
|
+
if "can't find session" in str(exc):
|
|
546
|
+
return
|
|
547
|
+
raise
|
|
548
|
+
|
|
549
|
+
def _target(self) -> str:
|
|
550
|
+
return self.session
|
|
551
|
+
|
|
552
|
+
def _run_tmux(self, args: Iterable[str]) -> str:
|
|
553
|
+
cmd = [self.tmux_bin, *args]
|
|
554
|
+
try:
|
|
555
|
+
completed = subprocess.run(
|
|
556
|
+
cmd,
|
|
557
|
+
check=True,
|
|
558
|
+
text=True,
|
|
559
|
+
stdout=subprocess.PIPE,
|
|
560
|
+
stderr=subprocess.PIPE,
|
|
561
|
+
)
|
|
562
|
+
except FileNotFoundError as exc:
|
|
563
|
+
raise RuntimeError(f"tmux binary not found: {self.tmux_bin}") from exc
|
|
564
|
+
except subprocess.CalledProcessError as exc:
|
|
565
|
+
message = exc.stderr.strip() or exc.stdout.strip()
|
|
566
|
+
raise RuntimeError(f"tmux command failed: {message}") from exc
|
|
567
|
+
return completed.stdout
|
|
568
|
+
|
|
569
|
+
def _window_target(self) -> str:
|
|
570
|
+
return self.session
|
|
571
|
+
|
|
572
|
+
def _attach_capture(self) -> list[str]:
|
|
573
|
+
master_fd, slave_fd = pty.openpty()
|
|
574
|
+
try:
|
|
575
|
+
width, height = self._window_size()
|
|
576
|
+
except RuntimeError:
|
|
577
|
+
width, height = self._default_size
|
|
578
|
+
self._set_pty_size(slave_fd, width, height)
|
|
579
|
+
try:
|
|
580
|
+
self._run_tmux(["refresh-client", "-S", "-t", self._window_target()])
|
|
581
|
+
except RuntimeError:
|
|
582
|
+
pass
|
|
583
|
+
env = os.environ.copy()
|
|
584
|
+
env.setdefault("TERM", "xterm-256color")
|
|
585
|
+
proc = subprocess.Popen(
|
|
586
|
+
[self.tmux_bin, "attach", "-t", self._window_target()],
|
|
587
|
+
stdin=slave_fd,
|
|
588
|
+
stdout=slave_fd,
|
|
589
|
+
stderr=slave_fd,
|
|
590
|
+
close_fds=True,
|
|
591
|
+
env=env,
|
|
592
|
+
)
|
|
593
|
+
os.close(slave_fd)
|
|
594
|
+
|
|
595
|
+
output = b""
|
|
596
|
+
deadline = time.time() + 1.5
|
|
597
|
+
while time.time() < deadline:
|
|
598
|
+
readable, _, _ = select.select([master_fd], [], [], 0.05)
|
|
599
|
+
if master_fd in readable:
|
|
600
|
+
try:
|
|
601
|
+
chunk = os.read(master_fd, 65536)
|
|
602
|
+
except OSError:
|
|
603
|
+
break
|
|
604
|
+
if not chunk:
|
|
605
|
+
break
|
|
606
|
+
output += chunk
|
|
607
|
+
|
|
608
|
+
try:
|
|
609
|
+
os.write(master_fd, b"\x02d")
|
|
610
|
+
except OSError:
|
|
611
|
+
pass
|
|
612
|
+
|
|
613
|
+
try:
|
|
614
|
+
proc.wait(timeout=0.5)
|
|
615
|
+
except subprocess.TimeoutExpired:
|
|
616
|
+
proc.terminate()
|
|
617
|
+
|
|
618
|
+
os.close(master_fd)
|
|
619
|
+
|
|
620
|
+
text = output.decode("utf-8", errors="ignore")
|
|
621
|
+
return self.renderer.render(text, width, height)
|
|
622
|
+
|
|
623
|
+
def _window_size(self) -> tuple[int, int]:
|
|
624
|
+
target = self._window_target()
|
|
625
|
+
output = self._run_tmux(["display-message", "-p", "-t", target, "#{window_width} #{window_height}"]).strip()
|
|
626
|
+
width_str, height_str = output.split()
|
|
627
|
+
return int(width_str), int(height_str) + 1
|
|
628
|
+
|
|
629
|
+
def _client_size(self) -> Optional[tuple[int, int]]:
|
|
630
|
+
try:
|
|
631
|
+
output = self._run_tmux([
|
|
632
|
+
"list-clients",
|
|
633
|
+
"-t",
|
|
634
|
+
self.session,
|
|
635
|
+
"-F",
|
|
636
|
+
"#{client_active} #{client_width} #{client_height}",
|
|
637
|
+
]).splitlines()
|
|
638
|
+
except RuntimeError:
|
|
639
|
+
return None
|
|
640
|
+
if not output:
|
|
641
|
+
return None
|
|
642
|
+
active = None
|
|
643
|
+
largest = None
|
|
644
|
+
for line in output:
|
|
645
|
+
parts = line.split()
|
|
646
|
+
if len(parts) != 3:
|
|
647
|
+
continue
|
|
648
|
+
is_active, w, h = parts
|
|
649
|
+
size = (int(w), int(h))
|
|
650
|
+
if largest is None or (size[0] * size[1]) > (largest[0] * largest[1]):
|
|
651
|
+
largest = size
|
|
652
|
+
if is_active == "1":
|
|
653
|
+
active = size
|
|
654
|
+
break
|
|
655
|
+
if active is not None:
|
|
656
|
+
return active
|
|
657
|
+
return largest
|
|
658
|
+
|
|
659
|
+
@staticmethod
|
|
660
|
+
def _set_pty_size(fd: int, width: int, height: int) -> None:
|
|
661
|
+
winsize = struct.pack("HHHH", height, width, 0, 0)
|
|
662
|
+
fcntl.ioctl(fd, 0x5414, winsize)
|
|
663
|
+
|
|
664
|
+
@staticmethod
|
|
665
|
+
@lru_cache(maxsize=1)
|
|
666
|
+
def _tmux_version() -> tuple[int, int, int]:
|
|
667
|
+
output = subprocess.run(
|
|
668
|
+
["tmux", "-V"],
|
|
669
|
+
check=True,
|
|
670
|
+
text=True,
|
|
671
|
+
stdout=subprocess.PIPE,
|
|
672
|
+
stderr=subprocess.PIPE,
|
|
673
|
+
).stdout.strip()
|
|
674
|
+
version = output.split()[-1]
|
|
675
|
+
digits: list[int] = []
|
|
676
|
+
for part in version.replace("a", "").replace("b", "").split("."):
|
|
677
|
+
if part.isdigit():
|
|
678
|
+
digits.append(int(part))
|
|
679
|
+
while len(digits) < 3:
|
|
680
|
+
digits.append(0)
|
|
681
|
+
return tuple(digits[:3])
|
|
682
|
+
|
|
683
|
+
def _in_copy_mode(self) -> bool:
|
|
684
|
+
return self._run_tmux(
|
|
685
|
+
["display-message", "-p", "-t", self._target(), "#{pane_in_mode}"]
|
|
686
|
+
).strip() == "1"
|
|
687
|
+
|
|
688
|
+
def _scroll_position(self) -> Optional[int]:
|
|
689
|
+
value = self._run_tmux(
|
|
690
|
+
["display-message", "-p", "-t", self._target(), "#{scroll_position}"]
|
|
691
|
+
).strip()
|
|
692
|
+
if not value:
|
|
693
|
+
return None
|
|
694
|
+
return int(value)
|
|
695
|
+
|
|
696
|
+
@staticmethod
|
|
697
|
+
def _diff_lines(
|
|
698
|
+
before: list[str],
|
|
699
|
+
after: list[str],
|
|
700
|
+
include_context: bool,
|
|
701
|
+
) -> list[str]:
|
|
702
|
+
diff = []
|
|
703
|
+
for line in difflib.ndiff(before, after):
|
|
704
|
+
if line.startswith(("- ", "? ")):
|
|
705
|
+
continue
|
|
706
|
+
if line.startswith("+ "):
|
|
707
|
+
diff.append(f"!!{line[2:]}")
|
|
708
|
+
continue
|
|
709
|
+
if include_context:
|
|
710
|
+
diff.append(line)
|
|
711
|
+
return diff
|
|
712
|
+
|
|
713
|
+
@staticmethod
|
|
714
|
+
def _glance_lines(before: list[str], after: list[str]) -> list[str]:
|
|
715
|
+
lines = []
|
|
716
|
+
pending_context = 0
|
|
717
|
+
saw_addition = False
|
|
718
|
+
|
|
719
|
+
for line in difflib.ndiff(before, after):
|
|
720
|
+
if line.startswith(("- ", "? ")):
|
|
721
|
+
continue
|
|
722
|
+
if line.startswith("+ "):
|
|
723
|
+
if pending_context:
|
|
724
|
+
suffix = "line" if pending_context == 1 else "lines"
|
|
725
|
+
lines.append(f"...[{pending_context} unchanged {suffix}]")
|
|
726
|
+
pending_context = 0
|
|
727
|
+
lines.append(f"!!{line[2:]}")
|
|
728
|
+
saw_addition = True
|
|
729
|
+
continue
|
|
730
|
+
pending_context += 1
|
|
731
|
+
|
|
732
|
+
if not saw_addition:
|
|
733
|
+
return []
|
|
734
|
+
if pending_context:
|
|
735
|
+
suffix = "line" if pending_context == 1 else "lines"
|
|
736
|
+
lines.append(f"...[{pending_context} unchanged {suffix}]")
|
|
737
|
+
return lines
|
|
738
|
+
|
|
739
|
+
def _enter_copy_mode(self) -> None:
|
|
740
|
+
if self._in_copy_mode():
|
|
741
|
+
return
|
|
742
|
+
self._run_tmux(["copy-mode", "-t", self._target()])
|
|
743
|
+
for _ in range(5):
|
|
744
|
+
if self._in_copy_mode():
|
|
745
|
+
return
|
|
746
|
+
|
|
747
|
+
def _try_copy_mode_action(self, actions: list[str], repeat: int = 1) -> bool:
|
|
748
|
+
for action in actions:
|
|
749
|
+
cmd = ["send-keys", "-X"]
|
|
750
|
+
if repeat != 1:
|
|
751
|
+
cmd.extend(["-N", str(repeat)])
|
|
752
|
+
cmd.extend(["-t", self._target(), action])
|
|
753
|
+
try:
|
|
754
|
+
self._run_tmux(cmd)
|
|
755
|
+
return True
|
|
756
|
+
except RuntimeError:
|
|
757
|
+
continue
|
|
758
|
+
return False
|
|
759
|
+
|
|
760
|
+
@staticmethod
|
|
761
|
+
def _normalize_scroll_lines(lines: int) -> int:
|
|
762
|
+
if lines < 0:
|
|
763
|
+
raise ValueError("lines must be >= 0")
|
|
764
|
+
return lines
|
|
765
|
+
|
|
766
|
+
def _ensure_session(self) -> None:
|
|
767
|
+
try:
|
|
768
|
+
self._run_tmux(["has-session", "-t", self.session])
|
|
769
|
+
self._owns_session = False
|
|
770
|
+
except RuntimeError:
|
|
771
|
+
self._run_tmux(["new-session", "-d", "-s", self.session])
|
|
772
|
+
self._owns_session = True
|
|
773
|
+
|
|
774
|
+
def _safe_delete(self) -> None:
|
|
775
|
+
try:
|
|
776
|
+
if self._owns_session:
|
|
777
|
+
self.delete()
|
|
778
|
+
except Exception:
|
|
779
|
+
return
|
|
780
|
+
|
|
781
|
+
@staticmethod
|
|
782
|
+
def _is_prefix_chord(chord: tuple[Keys, ...]) -> bool:
|
|
783
|
+
return set(chord) == {Keys.Ctrl, Keys.B}
|
|
784
|
+
|
|
785
|
+
def _handle_tmux_binding(self, chord: tuple[Keys, ...]) -> bool:
|
|
786
|
+
mods, base = self._split_chord(chord)
|
|
787
|
+
if base in (Keys.Up, Keys.Down, Keys.Left, Keys.Right) and not mods:
|
|
788
|
+
direction = {
|
|
789
|
+
Keys.Up: "U",
|
|
790
|
+
Keys.Down: "D",
|
|
791
|
+
Keys.Left: "L",
|
|
792
|
+
Keys.Right: "R",
|
|
793
|
+
}[base]
|
|
794
|
+
self._run_tmux(["select-pane", f"-{direction}", "-t", self._target()])
|
|
795
|
+
return True
|
|
796
|
+
if base is Keys.PageUp and not mods:
|
|
797
|
+
self._enter_copy_mode()
|
|
798
|
+
if not self._try_copy_mode_action(["page-up", "scroll-up"]):
|
|
799
|
+
self._run_tmux(["send-keys", "-t", self._target(), "PageUp"])
|
|
800
|
+
return True
|
|
801
|
+
if base is Keys.PageDown and not mods:
|
|
802
|
+
self._enter_copy_mode()
|
|
803
|
+
if not self._try_copy_mode_action(["page-down", "scroll-down"]):
|
|
804
|
+
self._run_tmux(["send-keys", "-t", self._target(), "PageDown"])
|
|
805
|
+
return True
|
|
806
|
+
if base is Keys.Digit5 and not mods:
|
|
807
|
+
self._run_tmux(["split-window", "-h", "-t", self._target()])
|
|
808
|
+
return True
|
|
809
|
+
if mods and any(mod in mods for mod in (Keys.Ctrl, Keys.Alt)):
|
|
810
|
+
return False
|
|
811
|
+
|
|
812
|
+
char = self._encode_character_key(mods, base)
|
|
813
|
+
if char is None:
|
|
814
|
+
return False
|
|
815
|
+
|
|
816
|
+
action = self._PREFIX_BINDINGS.get(char)
|
|
817
|
+
if action is None:
|
|
818
|
+
return False
|
|
819
|
+
|
|
820
|
+
cmd, *extra = action
|
|
821
|
+
self._run_tmux([cmd, *extra, "-t", self._target()])
|
|
822
|
+
return True
|
|
823
|
+
return False
|
|
824
|
+
|
|
825
|
+
@staticmethod
|
|
826
|
+
def _split_chord(chord: tuple[Keys, ...]) -> tuple[list[Keys], Keys]:
|
|
827
|
+
modifiers = {Keys.Ctrl, Keys.Alt, Keys.Shift}
|
|
828
|
+
mods = [key for key in chord if key in modifiers]
|
|
829
|
+
base_keys = [key for key in chord if key not in modifiers]
|
|
830
|
+
if len(base_keys) != 1:
|
|
831
|
+
raise ValueError(f"Chord must contain exactly one base key: {chord}")
|
|
832
|
+
return mods, base_keys[0]
|
|
833
|
+
|
|
834
|
+
@staticmethod
|
|
835
|
+
def _encode_chord(chord: tuple[Keys, ...]) -> str:
|
|
836
|
+
mods, base = TMUXWrapper._split_chord(chord)
|
|
837
|
+
return TMUXWrapper._encode_key(mods, base)
|
|
838
|
+
|
|
839
|
+
@staticmethod
|
|
840
|
+
def _encode_key(mods: list[Keys], base: Keys) -> str:
|
|
841
|
+
char = TMUXWrapper._encode_character_key(mods, base)
|
|
842
|
+
if char is not None:
|
|
843
|
+
return char
|
|
844
|
+
|
|
845
|
+
return TMUXWrapper._encode_special_key(mods, base)
|
|
846
|
+
|
|
847
|
+
@staticmethod
|
|
848
|
+
def _encode_character_key(mods: list[Keys], base: Keys) -> Optional[str]:
|
|
849
|
+
shifted = Keys.Shift in mods
|
|
850
|
+
|
|
851
|
+
if base in TMUXWrapper._LETTER_KEYS:
|
|
852
|
+
letter = base.value.lower()
|
|
853
|
+
if shifted:
|
|
854
|
+
letter = letter.upper()
|
|
855
|
+
return TMUXWrapper._apply_modifiers(mods, letter)
|
|
856
|
+
|
|
857
|
+
if base in TMUXWrapper._DIGIT_KEYS:
|
|
858
|
+
unshifted, shifted_char = TMUXWrapper._DIGIT_KEYS[base]
|
|
859
|
+
char = shifted_char if shifted else unshifted
|
|
860
|
+
return TMUXWrapper._apply_modifiers(mods, char)
|
|
861
|
+
|
|
862
|
+
if base in TMUXWrapper._PUNCT_KEYS:
|
|
863
|
+
unshifted, shifted_char = TMUXWrapper._PUNCT_KEYS[base]
|
|
864
|
+
char = shifted_char if shifted else unshifted
|
|
865
|
+
return TMUXWrapper._apply_modifiers(mods, char)
|
|
866
|
+
|
|
867
|
+
if base is Keys.Space:
|
|
868
|
+
return TMUXWrapper._apply_modifiers(mods, " ")
|
|
869
|
+
|
|
870
|
+
return None
|
|
871
|
+
|
|
872
|
+
@staticmethod
|
|
873
|
+
def _encode_special_key(mods: list[Keys], base: Keys) -> str:
|
|
874
|
+
key_name = TMUXWrapper._SPECIAL_KEYS.get(base, base.value)
|
|
875
|
+
return TMUXWrapper._apply_modifiers(mods, key_name, force_named=True)
|
|
876
|
+
|
|
877
|
+
@staticmethod
|
|
878
|
+
def _apply_modifiers(mods: list[Keys], key: str, force_named: bool = False) -> str:
|
|
879
|
+
mod_prefix = []
|
|
880
|
+
if Keys.Ctrl in mods:
|
|
881
|
+
mod_prefix.append("C")
|
|
882
|
+
if Keys.Alt in mods:
|
|
883
|
+
mod_prefix.append("M")
|
|
884
|
+
if Keys.Shift in mods and force_named:
|
|
885
|
+
mod_prefix.append("S")
|
|
886
|
+
|
|
887
|
+
if not mod_prefix:
|
|
888
|
+
return key
|
|
889
|
+
return f"{'-'.join(mod_prefix)}-{key}"
|
|
890
|
+
|
|
891
|
+
_LETTER_KEYS = {
|
|
892
|
+
Keys.A, Keys.B, Keys.C, Keys.D, Keys.E, Keys.F, Keys.G, Keys.H, Keys.I, Keys.J,
|
|
893
|
+
Keys.K, Keys.L, Keys.M, Keys.N, Keys.O, Keys.P, Keys.Q, Keys.R, Keys.S, Keys.T,
|
|
894
|
+
Keys.U, Keys.V, Keys.W, Keys.X, Keys.Y, Keys.Z,
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
_DIGIT_KEYS = {
|
|
898
|
+
Keys.Digit0: ("0", ")"),
|
|
899
|
+
Keys.Digit1: ("1", "!"),
|
|
900
|
+
Keys.Digit2: ("2", "@"),
|
|
901
|
+
Keys.Digit3: ("3", "#"),
|
|
902
|
+
Keys.Digit4: ("4", "$"),
|
|
903
|
+
Keys.Digit5: ("5", "%"),
|
|
904
|
+
Keys.Digit6: ("6", "^"),
|
|
905
|
+
Keys.Digit7: ("7", "&"),
|
|
906
|
+
Keys.Digit8: ("8", "*"),
|
|
907
|
+
Keys.Digit9: ("9", "("),
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
_PUNCT_KEYS = {
|
|
911
|
+
Keys.Backtick: ("`", "~"),
|
|
912
|
+
Keys.Minus: ("-", "_"),
|
|
913
|
+
Keys.Equal: ("=", "+"),
|
|
914
|
+
Keys.LeftBracket: ("[", "{"),
|
|
915
|
+
Keys.RightBracket: ("]", "}"),
|
|
916
|
+
Keys.Backslash: ("\\", "|"),
|
|
917
|
+
Keys.Semicolon: (";", ":"),
|
|
918
|
+
Keys.Quote: ("'", "\""),
|
|
919
|
+
Keys.Comma: (",", "<"),
|
|
920
|
+
Keys.Period: (".", ">"),
|
|
921
|
+
Keys.Slash: ("/", "?"),
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
_SPECIAL_KEYS = {
|
|
925
|
+
Keys.Enter: "Enter",
|
|
926
|
+
Keys.Tab: "Tab",
|
|
927
|
+
Keys.Escape: "Escape",
|
|
928
|
+
Keys.Backspace: "BSpace",
|
|
929
|
+
Keys.CapsLock: "CapsLock",
|
|
930
|
+
Keys.Up: "Up",
|
|
931
|
+
Keys.Down: "Down",
|
|
932
|
+
Keys.Left: "Left",
|
|
933
|
+
Keys.Right: "Right",
|
|
934
|
+
Keys.Home: "Home",
|
|
935
|
+
Keys.End: "End",
|
|
936
|
+
Keys.PageUp: "PageUp",
|
|
937
|
+
Keys.PageDown: "PageDown",
|
|
938
|
+
Keys.Insert: "Insert",
|
|
939
|
+
Keys.Delete: "Delete",
|
|
940
|
+
Keys.F1: "F1",
|
|
941
|
+
Keys.F2: "F2",
|
|
942
|
+
Keys.F3: "F3",
|
|
943
|
+
Keys.F4: "F4",
|
|
944
|
+
Keys.F5: "F5",
|
|
945
|
+
Keys.F6: "F6",
|
|
946
|
+
Keys.F7: "F7",
|
|
947
|
+
Keys.F8: "F8",
|
|
948
|
+
Keys.F9: "F9",
|
|
949
|
+
Keys.F10: "F10",
|
|
950
|
+
Keys.F11: "F11",
|
|
951
|
+
Keys.F12: "F12",
|
|
952
|
+
Keys.PrintScreen: "PPrint",
|
|
953
|
+
Keys.ScrollLock: "ScrollLock",
|
|
954
|
+
Keys.Pause: "Pause",
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
_PREFIX_BINDINGS = {
|
|
958
|
+
"\"": ("split-window", "-v"),
|
|
959
|
+
"%": ("split-window", "-h"),
|
|
960
|
+
"5": ("split-window", "-h"),
|
|
961
|
+
"c": ("new-window",),
|
|
962
|
+
"x": ("kill-pane",),
|
|
963
|
+
"z": ("resize-pane", "-Z"),
|
|
964
|
+
"o": ("select-pane", "-t", ":.+"),
|
|
965
|
+
";": ("last-pane",),
|
|
966
|
+
"n": ("next-window",),
|
|
967
|
+
"p": ("previous-window",),
|
|
968
|
+
"l": ("last-window",),
|
|
969
|
+
"0": ("select-window", "-t", ":0"),
|
|
970
|
+
"1": ("select-window", "-t", ":1"),
|
|
971
|
+
"2": ("select-window", "-t", ":2"),
|
|
972
|
+
"3": ("select-window", "-t", ":3"),
|
|
973
|
+
"4": ("select-window", "-t", ":4"),
|
|
974
|
+
"5": ("select-window", "-t", ":5"),
|
|
975
|
+
"6": ("select-window", "-t", ":6"),
|
|
976
|
+
"7": ("select-window", "-t", ":7"),
|
|
977
|
+
"8": ("select-window", "-t", ":8"),
|
|
978
|
+
"9": ("select-window", "-t", ":9"),
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _parse_cli_key(name: str) -> Keys:
|
|
983
|
+
normalized = name.strip()
|
|
984
|
+
if not normalized:
|
|
985
|
+
raise ValueError("Key name cannot be empty")
|
|
986
|
+
|
|
987
|
+
aliases = {
|
|
988
|
+
"esc": Keys.Escape,
|
|
989
|
+
"return": Keys.Enter,
|
|
990
|
+
"pgup": Keys.PageUp,
|
|
991
|
+
"pageup": Keys.PageUp,
|
|
992
|
+
"pgdn": Keys.PageDown,
|
|
993
|
+
"pagedown": Keys.PageDown,
|
|
994
|
+
"space": Keys.Space,
|
|
995
|
+
}
|
|
996
|
+
key = aliases.get(normalized.lower())
|
|
997
|
+
if key is not None:
|
|
998
|
+
return key
|
|
999
|
+
|
|
1000
|
+
for candidate in Keys:
|
|
1001
|
+
if normalized.lower() in (candidate.name.lower(), candidate.value.lower()):
|
|
1002
|
+
return candidate
|
|
1003
|
+
raise ValueError(f"Unknown key: {name}")
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _parse_cli_chord(chord: str) -> tuple[Keys, ...]:
|
|
1007
|
+
parts = [part for part in chord.replace("-", "+").split("+") if part]
|
|
1008
|
+
if not parts:
|
|
1009
|
+
raise ValueError("Chord cannot be empty")
|
|
1010
|
+
return tuple(_parse_cli_key(part) for part in parts)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
class _TMUXWrapperCLI:
|
|
1014
|
+
"""Command-line facade for a single tmux session.
|
|
1015
|
+
|
|
1016
|
+
Default workflow: use `glance` before/after each action. Prefer
|
|
1017
|
+
`scroll_up` / `scroll_down` for paging instead of relying on `view`.
|
|
1018
|
+
Common `press` keys: `Enter`, `Up`, `Down`, `Left`, `Right`, `PageUp`,
|
|
1019
|
+
`PageDown`, `Ctrl+C`, `Ctrl+B Z`, `Ctrl+B Left`, `Ctrl+B Right`.
|
|
1020
|
+
"""
|
|
1021
|
+
|
|
1022
|
+
def __init__(self, session: str, tmux_bin: str = "tmux") -> None:
|
|
1023
|
+
self._tmux = TMUXWrapper(session=session, tmux_bin=tmux_bin)
|
|
1024
|
+
# CLI calls happen in separate processes, so keep the session alive
|
|
1025
|
+
# until the user explicitly runs `delete`.
|
|
1026
|
+
self._tmux._owns_session = False
|
|
1027
|
+
digest = hashlib.sha1(session.encode("utf-8")).hexdigest()
|
|
1028
|
+
self._state_path = Path(tempfile.gettempdir()) / "tmux_wrapper" / f"{digest}.json"
|
|
1029
|
+
|
|
1030
|
+
def _load_afterimage(self) -> None:
|
|
1031
|
+
try:
|
|
1032
|
+
self._tmux._afterimage = json.loads(self._state_path.read_text())
|
|
1033
|
+
except FileNotFoundError:
|
|
1034
|
+
self._tmux._afterimage = []
|
|
1035
|
+
|
|
1036
|
+
def _save_afterimage(self) -> None:
|
|
1037
|
+
self._state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1038
|
+
self._state_path.write_text(json.dumps(self._tmux._afterimage))
|
|
1039
|
+
|
|
1040
|
+
def snapshot(self) -> literal:
|
|
1041
|
+
"""Capture the whole current window and reset the baseline."""
|
|
1042
|
+
rendered = self._tmux.snapshot()
|
|
1043
|
+
self._save_afterimage()
|
|
1044
|
+
return rendered
|
|
1045
|
+
|
|
1046
|
+
def view(self) -> literal:
|
|
1047
|
+
"""Show a contextual diff against the previous CLI capture."""
|
|
1048
|
+
self._load_afterimage()
|
|
1049
|
+
rendered = self._tmux.view()
|
|
1050
|
+
self._save_afterimage()
|
|
1051
|
+
return rendered
|
|
1052
|
+
|
|
1053
|
+
def glance(self) -> literal:
|
|
1054
|
+
"""Show only incremental additions against the previous CLI capture."""
|
|
1055
|
+
self._load_afterimage()
|
|
1056
|
+
rendered = self._tmux.glance()
|
|
1057
|
+
self._save_afterimage()
|
|
1058
|
+
return rendered
|
|
1059
|
+
|
|
1060
|
+
def type(self, text: str) -> None:
|
|
1061
|
+
"""Send literal text without pressing Enter."""
|
|
1062
|
+
self._tmux.type(text)
|
|
1063
|
+
|
|
1064
|
+
def press(self, *chords: str) -> None:
|
|
1065
|
+
"""Send key chords such as `Enter`, `Ctrl+C`, or `Ctrl+B Z`.
|
|
1066
|
+
|
|
1067
|
+
Common keys: `Enter`, `Up`, `Down`, `Left`, `Right`, `PageUp`,
|
|
1068
|
+
`PageDown`, `Escape`, `Tab`, `Backspace`, `Ctrl+C`, `Ctrl+B Z`,
|
|
1069
|
+
`Ctrl+B Left`, `Ctrl+B Right`, `Ctrl+B Digit5`.
|
|
1070
|
+
"""
|
|
1071
|
+
if not chords:
|
|
1072
|
+
raise ValueError("Provide at least one chord, e.g. `press Enter`")
|
|
1073
|
+
self._tmux.press([_parse_cli_chord(chord) for chord in chords])
|
|
1074
|
+
|
|
1075
|
+
def scroll_up(self, lines: int = 3) -> None:
|
|
1076
|
+
"""Enter copy mode and scroll up by the given number of lines."""
|
|
1077
|
+
self._tmux.scroll_up(lines)
|
|
1078
|
+
|
|
1079
|
+
def scroll_down(self, lines: int = 3) -> None:
|
|
1080
|
+
"""Enter copy mode and scroll down by the given number of lines."""
|
|
1081
|
+
self._tmux.scroll_down(lines)
|
|
1082
|
+
|
|
1083
|
+
def delete(self) -> None:
|
|
1084
|
+
"""Delete the tmux session and clear the saved CLI afterimage."""
|
|
1085
|
+
self._tmux.delete()
|
|
1086
|
+
self._state_path.unlink(missing_ok=True)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
1090
|
+
"""Run the Fire-powered tmux CLI."""
|
|
1091
|
+
import fire
|
|
1092
|
+
|
|
1093
|
+
args = list(os.sys.argv[1:] if argv is None else argv)
|
|
1094
|
+
if not args:
|
|
1095
|
+
print("Usage: tmux-c <session> <command> [args...]")
|
|
1096
|
+
print("Examples:")
|
|
1097
|
+
print(" tmux-c test snapshot")
|
|
1098
|
+
print(' tmux-c test type "ls"')
|
|
1099
|
+
print(" tmux-c test press Enter")
|
|
1100
|
+
print(" tmux-c test view")
|
|
1101
|
+
print(" tmux-c test glance")
|
|
1102
|
+
print(" tmux-c test press Ctrl+C")
|
|
1103
|
+
print(" tmux-c test press Ctrl+B Z")
|
|
1104
|
+
print(" tmux-c test scroll_up 5")
|
|
1105
|
+
print("Common press keys: Enter, Up, Down, Left, Right, PageUp, PageDown")
|
|
1106
|
+
return 1
|
|
1107
|
+
|
|
1108
|
+
session, *command = args
|
|
1109
|
+
fire.Fire(_TMUXWrapperCLI(session), command=command)
|
|
1110
|
+
return 0
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
if __name__ == "__main__":
|
|
1114
|
+
raise SystemExit(main())
|