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
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
//! Shell exit action handling for WindowState (called from the RedrawRequested event arm).
//!
//! Contains:
//! - `handle_shell_exit`: dispatches shell exit actions (Keep, Close, Restart variants)
//! for all tabs and panes. Returns true if the window should close.
use crate::app::window_state::WindowState;
impl WindowState {
/// Handle shell exit based on the configured `shell_exit_action`.
///
/// Returns `true` if the window should close (last tab exited and action is Close).
pub(crate) fn handle_shell_exit(&mut self) -> bool {
use crate::config::ShellExitAction;
use crate::pane::RestartState;
self.capture_crash_offers();
match self.config.load().shell.shell_exit_action {
ShellExitAction::Keep => {
// Do nothing - keep dead shells showing
}
ShellExitAction::Close => {
// Detect exited panes across all tabs.
// After R-32, pane_manager is always Some, so we always use the pane path.
// The primary pane (single-pane tabs) wraps tab.terminal; when it exits
// close_exited_panes() returns tab_should_close=true just as before.
let mut tabs_needing_resize: Vec<crate::tab::TabId> = Vec::new();
// Collect (tab_id, tab_title, exit_notified) for single-pane tabs that exit,
// so we can fire the session-ended notification before closing.
let mut single_pane_exiting: Vec<(crate::tab::TabId, String, bool)> = Vec::new();
let tabs_to_close: Vec<crate::tab::TabId> = self
.tab_manager
.tabs_mut()
.iter_mut()
.filter_map(|tab| {
if tab.tmux.tmux_gateway_active || tab.tmux.tmux_pane_id.is_some() {
return None;
}
// pane_manager is always Some after R-32.
let (closed_panes, tab_should_close) = tab.close_exited_panes();
if !closed_panes.is_empty() {
log::info!(
"Tab {}: closed {} exited pane(s)",
tab.id,
closed_panes.len()
);
if !tab_should_close {
tabs_needing_resize.push(tab.id);
}
}
if tab_should_close {
// Record metadata for notification (single-pane exit path).
// Previously this was handled by the legacy pane_manager.is_none()
// check; now we capture it here while the tab is still alive.
if !tab.has_multiple_panes() {
single_pane_exiting.push((
tab.id,
tab.title.clone(),
tab.activity.exit_notified,
));
tab.activity.exit_notified = true;
}
return Some(tab.id);
}
None
})
.collect();
// Send session-ended notifications for single-pane tabs before closing them.
if self.config.load().notifications.notification_session_ended {
for (_tab_id, tab_title, exit_notified) in &single_pane_exiting {
if !exit_notified {
log::info!("Shell in tab '{}' has exited", tab_title);
let title = format!("Shell Exited: {}", tab_title);
let message = "The shell process has exited".to_string();
self.deliver_notification(&title, &message);
}
}
}
if !tabs_needing_resize.is_empty()
&& let Some(renderer) = &self.renderer
{
let cell_width = renderer.cell_width();
let cell_height = renderer.cell_height();
let title_offset = if self.config.load().panes.show_pane_titles {
self.config.load().panes.pane_title_height
} else {
0.0
};
for tab_id in tabs_needing_resize {
if let Some(tab) = self.tab_manager.get_tab_mut(tab_id)
&& let Some(pm) = tab.pane_manager_mut()
{
// Suppress padding for single-pane tabs (no dividers).
// In split mode, add half the divider width as base so content
// doesn't render under the divider, plus the user-configured extra.
let padding = if pm.pane_count() <= 1 {
0.0
} else {
self.config.load().panes.pane_divider_width.unwrap_or(2.0) / 2.0
+ self.config.load().panes.pane_padding
};
pm.resize_all_terminals_with_padding(
cell_width,
cell_height,
padding,
title_offset,
);
}
}
}
for tab_id in &tabs_to_close {
log::info!("Closing tab {} - all panes exited", tab_id);
if self.tab_manager.visible_tab_count() <= 1 {
log::info!("Last tab, closing window");
self.is_shutting_down = true;
for tab in self.tab_manager.tabs_mut() {
tab.stop_refresh_task();
}
return true;
} else {
let _ = self.tab_manager.close_tab(*tab_id);
}
}
}
ShellExitAction::RestartImmediately
| ShellExitAction::RestartWithPrompt
| ShellExitAction::RestartAfterDelay => {
// Handle restart variants
let config_clone = (**self.config.load()).clone();
for tab in self.tab_manager.tabs_mut() {
if tab.tmux.tmux_gateway_active || tab.tmux.tmux_pane_id.is_some() {
continue;
}
if let Some(pm) = tab.pane_manager_mut() {
for pane in pm.all_panes_mut() {
let is_running = pane.is_running();
// Check if pane needs restart action
if !is_running && pane.restart_state.is_none() {
// Shell just exited, handle based on action
match self.config.load().shell.shell_exit_action {
ShellExitAction::RestartImmediately => {
log::info!(
"Pane {} shell exited, restarting immediately",
pane.id
);
if let Err(e) = pane.respawn_shell(&config_clone) {
log::error!(
"Failed to respawn shell in pane {}: {}",
pane.id,
e
);
}
}
ShellExitAction::RestartWithPrompt => {
log::info!(
"Pane {} shell exited, showing restart prompt",
pane.id
);
pane.write_restart_prompt();
pane.restart_state = Some(RestartState::AwaitingInput);
}
ShellExitAction::RestartAfterDelay => {
log::info!(
"Pane {} shell exited, will restart after 1s",
pane.id
);
pane.restart_state = Some(RestartState::AwaitingDelay(
std::time::Instant::now(),
));
}
_ => {}
}
}
// Check if waiting for delay and time has elapsed
if let Some(RestartState::AwaitingDelay(exit_time)) =
&pane.restart_state
&& exit_time.elapsed() >= std::time::Duration::from_secs(1)
{
log::info!("Pane {} delay elapsed, restarting shell", pane.id);
if let Err(e) = pane.respawn_shell(&config_clone) {
log::error!(
"Failed to respawn shell in pane {}: {}",
pane.id,
e
);
}
}
}
}
}
}
}
false // Window stays open
}
/// One crash-triage capture pass: every dead pane of a non-mux tab not
/// yet processed is recorded — clean exits are deduped inside `record`.
/// Runs before the shell-exit actions so the pane's terminal text still
/// exists when the payload is written (the Close action destroys the
/// pane in the same frame).
fn capture_crash_offers(&mut self) {
let live_tabs: std::collections::HashSet<u64> =
self.tab_manager.tabs().iter().map(|tab| tab.id).collect();
self.crash_triage.forget_tabs_except(&live_tabs);
let config = self.config.load();
let shell_line = match &config.shell.custom_shell {
Some(cmd) => {
let args = config
.shell
.shell_args
.as_ref()
.map(|args| args.join(" "))
.unwrap_or_default();
format!("{cmd} {args}")
}
None => "user default shell".to_string(),
};
drop(config);
let mut toasts: Vec<String> = Vec::new();
let mut respawned: Vec<(u64, u64)> = Vec::new();
for tab in self.tab_manager.tabs() {
if tab.tmux.tmux_gateway_active || tab.tmux.tmux_pane_id.is_some() {
continue;
}
let Some(pm) = tab.pane_manager() else {
continue;
};
for pane in pm.all_panes() {
if pane.is_running() {
// A running pane with a captured key was respawned — its
// next death is a new crash episode.
respawned.push((tab.id, pane.id));
continue;
}
let key = (tab.id, pane.id);
if self.crash_triage.already_captured(key) {
continue;
}
// A crash capture must not be dropped on lock contention: the
// pane is about to be destroyed by the Close action below.
let terminal = pane.terminal.blocking_read();
let Some(exit_code) = terminal.exit_code_if_dead() else {
// Dead by the reader flag but not yet reaped; a later
// pass picks it up.
continue;
};
let tail = terminal.export_text();
drop(terminal);
let label = format!("'{}' pane {}", tab.title, pane.id);
if self
.crash_triage
.record(key, label.clone(), exit_code, &shell_line, &tail)
.is_some()
{
toasts.push(format!(
"Crash captured ({label}, exit {exit_code}) — Triage in the command palette"
));
}
}
}
for toast in toasts {
self.show_toast(toast);
}
self.crash_triage.clear_captured(&respawned);
}
}