multistack 1.0.0-rc1

Open source lightweight TUI for parallel agent management
use std::io::Write;

use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use portable_pty::{NativePtySystem, PtySize};

use crate::Mode;
use crate::PromptPurpose;
use crate::process::{Process, spawn_process};

pub fn process_event(
    mode: &mut Mode,
    processes: &mut Vec<Process>,
    next_id: &mut usize,
    pty_system: &NativePtySystem,
    event: Event,
    term_rows: &mut u16,
    term_cols: &mut u16,
) -> std::io::Result<bool> {
    match event {
        Event::Resize(w, h) => {
            *term_cols = w;
            *term_rows = h;
            for proc in processes.iter() {
                if let Some(ref master) = proc.master {
                    let _ = master.resize(PtySize {
                        rows: h,
                        cols: w,
                        pixel_width: 0,
                        pixel_height: 0,
                    });
                }
            }
        }
        Event::Key(key) if key.kind != KeyEventKind::Release => {
            return process_key(mode, processes, next_id, pty_system, key, *term_rows, *term_cols);
        }
        _ => {}
    }
    Ok(false)
}

fn process_key(
    mode: &mut Mode,
    processes: &mut Vec<Process>,
    next_id: &mut usize,
    pty_system: &NativePtySystem,
    key: crossterm::event::KeyEvent,
    term_rows: u16,
    term_cols: u16,
) -> std::io::Result<bool> {
    match mode {
        Mode::Normal { selected } => match key.code {
            KeyCode::Char('n') => {
                *mode = Mode::Prompt {
                    purpose: PromptPurpose::NewProcess,
                    selected: *selected,
                    input: String::new(),
                };
            }
            KeyCode::Char('r') => {
                if !processes.is_empty() && *selected < processes.len() {
                    let pid = processes[*selected].id;
                    let current = processes[*selected].name.clone();
                    let suffix = format!(" [{}]", pid);
                    let default = current.strip_suffix(&suffix).unwrap_or(&current).to_string();
                    *mode = Mode::Prompt {
                        purpose: PromptPurpose::Rename(pid),
                        selected: *selected,
                        input: default,
                    };
                }
            }
            KeyCode::Char('k') => {
                if !processes.is_empty() && *selected < processes.len() {
                    processes.remove(*selected);
                    if *selected >= processes.len() && *selected > 0 {
                        *selected -= 1;
                    }
                }
            }
            KeyCode::Enter => {
                if !processes.is_empty() && *selected < processes.len() {
                    let pid = processes[*selected].id;
                    *mode = Mode::Tty { process_id: pid };
                }
            }
            KeyCode::Up => {
                if *selected > 0 {
                    *selected -= 1;
                }
            }
            KeyCode::Down => {
                if *selected + 1 < processes.len() {
                    *selected += 1;
                }
            }
            KeyCode::Esc | KeyCode::Char('q') => return Ok(true),
            _ => {}
        },
        Mode::Tty { process_id } => {
            let pid = *process_id;
            match key.code {
                KeyCode::Esc => {
                    let idx = processes.iter().position(|p| p.id == pid).unwrap_or(0);
                    *mode = Mode::Normal { selected: idx };
                }
                _ => {
                    if let Some(proc) = processes.iter_mut().find(|p| p.id == pid) {
                        if let Some(ref mut writer) = proc.master_writer {
                            let bytes = key_to_bytes(&key);
                            if !bytes.is_empty() {
                                let _ = writer.write_all(&bytes);
                                let _ = writer.flush();
                            }
                        }
                    }
                }
            }
        }
        Mode::Prompt { purpose, selected, input } => match key.code {
            KeyCode::Esc => {
                *mode = Mode::Normal { selected: *selected };
            }
            KeyCode::Enter => {
                let title = std::mem::take(input);
                let title = title.trim().to_string();
                match purpose {
                    PromptPurpose::NewProcess => {
                        let title_opt = if title.is_empty() { None } else { Some(title.as_str()) };
                        let id = *next_id;
                        let socket_path = format!("/tmp/multistack-{}.sock", id);
                        let args = ["--parallel", "--status-socket", &socket_path];
                        let proc = spawn_process(pty_system, next_id, "zerostack", &args, title_opt, term_rows, term_cols, Some(&socket_path))?;
                        if processes.is_empty() {
                            *selected = 0;
                        }
                        processes.push(proc);
                    }
                    PromptPurpose::Rename(pid) => {
                        if !title.is_empty() {
                            if let Some(proc) = processes.iter_mut().find(|p| p.id == *pid) {
                                proc.name = format!("{} [{}]", title, pid);
                            }
                        }
                    }
                }
                let new_selected = if processes.is_empty() { 0 } else { *selected };
                *mode = Mode::Normal { selected: new_selected };
            }
            KeyCode::Backspace => {
                input.pop();
            }
            KeyCode::Char(c) => {
                input.push(c);
            }
            _ => {}
        },
    }
    Ok(false)
}

fn key_to_bytes(key: &crossterm::event::KeyEvent) -> Vec<u8> {
    if key.modifiers.contains(KeyModifiers::ALT) {
        if let KeyCode::Char(c) = key.code {
            let mut bytes = vec![0x1b];
            let mut buf = [0u8; 4];
            let encoded = c.encode_utf8(&mut buf);
            bytes.extend_from_slice(encoded.as_bytes());
            return bytes;
        }
    }

    match key.code {
        KeyCode::Char(c) => {
            if key.modifiers.contains(KeyModifiers::CONTROL) {
                match c {
                    'a'..='z' => vec![c as u8 - b'a' + 1],
                    'A'..='Z' => vec![c as u8 - b'A' + 1],
                    '[' => vec![0x1b],
                    '\\' => vec![0x1c],
                    ']' => vec![0x1d],
                    '^' => vec![0x1e],
                    '_' => vec![0x1f],
                    '?' => vec![0x7f],
                    '2' => vec![0x00],
                    '6' => vec![0x1e],
                    _ => vec![],
                }
            } else {
                let mut buf = [0u8; 4];
                let encoded = c.encode_utf8(&mut buf);
                encoded.as_bytes().to_vec()
            }
        }
        KeyCode::Enter => vec![b'\r'],
        KeyCode::Backspace => vec![0x7f],
        KeyCode::Tab => vec![b'\t'],
        KeyCode::BackTab => vec![0x1b, b'[', b'Z'],
        KeyCode::Esc => vec![0x1b],
        KeyCode::Up => vec![0x1b, b'[', b'A'],
        KeyCode::Down => vec![0x1b, b'[', b'B'],
        KeyCode::Right => vec![0x1b, b'[', b'C'],
        KeyCode::Left => vec![0x1b, b'[', b'D'],
        KeyCode::Home => vec![0x1b, b'[', b'H'],
        KeyCode::End => vec![0x1b, b'[', b'F'],
        KeyCode::Delete => vec![0x1b, b'[', b'3', b'~'],
        KeyCode::Insert => vec![0x1b, b'[', b'2', b'~'],
        KeyCode::PageUp => vec![0x1b, b'[', b'5', b'~'],
        KeyCode::PageDown => vec![0x1b, b'[', b'6', b'~'],
        KeyCode::F(n) => f_key(n),
        KeyCode::Null => vec![],
        _ => vec![],
    }
}

fn f_key(n: u8) -> Vec<u8> {
    match n {
        1 => vec![0x1b, b'O', b'P'],
        2 => vec![0x1b, b'O', b'Q'],
        3 => vec![0x1b, b'O', b'R'],
        4 => vec![0x1b, b'O', b'S'],
        5 => vec![0x1b, b'[', b'1', b'5', b'~'],
        6 => vec![0x1b, b'[', b'1', b'7', b'~'],
        7 => vec![0x1b, b'[', b'1', b'8', b'~'],
        8 => vec![0x1b, b'[', b'1', b'9', b'~'],
        9 => vec![0x1b, b'[', b'2', b'0', b'~'],
        10 => vec![0x1b, b'[', b'2', b'1', b'~'],
        11 => vec![0x1b, b'[', b'2', b'3', b'~'],
        12 => vec![0x1b, b'[', b'2', b'4', b'~'],
        13 => vec![0x1b, b'[', b'2', b'5', b'~'],
        14 => vec![0x1b, b'[', b'2', b'6', b'~'],
        15 => vec![0x1b, b'[', b'2', b'8', b'~'],
        _ => vec![],
    }
}