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
use std::{
env,
io::{self, Read, Write},
os::fd::AsRawFd,
os::fd::BorrowedFd,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::{Duration, Instant},
};
use anyhow::{Context, Result};
use crossterm::{
cursor,
terminal,
};
use nix::poll::{poll, PollFd, PollFlags};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use signal_hook::{consts::signal::SIGWINCH, flag as signal_flag};
use super::AppTerminal;
pub(crate) fn hide_to_shell_toggleable(terminal: &mut AppTerminal) -> Result<()> {
// We keep raw mode enabled and act like a minimal terminal multiplexer (tmux-like):
// forward *raw stdin bytes* to a PTY-backed shell, but intercept F12 to return to Trix.
// This avoids lossy key mapping and makes the subshell feel like a real terminal.
// Leave the TUI so the normal terminal screen is visible.
{
let backend = terminal.backend_mut();
crossterm::execute!(backend, terminal::LeaveAlternateScreen, cursor::Show)
.context("leave alternate screen")?;
let _ = std::io::Write::flush(backend);
}
let (cols, rows) = terminal::size().unwrap_or((80, 24));
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("open pty")?;
let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
let spawn_shell = || -> Result<Box<dyn portable_pty::Child + Send + Sync>> {
let cmd = CommandBuilder::new(shell.clone());
pair.slave.spawn_command(cmd).context("spawn shell")
};
let mut child = spawn_shell()?;
// Print a small hint on the real terminal.
{
let mut out = io::stdout();
writeln!(out, "\nTrix hidden. Press F12 again to return.\n")?;
out.flush().ok();
}
let mut pty_writer = pair.master.take_writer().context("pty take writer")?;
let mut pty_reader = pair.master.try_clone_reader().context("pty clone reader")?;
let master_fd = pair
.master
.as_raw_fd()
.context("get pty master raw fd")?;
// Track window-size changes so the shell gets correct $COLUMNS/$LINES behavior.
let winch = Arc::new(AtomicBool::new(false));
signal_flag::register(SIGWINCH, Arc::clone(&winch)).ok();
// Forward raw user input bytes into the PTY.
// Intercept F12 (commonly sent as ESC [ 2 4 ~) to return.
let stdin_fd = io::stdin().as_raw_fd();
// Safety: stdin_fd is a valid FD for the life of this function.
let stdin_borrowed = unsafe { BorrowedFd::borrow_raw(stdin_fd) };
// Safety: master_fd remains valid while `pair.master` is alive.
let master_borrowed = unsafe { BorrowedFd::borrow_raw(master_fd) };
let mut poll_fds = [
PollFd::new(stdin_borrowed, PollFlags::POLLIN),
PollFd::new(master_borrowed, PollFlags::POLLIN),
];
let mut stdin = io::stdin();
let mut out = io::stdout();
let mut pending_esc = false;
let mut pending_esc_since: Option<Instant> = None;
let mut esc_buf: Vec<u8> = Vec::new();
let mut stdin_buf = [0u8; 4096];
// Most xterm-compatible terminals send F12 as ESC [ 2 4 ~.
// We treat this as the hide/unhide toggle while the subshell is active.
const F12_SEQ: &[u8] = b"[24~";
let mut return_to_tui = false;
'hidden: loop {
// If the shell exits, immediately respawn it.
// This keeps "hide-to-shell" mode active until the user presses F12.
if let Ok(Some(_)) = child.try_wait() {
child = spawn_shell()?;
pty_reader = pair.master.try_clone_reader().context("pty clone reader")?;
let _ = writeln!(
out,
"\n(shell exited; started a new one — press F12 to return to Trix)\n"
);
let _ = out.flush();
}
// Apply resize if we saw a SIGWINCH.
if winch.swap(false, Ordering::Relaxed) {
if let Ok((cols, rows)) = terminal::size() {
let _ = pair.master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
}
// If the user pressed ESC alone, don't wait forever for a following byte.
if pending_esc {
if let Some(since) = pending_esc_since {
if since.elapsed() >= Duration::from_millis(40) {
// Flush a bare ESC or an incomplete escape sequence.
if esc_buf.is_empty() {
let _ = pty_writer.write_all(&[0x1b]);
} else {
let _ = pty_writer.write_all(&[0x1b]);
let _ = pty_writer.write_all(&esc_buf);
esc_buf.clear();
}
let _ = pty_writer.flush();
pending_esc = false;
pending_esc_since = None;
}
}
}
// Poll stdin so we can also periodically check child exit + SIGWINCH.
match poll(&mut poll_fds, 50u16) {
Ok(0) => continue,
Ok(_) => {}
Err(nix::errno::Errno::EINTR) => continue,
Err(e) => {
let _ = child.kill();
return Err(anyhow::Error::new(e)).context("poll stdin while hidden");
}
}
// Drain PTY output when available.
let pty_ready = poll_fds
.get(1)
.and_then(|fd| fd.revents())
.map(|ev| ev.contains(PollFlags::POLLIN))
.unwrap_or(false);
if pty_ready {
let mut buf = [0u8; 8192];
match pty_reader.read(&mut buf) {
Ok(0) => {
// The slave side may have closed. We'll respawn the shell on the next loop.
}
Ok(n) => {
let _ = out.write_all(&buf[..n]);
let _ = out.flush();
}
Err(_) => {}
}
}
// Read stdin bytes when available.
let stdin_ready = poll_fds
.get(0)
.and_then(|fd| fd.revents())
.map(|ev| ev.contains(PollFlags::POLLIN))
.unwrap_or(false);
if stdin_ready {
let n = match stdin.read(&mut stdin_buf) {
Ok(0) => {
// stdin closed; treat as "return" to avoid leaving the user stuck.
return_to_tui = true;
break 'hidden;
}
Ok(n) => n,
Err(e) => {
let _ = child.kill();
return Err(anyhow::Error::new(e)).context("read stdin bytes while hidden");
}
};
for &b in &stdin_buf[..n] {
if pending_esc {
esc_buf.push(b);
// Check for F12 sequence (ESC + [24~).
if esc_buf.len() <= F12_SEQ.len() && esc_buf == F12_SEQ[..esc_buf.len()] {
if esc_buf.len() == F12_SEQ.len() {
return_to_tui = true;
pending_esc = false;
pending_esc_since = None;
esc_buf.clear();
break;
}
// Still matching the prefix; keep waiting for more bytes.
pending_esc_since = Some(Instant::now());
continue;
}
// Not a recognized sequence: forward ESC + buffered bytes to the PTY.
let _ = pty_writer.write_all(&[0x1b]);
let _ = pty_writer.write_all(&esc_buf);
esc_buf.clear();
pending_esc = false;
pending_esc_since = None;
continue;
}
if b == 0x1b {
pending_esc = true;
pending_esc_since = Some(Instant::now());
esc_buf.clear();
continue;
}
let _ = pty_writer.write_all(&[b]);
}
let _ = pty_writer.flush();
if return_to_tui {
break 'hidden;
}
}
}
if return_to_tui {
// Best-effort termination: avoid blocking forever here.
let _ = child.kill();
let deadline = Instant::now() + Duration::from_millis(800);
while Instant::now() < deadline {
if let Ok(Some(_)) = child.try_wait() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
}
// Restore the TUI.
{
let backend = terminal.backend_mut();
crossterm::execute!(backend, terminal::EnterAlternateScreen, terminal::Clear(terminal::ClearType::All), cursor::Hide)
.context("enter alternate screen")?;
let _ = std::io::Write::flush(backend);
}
terminal.clear().ok();
Ok(())
}