use std::io::{self, Write};
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::{Duration, Instant};
use std::net::TcpListener;
use std::io::Read as _;
use std::io::BufRead as _;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use portable_pty::{CommandBuilder, MasterPty, PtySize, PtySystemSelection};
use ratatui::{prelude::*, widgets::*};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use crossterm::terminal::{enable_raw_mode, disable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{execute};
use crossterm::cursor::{EnableBlinking, DisableBlinking};
use crossterm::event::EnableMouseCapture;
use crossterm::event::DisableMouseCapture;
use ratatui::style::{Style, Modifier};
use unicode_width::UnicodeWidthStr;
use chrono::Local;
use std::env;
use crossterm::style::Print;
use serde::{Serialize, Deserialize};
struct Pane {
master: Box<dyn MasterPty>,
child: Box<dyn portable_pty::Child>,
term: Arc<Mutex<vt100::Parser>>,
last_rows: u16,
last_cols: u16,
id: usize,
title: String,
}
#[derive(Clone, Copy)]
enum LayoutKind { Horizontal, Vertical }
enum Node {
Leaf(Pane),
Split { kind: LayoutKind, sizes: Vec<u16>, children: Vec<Node> },
}
struct Window {
root: Node,
active_path: Vec<usize>,
name: String,
id: usize,
}
enum Mode {
Passthrough,
Prefix { armed_at: Instant },
CommandPrompt { input: String },
WindowChooser { selected: usize },
RenamePrompt { input: String },
CopyMode,
PaneChooser { opened_at: Instant },
}
#[derive(Clone, Copy)]
enum FocusDir { Left, Right, Up, Down }
struct AppState {
windows: Vec<Window>,
active_idx: usize,
mode: Mode,
escape_time_ms: u64,
prefix_key: (KeyCode, KeyModifiers),
drag: Option<DragState>,
last_window_area: Rect,
mouse_enabled: bool,
paste_buffers: Vec<String>,
status_left: String,
status_right: String,
copy_anchor: Option<(u16,u16)>,
copy_pos: Option<(u16,u16)>,
display_map: Vec<(usize, Vec<usize>)>,
binds: Vec<Bind>,
control_rx: Option<mpsc::Receiver<CtrlReq>>,
control_port: Option<u16>,
session_name: String,
attached_clients: usize,
created_at: chrono::DateTime<Local>,
next_win_id: usize,
next_pane_id: usize,
zoom_saved: Option<Vec<(Vec<usize>, Vec<u16>)>>,
sync_input: bool,
}
struct DragState {
split_path: Vec<usize>,
kind: LayoutKind,
index: usize,
start_x: u16,
start_y: u16,
left_initial: u16,
_right_initial: u16,
}
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn get_program_name() -> String {
std::env::current_exe()
.ok()
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
.unwrap_or_else(|| "psmux".to_string())
.to_lowercase()
.replace(".exe", "")
}
fn print_help() {
let prog = get_program_name();
println!(r#"{prog} - Terminal multiplexer for Windows (tmux alternative)
USAGE:
{prog} [COMMAND] [OPTIONS]
COMMANDS:
(no command) Start a new session or attach to existing one
new-session Create a new session
-s <name> Session name (default: "default")
-d Start detached (in background)
attach, attach-session
Attach to an existing session
-t <name> Target session name
ls, list-sessions List all active sessions
new-window Create a new window in current session
split-window Split current pane
-h Split horizontally (side by side)
-v Split vertically (top/bottom, default)
kill-pane Close the current pane
capture-pane Capture the content of current pane
server Run as a server (internal use)
help Show this help message
version Show version information
OPTIONS:
-h, --help Show this help message
-V, --version Show version information
KEY BINDINGS (default prefix: Ctrl+B):
prefix + c Create new window
prefix + n Next window
prefix + p Previous window
prefix + " Split pane horizontally
prefix + % Split pane vertically
prefix + o Switch to next pane
prefix + x Kill current pane
prefix + d Detach from session
prefix + [ Enter copy mode
prefix + : Enter command mode
prefix + , Rename current window
prefix + w Window chooser
prefix + q Display pane numbers
ENVIRONMENT VARIABLES:
PSMUX_SESSION_NAME Default session name
PSMUX_DEFAULT_SESSION Fallback default session name
PSMUX_CURSOR_STYLE Cursor style (block, underline, bar)
PSMUX_CURSOR_BLINK Cursor blinking (true/false)
CONFIG FILES:
%USERPROFILE%\.psmux.conf Main configuration file
%USERPROFILE%\.psmuxrc Alternative configuration file
EXAMPLES:
{prog} Start or attach to default session
{prog} new-session -s work Create a new session named "work"
{prog} attach -t work Attach to session "work"
{prog} ls List all sessions
{prog} split-window -h Split current pane horizontally
NOTE: psmux includes 'tmux' and 'pmux' aliases - use any command you prefer!
For more information: https://github.com/marlocarlo/psmux
"#, prog = prog);
}
fn print_version() {
let prog = get_program_name();
println!("{} {}", prog, VERSION);
}
fn main() -> io::Result<()> {
let args: Vec<String> = env::args().collect();
// Handle help and version flags first
if args.len() > 1 {
match args[1].as_str() {
"-h" | "--help" | "help" => {
print_help();
return Ok(());
}
"-V" | "--version" | "version" => {
print_version();
return Ok(());
}
_ => {}
}
}
if args.len() > 1 {
match args[1].as_str() {
"ls" | "list-sessions" => {
let home = env::var("USERPROFILE").or_else(|_| env::var("HOME")).unwrap_or_default();
let dir = format!("{}\\.psmux", home);
if let Ok(entries) = std::fs::read_dir(&dir) {
for e in entries.flatten() {
if let Some(name) = e.file_name().to_str() {
if let Some((base, ext)) = name.rsplit_once('.') {
if ext == "port" {
if let Ok(port_str) = std::fs::read_to_string(e.path()) {
if let Ok(_p) = port_str.trim().parse::<u16>() {
let addr = format!("127.0.0.1:{}", port_str.trim());
if let Ok(mut s) = std::net::TcpStream::connect(addr) {
let _ = std::io::Write::write_all(&mut s, b"session-info\n");
let mut br = std::io::BufReader::new(s);
let mut line = String::new();
let _ = br.read_line(&mut line);
if !line.trim().is_empty() { println!("{}", line.trim_end()); } else { println!("{}", base); }
} else { /* stale: skip */ }
}
}
}
}
}
}
}
return Ok(());
}
"attach" | "attach-session" => {
let name = args
.iter()
.position(|a| a == "-t")
.and_then(|i| args.get(i + 1))
.map(|s| s.clone())
.or_else(resolve_default_session_name)
.or_else(resolve_last_session_name)
.unwrap_or_else(|| "default".to_string());
env::set_var("PSMUX_SESSION_NAME", name);
env::set_var("PSMUX_REMOTE_ATTACH", "1");
}
"server" | "new-session" => {
let name = args.iter().position(|a| a == "-s").and_then(|i| args.get(i+1)).map(|s| s.clone()).unwrap_or_else(|| "default".to_string());
let detached = args.iter().any(|a| a == "-d");
if detached {
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("psmux"));
let mut cmd = std::process::Command::new(exe);
cmd.arg("server").arg("-s").arg(&name);
let _child = cmd.spawn().map_err(|e| io::Error::new(io::ErrorKind::Other, format!("failed to spawn server: {e}")))?;
return Ok(());
} else {
return run_server(name);
}
}
"new-window" => { send_control("new-window\n".to_string())?; return Ok(()); }
"split-window" => {
let flag = if args.iter().any(|a| a == "-h") { "-h" } else { "-v" };
send_control(format!("split-window {}\n", flag))?; return Ok(());
}
"kill-pane" => { send_control("kill-pane\n".to_string())?; return Ok(()); }
"capture-pane" => {
let resp = send_control_with_response("capture-pane\n".to_string())?; print!("{}", resp); return Ok(());
}
_ => {}
}
}
if env::var("PSMUX_ACTIVE").ok().as_deref() == Some("1") {
eprintln!("psmux: nested sessions are not allowed");
return Ok(());
}
env::set_var("PSMUX_ACTIVE", "1");
let mut stdout = io::stdout();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen, EnableBlinking, EnableMouseCapture)?;
apply_cursor_style(&mut stdout)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = if env::var("PSMUX_REMOTE_ATTACH").ok().as_deref() == Some("1") { run_remote(&mut terminal) } else { run(&mut terminal) };
disable_raw_mode()?;
execute!(terminal.backend_mut(), DisableBlinking, DisableMouseCapture, LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn run_remote(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
let name = env::var("PSMUX_SESSION_NAME").unwrap_or_else(|_| "default".to_string());
let home = env::var("USERPROFILE").or_else(|_| env::var("HOME")).unwrap_or_default();
let path = format!("{}\\.psmux\\{}.port", home, name);
let port = std::fs::read_to_string(&path).ok().and_then(|s| s.trim().parse::<u16>().ok()).ok_or_else(|| io::Error::new(io::ErrorKind::Other, "no session"))?;
let addr = format!("127.0.0.1:{}", port);
let mut stream = std::net::TcpStream::connect(addr.clone())?;
let _ = std::io::Write::write_all(&mut stream, b"client-attach\n");
let last_path = format!("{}\\.psmux\\last_session", home);
let _ = std::fs::write(&last_path, &name);
let mut quit = false;
let mut prefix_armed = false;
let mut renaming = false;
let mut rename_buf = String::new();
let mut pane_renaming = false;
let mut pane_title_buf = String::new();
let mut chooser = false;
let mut choices: Vec<(usize, usize)> = Vec::new();
let mut tree_chooser = false;
let mut tree_entries: Vec<(bool, usize, usize, String)> = Vec::new();
let mut tree_selected: usize = 0;
loop {
terminal.draw(|f| {
let area = f.size();
let chunks = Layout::default().direction(Direction::Vertical).constraints([Constraint::Min(1), Constraint::Length(1)].as_ref()).split(area);
// update server with client size
let mut cs = std::net::TcpStream::connect(addr.clone()).unwrap();
let _ = std::io::Write::write_all(&mut cs, format!("client-size {} {}\n", chunks[0].width, chunks[0].height).as_bytes());
// fetch layout json
let mut s = std::net::TcpStream::connect(addr.clone()).unwrap();
let _ = std::io::Write::write_all(&mut s, b"dump-layout\n");
let mut buf = String::new();
let _ = std::io::Read::read_to_string(&mut s, &mut buf);
let root: LayoutJson = serde_json::from_str(&buf).unwrap_or(LayoutJson::Leaf { id: 0, rows: 0, cols: 0, cursor_row: 0, cursor_col: 0, content: Vec::new() });
fn render_json(f: &mut Frame, node: &LayoutJson, area: Rect) {
match node {
LayoutJson::Leaf { id: _, rows: _, cols: _, cursor_row, cursor_col, content } => {
let pane_block = Block::default().borders(Borders::ALL);
let inner = pane_block.inner(area);
let mut lines: Vec<Line> = Vec::new();
for r in 0..inner.height.min(content.len() as u16) {
let mut spans: Vec<Span> = Vec::new();
let row = &content[r as usize];
for c in 0..inner.width.min(row.len() as u16) {
let cell = &row[c as usize];
let mut fg = map_color(&cell.fg);
let mut bg = map_color(&cell.bg);
if cell.inverse { std::mem::swap(&mut fg, &mut bg); }
let mut style = Style::default().fg(fg).bg(bg);
if cell.bold { style = style.add_modifier(Modifier::BOLD); }
if cell.italic { style = style.add_modifier(Modifier::ITALIC); }
if cell.underline { style = style.add_modifier(Modifier::UNDERLINED); }
spans.push(Span::styled(cell.text.clone(), style));
}
lines.push(Line::from(spans));
}
f.render_widget(pane_block, area);
f.render_widget(Clear, inner);
let para = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
f.render_widget(para, inner);
let cy = inner.y + (*cursor_row).min(inner.height.saturating_sub(1));
let cx = inner.x + (*cursor_col).min(inner.width.saturating_sub(1));
f.set_cursor(cx, cy);
}
LayoutJson::Split { kind, sizes, children } => {
let constraints: Vec<Constraint> = if sizes.len() == children.len() { sizes.iter().map(|p| Constraint::Percentage(*p)).collect() } else { vec![Constraint::Percentage((100 / children.len() as u16) as u16); children.len()] };
let rects = if kind == "Horizontal" { Layout::default().direction(Direction::Horizontal).constraints(constraints).split(area) } else { Layout::default().direction(Direction::Vertical).constraints(constraints).split(area) };
for (i, child) in children.iter().enumerate() { render_json(f, child, rects[i]); }
}
}
}
render_json(f, &root, chunks[0]);
if tree_chooser {
let overlay = Block::default().borders(Borders::ALL).title("choose-tree");
let oa = centered_rect(60, 30, chunks[0]);
f.render_widget(Clear, oa);
f.render_widget(&overlay, oa);
let mut lines: Vec<Line> = Vec::new();
for (i, (is_win, wid, pid, name)) in tree_entries.iter().enumerate() {
let marker = if *is_win { format!("@{}", wid) } else { format!("%{}", pid) };
let prefix = if *is_win { "".to_string() } else { " ".to_string() };
let line = if i == tree_selected { Line::from(Span::styled(format!("{}{} {}", prefix, marker, name), Style::default().bg(Color::Yellow).fg(Color::Black))) } else { Line::from(format!("{}{} {}", prefix, marker, name)) };
lines.push(line);
}
let para = Paragraph::new(Text::from(lines));
f.render_widget(para, overlay.inner(oa));
}
if chooser {
let mut rects: Vec<(usize, Rect)> = Vec::new();
fn rec(node: &LayoutJson, area: Rect, out: &mut Vec<(usize, Rect)>) {
match node {
LayoutJson::Leaf { id, .. } => { out.push((*id, area)); }
LayoutJson::Split { kind, sizes, children } => {
let constraints: Vec<Constraint> = if sizes.len() == children.len() { sizes.iter().map(|p| Constraint::Percentage(*p)).collect() } else { vec![Constraint::Percentage((100 / children.len() as u16) as u16); children.len()] };
let rects = if kind == "Horizontal" { Layout::default().direction(Direction::Horizontal).constraints(constraints).split(area) } else { Layout::default().direction(Direction::Vertical).constraints(constraints).split(area) };
for (i, child) in children.iter().enumerate() { rec(child, rects[i], out); }
}
}
}
rec(&root, chunks[0], &mut rects);
choices.clear();
for (i,(pid,r)) in rects.iter().enumerate() { if i<10 { choices.push((i+1,*pid)); let bw=7u16; let bh=3u16; let bx=r.x + r.width.saturating_sub(bw)/2; let by=r.y + r.height.saturating_sub(bh)/2; let b=Rect{ x:bx, y:by, width:bw, height:bh }; let block=Block::default().borders(Borders::ALL).style(Style::default().bg(Color::Yellow).fg(Color::Black)); let inner=block.inner(b); let disp=if i+1==10 {0} else {i+1}; let line=Line::from(Span::styled(format!(" {} ",disp), Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD))); let para=Paragraph::new(line).alignment(Alignment::Center); f.render_widget(Clear,b); f.render_widget(block,b); f.render_widget(para,inner);} }
}
let status_bar = Paragraph::new(Line::from(vec![Span::raw(format!("session:{}", name))])).style(Style::default().bg(Color::Green).fg(Color::Black));
f.render_widget(Clear, chunks[1]);
f.render_widget(status_bar, chunks[1]);
if pane_renaming {
let overlay = Block::default().borders(Borders::ALL).title("set pane title");
let oa = centered_rect(60, 3, chunks[0]);
f.render_widget(Clear, oa);
f.render_widget(&overlay, oa);
let para = Paragraph::new(format!("title: {}", pane_title_buf));
f.render_widget(para, overlay.inner(oa));
}
})?;
if event::poll(Duration::from_millis(50))? {
match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => {
if matches!(key.code, KeyCode::Char('q')) && key.modifiers.contains(KeyModifiers::CONTROL) { quit = true; }
else if matches!(key.code, KeyCode::Char('b')) && key.modifiers.contains(KeyModifiers::CONTROL) { prefix_armed = true; }
else if prefix_armed {
// tmux-like prefix mappings
match key.code {
KeyCode::Char('c') => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"new-window\n"); }
KeyCode::Char('%') => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"split-window -h\n"); }
KeyCode::Char('"') => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"split-window -v\n"); }
KeyCode::Char('x') => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"kill-pane\n"); }
KeyCode::Char('z') => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"zoom-pane\n"); }
KeyCode::Char('[') => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"copy-enter\n"); }
KeyCode::Char('n') => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"next-window\n"); }
KeyCode::Char('p') => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"previous-window\n"); }
KeyCode::Char(',') => { renaming = true; rename_buf.clear(); }
KeyCode::Char('t') => { pane_renaming = true; pane_title_buf.clear(); }
KeyCode::Char('w') => { tree_chooser = true; tree_entries.clear(); tree_selected = 0; let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"list-tree\n"); let mut buf = String::new(); let _ = std::io::Read::read_to_string(&mut s, &mut buf); let infos: Vec<WinTree> = serde_json::from_str(&buf).unwrap_or(Vec::new()); for wi in infos.into_iter() { tree_entries.push((true, wi.id, 0, wi.name)); for pi in wi.panes.into_iter() { tree_entries.push((false, wi.id, pi.id, pi.title)); } } }
KeyCode::Char('s') => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"toggle-sync\n"); }
KeyCode::Char('q') => { chooser = true; }
KeyCode::Left => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"copy-move -1 0\n"); }
KeyCode::Right => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"copy-move 1 0\n"); }
KeyCode::Up => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"copy-move 0 -1\n"); }
KeyCode::Down => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"copy-move 0 1\n"); }
KeyCode::Char('v') => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"copy-anchor\n"); }
KeyCode::Char('y') => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"copy-yank\n"); }
_ => {}
}
prefix_armed = false;
} else {
match key.code {
KeyCode::Up if tree_chooser => { if tree_selected>0 { tree_selected-=1; } }
KeyCode::Down if tree_chooser => { if tree_selected+1 < tree_entries.len() { tree_selected+=1; } }
KeyCode::Enter if tree_chooser => { if let Some((is_win, wid, pid, _)) = tree_entries.get(tree_selected) { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); if *is_win { let _ = std::io::Write::write_all(&mut s, format!("focus-window {}\n", wid).as_bytes()); } else { let _ = std::io::Write::write_all(&mut s, format!("focus-pane {}\n", pid).as_bytes()); } tree_chooser=false; } }
KeyCode::Esc if tree_chooser => { tree_chooser=false; }
KeyCode::Char(c) if renaming && !key.modifiers.contains(KeyModifiers::CONTROL) => { rename_buf.push(c); }
KeyCode::Char(c) if pane_renaming && !key.modifiers.contains(KeyModifiers::CONTROL) => { pane_title_buf.push(c); }
KeyCode::Backspace if renaming => { let _ = rename_buf.pop(); }
KeyCode::Backspace if pane_renaming => { let _ = pane_title_buf.pop(); }
KeyCode::Enter if renaming => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, format!("rename-window {}\n", rename_buf).as_bytes()); renaming=false; }
KeyCode::Enter if pane_renaming => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, format!("set-pane-title {}\n", pane_title_buf).as_bytes()); pane_renaming=false; }
KeyCode::Esc if renaming => { renaming=false; }
KeyCode::Esc if pane_renaming => { pane_renaming=false; }
KeyCode::Char(d) if chooser && d.is_ascii_digit() => {
let raw = d.to_digit(10).unwrap() as usize;
let choice = if raw==0 {10} else {raw};
if let Some((_,pid)) = choices.iter().find(|(n,_)| *n==choice) { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, format!("focus-pane {}\n", pid).as_bytes()); chooser=false; }
}
KeyCode::Esc if chooser => { chooser=false; }
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
let mut s = std::net::TcpStream::connect(addr.clone()).unwrap();
let _ = std::io::Write::write_all(&mut s, format!("send-text {}\n", c).as_bytes());
}
KeyCode::Enter => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"send-key enter\n"); }
KeyCode::Tab => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"send-key tab\n"); }
KeyCode::Backspace => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"send-key backspace\n"); }
KeyCode::Esc => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"send-key esc\n"); }
KeyCode::Left => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"send-key left\n"); }
KeyCode::Right => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"send-key right\n"); }
KeyCode::Up => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"send-key up\n"); }
KeyCode::Down => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"send-key down\n"); }
_ => {}
}
}
} Event::Mouse(me) => {
use crossterm::event::{MouseEventKind,MouseButton};
match me.kind {
MouseEventKind::Down(MouseButton::Left) => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, format!("mouse-down {} {}\n", me.column, me.row).as_bytes()); }
MouseEventKind::Drag(MouseButton::Left) => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, format!("mouse-drag {} {}\n", me.column, me.row).as_bytes()); }
MouseEventKind::Up(MouseButton::Left) => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, format!("mouse-up {} {}\n", me.column, me.row).as_bytes()); }
MouseEventKind::ScrollUp => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"scroll-up\n"); }
MouseEventKind::ScrollDown => { let mut s = std::net::TcpStream::connect(addr.clone()).unwrap(); let _ = std::io::Write::write_all(&mut s, b"scroll-down\n"); }
_ => {}
}
} _ => {} }
}
if reap_children_placeholder()? { /* no-op */ }
if quit { break; }
}
let _ = std::net::TcpStream::connect(addr).and_then(|mut s| { std::io::Write::write_all(&mut s, b"client-detach\n"); Ok(()) });
Ok(())
}
fn reap_children_placeholder() -> io::Result<bool> { Ok(false) }
fn send_control(line: String) -> io::Result<()> {
let home = env::var("USERPROFILE").or_else(|_| env::var("HOME")).unwrap_or_default();
let target = env::var("PSMUX_TARGET_SESSION").ok().unwrap_or_else(|| "default".to_string());
let path = format!("{}\\.psmux\\{}.port", home, target);
let port = std::fs::read_to_string(&path).ok().and_then(|s| s.trim().parse::<u16>().ok()).ok_or_else(|| io::Error::new(io::ErrorKind::Other, "no session"))?;
let addr = format!("127.0.0.1:{}", port);
let mut stream = std::net::TcpStream::connect(addr)?;
let _ = write!(stream, "{}", line);
Ok(())
}
fn send_control_with_response(line: String) -> io::Result<String> {
let home = env::var("USERPROFILE").or_else(|_| env::var("HOME")).unwrap_or_default();
let target = env::var("PSMUX_TARGET_SESSION").ok().unwrap_or_else(|| "default".to_string());
let path = format!("{}\\.psmux\\{}.port", home, target);
let port = std::fs::read_to_string(&path).ok().and_then(|s| s.trim().parse::<u16>().ok()).ok_or_else(|| io::Error::new(io::ErrorKind::Other, "no session"))?;
let addr = format!("127.0.0.1:{}", port);
let mut stream = std::net::TcpStream::connect(addr)?;
let _ = write!(stream, "{}", line);
let mut buf = String::new();
let _ = std::io::Read::read_to_string(&mut stream, &mut buf);
Ok(buf)
}
fn run(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
let pty_system = PtySystemSelection::default()
.get()
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("pty system error: {e}")))?;
let mut app = AppState {
windows: Vec::new(),
active_idx: 0,
mode: Mode::Passthrough,
escape_time_ms: 500,
prefix_key: (KeyCode::Char('b'), KeyModifiers::CONTROL),
drag: None,
last_window_area: Rect { x: 0, y: 0, width: 0, height: 0 },
mouse_enabled: true,
paste_buffers: Vec::new(),
status_left: "psmux:#I".to_string(),
status_right: "%H:%M".to_string(),
copy_anchor: None,
copy_pos: None,
display_map: Vec::new(),
binds: Vec::new(),
control_rx: None,
control_port: None,
session_name: env::var("PSMUX_SESSION_NAME").unwrap_or_else(|_| "default".to_string()),
attached_clients: 1,
created_at: Local::now(),
next_win_id: 1,
next_pane_id: 1,
zoom_saved: None,
sync_input: false,
};
load_config(&mut app);
create_window(&*pty_system, &mut app)?;
let (tx, rx) = mpsc::channel::<CtrlReq>();
app.control_rx = Some(rx);
let listener = TcpListener::bind(("127.0.0.1", 0))?;
let port = listener.local_addr()?.port();
app.control_port = Some(port);
let home = env::var("USERPROFILE").or_else(|_| env::var("HOME")).unwrap_or_default();
let dir = format!("{}\\.psmux", home);
let _ = std::fs::create_dir_all(&dir);
let regpath = format!("{}\\{}.port", dir, app.session_name);
let _ = std::fs::write(®path, port.to_string());
thread::spawn(move || {
for conn in listener.incoming() {
if let Ok(mut stream) = conn {
let mut line = String::new();
let mut r = io::BufReader::new(stream.try_clone().unwrap());
let _ = r.read_line(&mut line);
let mut parts = line.split_whitespace();
let cmd = parts.next().unwrap_or("");
// parse optional target specifier
let mut args: Vec<&str> = parts.by_ref().collect();
let mut target_win: Option<usize> = None;
let mut target_pane: Option<usize> = None;
let mut start_line: Option<u16> = None;
let mut end_line: Option<u16> = None;
let mut i = 0;
while i < args.len() {
if args[i] == "-t" {
if let Some(v) = args.get(i+1) {
if v.starts_with('%') { if let Ok(pid) = v[1..].parse::<usize>() { target_pane = Some(pid); } }
else if v.starts_with('@') { if let Ok(wid) = v[1..].parse::<usize>() { target_win = Some(wid); } }
}
i += 2; continue;
} else if args[i] == "-S" {
if let Some(v) = args.get(i+1) { if let Ok(n) = v.parse::<u16>() { start_line = Some(n); } }
i += 2; continue;
} else if args[i] == "-E" {
if let Some(v) = args.get(i+1) { if let Ok(n) = v.parse::<u16>() { end_line = Some(n); } }
i += 2; continue;
}
i += 1;
}
if let Some(wid) = target_win { let _ = tx.send(CtrlReq::FocusWindow(wid)); }
if let Some(pid) = target_pane { let _ = tx.send(CtrlReq::FocusPane(pid)); }
match cmd {
"new-window" => { let _ = tx.send(CtrlReq::NewWindow); }
"split-window" => {
let kind = if args.iter().any(|a| *a == "-h") { LayoutKind::Horizontal } else { LayoutKind::Vertical };
let _ = tx.send(CtrlReq::SplitWindow(kind));
}
"kill-pane" => { let _ = tx.send(CtrlReq::KillPane); }
"capture-pane" => {
let (rtx, rrx) = mpsc::channel::<String>();
if start_line.is_some() || end_line.is_some() { let _ = tx.send(CtrlReq::CapturePaneRange(rtx, start_line, end_line)); }
else { let _ = tx.send(CtrlReq::CapturePane(rtx)); }
if let Ok(text) = rrx.recv() { let _ = write!(stream, "{}", text); }
}
"client-attach" => { let _ = tx.send(CtrlReq::ClientAttach); let _ = write!(stream, "ok\n"); }
"client-detach" => { let _ = tx.send(CtrlReq::ClientDetach); let _ = write!(stream, "ok\n"); }
"session-info" => {
let (rtx, rrx) = mpsc::channel::<String>();
let _ = tx.send(CtrlReq::SessionInfo(rtx));
if let Ok(line) = rrx.recv() { let _ = write!(stream, "{}", line); let _ = stream.flush(); }
}
_ => {}
}
}
}
});
let mut last_resize = Instant::now();
let mut quit = false;
loop {
terminal.draw(|f| {
let area = f.size();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)].as_ref())
.split(area);
app.last_window_area = chunks[0];
render_window(f, &mut app, chunks[0]);
let _mode_str = match app.mode { Mode::Passthrough => "", Mode::Prefix { .. } => "PREFIX", Mode::CommandPrompt { .. } => ":", Mode::WindowChooser { .. } => "W", Mode::RenamePrompt { .. } => "REN", Mode::CopyMode => "CPY", Mode::PaneChooser { .. } => "PANE" };
let time_str = Local::now().format("%H:%M").to_string();
let mut windows_list = String::new();
for (i, _) in app.windows.iter().enumerate() {
if i == app.active_idx { windows_list.push_str(&format!(" #[{}]", i+1)); } else { windows_list.push_str(&format!(" {}", i+1)); }
}
let status_spans = parse_status(&app.status_left, &app, &time_str);
let mut right_spans = parse_status(&app.status_right, &app, &time_str);
let mut combined: Vec<Span<'static>> = status_spans;
combined.push(Span::raw(" "));
combined.append(&mut right_spans);
let status_bar = Paragraph::new(Line::from(combined)).style(Style::default().bg(Color::Green).fg(Color::Black));
f.render_widget(Clear, chunks[1]);
f.render_widget(status_bar, chunks[1]);
if let Mode::CommandPrompt { input } = &app.mode {
let overlay = Paragraph::new(format!(":{}", input)).block(Block::default().borders(Borders::ALL).title("command"));
let oa = centered_rect(80, 3, area);
f.render_widget(Clear, oa);
f.render_widget(overlay, oa);
}
if let Mode::WindowChooser { selected } = app.mode {
let mut lines: Vec<Line> = Vec::new();
for (i,w) in app.windows.iter().enumerate() {
let marker = if i == selected { ">" } else { " " };
lines.push(Line::from(format!("{} [{}] {}", marker, i+1, w.name)));
}
let overlay = Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title("windows"));
let oa = centered_rect(60, (app.windows.len() as u16 + 2).min(10), area);
f.render_widget(Clear, oa);
f.render_widget(overlay, oa);
}
if let Mode::RenamePrompt { input } = &app.mode {
let overlay = Paragraph::new(format!("rename: {}", input)).block(Block::default().borders(Borders::ALL).title("rename window"));
let oa = centered_rect(60, 3, area);
f.render_widget(Clear, oa);
f.render_widget(overlay, oa);
}
if let Mode::PaneChooser { .. } = &app.mode {
let win = &app.windows[app.active_idx];
let mut rects: Vec<(Vec<usize>, Rect)> = Vec::new();
compute_rects(&win.root, app.last_window_area, &mut rects);
for (i, (_, r)) in rects.iter().enumerate() {
let n = i + 1;
if n > 9 { break; }
let bw = 7u16;
let bh = 3u16;
let bx = r.x + r.width.saturating_sub(bw) / 2;
let by = r.y + r.height.saturating_sub(bh) / 2;
let b = Rect { x: bx, y: by, width: bw, height: bh };
let block = Block::default().borders(Borders::ALL).style(Style::default().bg(Color::Yellow).fg(Color::Black));
let inner = block.inner(b);
let disp = if n == 10 { 0 } else { n };
let line = Line::from(Span::styled(format!(" {} ", disp), Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD)));
let para = Paragraph::new(line).alignment(Alignment::Center);
f.render_widget(Clear, b);
f.render_widget(block, b);
f.render_widget(para, inner);
}
}
})?;
if let Mode::PaneChooser { opened_at } = &app.mode {
if opened_at.elapsed() > Duration::from_millis(1500) { app.mode = Mode::Passthrough; }
}
if event::poll(Duration::from_millis(20))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if handle_key(&mut app, key)? {
quit = true;
}
}
Event::Mouse(me) => {
let area = app.last_window_area;
handle_mouse(&mut app, me, area)?;
}
Event::Resize(cols, rows) => {
if last_resize.elapsed() > Duration::from_millis(50) {
let win = &mut app.windows[app.active_idx];
if let Some(pane) = active_pane_mut(&mut win.root, &win.active_path) {
let _ = pane.master.resize(PtySize { rows: rows as u16, cols: cols as u16, pixel_width: 0, pixel_height: 0 });
let mut parser = pane.term.lock().unwrap();
parser.screen_mut().set_size(rows, cols);
}
last_resize = Instant::now();
}
}
_ => {}
}
}
loop {
let req = if let Some(rx) = app.control_rx.as_ref() { rx.try_recv().ok() } else { None };
let Some(req) = req else { break; };
match req {
CtrlReq::NewWindow => {
let pty_system = PtySystemSelection::default().get().map_err(|e| io::Error::new(io::ErrorKind::Other, format!("pty system error: {e}")))?;
create_window(&*pty_system, &mut app)?;
}
CtrlReq::SplitWindow(k) => { let _ = split_active(&mut app, k); }
CtrlReq::KillPane => { let _ = kill_active_pane(&mut app); }
CtrlReq::CapturePane(resp) => {
if let Some(text) = capture_active_pane_text(&mut app)? { let _ = resp.send(text); } else { let _ = resp.send(String::new()); }
}
CtrlReq::CapturePaneRange(resp, s, e) => {
if let Some(text) = capture_active_pane_range(&mut app, s, e)? { let _ = resp.send(text); } else { let _ = resp.send(String::new()); }
}
CtrlReq::FocusWindow(wid) => { if let Some(idx) = find_window_index_by_id(&app, wid) { app.active_idx = idx; } }
CtrlReq::FocusPane(pid) => { focus_pane_by_id(&mut app, pid); }
CtrlReq::SessionInfo(resp) => {
let attached = if app.attached_clients > 0 { "(attached)" } else { "(detached)" };
let windows = app.windows.len();
let (w,h) = {
let win = &mut app.windows[app.active_idx];
let mut size = (0,0);
if let Some(p) = active_pane_mut(&mut win.root, &win.active_path) { size = (p.last_cols as i32, p.last_rows as i32); }
size
};
let created = app.created_at.format("%a %b %e %H:%M:%S %Y");
let line = format!("{}: {} windows (created {}) [{}x{}] {}\n", app.session_name, windows, created, w, h, attached);
let _ = resp.send(line);
}
CtrlReq::ClientAttach => { app.attached_clients = app.attached_clients.saturating_add(1); }
CtrlReq::ClientDetach => { app.attached_clients = app.attached_clients.saturating_sub(1); }
CtrlReq::DumpLayout(resp) => {
let json = dump_layout_json(&mut app)?;
let _ = resp.send(json);
}
CtrlReq::SendText(s) => { send_text_to_active(&mut app, &s)?; }
CtrlReq::SendKey(k) => { send_key_to_active(&mut app, &k)?; }
CtrlReq::ZoomPane => { toggle_zoom(&mut app); }
CtrlReq::CopyEnter => { enter_copy_mode(&mut app); }
CtrlReq::CopyMove(dx, dy) => { move_copy_cursor(&mut app, dx, dy); }
CtrlReq::CopyAnchor => { if let Some((r,c)) = current_prompt_pos(&mut app) { app.copy_anchor = Some((r,c)); app.copy_pos = Some((r,c)); } }
CtrlReq::CopyYank => { let _ = yank_selection(&mut app); app.mode = Mode::Passthrough; }
CtrlReq::ClientSize(w, h) => { app.last_window_area = Rect { x: 0, y: 0, width: w, height: h }; }
CtrlReq::FocusPaneCmd(pid) => { focus_pane_by_id(&mut app, pid); }
CtrlReq::FocusWindowCmd(wid) => { if let Some(idx) = find_window_index_by_id(&app, wid) { app.active_idx = idx; } }
CtrlReq::MouseDown(x,y) => { remote_mouse_down(&mut app, x, y); }
CtrlReq::MouseDrag(x,y) => { remote_mouse_drag(&mut app, x, y); }
CtrlReq::MouseUp(_,_) => { app.drag = None; }
CtrlReq::ScrollUp => { remote_scroll_up(&mut app); }
CtrlReq::ScrollDown => { remote_scroll_down(&mut app); }
CtrlReq::NextWindow => { if !app.windows.is_empty() { app.active_idx = (app.active_idx + 1) % app.windows.len(); } }
CtrlReq::PrevWindow => { if !app.windows.is_empty() { app.active_idx = (app.active_idx + app.windows.len() - 1) % app.windows.len(); } }
CtrlReq::RenameWindow(name) => { let win = &mut app.windows[app.active_idx]; win.name = name; }
CtrlReq::ListWindows(resp) => { let json = list_windows_json(&app)?; let _ = resp.send(json); }
CtrlReq::ListTree(resp) => { let json = list_tree_json(&app)?; let _ = resp.send(json); }
CtrlReq::ToggleSync => { app.sync_input = !app.sync_input; }
CtrlReq::SetPaneTitle(title) => {
let win = &mut app.windows[app.active_idx];
if let Some(p) = active_pane_mut(&mut win.root, &win.active_path) { p.title = title; }
}
}
}
if reap_children(&mut app)? {
quit = true;
}
if quit { break; }
}
// teardown: kill all pane children
for win in app.windows.iter_mut() {
kill_all_children(&mut win.root);
}
Ok(())
}
fn create_window(pty_system: &dyn portable_pty::PtySystem, app: &mut AppState) -> io::Result<()> {
let size = PtySize { rows: 30, cols: 120, pixel_width: 0, pixel_height: 0 };
let pair = pty_system
.openpty(size)
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("openpty error: {e}")))?;
let shell_cmd = detect_shell();
let child = pair
.slave
.spawn_command(shell_cmd)
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("spawn shell error: {e}")))?;
let term: Arc<Mutex<vt100::Parser>> = Arc::new(Mutex::new(vt100::Parser::new(size.rows, size.cols, 0)));
let term_reader = term.clone();
let mut reader = pair
.master
.try_clone_reader()
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("clone reader error: {e}")))?;
thread::spawn(move || {
let mut local = [0u8; 8192];
loop {
match reader.read(&mut local) {
Ok(n) if n > 0 => {
let mut parser = term_reader.lock().unwrap();
parser.process(&local[..n]);
}
Ok(_) => thread::sleep(Duration::from_millis(5)),
Err(_) => break,
}
}
});
let pane = Pane { master: pair.master, child, term, last_rows: size.rows, last_cols: size.cols, id: app.next_pane_id, title: format!("pane %{}", app.next_pane_id) };
app.next_pane_id += 1;
app.windows.push(Window { root: Node::Leaf(pane), active_path: vec![], name: format!("win {}", app.windows.len()+1), id: app.next_win_id });
app.next_win_id += 1;
app.active_idx = app.windows.len() - 1;
Ok(())
}
fn handle_key(app: &mut AppState, key: KeyEvent) -> io::Result<bool> {
if matches!(key.code, KeyCode::Char('q')) && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(true);
}
match app.mode {
Mode::Passthrough => {
let is_ctrl_b = (key.code, key.modifiers) == app.prefix_key
|| matches!(key.code, KeyCode::Char(c) if c == '\u{0002}');
if is_ctrl_b {
app.mode = Mode::Prefix { armed_at: Instant::now() };
return Ok(false);
}
forward_key_to_active(app, key)?;
Ok(false)
}
Mode::Prefix { armed_at } => {
let elapsed = armed_at.elapsed().as_millis() as u64;
let handled = match key.code {
KeyCode::Left => { move_focus(app, FocusDir::Left); true }
KeyCode::Right => { move_focus(app, FocusDir::Right); true }
KeyCode::Up => { move_focus(app, FocusDir::Up); true }
KeyCode::Down => { move_focus(app, FocusDir::Down); true }
KeyCode::Char(d) if d.is_ascii_digit() => {
let idx = d.to_digit(10).unwrap() as usize;
if idx > 0 && idx <= app.windows.len() { app.active_idx = idx - 1; }
true
}
KeyCode::Char('c') => {
let pty_system = PtySystemSelection::default()
.get()
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("pty system error: {e}")))?;
create_window(&*pty_system, app)?;
true
}
KeyCode::Char('n') => {
if !app.windows.is_empty() {
app.active_idx = (app.active_idx + 1) % app.windows.len();
}
true
}
KeyCode::Char('p') => {
if !app.windows.is_empty() {
app.active_idx = (app.active_idx + app.windows.len() - 1) % app.windows.len();
}
true
}
KeyCode::Char('%') => {
split_active(app, LayoutKind::Horizontal)?;
true
}
KeyCode::Char('"') => {
split_active(app, LayoutKind::Vertical)?;
true
}
KeyCode::Char('x') => {
kill_active_pane(app)?;
true
}
KeyCode::Char('d') => {
// detach: exit pmux cleanly
return Ok(true);
}
KeyCode::Char('w') => { app.mode = Mode::WindowChooser { selected: app.active_idx }; true }
KeyCode::Char(',') => { app.mode = Mode::RenamePrompt { input: String::new() }; true }
KeyCode::Char(' ') => { cycle_top_layout(app); true }
KeyCode::Char('[') => { enter_copy_mode(app); true }
KeyCode::Char(']') => { paste_latest(app)?; app.mode = Mode::Passthrough; true }
KeyCode::Char(':') => {
app.mode = Mode::CommandPrompt { input: String::new() };
true
}
KeyCode::Char('q') => {
let win = &app.windows[app.active_idx];
let mut rects: Vec<(Vec<usize>, Rect)> = Vec::new();
compute_rects(&win.root, app.last_window_area, &mut rects);
app.display_map.clear();
for (i, (path, _)) in rects.into_iter().enumerate() {
let n = i + 1;
if n <= 10 { app.display_map.push((n, path)); } else { break; }
}
app.mode = Mode::PaneChooser { opened_at: Instant::now() };
true
}
_ => false,
};
if matches!(app.mode, Mode::Prefix { .. }) {
if !handled && elapsed < app.escape_time_ms {
// Unrecognized after prefix: do not send '^B'; swallow and return
return Ok(false);
}
app.mode = Mode::Passthrough;
}
Ok(false)
}
Mode::CommandPrompt { .. } => {
match key.code {
KeyCode::Esc => { app.mode = Mode::Passthrough; }
KeyCode::Enter => { execute_command_prompt(app)?; }
KeyCode::Backspace => {
if let Mode::CommandPrompt { input } = &mut app.mode { let _ = input.pop(); }
}
KeyCode::Char(c) => {
if let Mode::CommandPrompt { input } = &mut app.mode { input.push(c); }
}
_ => {}
}
Ok(false)
}
Mode::WindowChooser { selected } => {
match key.code {
KeyCode::Esc => { app.mode = Mode::Passthrough; }
KeyCode::Up | KeyCode::Left => { if selected > 0 { if let Mode::WindowChooser { selected: s } = &mut app.mode { *s -= 1; } } }
KeyCode::Down | KeyCode::Right => { if selected + 1 < app.windows.len() { if let Mode::WindowChooser { selected: s } = &mut app.mode { *s += 1; } } }
KeyCode::Enter => { if let Mode::WindowChooser { selected: s } = &mut app.mode { app.active_idx = *s; app.mode = Mode::Passthrough; } }
_ => {}
}
Ok(false)
}
Mode::RenamePrompt { .. } => {
match key.code {
KeyCode::Esc => { app.mode = Mode::Passthrough; }
KeyCode::Enter => { if let Mode::RenamePrompt { input } = &mut app.mode { app.windows[app.active_idx].name = input.clone(); app.mode = Mode::Passthrough; } }
KeyCode::Backspace => { if let Mode::RenamePrompt { input } = &mut app.mode { let _ = input.pop(); } }
KeyCode::Char(c) => { if let Mode::RenamePrompt { input } = &mut app.mode { input.push(c); } }
_ => {}
}
Ok(false)
}
Mode::CopyMode => {
match key.code {
KeyCode::Esc | KeyCode::Char(']') => { app.mode = Mode::Passthrough; app.copy_anchor = None; app.copy_pos = None; }
KeyCode::Left => { move_copy_cursor(app, -1, 0); }
KeyCode::Right => { move_copy_cursor(app, 1, 0); }
KeyCode::Up => { move_copy_cursor(app, 0, -1); }
KeyCode::Down => { move_copy_cursor(app, 0, 1); }
KeyCode::Char('v') => { if let Some((r,c)) = current_prompt_pos(app) { app.copy_anchor = Some((r,c)); app.copy_pos = Some((r,c)); } }
KeyCode::Char('y') => { yank_selection(app)?; app.mode = Mode::Passthrough; }
_ => {}
}
Ok(false)
}
Mode::PaneChooser { .. } => {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => { app.mode = Mode::Passthrough; }
KeyCode::Char(d) if d.is_ascii_digit() => {
let raw = d.to_digit(10).unwrap() as usize;
let choice = if raw == 0 { 10 } else { raw };
if let Some((_, path)) = app.display_map.iter().find(|(n, _)| *n == choice) {
let win = &mut app.windows[app.active_idx];
win.active_path = path.clone();
app.mode = Mode::Passthrough;
}
}
_ => {}
}
Ok(false)
}
}
}
fn move_focus(app: &mut AppState, dir: FocusDir) {
let win = &mut app.windows[app.active_idx];
let mut rects: Vec<(Vec<usize>, Rect)> = Vec::new();
compute_rects(&win.root, app.last_window_area, &mut rects);
// find active index
let mut active_idx = None;
for (i, (path, _)) in rects.iter().enumerate() { if *path == win.active_path { active_idx = Some(i); break; } }
let Some(ai) = active_idx else { return; };
let (_, arect) = &rects[ai];
// pick nearest neighbor in direction
let mut best: Option<(usize, u32)> = None;
for (i, (_, r)) in rects.iter().enumerate() {
if i == ai { continue; }
let candidate = match dir {
FocusDir::Left => if r.x + r.width <= arect.x { Some((arect.x - (r.x + r.width)) as u32) } else { None },
FocusDir::Right => if r.x >= arect.x + arect.width { Some((r.x - (arect.x + arect.width)) as u32) } else { None },
FocusDir::Up => if r.y + r.height <= arect.y { Some((arect.y - (r.y + r.height)) as u32) } else { None },
FocusDir::Down => if r.y >= arect.y + arect.height { Some((r.y - (arect.y + arect.height)) as u32) } else { None },
};
if let Some(dist) = candidate { if best.map_or(true, |(_,bd)| dist < bd) { best = Some((i, dist)); } }
}
if let Some((ni, _)) = best { win.active_path = rects[ni].0.clone(); }
}
fn forward_key_to_active(app: &mut AppState, key: KeyEvent) -> io::Result<()> {
let win = &mut app.windows[app.active_idx];
let Some(active) = active_pane_mut(&mut win.root, &win.active_path) else { return Ok(()); };
match key.code {
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
let _ = write!(active.master, "{}", c);
}
KeyCode::Enter => { let _ = write!(active.master, "\r"); }
KeyCode::Tab => { let _ = write!(active.master, "\t"); }
KeyCode::Backspace => { let _ = write!(active.master, "\x08"); }
KeyCode::Esc => { let _ = write!(active.master, "\x1b"); }
KeyCode::Left => { let _ = write!(active.master, "\x1b[D"); }
KeyCode::Right => { let _ = write!(active.master, "\x1b[C"); }
KeyCode::Up => { let _ = write!(active.master, "\x1b[A"); }
KeyCode::Down => { let _ = write!(active.master, "\x1b[B"); }
_ => {}
}
Ok(())
}
fn split_active(app: &mut AppState, kind: LayoutKind) -> io::Result<()> {
let pty_system = PtySystemSelection::default().get().map_err(|e| io::Error::new(io::ErrorKind::Other, format!("pty system error: {e}")))?;
let size = PtySize { rows: 30, cols: 120, pixel_width: 0, pixel_height: 0 };
let pair = pty_system.openpty(size).map_err(|e| io::Error::new(io::ErrorKind::Other, format!("openpty error: {e}")))?;
let shell_cmd = detect_shell();
let child = pair.slave.spawn_command(shell_cmd).map_err(|e| io::Error::new(io::ErrorKind::Other, format!("spawn shell error: {e}")))?;
let term: Arc<Mutex<vt100::Parser>> = Arc::new(Mutex::new(vt100::Parser::new(size.rows, size.cols, 0)));
let term_reader = term.clone();
let mut reader = pair.master.try_clone_reader().map_err(|e| io::Error::new(io::ErrorKind::Other, format!("clone reader error: {e}")))?;
thread::spawn(move || {
let mut local = [0u8; 8192];
loop {
match reader.read(&mut local) {
Ok(n) if n > 0 => { let mut parser = term_reader.lock().unwrap(); parser.process(&local[..n]); }
Ok(_) => thread::sleep(Duration::from_millis(5)),
Err(_) => break,
}
}
});
let new_leaf = Node::Leaf(Pane { master: pair.master, child, term, last_rows: size.rows, last_cols: size.cols, id: app.next_pane_id, title: format!("pane %{}", app.next_pane_id) });
app.next_pane_id += 1;
let win = &mut app.windows[app.active_idx];
replace_leaf_with_split(&mut win.root, &win.active_path, kind, new_leaf);
// focus the newly created right child of the split
let mut new_path = win.active_path.clone();
new_path.push(1);
win.active_path = new_path;
Ok(())
}
fn handle_mouse(app: &mut AppState, me: crossterm::event::MouseEvent, window_area: Rect) -> io::Result<()> {
let win = &mut app.windows[app.active_idx];
// compute leaf rects from split tree
let mut rects: Vec<(Vec<usize>, Rect)> = Vec::new();
compute_rects(&win.root, window_area, &mut rects);
// compute borders for splits
let mut borders: Vec<(Vec<usize>, LayoutKind, usize, u16)> = Vec::new();
compute_split_borders(&win.root, window_area, &mut borders);
use crossterm::event::{MouseEventKind, MouseButton};
match me.kind {
MouseEventKind::Down(MouseButton::Left) => {
// focus pane
for (path, area) in rects.iter() {
if area.contains(ratatui::layout::Position { x: me.column, y: me.row }) { win.active_path = path.clone(); }
}
// check border resize start
let tol = 1u16;
for (path, kind, idx, pos) in borders.iter() {
match kind {
LayoutKind::Horizontal => {
if me.column >= pos.saturating_sub(tol) && me.column <= pos + tol {
// record initial sizes from split node
if let Some((left,right)) = split_sizes_at(&win.root, path.clone(), *idx) {
app.drag = Some(DragState { split_path: path.clone(), kind: *kind, index: *idx, start_x: me.column, start_y: me.row, left_initial: left, _right_initial: right });
}
break;
}
}
LayoutKind::Vertical => {
if me.row >= pos.saturating_sub(tol) && me.row <= pos + tol {
if let Some((left,right)) = split_sizes_at(&win.root, path.clone(), *idx) {
app.drag = Some(DragState { split_path: path.clone(), kind: *kind, index: *idx, start_x: me.column, start_y: me.row, left_initial: left, _right_initial: right });
}
break;
}
}
}
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(d) = &app.drag {
adjust_split_sizes(&mut win.root, d, me.column, me.row);
}
}
MouseEventKind::Up(MouseButton::Left) => { app.drag = None; }
MouseEventKind::ScrollUp => {
if let Some(active) = active_pane_mut(&mut win.root, &win.active_path) { let _ = write!(active.master, "\x1b[A"); }
}
MouseEventKind::ScrollDown => {
if let Some(active) = active_pane_mut(&mut win.root, &win.active_path) { let _ = write!(active.master, "\x1b[B"); }
}
_ => {}
}
Ok(())
}
// TODO: implement split-border detection and per-node size adjustment
fn kill_active_pane(app: &mut AppState) -> io::Result<()> {
let win = &mut app.windows[app.active_idx];
kill_leaf(&mut win.root, &win.active_path);
Ok(())
}
fn detect_shell() -> CommandBuilder {
let pwsh = which::which("pwsh").ok().map(|p| p.to_string_lossy().into_owned());
let cmd = which::which("cmd").ok().map(|p| p.to_string_lossy().into_owned());
match pwsh.or(cmd) {
Some(path) => CommandBuilder::new(path),
None => CommandBuilder::new("pwsh.exe"),
}
}
fn execute_command_prompt(app: &mut AppState) -> io::Result<()> {
let cmdline = match &app.mode { Mode::CommandPrompt { input } => input.clone(), _ => String::new() };
app.mode = Mode::Passthrough;
let parts: Vec<&str> = cmdline.split_whitespace().collect();
if parts.is_empty() { return Ok(()); }
match parts[0] {
"new-window" => {
let pty_system = PtySystemSelection::default().get().map_err(|e| io::Error::new(io::ErrorKind::Other, format!("pty system error: {e}")))?;
create_window(&*pty_system, app)?;
}
"split-window" => {
let kind = if parts.iter().any(|p| *p == "-h") { LayoutKind::Horizontal } else { LayoutKind::Vertical };
split_active(app, kind)?;
}
"kill-pane" => { kill_active_pane(app)?; }
"capture-pane" => { capture_active_pane(app)?; }
"save-buffer" => { if let Some(file) = parts.get(1) { save_latest_buffer(app, file)?; } }
"list-sessions" => { println!("default"); }
"attach-session" => { /* already attached */ }
"next-window" => { app.active_idx = (app.active_idx + 1) % app.windows.len(); }
"previous-window" => { app.active_idx = (app.active_idx + app.windows.len() - 1) % app.windows.len(); }
"select-window" => {
if let Some(tidx) = parts.iter().position(|p| *p == "-t").and_then(|i| parts.get(i+1)) { if let Ok(n) = tidx.parse::<usize>() { if n>0 && n<=app.windows.len() { app.active_idx = n-1; } } }
}
_ => {}
}
Ok(())
}
fn centered_rect(percent_x: u16, height: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(50),
Constraint::Length(height),
Constraint::Percentage(50),
])
.split(r);
let middle = popup_layout[1];
let width = (middle.width * percent_x) / 100;
let x = middle.x + (middle.width - width) / 2;
Rect { x, y: middle.y, width, height }
}
fn reap_children(app: &mut AppState) -> io::Result<bool> {
// Transform each window's split tree by pruning exited leaves.
for i in (0..app.windows.len()).rev() {
let root = std::mem::replace(&mut app.windows[i].root, Node::Split { kind: LayoutKind::Horizontal, sizes: vec![], children: vec![] });
match prune_exited(root) {
Some(new_root) => {
app.windows[i].root = new_root;
// ensure active_path remains valid; if not, pick first available leaf
if !path_exists(&app.windows[i].root, &app.windows[i].active_path) {
app.windows[i].active_path = first_leaf_path(&app.windows[i].root);
}
}
None => { app.windows.remove(i); }
}
}
Ok(app.windows.is_empty())
}
fn vt_to_color(c: vt100::Color) -> Color {
match c {
vt100::Color::Default => Color::Reset,
vt100::Color::Idx(i) => match i {
0 => Color::Black,
1 => Color::Red,
2 => Color::Green,
3 => Color::Yellow,
4 => Color::Blue,
5 => Color::Magenta,
6 => Color::Cyan,
7 => Color::Gray,
8 => Color::DarkGray,
9 => Color::LightRed,
10 => Color::LightGreen,
11 => Color::LightYellow,
12 => Color::LightBlue,
13 => Color::LightMagenta,
14 => Color::LightCyan,
15 => Color::White,
_ => Color::Reset,
},
vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
}
}
fn apply_cursor_style<W: Write>(out: &mut W) -> io::Result<()> {
let style = env::var("PSMUX_CURSOR_STYLE").unwrap_or_else(|_| "bar".to_string());
let blink = env::var("PSMUX_CURSOR_BLINK").unwrap_or_else(|_| "1".to_string()) != "0";
let code = match style.as_str() {
"block" => if blink { 1 } else { 2 },
"underline" => if blink { 3 } else { 4 },
"bar" | "beam" => if blink { 5 } else { 6 },
_ => if blink { 5 } else { 6 },
};
execute!(out, Print(format!("\x1b[{} q", code)))?;
Ok(())
}
fn render_window(f: &mut Frame, app: &mut AppState, area: Rect) {
let win = &mut app.windows[app.active_idx];
render_node(f, &mut win.root, &win.active_path, &mut Vec::new(), area);
}
fn enter_copy_mode(app: &mut AppState) { app.mode = Mode::CopyMode; }
fn cycle_top_layout(app: &mut AppState) {
let win = &mut app.windows[app.active_idx];
// toggle parent of active path, else toggle root
if !win.active_path.is_empty() {
let parent_path = &win.active_path[..win.active_path.len()-1].to_vec();
if let Some(Node::Split { kind, sizes, .. }) = get_split_mut(&mut win.root, &parent_path.to_vec()) {
*kind = match *kind { LayoutKind::Horizontal => LayoutKind::Vertical, LayoutKind::Vertical => LayoutKind::Horizontal };
*sizes = vec![50,50];
}
} else {
if let Node::Split { kind, sizes, .. } = &mut win.root { *kind = match *kind { LayoutKind::Horizontal => LayoutKind::Vertical, LayoutKind::Vertical => LayoutKind::Horizontal }; *sizes = vec![50,50]; }
}
}
fn render_node(f: &mut Frame, node: &mut Node, active_path: &Vec<usize>, cur_path: &mut Vec<usize>, area: Rect) {
match node {
Node::Leaf(pane) => {
let is_active = *cur_path == *active_path;
let title = if is_active { "* pane" } else { " pane" };
let pane_block = Block::default().borders(Borders::ALL).title(title);
let inner = pane_block.inner(area);
let target_rows = inner.height.max(1);
let target_cols = inner.width.max(1);
if pane.last_rows != target_rows || pane.last_cols != target_cols {
let _ = pane.master.resize(PtySize { rows: target_rows, cols: target_cols, pixel_width: 0, pixel_height: 0 });
let mut parser = pane.term.lock().unwrap();
parser.screen_mut().set_size(target_rows, target_cols);
pane.last_rows = target_rows;
pane.last_cols = target_cols;
}
let parser = pane.term.lock().unwrap();
let screen = parser.screen();
let mut lines: Vec<Line> = Vec::with_capacity(target_rows as usize);
for r in 0..target_rows {
let mut spans: Vec<Span> = Vec::with_capacity(target_cols as usize);
let mut c = 0;
while c < target_cols {
if let Some(cell) = screen.cell(r, c) {
let mut fg = vt_to_color(cell.fgcolor());
let mut bg = vt_to_color(cell.bgcolor());
if cell.inverse() { std::mem::swap(&mut fg, &mut bg); }
let mut style = Style::default().fg(fg).bg(bg);
if cell.bold() { style = style.add_modifier(Modifier::BOLD); }
if cell.italic() { style = style.add_modifier(Modifier::ITALIC); }
if cell.underline() { style = style.add_modifier(Modifier::UNDERLINED); }
let text = cell.contents().to_string();
let w = UnicodeWidthStr::width(text.as_str()) as u16;
if w == 0 {
spans.push(Span::styled(" ", style));
c += 1;
} else if w >= 2 && c + 1 < target_cols {
spans.push(Span::styled(text, style));
spans.push(Span::styled(" ", style));
c += 2;
} else {
spans.push(Span::styled(text, style));
c += 1;
}
} else {
spans.push(Span::raw(" "));
c += 1;
}
}
lines.push(Line::from(spans));
}
f.render_widget(pane_block, area);
f.render_widget(Clear, inner);
let para = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
f.render_widget(para, inner);
if is_active {
let (cr, cc) = screen.cursor_position();
let cr = cr.min(target_rows.saturating_sub(1));
let cc = cc.min(target_cols.saturating_sub(1));
let cx = inner.x + cc;
let cy = inner.y + cr;
f.set_cursor(cx, cy);
}
}
Node::Split { kind, sizes, children } => {
let constraints: Vec<Constraint> = if sizes.len() == children.len() {
sizes.iter().map(|p| Constraint::Percentage(*p)).collect()
} else { vec![Constraint::Percentage((100 / children.len() as u16) as u16); children.len()] };
let rects = match *kind {
LayoutKind::Horizontal => Layout::default().direction(Direction::Horizontal).constraints(constraints).split(area),
LayoutKind::Vertical => Layout::default().direction(Direction::Vertical).constraints(constraints).split(area),
};
for (i, child) in children.iter_mut().enumerate() {
cur_path.push(i);
render_node(f, child, active_path, cur_path, rects[i]);
cur_path.pop();
}
}
}
}
fn active_pane_mut<'a>(node: &'a mut Node, path: &Vec<usize>) -> Option<&'a mut Pane> {
let mut cur = node;
for &idx in path.iter() {
match cur {
Node::Split { children, .. } => { cur = children.get_mut(idx)?; }
Node::Leaf(_) => return None,
}
}
match cur { Node::Leaf(p) => Some(p), _ => None }
}
fn replace_leaf_with_split(node: &mut Node, path: &Vec<usize>, kind: LayoutKind, new_leaf: Node) {
if path.is_empty() {
let old = std::mem::replace(node, Node::Split { kind, sizes: vec![50,50], children: vec![] });
if let Node::Split { children, .. } = node { children.push(old); children.push(new_leaf); }
return;
}
let mut cur = node;
for (depth, &idx) in path.iter().enumerate() {
match cur {
Node::Split { children, .. } => {
if depth == path.len()-1 {
let leaf = std::mem::replace(&mut children[idx], Node::Split { kind, sizes: vec![50,50], children: vec![] });
if let Node::Split { children: c, .. } = &mut children[idx] { c.push(leaf); c.push(new_leaf); }
return;
} else { cur = &mut children[idx]; }
}
Node::Leaf(_) => return,
}
}
}
fn kill_leaf(node: &mut Node, path: &Vec<usize>) {
*node = remove_node(std::mem::replace(node, Node::Split { kind: LayoutKind::Horizontal, sizes: vec![], children: vec![] }), path);
}
fn remove_node(n: Node, path: &Vec<usize>) -> Node {
match n {
Node::Leaf(p) => {
// if path points here, removing leaf yields an empty split collapse handled by parent; return leaf
Node::Leaf(p)
}
Node::Split { kind, sizes, children } => {
if path.is_empty() { return Node::Split { kind, sizes, children }; }
let idx = path[0];
let mut new_children: Vec<Node> = Vec::new();
for (i, child) in children.into_iter().enumerate() {
if i == idx {
if path.len() > 1 { new_children.push(remove_node(child, &path[1..].to_vec())); }
// else: drop this child (removed)
} else { new_children.push(child); }
}
if new_children.len() == 1 { new_children.into_iter().next().unwrap() }
else {
// normalize sizes equally
let mut eq = vec![100 / new_children.len() as u16; new_children.len()];
let rem = 100 - eq.iter().sum::<u16>();
if let Some(last) = eq.last_mut() { *last += rem; }
Node::Split { kind, sizes: eq, children: new_children }
}
}
}
}
fn compute_rects(node: &Node, area: Rect, out: &mut Vec<(Vec<usize>, Rect)>) {
fn rec(node: &Node, area: Rect, path: &mut Vec<usize>, out: &mut Vec<(Vec<usize>, Rect)>) {
match node {
Node::Leaf(_) => { out.push((path.clone(), area)); }
Node::Split { kind, sizes, children } => {
let constraints: Vec<Constraint> = if sizes.len() == children.len() {
sizes.iter().map(|p| Constraint::Percentage(*p)).collect()
} else { vec![Constraint::Percentage((100 / children.len() as u16) as u16); children.len()] };
let rects = match *kind {
LayoutKind::Horizontal => Layout::default().direction(Direction::Horizontal).constraints(constraints).split(area),
LayoutKind::Vertical => Layout::default().direction(Direction::Vertical).constraints(constraints).split(area),
};
for (i, child) in children.iter().enumerate() { path.push(i); rec(child, rects[i], path, out); path.pop(); }
}
}
}
let mut path = Vec::new();
rec(node, area, &mut path, out);
}
fn kill_all_children(node: &mut Node) {
match node {
Node::Leaf(p) => { let _ = p.child.kill(); }
Node::Split { children, .. } => { for child in children.iter_mut() { kill_all_children(child); } }
}
}
fn compute_split_borders(node: &Node, area: Rect, out: &mut Vec<(Vec<usize>, LayoutKind, usize, u16)>) {
fn rec(node: &Node, area: Rect, path: &mut Vec<usize>, out: &mut Vec<(Vec<usize>, LayoutKind, usize, u16)>) {
match node {
Node::Leaf(_) => {}
Node::Split { kind, sizes, children } => {
let constraints: Vec<Constraint> = if sizes.len() == children.len() {
sizes.iter().map(|p| Constraint::Percentage(*p)).collect()
} else { vec![Constraint::Percentage((100 / children.len() as u16) as u16); children.len()] };
let rects = match *kind {
LayoutKind::Horizontal => Layout::default().direction(Direction::Horizontal).constraints(constraints).split(area),
LayoutKind::Vertical => Layout::default().direction(Direction::Vertical).constraints(constraints).split(area),
};
for i in 0..children.len()-1 {
let pos = match *kind {
LayoutKind::Horizontal => rects[i].x + rects[i].width,
LayoutKind::Vertical => rects[i].y + rects[i].height,
};
out.push((path.clone(), *kind, i, pos));
}
for (i, child) in children.iter().enumerate() { path.push(i); rec(child, rects[i], path, out); path.pop(); }
}
}
}
let mut path = Vec::new();
rec(node, area, &mut path, out);
}
fn split_sizes_at<'a>(node: &'a Node, path: Vec<usize>, idx: usize) -> Option<(u16,u16)> {
let mut cur = node;
for &i in path.iter() {
match cur { Node::Split { children, .. } => { cur = children.get(i)?; } _ => return None }
}
if let Node::Split { sizes, .. } = cur {
if idx+1 < sizes.len() { Some((sizes[idx], sizes[idx+1])) } else { None }
} else { None }
}
fn adjust_split_sizes(root: &mut Node, d: &DragState, x: u16, y: u16) {
if let Some(Node::Split { sizes, .. }) = get_split_mut(root, &d.split_path) {
let total = sizes[d.index] + sizes[d.index+1];
let min_pct = 5u16;
let delta: i16 = match d.kind {
LayoutKind::Horizontal => (x as i32 - d.start_x as i32).clamp(-100, 100) as i16,
LayoutKind::Vertical => (y as i32 - d.start_y as i32).clamp(-100, 100) as i16,
};
let left = (d.left_initial as i16 + delta).clamp(min_pct as i16, (total - min_pct) as i16) as u16;
let right = total - left;
sizes[d.index] = left;
sizes[d.index+1] = right;
}
}
fn get_split_mut<'a>(node: &'a mut Node, path: &Vec<usize>) -> Option<&'a mut Node> {
let mut cur = node;
for &idx in path.iter() {
match cur { Node::Split { children, .. } => { cur = children.get_mut(idx)?; } _ => return None }
}
Some(cur)
}
fn prune_exited(n: Node) -> Option<Node> {
match n {
Node::Leaf(mut p) => {
match p.child.try_wait() {
Ok(Some(_)) => None,
_ => Some(Node::Leaf(p)),
}
}
Node::Split { kind, sizes: _sizes, children } => {
let mut new_children: Vec<Node> = Vec::new();
for child in children { if let Some(c) = prune_exited(child) { new_children.push(c); } }
if new_children.is_empty() { None }
else if new_children.len() == 1 { Some(new_children.remove(0)) }
else {
// equalize sizes
let mut eq = vec![100 / new_children.len() as u16; new_children.len()];
let rem = 100 - eq.iter().sum::<u16>();
if let Some(last) = eq.last_mut() { *last += rem; }
Some(Node::Split { kind, sizes: eq, children: new_children })
}
}
}
}
fn expand_status(fmt: &str, app: &AppState, time_str: &str) -> String {
let mut s = fmt.to_string();
let window = &app.windows[app.active_idx];
s = s.replace("#I", &(app.active_idx + 1).to_string());
s = s.replace("#W", &window.name);
s = s.replace("#S", "psmux");
s = s.replace("%H:%M", time_str);
s
}
fn load_config(app: &mut AppState) {
let home = env::var("USERPROFILE").or_else(|_| env::var("HOME")).unwrap_or_default();
let path = format!("{}\\.psmux.conf", home);
if let Ok(content) = std::fs::read_to_string(&path) {
for line in content.lines() {
let l = line.trim();
if l.is_empty() || l.starts_with('#') { continue; }
if l.starts_with("set -g ") {
let rest = &l[7..];
if let Some((key, value)) = rest.split_once(' ') {
match key.trim() {
"status-left" => app.status_left = value.trim().to_string(),
"status-right" => app.status_right = value.trim().to_string(),
"mouse" => app.mouse_enabled = matches!(value.trim(), "on" | "true"),
"cursor-style" => env::set_var("PSMUX_CURSOR_STYLE", value.trim()),
"cursor-blink" => env::set_var("PSMUX_CURSOR_BLINK", if matches!(value.trim(), "on"|"true") { "1" } else { "0" }),
"prefix" => {
if value.trim().eq_ignore_ascii_case("C-b") { app.prefix_key = (KeyCode::Char('b'), KeyModifiers::CONTROL); }
if value.trim().eq_ignore_ascii_case("C-a") { app.prefix_key = (KeyCode::Char('a'), KeyModifiers::CONTROL); }
}
_ => {}
}
}
}
}
}
}
fn parse_status(fmt: &str, app: &AppState, time_str: &str) -> Vec<Span<'static>> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut cur_style = Style::default();
let mut i = 0;
while i < fmt.len() {
if fmt.as_bytes()[i] == b'#' && i + 1 < fmt.len() && fmt.as_bytes()[i+1] == b'[' {
// parse style token #[...]
if let Some(end) = fmt[i+2..].find(']') {
let token = &fmt[i+2..i+2+end];
for part in token.split(',') {
let p = part.trim();
if p.starts_with("fg=") { cur_style = cur_style.fg(map_color(&p[3..])); }
else if p.starts_with("bg=") { cur_style = cur_style.bg(map_color(&p[3..])); }
else if p == "bold" { cur_style = cur_style.add_modifier(Modifier::BOLD); }
else if p == "italic" { cur_style = cur_style.add_modifier(Modifier::ITALIC); }
else if p == "underline" { cur_style = cur_style.add_modifier(Modifier::UNDERLINED); }
else if p == "default" { cur_style = Style::default(); }
}
i += 2 + end + 1;
continue;
}
}
// regular text, expand placeholders
let mut j = i;
while j < fmt.len() && !(fmt.as_bytes()[j] == b'#' && j + 1 < fmt.len() && fmt.as_bytes()[j+1] == b'[') { j += 1; }
let chunk = &fmt[i..j];
let text = expand_status(chunk, app, time_str);
spans.push(Span::styled(text, cur_style));
i = j;
}
spans
}
fn map_color(name: &str) -> Color {
match name.to_lowercase().as_str() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"white" => Color::White,
"default" => Color::Reset,
_ => Color::Reset,
}
}
fn current_prompt_pos(app: &mut AppState) -> Option<(u16,u16)> {
let win = &mut app.windows[app.active_idx];
let p = active_pane_mut(&mut win.root, &win.active_path)?;
let parser = p.term.lock().ok()?;
let (r,c) = parser.screen().cursor_position();
Some((r,c))
}
fn move_copy_cursor(app: &mut AppState, dx: i16, dy: i16) {
let win = &mut app.windows[app.active_idx];
let p = match active_pane_mut(&mut win.root, &win.active_path) { Some(p) => p, None => return };
let parser = match p.term.lock() { Ok(g) => g, Err(_) => return };
// track copy position externally (no direct cursor mutation)
let (r,c) = parser.screen().cursor_position();
let nr = (r as i16 + dy).max(0) as u16;
let nc = (c as i16 + dx).max(0) as u16;
app.copy_pos = Some((nr,nc));
}
fn yank_selection(app: &mut AppState) -> io::Result<()> {
let (anchor, pos) = match (app.copy_anchor, app.copy_pos) { (Some(a), Some(p)) => (a,p), _ => return Ok(()) };
let win = &mut app.windows[app.active_idx];
let p = match active_pane_mut(&mut win.root, &win.active_path) { Some(p) => p, None => return Ok(()) };
let parser = match p.term.lock() { Ok(g) => g, Err(_) => return Ok(()) };
let screen = parser.screen();
let r0 = anchor.0.min(pos.0); let r1 = anchor.0.max(pos.0);
let c0 = anchor.1.min(pos.1); let c1 = anchor.1.max(pos.1);
let mut text = String::new();
for r in r0..=r1 {
for c in c0..=c1 {
if let Some(cell) = screen.cell(r, c) { text.push_str(&cell.contents().to_string()); } else { text.push(' '); }
}
if r < r1 { text.push('\n'); }
}
app.paste_buffers.push(text);
Ok(())
}
fn paste_latest(app: &mut AppState) -> io::Result<()> {
if let Some(buf) = app.paste_buffers.last() {
let win = &mut app.windows[app.active_idx];
if let Some(p) = active_pane_mut(&mut win.root, &win.active_path) { let _ = write!(p.master, "{}", buf); }
}
Ok(())
}
fn path_exists(node: &Node, path: &Vec<usize>) -> bool {
let mut cur = node;
for &idx in path.iter() {
match cur {
Node::Split { children, .. } => {
if let Some(next) = children.get(idx) { cur = next; } else { return false; }
}
Node::Leaf(_) => return false,
}
}
matches!(cur, Node::Leaf(_) | Node::Split { .. })
}
fn first_leaf_path(node: &Node) -> Vec<usize> {
fn rec(n: &Node, path: &mut Vec<usize>) -> Option<Vec<usize>> {
match n {
Node::Leaf(_) => Some(path.clone()),
Node::Split { children, .. } => {
for (i, child) in children.iter().enumerate() {
path.push(i);
if let Some(p) = rec(child, path) { return Some(p); }
path.pop();
}
None
}
}
}
rec(node, &mut Vec::new()).unwrap_or_default()
}
fn capture_active_pane(app: &mut AppState) -> io::Result<()> {
let win = &mut app.windows[app.active_idx];
let p = match active_pane_mut(&mut win.root, &win.active_path) { Some(p) => p, None => return Ok(()) };
let parser = match p.term.lock() { Ok(g) => g, Err(_) => return Ok(()) };
let screen = parser.screen();
let mut text = String::new();
for r in 0..p.last_rows { for c in 0..p.last_cols { if let Some(cell) = screen.cell(r, c) { text.push_str(&cell.contents().to_string()); } else { text.push(' '); } } text.push('\n'); }
app.paste_buffers.push(text);
Ok(())
}
fn capture_active_pane_text(app: &mut AppState) -> io::Result<Option<String>> {
let win = &mut app.windows[app.active_idx];
let p = match active_pane_mut(&mut win.root, &win.active_path) { Some(p) => p, None => return Ok(None) };
let parser = match p.term.lock() { Ok(g) => g, Err(_) => return Ok(None) };
let screen = parser.screen();
let mut text = String::new();
for r in 0..p.last_rows { for c in 0..p.last_cols { if let Some(cell) = screen.cell(r, c) { text.push_str(&cell.contents().to_string()); } else { text.push(' '); } } text.push('\n'); }
Ok(Some(text))
}
fn save_latest_buffer(app: &mut AppState, file: &str) -> io::Result<()> {
if let Some(buf) = app.paste_buffers.last() { std::fs::write(file, buf)?; }
Ok(())
}
#[derive(Clone)]
enum Action { DisplayPanes, MoveFocus(FocusDir) }
#[derive(Clone)]
struct Bind { key: (KeyCode, KeyModifiers), action: Action }
enum CtrlReq {
NewWindow,
SplitWindow(LayoutKind),
KillPane,
CapturePane(mpsc::Sender<String>),
FocusWindow(usize),
FocusPane(usize),
SessionInfo(mpsc::Sender<String>),
CapturePaneRange(mpsc::Sender<String>, Option<u16>, Option<u16>),
ClientAttach,
ClientDetach,
DumpLayout(mpsc::Sender<String>),
SendText(String),
SendKey(String),
ZoomPane,
CopyEnter,
CopyMove(i16, i16),
CopyAnchor,
CopyYank,
ClientSize(u16, u16),
FocusPaneCmd(usize),
FocusWindowCmd(usize),
MouseDown(u16,u16),
MouseDrag(u16,u16),
MouseUp(u16,u16),
ScrollUp,
ScrollDown,
NextWindow,
PrevWindow,
RenameWindow(String),
ListWindows(mpsc::Sender<String>),
ListTree(mpsc::Sender<String>),
ToggleSync,
SetPaneTitle(String),
}
fn find_window_index_by_id(app: &AppState, wid: usize) -> Option<usize> {
for (i, w) in app.windows.iter().enumerate() { if w.id == wid { return Some(i); } }
None
}
fn focus_pane_by_id(app: &mut AppState, pid: usize) {
let win = &mut app.windows[app.active_idx];
fn rec(node: &Node, path: &mut Vec<usize>, found: &mut Option<Vec<usize>>, pid: usize) {
match node {
Node::Leaf(p) => { if p.id == pid { *found = Some(path.clone()); } }
Node::Split { children, .. } => {
for (i, c) in children.iter().enumerate() { path.push(i); rec(c, path, found, pid); path.pop(); }
}
}
}
let mut found = None;
rec(&win.root, &mut Vec::new(), &mut found, pid);
if let Some(p) = found { win.active_path = p; }
}
fn run_server(session_name: String) -> io::Result<()> {
let pty_system = PtySystemSelection::default()
.get()
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("pty system error: {e}")))?;
let mut app = AppState {
windows: Vec::new(),
active_idx: 0,
mode: Mode::Passthrough,
escape_time_ms: 500,
prefix_key: (KeyCode::Char('b'), KeyModifiers::CONTROL),
drag: None,
last_window_area: Rect { x: 0, y: 0, width: 0, height: 0 },
mouse_enabled: true,
paste_buffers: Vec::new(),
status_left: "psmux:#I".to_string(),
status_right: "%H:%M".to_string(),
copy_anchor: None,
copy_pos: None,
display_map: Vec::new(),
binds: Vec::new(),
control_rx: None,
control_port: None,
session_name,
attached_clients: 0,
created_at: Local::now(),
next_win_id: 1,
next_pane_id: 1,
zoom_saved: None,
sync_input: false,
};
create_window(&*pty_system, &mut app)?;
let (tx, rx) = mpsc::channel::<CtrlReq>();
app.control_rx = Some(rx);
let listener = TcpListener::bind(("127.0.0.1", 0))?;
let port = listener.local_addr()?.port();
app.control_port = Some(port);
let home = env::var("USERPROFILE").or_else(|_| env::var("HOME")).unwrap_or_default();
let dir = format!("{}\\.psmux", home);
let _ = std::fs::create_dir_all(&dir);
let regpath = format!("{}\\{}.port", dir, app.session_name);
let _ = std::fs::write(®path, port.to_string());
thread::spawn(move || {
for conn in listener.incoming() {
if let Ok(mut stream) = conn {
let mut line = String::new();
let mut r = io::BufReader::new(stream.try_clone().unwrap());
let _ = r.read_line(&mut line);
let mut parts = line.split_whitespace();
let cmd = parts.next().unwrap_or("");
let mut args: Vec<&str> = parts.by_ref().collect();
let mut target_win: Option<usize> = None;
let mut target_pane: Option<usize> = None;
let mut i = 0;
while i < args.len() {
if args[i] == "-t" {
if let Some(v) = args.get(i+1) {
if v.starts_with('%') { if let Ok(pid) = v[1..].parse::<usize>() { target_pane = Some(pid); } }
else if v.starts_with('@') { if let Ok(wid) = v[1..].parse::<usize>() { target_win = Some(wid); } }
}
i += 2; continue;
}
i += 1;
}
if let Some(wid) = target_win { let _ = tx.send(CtrlReq::FocusWindow(wid)); }
if let Some(pid) = target_pane { let _ = tx.send(CtrlReq::FocusPane(pid)); }
match cmd {
"new-window" => { let _ = tx.send(CtrlReq::NewWindow); }
"split-window" => {
let kind = if args.iter().any(|a| *a == "-h") { LayoutKind::Horizontal } else { LayoutKind::Vertical };
let _ = tx.send(CtrlReq::SplitWindow(kind));
}
"kill-pane" => { let _ = tx.send(CtrlReq::KillPane); }
"capture-pane" => {
let (rtx, rrx) = mpsc::channel::<String>();
let _ = tx.send(CtrlReq::CapturePane(rtx));
if let Ok(text) = rrx.recv() { let _ = write!(stream, "{}", text); }
}
"dump-layout" => {
let (rtx, rrx) = mpsc::channel::<String>();
let _ = tx.send(CtrlReq::DumpLayout(rtx));
if let Ok(text) = rrx.recv() { let _ = write!(stream, "{}", text); }
}
"send-text" => {
if let Some(payload) = args.get(0) { let _ = tx.send(CtrlReq::SendText(payload.to_string())); }
}
"send-key" => {
if let Some(payload) = args.get(0) { let _ = tx.send(CtrlReq::SendKey(payload.to_string())); }
}
"zoom-pane" => { let _ = tx.send(CtrlReq::ZoomPane); }
"copy-enter" => { let _ = tx.send(CtrlReq::CopyEnter); }
"copy-move" => {
if args.len() >= 2 { if let (Ok(dx), Ok(dy)) = (args[0].parse::<i16>(), args[1].parse::<i16>()) { let _ = tx.send(CtrlReq::CopyMove(dx, dy)); } }
}
"copy-anchor" => { let _ = tx.send(CtrlReq::CopyAnchor); }
"copy-yank" => { let _ = tx.send(CtrlReq::CopyYank); }
"client-size" => {
if args.len() >= 2 { if let (Ok(w), Ok(h)) = (args[0].parse::<u16>(), args[1].parse::<u16>()) { let _ = tx.send(CtrlReq::ClientSize(w, h)); } }
}
"focus-pane" => {
if let Some(pid) = args.get(0).and_then(|s| s.parse::<usize>().ok()) { let _ = tx.send(CtrlReq::FocusPaneCmd(pid)); }
}
"focus-window" => {
if let Some(wid) = args.get(0).and_then(|s| s.parse::<usize>().ok()) { let _ = tx.send(CtrlReq::FocusWindowCmd(wid)); }
}
"mouse-down" => {
if args.len()>=2 { if let (Ok(x),Ok(y))=(args[0].parse::<u16>(),args[1].parse::<u16>()) { let _ = tx.send(CtrlReq::MouseDown(x,y)); } }
}
"mouse-drag" => {
if args.len()>=2 { if let (Ok(x),Ok(y))=(args[0].parse::<u16>(),args[1].parse::<u16>()) { let _ = tx.send(CtrlReq::MouseDrag(x,y)); } }
}
"mouse-up" => {
if args.len()>=2 { if let (Ok(x),Ok(y))=(args[0].parse::<u16>(),args[1].parse::<u16>()) { let _ = tx.send(CtrlReq::MouseUp(x,y)); } }
}
"scroll-up" => { let _ = tx.send(CtrlReq::ScrollUp); }
"scroll-down" => { let _ = tx.send(CtrlReq::ScrollDown); }
"next-window" => { let _ = tx.send(CtrlReq::NextWindow); }
"previous-window" => { let _ = tx.send(CtrlReq::PrevWindow); }
"rename-window" => { if let Some(name) = args.get(0) { let _ = tx.send(CtrlReq::RenameWindow((*name).to_string())); } }
"list-windows" => { let (rtx, rrx) = mpsc::channel::<String>(); let _ = tx.send(CtrlReq::ListWindows(rtx)); if let Ok(text) = rrx.recv() { let _ = write!(stream, "{}", text); } }
"list-tree" => { let (rtx, rrx) = mpsc::channel::<String>(); let _ = tx.send(CtrlReq::ListTree(rtx)); if let Ok(text) = rrx.recv() { let _ = write!(stream, "{}", text); } }
"toggle-sync" => { let _ = tx.send(CtrlReq::ToggleSync); }
"set-pane-title" => { let title = args.join(" "); let _ = tx.send(CtrlReq::SetPaneTitle(title)); }
_ => {}
}
}
}
});
loop {
while let Some(req) = app.control_rx.as_ref().and_then(|rx| rx.try_recv().ok()) {
match req {
CtrlReq::NewWindow => { let _ = create_window(&*pty_system, &mut app); }
CtrlReq::SplitWindow(k) => { let _ = split_active(&mut app, k); }
CtrlReq::KillPane => { let _ = kill_active_pane(&mut app); }
CtrlReq::CapturePane(resp) => {
if let Some(text) = capture_active_pane_text(&mut app)? { let _ = resp.send(text); } else { let _ = resp.send(String::new()); }
}
CtrlReq::CapturePaneRange(resp, s, e) => {
if let Some(text) = capture_active_pane_range(&mut app, s, e)? { let _ = resp.send(text); } else { let _ = resp.send(String::new()); }
}
CtrlReq::FocusWindow(wid) => { if let Some(idx) = find_window_index_by_id(&app, wid) { app.active_idx = idx; } }
CtrlReq::FocusPane(pid) => { focus_pane_by_id(&mut app, pid); }
CtrlReq::SessionInfo(resp) => {
let attached = if app.attached_clients > 0 { "(attached)" } else { "(detached)" };
let windows = app.windows.len();
let (w,h) = {
let win = &mut app.windows[app.active_idx];
let mut size = (0,0);
if let Some(p) = active_pane_mut(&mut win.root, &win.active_path) { size = (p.last_cols as i32, p.last_rows as i32); }
size
};
let created = app.created_at.format("%a %b %e %H:%M:%S %Y");
let line = format!("{}: {} windows (created {}) [{}x{}] {}\n", app.session_name, windows, created, w, h, attached);
let _ = resp.send(line);
}
CtrlReq::ClientAttach => { app.attached_clients = app.attached_clients.saturating_add(1); }
CtrlReq::ClientDetach => { app.attached_clients = app.attached_clients.saturating_sub(1); }
CtrlReq::DumpLayout(resp) => {
let json = dump_layout_json(&mut app)?;
let _ = resp.send(json);
}
CtrlReq::SendText(s) => { send_text_to_active(&mut app, &s)?; }
CtrlReq::SendKey(k) => { send_key_to_active(&mut app, &k)?; }
CtrlReq::ZoomPane => { toggle_zoom(&mut app); }
CtrlReq::CopyEnter => { enter_copy_mode(&mut app); }
CtrlReq::CopyMove(dx, dy) => { move_copy_cursor(&mut app, dx, dy); }
CtrlReq::CopyAnchor => { if let Some((r,c)) = current_prompt_pos(&mut app) { app.copy_anchor = Some((r,c)); app.copy_pos = Some((r,c)); } }
CtrlReq::CopyYank => { let _ = yank_selection(&mut app); app.mode = Mode::Passthrough; }
CtrlReq::ClientSize(w, h) => { app.last_window_area = Rect { x: 0, y: 0, width: w, height: h }; }
CtrlReq::FocusPaneCmd(pid) => { focus_pane_by_id(&mut app, pid); }
CtrlReq::FocusWindowCmd(wid) => { if let Some(idx) = find_window_index_by_id(&app, wid) { app.active_idx = idx; } }
CtrlReq::MouseDown(x,y) => { remote_mouse_down(&mut app, x, y); }
CtrlReq::MouseDrag(x,y) => { remote_mouse_drag(&mut app, x, y); }
CtrlReq::MouseUp(_,_) => { app.drag = None; }
CtrlReq::ScrollUp => { remote_scroll_up(&mut app); }
CtrlReq::ScrollDown => { remote_scroll_down(&mut app); }
CtrlReq::NextWindow => { if !app.windows.is_empty() { app.active_idx = (app.active_idx + 1) % app.windows.len(); } }
CtrlReq::PrevWindow => { if !app.windows.is_empty() { app.active_idx = (app.active_idx + app.windows.len() - 1) % app.windows.len(); } }
CtrlReq::RenameWindow(name) => { let win = &mut app.windows[app.active_idx]; win.name = name; }
CtrlReq::ListWindows(resp) => { let json = list_windows_json(&app)?; let _ = resp.send(json); }
CtrlReq::ListTree(resp) => { let json = list_tree_json(&app)?; let _ = resp.send(json); }
CtrlReq::ToggleSync => { app.sync_input = !app.sync_input; }
CtrlReq::SetPaneTitle(title) => {
let win = &mut app.windows[app.active_idx];
if let Some(p) = active_pane_mut(&mut win.root, &win.active_path) { p.title = title; }
}
}
}
let _ = reap_children(&mut app)?;
thread::sleep(Duration::from_millis(20));
}
}
fn capture_active_pane_range(app: &mut AppState, s: Option<u16>, e: Option<u16>) -> io::Result<Option<String>> {
let win = &mut app.windows[app.active_idx];
let p = match active_pane_mut(&mut win.root, &win.active_path) { Some(p) => p, None => return Ok(None) };
let parser = match p.term.lock() { Ok(g) => g, Err(_) => return Ok(None) };
let screen = parser.screen();
let start = s.unwrap_or(0).min(p.last_rows.saturating_sub(1));
let end = e.unwrap_or(p.last_rows.saturating_sub(1)).min(p.last_rows.saturating_sub(1));
let mut text = String::new();
for r in start..=end { for c in 0..p.last_cols { if let Some(cell) = screen.cell(r, c) { text.push_str(&cell.contents().to_string()); } else { text.push(' '); } } text.push('\n'); }
Ok(Some(text))
}
#[derive(Serialize, Deserialize)]
struct CellJson { text: String, fg: String, bg: String, bold: bool, italic: bool, underline: bool, inverse: bool }
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum LayoutJson {
#[serde(rename = "split")]
Split { kind: String, sizes: Vec<u16>, children: Vec<LayoutJson> },
#[serde(rename = "leaf")]
Leaf { id: usize, rows: u16, cols: u16, cursor_row: u16, cursor_col: u16, content: Vec<Vec<CellJson>> },
}
fn dump_layout_json(app: &mut AppState) -> io::Result<String> {
fn build(node: &mut Node) -> LayoutJson {
match node {
Node::Split { kind, sizes, children } => {
let k = match *kind { LayoutKind::Horizontal => "Horizontal".to_string(), LayoutKind::Vertical => "Vertical".to_string() };
let mut ch: Vec<LayoutJson> = Vec::new();
for c in children.iter_mut() { ch.push(build(c)); }
LayoutJson::Split { kind: k, sizes: sizes.clone(), children: ch }
}
Node::Leaf(p) => {
let parser = p.term.lock().unwrap();
let screen = parser.screen();
let (cr, cc) = screen.cursor_position();
if let Some(t) = infer_title_from_prompt(&screen, p.last_rows, p.last_cols) { p.title = t; }
let mut lines: Vec<Vec<CellJson>> = Vec::new();
for r in 0..p.last_rows {
let mut row: Vec<CellJson> = Vec::new();
for c in 0..p.last_cols {
if let Some(cell) = screen.cell(r, c) {
let fg = color_to_name(cell.fgcolor());
let bg = color_to_name(cell.bgcolor());
let text = cell.contents().to_string();
row.push(CellJson { text, fg, bg, bold: cell.bold(), italic: cell.italic(), underline: cell.underline(), inverse: cell.inverse() });
} else {
row.push(CellJson { text: " ".to_string(), fg: "default".to_string(), bg: "default".to_string(), bold: false, italic: false, underline: false, inverse: false });
}
}
lines.push(row);
}
LayoutJson::Leaf { id: p.id, rows: p.last_rows, cols: p.last_cols, cursor_row: cr, cursor_col: cc, content: lines }
}
}
}
let win = &mut app.windows[app.active_idx];
let root = build(&mut win.root);
let s = serde_json::to_string(&root).map_err(|e| io::Error::new(io::ErrorKind::Other, format!("json error: {e}")))?;
Ok(s)
}
fn infer_title_from_prompt(screen: &vt100::Screen, rows: u16, cols: u16) -> Option<String> {
let mut last: Option<String> = None;
for r in (0..rows).rev() {
let mut s = String::new();
for c in 0..cols { if let Some(cell) = screen.cell(r, c) { s.push_str(&cell.contents().to_string()); } else { s.push(' '); } }
let t = s.trim_end().to_string();
if !t.trim().is_empty() { last = Some(t); break; }
}
let Some(line) = last else { return None };
let trimmed = line.trim().to_string();
if let Some(pos) = trimmed.rfind('>') {
let before = trimmed[..pos].trim().to_string();
if before.contains("\\") || before.contains("/") {
let parts: Vec<&str> = before.trim_matches(|ch: char| ch == '"').split(['\\','/']).collect();
if let Some(base) = parts.last() { return Some(base.to_string()); }
}
return Some(before);
}
if let Some(pos) = trimmed.rfind('$') { return Some(trimmed[..pos].trim().to_string()); }
if let Some(pos) = trimmed.rfind('#') { return Some(trimmed[..pos].trim().to_string()); }
Some(trimmed)
}
fn resolve_last_session_name() -> Option<String> {
let home = env::var("USERPROFILE").or_else(|_| env::var("HOME")).ok()?;
let dir = format!("{}\\.psmux", home);
let last = std::fs::read_to_string(format!("{}\\last_session", dir)).ok();
if let Some(name) = last {
let name = name.trim().to_string();
let p = format!("{}\\{}.port", dir, name);
if std::path::Path::new(&p).exists() { return Some(name); }
}
let mut picks: Vec<(String, std::time::SystemTime)> = Vec::new();
if let Ok(rd) = std::fs::read_dir(&dir) {
for e in rd.flatten() {
if let Some(fname) = e.file_name().to_str() {
if let Some((base, ext)) = fname.rsplit_once('.') {
if ext == "port" { if let Ok(md) = e.metadata() { picks.push((base.to_string(), md.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH))); } }
}
}
}
}
picks.sort_by_key(|(_, t)| *t);
picks.last().map(|(n, _)| n.clone())
}
fn resolve_default_session_name() -> Option<String> {
if let Ok(name) = env::var("PSMUX_DEFAULT_SESSION") {
let home = env::var("USERPROFILE").or_else(|_| env::var("HOME")).ok()?;
let p = format!("{}\\.psmux\\{}.port", home, name);
if std::path::Path::new(&p).exists() { return Some(name); }
}
let home = env::var("USERPROFILE").or_else(|_| env::var("HOME")).ok()?;
let candidates = [format!("{}\\.psmuxrc", home), format!("{}\\.psmux\\pmuxrc", home)];
for cfg in candidates.iter() {
if let Ok(text) = std::fs::read_to_string(cfg) {
let line = text.lines().find(|l| !l.trim().is_empty())?;
let name = if let Some(rest) = line.strip_prefix("default-session ") { rest.trim().to_string() } else { line.trim().to_string() };
let p = format!("{}\\.psmux\\{}.port", home, name);
if std::path::Path::new(&p).exists() { return Some(name); }
}
}
None
}
#[derive(Serialize, Deserialize)]
struct WinInfo { id: usize, name: String, active: bool }
#[derive(Serialize, Deserialize)]
struct PaneInfo { id: usize, title: String }
#[derive(Serialize, Deserialize)]
struct WinTree { id: usize, name: String, active: bool, panes: Vec<PaneInfo> }
fn list_windows_json(app: &AppState) -> io::Result<String> {
let mut v: Vec<WinInfo> = Vec::new();
for (i, w) in app.windows.iter().enumerate() { v.push(WinInfo { id: w.id, name: w.name.clone(), active: i == app.active_idx }); }
let s = serde_json::to_string(&v).map_err(|e| io::Error::new(io::ErrorKind::Other, format!("json error: {e}")))?;
Ok(s)
}
fn list_tree_json(app: &AppState) -> io::Result<String> {
fn collect_panes(node: &Node, out: &mut Vec<PaneInfo>) {
match node {
Node::Leaf(p) => { out.push(PaneInfo { id: p.id, title: p.title.clone() }); }
Node::Split { children, .. } => { for c in children.iter() { collect_panes(c, out); } }
}
}
let mut v: Vec<WinTree> = Vec::new();
for (i, w) in app.windows.iter().enumerate() {
let mut panes = Vec::new();
collect_panes(&w.root, &mut panes);
v.push(WinTree { id: w.id, name: w.name.clone(), active: i == app.active_idx, panes });
}
let s = serde_json::to_string(&v).map_err(|e| io::Error::new(io::ErrorKind::Other, format!("json error: {e}")))?;
Ok(s)
}
fn color_to_name(c: vt100::Color) -> String {
match c {
vt100::Color::Default => "default".to_string(),
vt100::Color::Idx(i) => format!("idx:{}", i),
vt100::Color::Rgb(r,g,b) => format!("rgb:{},{},{}", r,g,b),
}
}
fn send_text_to_active(app: &mut AppState, text: &str) -> io::Result<()> {
let win = &mut app.windows[app.active_idx];
if let Some(p) = active_pane_mut(&mut win.root, &win.active_path) { let _ = write!(p.master, "{}", text); }
Ok(())
}
fn send_key_to_active(app: &mut AppState, k: &str) -> io::Result<()> {
let win = &mut app.windows[app.active_idx];
if let Some(p) = active_pane_mut(&mut win.root, &win.active_path) {
match k {
"enter" => { let _ = write!(p.master, "\r"); }
"tab" => { let _ = write!(p.master, "\t"); }
"backspace" => { let _ = write!(p.master, "\x08"); }
"esc" => { let _ = write!(p.master, "\x1b"); }
"left" => { let _ = write!(p.master, "\x1b[D"); }
"right" => { let _ = write!(p.master, "\x1b[C"); }
"up" => { let _ = write!(p.master, "\x1b[A"); }
"down" => { let _ = write!(p.master, "\x1b[B"); }
_ => {}
}
}
Ok(())
}
fn toggle_zoom(app: &mut AppState) {
let win = &mut app.windows[app.active_idx];
if app.zoom_saved.is_none() {
let mut saved: Vec<(Vec<usize>, Vec<u16>)> = Vec::new();
for depth in 0..win.active_path.len() {
let p = win.active_path[..depth].to_vec();
if let Some(Node::Split { sizes, .. }) = get_split_mut(&mut win.root, &p) {
let idx = win.active_path.get(depth).copied().unwrap_or(0);
saved.push((p.clone(), sizes.clone()));
for i in 0..sizes.len() { sizes[i] = if i == idx { 100 } else { 0 }; }
}
}
app.zoom_saved = Some(saved);
} else {
if let Some(saved) = app.zoom_saved.take() {
for (p, sz) in saved.into_iter() {
if let Some(Node::Split { sizes, .. }) = get_split_mut(&mut win.root, &p) { *sizes = sz; }
}
}
}
}
fn remote_mouse_down(app: &mut AppState, x: u16, y: u16) {
let win = &mut app.windows[app.active_idx];
let mut rects: Vec<(Vec<usize>, Rect)> = Vec::new();
compute_rects(&win.root, app.last_window_area, &mut rects);
for (path, area) in rects.iter() { if area.contains(ratatui::layout::Position { x, y }) { win.active_path = path.clone(); } }
let mut borders: Vec<(Vec<usize>, LayoutKind, usize, u16)> = Vec::new();
compute_split_borders(&win.root, app.last_window_area, &mut borders);
let tol = 1u16;
for (path, kind, idx, pos) in borders.iter() {
match kind {
LayoutKind::Horizontal => {
if x >= pos.saturating_sub(tol) && x <= pos + tol { if let Some((left,right)) = split_sizes_at(&win.root, path.clone(), *idx) { app.drag = Some(DragState { split_path: path.clone(), kind: *kind, index: *idx, start_x: x, start_y: y, left_initial: left, _right_initial: right }); } break; }
}
LayoutKind::Vertical => {
if y >= pos.saturating_sub(tol) && y <= pos + tol { if let Some((left,right)) = split_sizes_at(&win.root, path.clone(), *idx) { app.drag = Some(DragState { split_path: path.clone(), kind: *kind, index: *idx, start_x: x, start_y: y, left_initial: left, _right_initial: right }); } break; }
}
}
}
}
fn remote_mouse_drag(app: &mut AppState, x: u16, y: u16) { let win = &mut app.windows[app.active_idx]; if let Some(d) = &app.drag { adjust_split_sizes(&mut win.root, d, x, y); } }
fn remote_scroll_up(app: &mut AppState) { let win = &mut app.windows[app.active_idx]; if let Some(p) = active_pane_mut(&mut win.root, &win.active_path) { let _ = write!(p.master, "\x1b[A"); } }
fn remote_scroll_down(app: &mut AppState) { let win = &mut app.windows[app.active_idx]; if let Some(p) = active_pane_mut(&mut win.root, &win.active_path) { let _ = write!(p.master, "\x1b[B"); } }