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
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
use super::ansi_handler::AnsiHandler;
use super::term_grid::TerminalGrid;
use crate::app::session::{
MAX_LINES_PER_TERMINAL, SerializableCell, SerializableCursor, SerializableTerminalLine,
};
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
use std::io::{BufWriter, Read, Write};
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
use std::sync::{Arc, Mutex};
use std::thread;
use vte::Parser;
#[cfg(not(target_os = "linux"))]
use std::process::Command;
/// Shell configuration for terminal emulator
#[derive(Clone, Debug, Default)]
pub struct ShellConfig {
/// Path to shell executable, None means use OS default
pub shell_path: Option<String>,
}
/// Check if a shell can be found, either as a direct path or via PATH lookup
fn shell_exists(name: &str) -> bool {
let path = std::path::Path::new(name);
if path.exists() {
return true;
}
// Only search PATH for bare names (no directory separators)
if name.contains('/') || name.contains('\\') {
return false;
}
if let Ok(path_var) = std::env::var("PATH") {
for dir in std::env::split_paths(&path_var) {
if dir.join(name).exists() {
return true;
}
#[cfg(windows)]
{
if !name.contains('.') {
if dir.join(format!("{}.exe", name)).exists() {
return true;
}
if dir.join(format!("{}.cmd", name)).exists() {
return true;
}
}
}
}
}
false
}
impl ShellConfig {
/// Create a shell config with a custom shell path
pub fn custom_shell(path: String) -> Self {
Self {
shell_path: Some(path),
}
}
/// Validate the shell configuration
/// Returns Ok(()) if valid, Err with message if invalid
pub fn validate(&self) -> Result<(), String> {
if let Some(ref path) = self.shell_path {
if !shell_exists(path) {
return Err(format!("Shell '{}' not found", path));
}
// Check if file is executable on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(path) {
if metadata.permissions().mode() & 0o111 == 0 {
return Err(format!("Shell '{}' is not executable", path));
}
}
}
}
Ok(())
}
}
/// Terminal emulator that manages PTY, parser, and terminal grid
pub struct TerminalEmulator {
/// Terminal grid (screen buffer)
grid: Arc<Mutex<TerminalGrid>>,
/// VTE parser
parser: Parser,
/// PTY master (for reading/writing)
pty_master: Box<dyn MasterPty + Send>,
/// PTY writer (buffered for efficiency)
writer: BufWriter<Box<dyn Write + Send>>,
/// Child process handle
child: Box<dyn Child + Send>,
/// Channel to receive data from PTY reader thread
rx: Receiver<Vec<u8>>,
}
impl TerminalEmulator {
/// Create a new terminal emulator with a shell process or direct command
///
/// # Arguments
/// * `cols` - Number of columns
/// * `rows` - Number of rows
/// * `max_scrollback` - Maximum scrollback lines
/// * `command` - Optional command to run directly. If None, spawns shell based on shell_config.
/// Format: Some(("program", vec!["arg1", "arg2"]))
/// * `shell_config` - Configuration for which shell to use when command is None
pub fn new(
cols: usize,
rows: usize,
max_scrollback: usize,
command: Option<(String, Vec<String>)>,
shell_config: &ShellConfig,
) -> std::io::Result<Self> {
let pty_system = native_pty_system();
// Create PTY with specified size
let pty_pair = pty_system
.openpty(PtySize {
rows: rows as u16,
cols: cols as u16,
pixel_width: 0,
pixel_height: 0,
})
.map_err(std::io::Error::other)?;
// Spawn process (either command, custom shell, or default shell)
let mut cmd = if let Some((program, args)) = command {
// Launch specific command directly (e.g., from Slight launcher)
let mut cmd = CommandBuilder::new(program);
for arg in args {
cmd.arg(arg);
}
cmd
} else if let Some(ref shell_path) = shell_config.shell_path {
// Use custom shell if specified and valid
if shell_exists(shell_path) {
CommandBuilder::new(shell_path)
} else {
// Shell doesn't exist, fall back to default (validation should catch this earlier)
CommandBuilder::new_default_prog()
}
} else {
// Spawn default shell
CommandBuilder::new_default_prog()
};
// Set environment variables
cmd.env("TERM", "xterm-256color");
// Enable true color (24-bit RGB) support for applications like nvim, vim, etc.
cmd.env("COLORTERM", "truecolor");
// Disable zsh's PROMPT_SP feature (which shows "%" for unterminated lines)
// This prevents the "%" character from appearing at startup and after 'clear'
// Set PROMPT_EOL_MARK to empty string to hide the mark entirely
cmd.env("PROMPT_EOL_MARK", "");
// Disable PROMPT_SP entirely to prevent any cursor positioning at startup
cmd.env("PROMPT_SP", "");
let child = pty_pair
.slave
.spawn_command(cmd)
.map_err(std::io::Error::other)?;
// Get master PTY for I/O
let pty_master = pty_pair.master;
// Get reader and writer
let mut reader = pty_master
.try_clone_reader()
.map_err(std::io::Error::other)?;
let writer = BufWriter::new(pty_master.take_writer().map_err(std::io::Error::other)?);
// Create bounded channel for reading from PTY in background thread
// Capacity of 64 provides back-pressure while allowing efficient batching
let (tx, rx): (SyncSender<Vec<u8>>, Receiver<Vec<u8>>) = sync_channel(64);
// Spawn reader thread
thread::spawn(move || {
let mut buffer = vec![0u8; 8192];
loop {
match reader.read(&mut buffer) {
Ok(0) => {
// EOF - child process exited normally
break;
}
Ok(n) => {
if tx.send(buffer[..n].to_vec()).is_err() {
// Receiver dropped - main thread no longer listening
break;
}
}
Err(e) => {
// Log the error for diagnostics
eprintln!("PTY reader thread error: {}", e);
break;
}
}
}
});
let grid = Arc::new(Mutex::new(TerminalGrid::new(cols, rows, max_scrollback)));
let parser = Parser::new();
Ok(Self {
grid,
parser,
pty_master,
writer,
child,
rx,
})
}
/// Get a clone of the grid Arc for sharing with renderer
pub fn grid(&self) -> Arc<Mutex<TerminalGrid>> {
self.grid.clone()
}
/// Read output from PTY and process it through the parser
pub fn process_output(&mut self) -> std::io::Result<bool> {
// Collect ALL available data from PTY reader thread (non-blocking)
// This ensures complete escape sequences are processed before rendering,
// which is important for TUI applications that use cursor movement for redraws
let mut chunks = Vec::new();
let mut process_result = Ok(true);
// First, drain all available chunks without holding the grid lock
loop {
match self.rx.try_recv() {
Ok(data) => {
chunks.push(data);
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
// No more data available right now
break;
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// Reader thread died - child process exited
process_result = Ok(false);
break;
}
}
}
// Now process all chunks with a single grid lock acquisition
if !chunks.is_empty() {
let mut grid = self.grid.lock().expect("terminal grid mutex poisoned");
let mut handler = AnsiHandler::new(&mut grid);
for data in chunks {
self.parser.advance(&mut handler, &data);
}
}
// Process any queued responses (e.g., DSR cursor position reports)
let responses = {
let mut grid = self.grid.lock().expect("terminal grid mutex poisoned");
grid.take_responses()
};
for response in responses {
// Send response back to PTY
if let Err(e) = self.write_input(response.as_bytes()) {
eprintln!("Failed to write terminal response: {}", e);
}
}
// On Windows, the PTY reader thread may not immediately detect when a child
// process exits (e.g., cmd.exe). Explicitly check if the child has exited
// using try_wait() to ensure windows are auto-closed properly.
if process_result.as_ref().is_ok_and(|&running| running) {
if let Ok(Some(_exit_status)) = self.child.try_wait() {
// Child process has exited
process_result = Ok(false);
}
}
process_result
}
/// Write input to the PTY (send to shell)
/// On Windows: flushes immediately to avoid ConPTY buffering issues
/// On other platforms: buffered for efficiency, call flush_input() after batch
pub fn write_input(&mut self, data: &[u8]) -> std::io::Result<()> {
self.writer.write_all(data)?;
// Windows ConPTY can lose buffered data - flush immediately
#[cfg(target_os = "windows")]
self.writer.flush()?;
Ok(())
}
/// Flush any buffered PTY input
/// Call this once after processing a batch of keyboard events
pub fn flush_input(&mut self) -> std::io::Result<()> {
self.writer.flush()
}
/// Resize the terminal and notify the PTY
pub fn resize(&mut self, cols: usize, rows: usize) -> std::io::Result<()> {
// Resize the grid
{
let mut grid = self.grid.lock().expect("terminal grid mutex poisoned");
grid.resize(cols, rows);
}
// Notify PTY of size change (sends SIGWINCH to child process)
self.pty_master
.resize(PtySize {
rows: rows as u16,
cols: cols as u16,
pixel_width: 0,
pixel_height: 0,
})
.map_err(std::io::Error::other)?;
Ok(())
}
/// Send a string to the terminal
pub fn send_str(&mut self, s: &str) -> std::io::Result<()> {
self.write_input(s.as_bytes())
}
/// Send a character to the terminal
pub fn send_char(&mut self, c: char) -> std::io::Result<()> {
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
self.write_input(s.as_bytes())
}
/// Send pasted text to the terminal, respecting bracketed paste mode
/// When bracketed paste mode is enabled (?2004), wraps the text with
/// ESC[200~ (start) and ESC[201~ (end) sequences
pub fn send_paste(&mut self, text: &str) -> std::io::Result<()> {
let bracketed_paste_mode = {
let grid = self.grid.lock().expect("terminal grid mutex poisoned");
grid.bracketed_paste_mode
};
if bracketed_paste_mode {
// Bracketed paste: wrap with ESC[200~ and ESC[201~
self.write_input(b"\x1b[200~")?;
self.write_input(text.as_bytes())?;
self.write_input(b"\x1b[201~")?;
} else {
// Normal paste: send text directly
self.write_input(text.as_bytes())?;
}
// Flush to ensure paste is sent immediately
self.writer.flush()
}
/// Extract terminal content (scrollback + visible lines) for session persistence
/// Returns at most MAX_LINES_PER_TERMINAL lines (most recent lines are kept)
pub fn get_terminal_content(&self) -> (Vec<SerializableTerminalLine>, SerializableCursor) {
let grid = self.grid.lock().expect("terminal grid mutex poisoned");
let mut all_lines = Vec::new();
// Get scrollback lines (oldest first)
let scrollback_len = grid.scrollback_len();
for i in 0..scrollback_len {
if let Some(line) = grid.get_scrollback_line(i) {
let cells: Vec<SerializableCell> =
line.iter().map(SerializableCell::from).collect();
all_lines.push(SerializableTerminalLine { cells });
}
}
// Get visible screen lines
let rows = grid.rows();
for y in 0..rows {
let mut cells = Vec::new();
let cols = grid.cols();
for x in 0..cols {
if let Some(cell) = grid.get_cell(x, y) {
cells.push(SerializableCell::from(cell));
}
}
all_lines.push(SerializableTerminalLine { cells });
}
// Limit to MAX_LINES_PER_TERMINAL (keep most recent lines)
if all_lines.len() > MAX_LINES_PER_TERMINAL {
let skip = all_lines.len() - MAX_LINES_PER_TERMINAL;
all_lines = all_lines.into_iter().skip(skip).collect();
}
// Get cursor position
let cursor = SerializableCursor::from(&grid.cursor);
(all_lines, cursor)
}
/// Restore terminal content from saved session data
/// This is called after creating a new terminal to restore previous session content
pub fn restore_terminal_content(
&mut self,
lines: Vec<SerializableTerminalLine>,
cursor: &SerializableCursor,
) {
// Convert SerializableTerminalLine back to Vec<TerminalCell>
let terminal_lines: Vec<Vec<super::term_grid::TerminalCell>> = lines
.iter()
.map(|line| {
line.cells
.iter()
.map(super::term_grid::TerminalCell::from)
.collect()
})
.collect();
// Restore content to grid
let mut grid = self.grid.lock().expect("terminal grid mutex poisoned");
grid.restore_content(terminal_lines);
grid.set_cursor(cursor.x, cursor.y, cursor.visible);
}
/// Get the name of the foreground process running in the terminal (macOS)
/// Returns the process name (e.g., "zsh", "vim", "cargo")
#[cfg(target_os = "macos")]
pub fn get_foreground_process_name(&self) -> Option<String> {
// Get the child process PID
let child_pid = self.child.process_id()?;
// Use ps to find the foreground process in the process group
// First, get the process group ID of the child
let output = Command::new("ps")
.args(["-o", "tpgid=", "-p", &child_pid.to_string()])
.output()
.ok()?;
let tpgid = String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u32>()
.ok()?;
// Now get the process name for the foreground process group
let output = Command::new("ps")
.args(["-o", "comm=", "-p", &tpgid.to_string()])
.output()
.ok()?;
let process_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if process_name.is_empty() {
// Fall back to child process name
let output = Command::new("ps")
.args(["-o", "comm=", "-p", &child_pid.to_string()])
.output()
.ok()?;
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() {
None
} else {
// Extract just the binary name from path
Some(name.rsplit('/').next().unwrap_or(&name).to_string())
}
} else {
// Extract just the binary name from path
Some(
process_name
.rsplit('/')
.next()
.unwrap_or(&process_name)
.to_string(),
)
}
}
/// Get the name of the foreground process running in the terminal (Linux)
#[cfg(target_os = "linux")]
pub fn get_foreground_process_name(&self) -> Option<String> {
use std::fs;
let child_pid = self.child.process_id()?;
// Read the stat file to get the foreground process group
let stat_path = format!("/proc/{}/stat", child_pid);
let stat_content = fs::read_to_string(&stat_path).ok()?;
// Parse the stat file to get tpgid (field 8, 1-indexed)
// The stat format is: pid (comm) state ppid pgrp session tty_nr tpgid ...
// We need to handle comm containing spaces/parentheses
let comm_end = stat_content.rfind(')')?;
let after_comm = &stat_content[comm_end + 2..]; // Skip ") "
let parts: Vec<&str> = after_comm.split_whitespace().collect();
// After comm: state(0) ppid(1) pgrp(2) session(3) tty_nr(4) tpgid(5)
if parts.len() < 6 {
return None;
}
let tpgid: u32 = parts[5].parse().ok()?;
// Get the process name from /proc/[tpgid]/comm
let comm_path = format!("/proc/{}/comm", tpgid);
let name = fs::read_to_string(&comm_path)
.ok()
.or_else(|| {
// Fall back to child process
fs::read_to_string(format!("/proc/{}/comm", child_pid)).ok()
})?
.trim()
.to_string();
if name.is_empty() { None } else { Some(name) }
}
/// Get the name of the foreground process running in the terminal (Windows)
#[cfg(target_os = "windows")]
pub fn get_foreground_process_name(&self) -> Option<String> {
// Get the child process PID
let child_pid = self.child.process_id()?;
// Use wmic to get the process name
// Note: On Windows, we can't easily get the "foreground" process like on Unix
// So we just return the shell process name (usually cmd.exe or powershell.exe)
let output = Command::new("wmic")
.args([
"process",
"where",
&format!("ProcessId={}", child_pid),
"get",
"Name",
"/value",
])
.output()
.ok()?;
let output_str = String::from_utf8_lossy(&output.stdout);
// Parse "Name=process.exe" format
for line in output_str.lines() {
if let Some(name) = line.strip_prefix("Name=") {
let name = name.trim();
if !name.is_empty() {
// Remove .exe extension for cleaner display
let name = name.strip_suffix(".exe").unwrap_or(name);
return Some(name.to_string());
}
}
}
None
}
/// Get the name of the foreground process running in the terminal (FreeBSD)
///
/// FreeBSD supports procfs but it's not mounted by default.
/// Falls back to using the `ps` command similar to macOS.
#[cfg(target_os = "freebsd")]
pub fn get_foreground_process_name(&self) -> Option<String> {
let child_pid = self.child.process_id()?;
// First try procfs if mounted (may not be available)
if let Ok(status) = std::fs::read_to_string(format!("/proc/{}/status", child_pid)) {
// FreeBSD procfs status file has different format than Linux
// First line is the process name
if let Some(first_line) = status.lines().next() {
let name = first_line.split_whitespace().next()?.to_string();
if !name.is_empty() {
return Some(name);
}
}
}
// Fallback to ps command (similar to macOS)
let output = Command::new("ps")
.args(["-o", "tpgid=", "-p", &child_pid.to_string()])
.output()
.ok()?;
let tpgid = String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u32>()
.ok()?;
let output = Command::new("ps")
.args(["-o", "comm=", "-p", &tpgid.to_string()])
.output()
.ok()?;
let process_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if process_name.is_empty() {
// Fall back to child process name
let output = Command::new("ps")
.args(["-o", "comm=", "-p", &child_pid.to_string()])
.output()
.ok()?;
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() {
None
} else {
Some(name.rsplit('/').next().unwrap_or(&name).to_string())
}
} else {
Some(
process_name
.rsplit('/')
.next()
.unwrap_or(&process_name)
.to_string(),
)
}
}
/// Get the name of the foreground process running in the terminal (NetBSD)
///
/// NetBSD has procfs similar to FreeBSD. Falls back to `ps` command.
#[cfg(target_os = "netbsd")]
pub fn get_foreground_process_name(&self) -> Option<String> {
let child_pid = self.child.process_id()?;
// Try procfs first
if let Ok(status) = std::fs::read_to_string(format!("/proc/{}/status", child_pid)) {
if let Some(first_line) = status.lines().next() {
let name = first_line.split_whitespace().next()?.to_string();
if !name.is_empty() {
return Some(name);
}
}
}
// Fallback to ps command
let output = Command::new("ps")
.args(["-o", "tpgid=", "-p", &child_pid.to_string()])
.output()
.ok()?;
let tpgid = String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u32>()
.ok()?;
let output = Command::new("ps")
.args(["-o", "comm=", "-p", &tpgid.to_string()])
.output()
.ok()?;
let process_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if process_name.is_empty() {
let output = Command::new("ps")
.args(["-o", "comm=", "-p", &child_pid.to_string()])
.output()
.ok()?;
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() {
None
} else {
Some(name.rsplit('/').next().unwrap_or(&name).to_string())
}
} else {
Some(
process_name
.rsplit('/')
.next()
.unwrap_or(&process_name)
.to_string(),
)
}
}
/// Get the name of the foreground process running in the terminal (OpenBSD)
///
/// OpenBSD does not have procfs. Uses `ps` command exclusively.
#[cfg(target_os = "openbsd")]
pub fn get_foreground_process_name(&self) -> Option<String> {
let child_pid = self.child.process_id()?;
// Use ps command (no procfs on OpenBSD)
let output = Command::new("ps")
.args(["-o", "tpgid=", "-p", &child_pid.to_string()])
.output()
.ok()?;
let tpgid = String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u32>()
.ok()?;
let output = Command::new("ps")
.args(["-o", "comm=", "-p", &tpgid.to_string()])
.output()
.ok()?;
let process_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if process_name.is_empty() {
let output = Command::new("ps")
.args(["-o", "comm=", "-p", &child_pid.to_string()])
.output()
.ok()?;
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() {
None
} else {
Some(name.rsplit('/').next().unwrap_or(&name).to_string())
}
} else {
Some(
process_name
.rsplit('/')
.next()
.unwrap_or(&process_name)
.to_string(),
)
}
}
/// Get the name of the foreground process (fallback for other platforms)
#[cfg(not(any(
target_os = "macos",
target_os = "linux",
target_os = "windows",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
)))]
pub fn get_foreground_process_name(&self) -> Option<String> {
None
}
}