Skip to content

Commit fc9ac59

Browse files
committed
ControlProtocol(fix): Correct notification parsing per tmux source
why: Analysis against ~/study/c/tmux/ revealed 4 parsing bugs where libtmux did not match the actual tmux protocol format. what: - Fix %extended-output: parse colon delimiter for payload (control.c:622) - Fix %subscription-changed: parse all 5 fields before colon delimiter, handle "-" placeholders for session/window/pane scope (control.c:858-1036) - Fix %session-changed: include session_name field (control-notify.c:165) - Fix %exit: include optional reason field (client.c:425-427) - Add 6 test fixtures using NamedTuple parametrization
1 parent 45298f1 commit fc9ac59

File tree

2 files changed

+86
-12
lines changed

2 files changed

+86
-12
lines changed

src/libtmux/_internal/engines/control_protocol.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,17 @@ def _parse_notification(line: str, parts: list[str]) -> Notification:
7878
if tag == "%output" and len(parts) >= 3:
7979
kind = NotificationKind.PANE_OUTPUT
8080
data = {"pane_id": parts[1], "payload": " ".join(parts[2:])}
81-
elif tag == "%extended-output" and len(parts) >= 4:
82-
kind = NotificationKind.PANE_EXTENDED_OUTPUT
83-
data = {
84-
"pane_id": parts[1],
85-
"behind_ms": parts[2],
86-
"payload": " ".join(parts[3:]),
87-
}
81+
elif tag == "%extended-output" and len(parts) >= 3:
82+
# Format: %extended-output %{pane_id} {age_ms} : {payload}
83+
# The colon separates metadata from payload
84+
colon_idx = line.find(" : ")
85+
if colon_idx != -1:
86+
kind = NotificationKind.PANE_EXTENDED_OUTPUT
87+
data = {
88+
"pane_id": parts[1],
89+
"behind_ms": parts[2],
90+
"payload": line[colon_idx + 3:],
91+
}
8892
elif tag == "%pane-mode-changed" and len(parts) >= 2:
8993
kind = NotificationKind.PANE_MODE_CHANGED
9094
data = {"pane_id": parts[1], "mode": parts[2:]}
@@ -117,9 +121,10 @@ def _parse_notification(line: str, parts: list[str]) -> Notification:
117121
elif tag == "%window-pane-changed" and len(parts) >= 3:
118122
kind = NotificationKind.WINDOW_PANE_CHANGED
119123
data = {"window_id": parts[1], "pane_id": parts[2]}
120-
elif tag == "%session-changed" and len(parts) >= 2:
124+
elif tag == "%session-changed" and len(parts) >= 3:
125+
# Format: %session-changed ${session_id} {session_name}
121126
kind = NotificationKind.SESSION_CHANGED
122-
data = {"session_id": parts[1]}
127+
data = {"session_id": parts[1], "session_name": " ".join(parts[2:])}
123128
elif tag == "%client-session-changed" and len(parts) >= 4:
124129
kind = NotificationKind.CLIENT_SESSION_CHANGED
125130
data = {
@@ -150,11 +155,24 @@ def _parse_notification(line: str, parts: list[str]) -> Notification:
150155
elif tag == "%continue" and len(parts) >= 2:
151156
kind = NotificationKind.CONTINUE
152157
data = {"pane_id": parts[1]}
153-
elif tag == "%subscription-changed" and len(parts) >= 4:
154-
kind = NotificationKind.SUBSCRIPTION_CHANGED
155-
data = {"name": parts[1], "type": parts[2], "value": " ".join(parts[3:])}
158+
elif tag == "%subscription-changed" and len(parts) >= 6:
159+
# Format: %subscription-changed {name} ${session_id} @{window_id} {index} %{pane_id} : {value}
160+
# Fields can be "-" for "not applicable". Colon separates metadata from value.
161+
colon_idx = line.find(" : ")
162+
if colon_idx != -1:
163+
kind = NotificationKind.SUBSCRIPTION_CHANGED
164+
data = {
165+
"name": parts[1],
166+
"session_id": parts[2] if parts[2] != "-" else None,
167+
"window_id": parts[3] if parts[3] != "-" else None,
168+
"window_index": parts[4] if parts[4] != "-" else None,
169+
"pane_id": parts[5] if parts[5] != "-" else None,
170+
"value": line[colon_idx + 3:],
171+
}
156172
elif tag == "%exit":
173+
# Format: %exit or %exit {reason}
157174
kind = NotificationKind.EXIT
175+
data = {"reason": " ".join(parts[1:]) if len(parts) > 1 else None}
158176
elif tag == "%message" and len(parts) >= 2:
159177
kind = NotificationKind.MESSAGE
160178
data = {"text": " ".join(parts[1:])}

tests/test_engine_protocol.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,62 @@ def test_control_protocol_skips_unexpected_begin() -> None:
203203
expected_kind=NotificationKind.CONFIG_ERROR,
204204
expected_subset={"error": "/home/user/.tmux.conf:10: unknown option"},
205205
),
206+
NotificationFixture(
207+
test_id="subscription_changed_session",
208+
line="%subscription-changed mysub $1 - - - : session value here",
209+
expected_kind=NotificationKind.SUBSCRIPTION_CHANGED,
210+
expected_subset={
211+
"name": "mysub",
212+
"session_id": "$1",
213+
"window_id": None,
214+
"pane_id": None,
215+
"value": "session value here",
216+
},
217+
),
218+
NotificationFixture(
219+
test_id="subscription_changed_pane",
220+
line="%subscription-changed mysub $1 @2 0 %3 : pane value",
221+
expected_kind=NotificationKind.SUBSCRIPTION_CHANGED,
222+
expected_subset={
223+
"name": "mysub",
224+
"session_id": "$1",
225+
"window_id": "@2",
226+
"window_index": "0",
227+
"pane_id": "%3",
228+
"value": "pane value",
229+
},
230+
),
231+
NotificationFixture(
232+
test_id="extended_output_with_colon",
233+
line="%extended-output %5 1500 : output with spaces",
234+
expected_kind=NotificationKind.PANE_EXTENDED_OUTPUT,
235+
expected_subset={
236+
"pane_id": "%5",
237+
"behind_ms": "1500",
238+
"payload": "output with spaces",
239+
},
240+
),
241+
NotificationFixture(
242+
test_id="session_changed_with_name",
243+
line="%session-changed $1 my session name",
244+
expected_kind=NotificationKind.SESSION_CHANGED,
245+
expected_subset={
246+
"session_id": "$1",
247+
"session_name": "my session name",
248+
},
249+
),
250+
NotificationFixture(
251+
test_id="exit_with_reason",
252+
line="%exit server exited",
253+
expected_kind=NotificationKind.EXIT,
254+
expected_subset={"reason": "server exited"},
255+
),
256+
NotificationFixture(
257+
test_id="exit_no_reason",
258+
line="%exit",
259+
expected_kind=NotificationKind.EXIT,
260+
expected_subset={"reason": None},
261+
),
206262
]
207263

208264

0 commit comments

Comments
 (0)