1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
//! Session-level tmux notification handlers.
//!
//! Covers session lifecycle events: session started/renamed/ended, client-size
//! synchronization, and window-title management.
use crate::app::window_state::WindowState;
impl WindowState {
/// Handle session started notification
pub(super) fn handle_tmux_session_started(&mut self, session_name: &str) {
crate::debug_info!("TMUX", "Session started: {}", session_name);
// Store the session name for later use (e.g., window title updates)
self.tmux_state.tmux_session_name = Some(session_name.to_string());
// Update window title with session name: "par-term - [tmux: session_name]"
self.update_window_title_with_tmux();
// Check for automatic profile switching based on tmux session name
self.apply_tmux_session_profile(session_name);
// Update the gateway tab's title to show tmux session
if let Some(gateway_tab_id) = self.tmux_state.tmux_gateway_tab_id
&& let Some(tab) = self.tab_manager.get_tab_mut(gateway_tab_id)
{
tab.set_title(&format!("[tmux: {}]", session_name));
crate::debug_info!(
"TMUX",
"Updated gateway tab {} title to '[tmux: {}]'",
gateway_tab_id,
session_name
);
}
// Enable sync now that session is connected
self.tmux_state.tmux_sync.enable();
// Note: tmux_gateway_active was already set on the gateway tab during initiate_tmux_gateway()
// Set window-size to 'smallest' so tmux respects par-term's size
// even when other (larger) clients are attached.
// This is critical for proper multi-client behavior.
let _ = self.write_to_gateway("set-option -g window-size smallest\n");
crate::debug_info!(
"TMUX",
"Set window-size to smallest for multi-client support"
);
// Tell tmux the terminal size so panes can be properly sized
// Without this, tmux uses a very small default and splits will fail
self.send_tmux_client_size();
// Note: Initial pane content comes from layout-change handling which sends Ctrl+L
// to each pane. We don't send Enter here as it would execute a command.
// Show success toast
self.show_toast(format!("tmux: Connected to session '{}'", session_name));
}
/// Send the terminal size to tmux so it knows the client dimensions
///
/// In control mode, tmux doesn't know the terminal size unless we tell it.
/// Without this, tmux uses a very small default and pane splits will fail
/// with "no space for new pane".
pub(super) fn send_tmux_client_size(&self) {
// Get the terminal grid size from the renderer
if let Some(renderer) = &self.renderer {
let (cols, rows) = renderer.grid_size();
let cmd = crate::tmux::TmuxCommand::set_client_size(cols, rows);
let cmd_str = format!("{}\n", cmd.as_str());
if self.write_to_gateway(&cmd_str) {
crate::debug_trace!("TMUX", "Sent client size to tmux: {}x{}", cols, rows);
} else {
crate::debug_error!("TMUX", "Failed to send client size to tmux");
}
} else {
crate::debug_error!("TMUX", "Cannot send client size - no renderer available");
}
}
/// Notify tmux of a window/pane resize
///
/// Called when the window is resized to keep tmux in sync with par-term's size.
/// This sends `refresh-client -C cols,rows` to tmux in gateway mode.
pub fn notify_tmux_of_resize(&self) {
// par-mux transport: push the new grid size and cell pixels through
// the daemon client; the gateway path below writes to the gateway
// PTY.
#[cfg(feature = "mux")]
if let Some(transport) = &self.tmux_state.transport
&& let Some(renderer) = &self.renderer
{
let (cols, rows) = super::mux::mux_client_grid(renderer);
let cell_px = super::mux::mux_client_cell_px(renderer);
// mux_focused_pane is unset after a fresh attach until a click
// or focus push — resolve the focused pane the same way input
// routing does, or every resize is silently skipped and daemon
// panes keep the old geometry (broken wrapping after resize).
let focused = self.focused_mux_pane_from_native();
super::mux::push_client_size(&**transport, focused, cols, rows, cell_px);
return;
}
// Only send if tmux gateway is active
if !self.is_gateway_active() {
return;
}
self.send_tmux_client_size();
}
/// Update window title with tmux session info
/// Format: "window_title - [tmux: session_name]"
pub(crate) fn update_window_title_with_tmux(&self) {
// UX.md T8: the par-mux drain routes %session-renamed here too, and a
// par-mux window must never be titled "tmux".
let label = if self.tmux_state.is_mux_attached() {
"mux"
} else {
"tmux"
};
let title = if let Some(session_name) = &self.tmux_state.tmux_session_name {
format!(
"{} - [{label}: {}]",
self.config.load().window_title,
session_name
)
} else {
self.config.load().window_title.clone()
};
let formatted = self.format_title(&title);
self.with_window(|w| w.set_title(&formatted));
}
/// Handle session renamed notification
pub(super) fn handle_tmux_session_renamed(&mut self, session_name: &str) {
crate::debug_info!("TMUX", "Session renamed to: {}", session_name);
// Update stored session name
self.tmux_state.tmux_session_name = Some(session_name.to_string());
// Update window title with new session name
self.update_window_title_with_tmux();
}
/// Test seam: run the shared session-ended teardown from outside the
/// notifications module (the chip tests end a view directly).
#[cfg(test)]
pub(crate) fn handle_tmux_session_ended_for_test(&mut self) {
self.handle_tmux_session_ended();
}
/// Handle session ended notification
pub(super) fn handle_tmux_session_ended(&mut self) {
crate::debug_info!("TMUX", "Session ended");
// UX.md T8: this handler also ends the par-mux view — daemon death
// synthesizes SessionEnded AFTER the transport is dropped, so the
// discriminant must survive the teardown below; mux_session_id and
// transport are both cleared inside this handler.
let mux_view_ended =
self.tmux_state.mux_session_id.is_some() || self.tmux_state.is_mux_attached();
// Restore gateway tab visibility before tearing down tmux state
self.show_gateway_tab();
// Collect tmux display tabs to close (tabs with tmux_pane_id set, excluding gateway)
let gateway_tab_id = self.tmux_state.tmux_gateway_tab_id;
let tmux_tabs_to_close: Vec<crate::tab::TabId> = self
.tab_manager
.tabs()
.iter()
.filter_map(|tab| {
// Close tabs that were displaying daemon content: a
// layout set their tmux_pane_id, or they are still mapped
// to a daemon window. The second test matters for a window
// that dies before its first %layout-change — no pane id
// was ever set, and without the mapping check its tab
// outlived the session as an immortal dead tab.
if (tab.tmux.tmux_pane_id.is_some()
|| self.tmux_state.tmux_sync.get_window(tab.id).is_some())
&& Some(tab.id) != gateway_tab_id
{
Some(tab.id)
} else {
None
}
})
.collect();
// Close tmux display tabs. The fast path pre-kills the hidden
// shells and releases their terminal Arcs on background threads —
// dropping them inline waits on each reader thread (up to 2 s per
// tab) on the UI thread.
for tab_id in tmux_tabs_to_close {
crate::debug_info!("TMUX", "Closing tmux display tab {}", tab_id);
let _ = self.tab_manager.close_tab_fast(tab_id);
}
// Disable tmux control mode on the gateway tab and clear auto-applied profile
if let Some(gateway_tab_id) = self.tmux_state.tmux_gateway_tab_id
&& let Some(tab) = self.tab_manager.get_tab_mut(gateway_tab_id)
&& tab.tmux.tmux_gateway_active
{
tab.tmux.tmux_gateway_active = false;
tab.tmux.tmux_pane_id = None;
tab.clear_auto_profile(); // Clear tmux session profile
// try_lock: intentional — session-ended cleanup runs from the sync event loop
// where blocking would stall the entire GUI.
// On miss: set the deferred flag so the notification poll loop retries on the
// next frame, guaranteeing the terminal parser eventually exits control mode.
if let Ok(term) = tab.terminal.try_read() {
term.set_tmux_control_mode(false);
} else {
crate::debug_error!(
"TAB",
"session-ended: could not acquire terminal lock to disable tmux control mode \
on tab {} — deferring to next poll cycle",
gateway_tab_id
);
tab.tmux.pending_tmux_mode_disable = true;
}
}
self.tmux_state.tmux_gateway_tab_id = None;
// Clean up tmux session state
if let Some(mut session) = self.tmux_state.tmux_session.take() {
session.disconnect();
}
self.tmux_state.tmux_session_name = None;
self.tmux_state.mux_session_id = None;
// Clear pane mappings
self.tmux_state.tmux_pane_owners.clear();
// Reset window title (now without tmux info)
self.update_window_title_with_tmux();
// Clear sync state
self.tmux_state.tmux_sync = crate::tmux::TmuxSync::new();
// Show toast. A lost par-mux daemon is an error the session chip
// holds until dismissed (UX.md M12); a caller that knows better
// (detach, a session ended elsewhere) clears it right after.
if mux_view_ended {
self.record_mux_error("par-mux: daemon connection lost");
} else {
self.show_toast("tmux: Session ended");
}
}
}