Skip to content

Commit f1327d1

Browse files
committed
Engine(feat[control-mode]): cover full tmux notification surface
1 parent b246d46 commit f1327d1

File tree

3 files changed

+146
-0
lines changed

3 files changed

+146
-0
lines changed

src/libtmux/_internal/engines/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,22 @@ class NotificationKind(enum.Enum):
4848
PANE_OUTPUT = enum.auto()
4949
PANE_EXTENDED_OUTPUT = enum.auto()
5050
PANE_MODE_CHANGED = enum.auto()
51+
WINDOW_LAYOUT_CHANGED = enum.auto()
5152
WINDOW_ADD = enum.auto()
5253
WINDOW_CLOSE = enum.auto()
54+
UNLINKED_WINDOW_ADD = enum.auto()
55+
UNLINKED_WINDOW_CLOSE = enum.auto()
56+
UNLINKED_WINDOW_RENAMED = enum.auto()
5357
WINDOW_RENAMED = enum.auto()
5458
WINDOW_PANE_CHANGED = enum.auto()
5559
SESSION_CHANGED = enum.auto()
60+
CLIENT_SESSION_CHANGED = enum.auto()
61+
CLIENT_DETACHED = enum.auto()
62+
SESSION_RENAMED = enum.auto()
5663
SESSIONS_CHANGED = enum.auto()
5764
SESSION_WINDOW_CHANGED = enum.auto()
65+
PASTE_BUFFER_CHANGED = enum.auto()
66+
PASTE_BUFFER_DELETED = enum.auto()
5867
PAUSE = enum.auto()
5968
CONTINUE = enum.auto()
6069
SUBSCRIPTION_CHANGED = enum.auto()

src/libtmux/_internal/engines/control_protocol.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,26 +79,62 @@ def _parse_notification(line: str, parts: list[str]) -> Notification:
7979
elif tag == "%pane-mode-changed" and len(parts) >= 2:
8080
kind = NotificationKind.PANE_MODE_CHANGED
8181
data = {"pane_id": parts[1], "mode": parts[2:]}
82+
elif tag == "%layout-change" and len(parts) >= 5:
83+
kind = NotificationKind.WINDOW_LAYOUT_CHANGED
84+
data = {
85+
"window_id": parts[1],
86+
"window_layout": parts[2],
87+
"window_visible_layout": parts[3],
88+
"window_raw_flags": parts[4],
89+
}
8290
elif tag == "%window-add" and len(parts) >= 2:
8391
kind = NotificationKind.WINDOW_ADD
8492
data = {"window_id": parts[1], "rest": parts[2:]}
93+
elif tag == "%unlinked-window-add" and len(parts) >= 2:
94+
kind = NotificationKind.UNLINKED_WINDOW_ADD
95+
data = {"window_id": parts[1], "rest": parts[2:]}
8596
elif tag == "%window-close" and len(parts) >= 2:
8697
kind = NotificationKind.WINDOW_CLOSE
8798
data = {"window_id": parts[1]}
99+
elif tag == "%unlinked-window-close" and len(parts) >= 2:
100+
kind = NotificationKind.UNLINKED_WINDOW_CLOSE
101+
data = {"window_id": parts[1]}
88102
elif tag == "%window-renamed" and len(parts) >= 3:
89103
kind = NotificationKind.WINDOW_RENAMED
90104
data = {"window_id": parts[1], "name": " ".join(parts[2:])}
105+
elif tag == "%unlinked-window-renamed" and len(parts) >= 3:
106+
kind = NotificationKind.UNLINKED_WINDOW_RENAMED
107+
data = {"window_id": parts[1], "name": " ".join(parts[2:])}
91108
elif tag == "%window-pane-changed" and len(parts) >= 3:
92109
kind = NotificationKind.WINDOW_PANE_CHANGED
93110
data = {"window_id": parts[1], "pane_id": parts[2]}
94111
elif tag == "%session-changed" and len(parts) >= 2:
95112
kind = NotificationKind.SESSION_CHANGED
96113
data = {"session_id": parts[1]}
114+
elif tag == "%client-session-changed" and len(parts) >= 4:
115+
kind = NotificationKind.CLIENT_SESSION_CHANGED
116+
data = {
117+
"client_name": parts[1],
118+
"session_id": parts[2],
119+
"session_name": parts[3],
120+
}
121+
elif tag == "%client-detached" and len(parts) >= 2:
122+
kind = NotificationKind.CLIENT_DETACHED
123+
data = {"client_name": parts[1]}
124+
elif tag == "%session-renamed" and len(parts) >= 3:
125+
kind = NotificationKind.SESSION_RENAMED
126+
data = {"session_id": parts[1], "session_name": " ".join(parts[2:])}
97127
elif tag == "%sessions-changed":
98128
kind = NotificationKind.SESSIONS_CHANGED
99129
elif tag == "%session-window-changed" and len(parts) >= 3:
100130
kind = NotificationKind.SESSION_WINDOW_CHANGED
101131
data = {"session_id": parts[1], "window_id": parts[2]}
132+
elif tag == "%paste-buffer-changed" and len(parts) >= 2:
133+
kind = NotificationKind.PASTE_BUFFER_CHANGED
134+
data = {"name": parts[1]}
135+
elif tag == "%paste-buffer-deleted" and len(parts) >= 2:
136+
kind = NotificationKind.PASTE_BUFFER_DELETED
137+
data = {"name": parts[1]}
102138
elif tag == "%pause" and len(parts) >= 2:
103139
kind = NotificationKind.PAUSE
104140
data = {"pane_id": parts[1]}

tests/test_engine_protocol.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
from __future__ import annotations
44

5+
import typing as t
6+
7+
import pytest
8+
59
from libtmux._internal.engines.base import (
610
CommandResult,
711
ExitStatus,
@@ -11,6 +15,15 @@
1115
from libtmux._internal.engines.control_protocol import CommandContext, ControlProtocol
1216

1317

18+
class NotificationFixture(t.NamedTuple):
19+
"""Fixture for notification parsing cases."""
20+
21+
test_id: str
22+
line: str
23+
expected_kind: NotificationKind
24+
expected_subset: dict[str, str]
25+
26+
1427
def test_command_result_wraps_tmux_cmd() -> None:
1528
"""CommandResult should adapt cleanly into tmux_cmd wrapper."""
1629
result = CommandResult(
@@ -59,3 +72,91 @@ def test_control_protocol_notifications() -> None:
5972
proto.feed_line("%sessions-changed")
6073
proto.feed_line("%sessions-changed")
6174
assert proto.get_stats(restarts=0).dropped_notifications >= 1
75+
76+
77+
NOTIFICATION_FIXTURES: list[NotificationFixture] = [
78+
NotificationFixture(
79+
test_id="layout_change",
80+
line="%layout-change @1 abcd efgh 0",
81+
expected_kind=NotificationKind.WINDOW_LAYOUT_CHANGED,
82+
expected_subset={
83+
"window_id": "@1",
84+
"window_layout": "abcd",
85+
"window_visible_layout": "efgh",
86+
"window_raw_flags": "0",
87+
},
88+
),
89+
NotificationFixture(
90+
test_id="unlinked_window_add",
91+
line="%unlinked-window-add @2",
92+
expected_kind=NotificationKind.UNLINKED_WINDOW_ADD,
93+
expected_subset={"window_id": "@2"},
94+
),
95+
NotificationFixture(
96+
test_id="unlinked_window_close",
97+
line="%unlinked-window-close @3",
98+
expected_kind=NotificationKind.UNLINKED_WINDOW_CLOSE,
99+
expected_subset={"window_id": "@3"},
100+
),
101+
NotificationFixture(
102+
test_id="unlinked_window_renamed",
103+
line="%unlinked-window-renamed @4 new-name",
104+
expected_kind=NotificationKind.UNLINKED_WINDOW_RENAMED,
105+
expected_subset={"window_id": "@4", "name": "new-name"},
106+
),
107+
NotificationFixture(
108+
test_id="client_session_changed",
109+
line="%client-session-changed c1 $5 sname",
110+
expected_kind=NotificationKind.CLIENT_SESSION_CHANGED,
111+
expected_subset={
112+
"client_name": "c1",
113+
"session_id": "$5",
114+
"session_name": "sname",
115+
},
116+
),
117+
NotificationFixture(
118+
test_id="client_detached",
119+
line="%client-detached c1",
120+
expected_kind=NotificationKind.CLIENT_DETACHED,
121+
expected_subset={"client_name": "c1"},
122+
),
123+
NotificationFixture(
124+
test_id="session_renamed",
125+
line="%session-renamed $5 new-name",
126+
expected_kind=NotificationKind.SESSION_RENAMED,
127+
expected_subset={"session_id": "$5", "session_name": "new-name"},
128+
),
129+
NotificationFixture(
130+
test_id="paste_buffer_changed",
131+
line="%paste-buffer-changed buf1",
132+
expected_kind=NotificationKind.PASTE_BUFFER_CHANGED,
133+
expected_subset={"name": "buf1"},
134+
),
135+
NotificationFixture(
136+
test_id="paste_buffer_deleted",
137+
line="%paste-buffer-deleted buf1",
138+
expected_kind=NotificationKind.PASTE_BUFFER_DELETED,
139+
expected_subset={"name": "buf1"},
140+
),
141+
]
142+
143+
144+
@pytest.mark.parametrize(
145+
list(NotificationFixture._fields),
146+
NOTIFICATION_FIXTURES,
147+
ids=[fixture.test_id for fixture in NOTIFICATION_FIXTURES],
148+
)
149+
def test_control_protocol_notification_parsing(
150+
test_id: str,
151+
line: str,
152+
expected_kind: NotificationKind,
153+
expected_subset: dict[str, str],
154+
) -> None:
155+
"""Ensure the parser recognizes mapped control-mode notifications."""
156+
proto = ControlProtocol()
157+
proto.feed_line(line)
158+
notif = proto.get_notification(timeout=0.05)
159+
assert notif is not None
160+
assert notif.kind is expected_kind
161+
for key, value in expected_subset.items():
162+
assert notif.data.get(key) == value

0 commit comments

Comments
 (0)