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
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
//! Terminal multi-session screen state and the `App` methods that open,
//! switch, scroll, and paste into PTY-backed tabs.
use std::sync::{Arc, Mutex};
use super::*;
use crate::event::SessionId;
use crate::ssh::pty::PtyManager;
// ---------------------------------------------------------------------------
// Terminal multi-session view state
// ---------------------------------------------------------------------------
/// Direction of the split-view layout.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SplitDirection {
/// Two panes side-by-side (left | right).
Vertical,
/// Two panes stacked (top / bottom).
Horizontal,
}
/// Which pane currently has keyboard focus in split-view mode.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SplitFocus {
#[default]
Primary,
Secondary,
}
/// Layout description when split-view is active.
#[derive(Debug, Clone)]
pub struct SplitView {
pub direction: SplitDirection,
/// Index into [`TerminalView::tabs`] shown in the secondary pane.
pub secondary_tab: usize,
}
/// A single open SSH/PTY tab.
pub struct TermTab {
/// Unique identifier matching the [`PtyManager`] session.
pub session_id: SessionId,
/// Display name (= `host.name`).
pub host_name: String,
/// Set to `true` when new output arrives while this tab is not focused,
/// providing an unread-output indicator in the tab bar.
pub has_activity: bool,
/// Shared VT100 parser — written by the PTY reader thread, snapshotted by
/// the render loop. Stored here (in ViewState) so rendering does not
/// require access to the PtyManager.
pub parser: Arc<Mutex<vt100::Parser>>,
/// Number of lines scrolled back from the live screen (0 = at the bottom).
/// Set via mouse-wheel; reset to 0 when the user types anything.
pub scroll_offset: usize,
}
/// Host-picker popup for opening a new terminal tab.
#[derive(Debug, Clone, Default)]
pub struct TermHostPicker {
/// Index of the currently highlighted host in `AppState.hosts`.
pub cursor: usize,
/// If `true`, the picker is in "switch pane mode" — selecting a host replaces
/// the focused pane's tab rather than creating a new tab.
pub switch_pane_mode: bool,
}
/// All UI state for the Terminal screen.
#[derive(Default)]
pub struct TerminalView {
/// Ordered list of open tabs.
pub tabs: Vec<TermTab>,
/// Index of the focused (primary) tab.
pub active_tab: usize,
/// Active split-view layout, if any.
pub split: Option<SplitView>,
/// Which pane has keyboard focus when split-view is active.
pub split_focus: SplitFocus,
/// Host-picker popup for creating a new tab (Ctrl+T).
pub host_picker: Option<TermHostPicker>,
/// When `true`, the next digit key 1–9 jumps directly to that tab.
/// Activated by pressing Tab (which also cycles to the next tab).
pub tab_select_mode: bool,
}
impl TerminalView {
/// Returns the [`SessionId`] of the currently focused pane, or `None` if
/// there are no open tabs.
pub fn active_session_id(&self) -> Option<SessionId> {
if self.tabs.is_empty() {
return None;
}
let idx = match &self.split {
Some(sv) if self.split_focus == SplitFocus::Secondary => sv.secondary_tab,
_ => self.active_tab,
};
self.tabs.get(idx).map(|t| t.session_id)
}
}
impl App {
/// Handles a mouse-wheel notch in the terminal screen.
///
/// On the normal screen the focused tab's local scrollback is moved. On the
/// alternate screen (vim, less, htop, ...) the notch is forwarded to the
/// foreground application instead, since the local scrollback is empty.
pub(crate) fn handle_term_scroll(&mut self, delta: i16) {
let tv = &mut self.view.terminal_view;
let focused_idx = match &tv.split {
Some(sv) if tv.split_focus == SplitFocus::Secondary => sv.secondary_tab,
_ => tv.active_tab,
};
let Some(tab) = tv.tabs.get_mut(focused_idx) else {
return;
};
// Inspect the foreground app under a brief parser lock, then release it.
let action = match tab.parser.lock() {
Ok(parser) => crate::utils::scroll::resolve_scroll(delta, parser.screen()),
Err(_) => return,
};
match action {
crate::utils::scroll::ScrollAction::Scrollback(d) => {
if d > 0 {
// Cap at the vt100 scrollback capacity (1000 lines, see pty.rs).
tab.scroll_offset = tab.scroll_offset.saturating_add(d as usize).min(1000);
} else {
tab.scroll_offset = tab.scroll_offset.saturating_sub((-d) as usize);
}
}
crate::utils::scroll::ScrollAction::Forward(bytes) => {
let id = tab.session_id;
if let Some(mgr) = &mut self.pty_manager {
if let Err(e) = mgr.write(id, &bytes) {
tracing::warn!("PTY scroll-forward write error for session {id}: {e}");
}
}
}
}
}
/// Forwards pasted text to the focused terminal tab's PTY.
///
/// The payload is wrapped in bracketed-paste markers when the foreground
/// application requested them (so `vim` inserts it verbatim without
/// auto-indent), otherwise it is sent as plain input.
pub(crate) fn handle_term_paste(&mut self, text: &str) {
let tv = &mut self.view.terminal_view;
let focused_idx = match &tv.split {
Some(sv) if tv.split_focus == SplitFocus::Secondary => sv.secondary_tab,
_ => tv.active_tab,
};
let Some(tab) = tv.tabs.get_mut(focused_idx) else {
return;
};
// Paste is input — jump back to the live screen, like typing.
tab.scroll_offset = 0;
let id = tab.session_id;
// Read the foreground app's bracketed-paste mode under a brief lock.
let bracketed = tab
.parser
.lock()
.map(|p| p.screen().bracketed_paste())
.unwrap_or(false);
let bytes = crate::utils::paste::encode_paste(text, bracketed);
if let Some(mgr) = &mut self.pty_manager {
if let Err(e) = mgr.write(id, &bytes) {
tracing::warn!("PTY paste write error for session {id}: {e}");
}
}
}
/// Opens a new PTY terminal tab for `AppState.hosts[host_idx]`.
///
/// Switches to the Terminal screen and sets `active_tab` to the new tab.
/// Reports errors in the status bar without panicking.
pub(crate) async fn open_term_tab(&mut self, host_idx: usize) {
let host = {
let state = self.state.read().await;
state.hosts.get(host_idx).cloned()
};
let Some(host) = host else {
self.view.status_message = Some("No such host.".to_string());
return;
};
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
// Reserve rows: status bar (1) + tab bar (1) + pane border top+bottom (2) = 4.
// Reserve cols: pane border left+right (2) = 2.
let pty_rows = rows.saturating_sub(4);
let pty_cols = cols.saturating_sub(2);
let mgr = self.pty_manager.get_or_insert_with(PtyManager::new);
match mgr.open(&host, pty_cols, pty_rows, self.event_tx.clone()) {
Ok(session_id) => {
let Some(parser) = mgr.parser_for(session_id) else {
tracing::error!(
session = session_id,
"parser not found for freshly created session"
);
return;
};
self.view.terminal_view.tabs.push(TermTab {
session_id,
host_name: host.name.clone(),
has_activity: false,
parser,
scroll_offset: 0,
});
self.view.terminal_view.active_tab =
self.view.terminal_view.tabs.len().saturating_sub(1);
self.state.write().await.screen = Screen::Terminal;
tracing::info!(
"Opened terminal tab for '{}' (session {})",
host.name,
session_id
);
}
Err(e) => {
self.view.status_message = Some(format!("PTY error: {e}"));
}
}
}
/// Switches the focused pane's host connection to a new host.
/// Closes the existing session for that pane and opens a new one.
pub(crate) async fn switch_focused_pane_host(&mut self, host_idx: usize) {
let host = {
let state = self.state.read().await;
state.hosts.get(host_idx).cloned()
};
let Some(host) = host else {
self.view.status_message = Some("No such host.".to_string());
return;
};
let tv = &mut self.view.terminal_view;
// Determine which tab index to replace based on split focus
let tab_idx = match &tv.split {
Some(sv) if tv.split_focus == SplitFocus::Secondary => sv.secondary_tab,
_ => tv.active_tab,
};
// Close the old session
if let Some(old_tab) = tv.tabs.get(tab_idx) {
if let Some(mgr) = &mut self.pty_manager {
mgr.close(old_tab.session_id);
tracing::info!(
"Closed terminal session {} for '{}'",
old_tab.session_id,
old_tab.host_name
);
}
}
// Open new session
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
let pty_rows = rows.saturating_sub(4);
let pty_cols = cols.saturating_sub(2);
let mgr = self.pty_manager.get_or_insert_with(PtyManager::new);
match mgr.open(&host, pty_cols, pty_rows, self.event_tx.clone()) {
Ok(session_id) => {
let Some(parser) = mgr.parser_for(session_id) else {
tracing::error!(
session = session_id,
"parser not found for freshly created session"
);
return;
};
// Replace the tab at the current position
let new_tab = TermTab {
session_id,
host_name: host.name.clone(),
has_activity: false,
parser,
scroll_offset: 0,
};
if let Some(slot) = tv.tabs.get_mut(tab_idx) {
*slot = new_tab;
}
tracing::info!(
"Switched pane {} to host '{}' (session {})",
tab_idx,
host.name,
session_id
);
}
Err(e) => {
self.view.status_message = Some(format!("PTY error: {e}"));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tab(session_id: SessionId) -> TermTab {
TermTab {
session_id,
host_name: String::new(),
has_activity: false,
parser: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
scroll_offset: 0,
}
}
// --- TerminalView::active_session_id (P1.7) ---------------------------
#[test]
fn active_session_id_none_when_no_tabs() {
let view = TerminalView::default();
assert_eq!(view.active_session_id(), None);
}
#[test]
fn active_session_id_returns_active_tab() {
let view = TerminalView {
tabs: vec![tab(10), tab(20)],
active_tab: 1,
..Default::default()
};
assert_eq!(view.active_session_id(), Some(20));
}
#[test]
fn active_session_id_uses_secondary_pane_when_focused() {
let view = TerminalView {
tabs: vec![tab(10), tab(20), tab(30)],
active_tab: 0,
split: Some(SplitView {
direction: SplitDirection::Vertical,
secondary_tab: 2,
}),
split_focus: SplitFocus::Secondary,
..Default::default()
};
assert_eq!(view.active_session_id(), Some(30));
}
#[test]
fn active_session_id_uses_primary_pane_when_focused() {
let view = TerminalView {
tabs: vec![tab(10), tab(20), tab(30)],
active_tab: 0,
split: Some(SplitView {
direction: SplitDirection::Vertical,
secondary_tab: 2,
}),
split_focus: SplitFocus::Primary,
..Default::default()
};
assert_eq!(view.active_session_id(), Some(10));
}
#[test]
fn active_session_id_none_when_secondary_index_out_of_range() {
let view = TerminalView {
tabs: vec![tab(10)],
split: Some(SplitView {
direction: SplitDirection::Horizontal,
secondary_tab: 99,
}),
split_focus: SplitFocus::Secondary,
..Default::default()
};
assert_eq!(view.active_session_id(), None);
}
}