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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tmux-c = tmux_wrapper:main
@@ -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())