Agent skill
portable-pty
Cross-platform pseudo-terminal (PTY) management for spawning shell processes
Install this agent skill to your Project
npx add-skill https://github.com/johnlindquist/script-kit-next/tree/main/.opencode/skill/portable-pty
SKILL.md
portable-pty
Cross-platform PTY (pseudo-terminal) library from the WezTerm project. Provides a unified API for working with PTYs on macOS, Linux, and Windows.
Platform Support
| Platform | Implementation |
|---|---|
| macOS | Native PTY via /dev/ptmx |
| Linux | Native PTY via /dev/ptmx or /dev/pts |
| Windows | ConPTY (Windows 10 1809+) |
Key Types
PtySystem (trait)
Factory for creating PTY pairs. Get the native implementation with:
use portable_pty::native_pty_system;
let pty_system = native_pty_system();
PtyPair (struct)
Contains the master and slave ends of a PTY:
struct PtyPair {
pub master: Box<dyn MasterPty + Send>,
pub slave: Box<dyn SlavePty + Send>,
}
MasterPty (trait)
The control end of the PTY. Key methods:
resize(PtySize)- Notify kernel of size change (sends SIGWINCH)get_size()- Query current PTY sizetry_clone_reader()- Get readable handle for child outputtake_writer()- Get writable handle for child input (call once!)process_group_leader()- Get PID of session leaderas_raw_fd()- Get underlying file descriptor (Unix)
SlavePty (trait)
The process end of the PTY:
spawn_command(CommandBuilder)- Spawn a process into the PTY
Child (trait)
Handle to spawned process:
try_wait()- Non-blocking check if process exitedwait()- Block until process exitskill()- Terminate the process (SIGKILL on Unix)process_id()- Get the PID
PtySize (struct)
Terminal dimensions:
PtySize {
rows: u16, // Number of text lines
cols: u16, // Number of text columns
pixel_width: u16, // Width in pixels (optional, often 0)
pixel_height: u16 // Height in pixels (optional, often 0)
}
CommandBuilder (struct)
Build commands to spawn. Similar to std::process::Command:
let mut cmd = CommandBuilder::new("bash");
cmd.arg("-c");
cmd.arg("echo hello");
cmd.env("TERM", "xterm-256color");
cmd.cwd("/home/user");
Usage in script-kit-gpui
The PtyManager in src/terminal/pty.rs wraps portable-pty:
pub struct PtyManager {
master: Box<dyn MasterPty + Send>,
child: Box<dyn Child + Send + Sync>,
reader: Option<Box<dyn Read + Send>>,
writer: Box<dyn Write + Send>,
size: PtySize,
}
Key patterns used:
- Shell detection: Uses
$SHELLon Unix,COMSPECon Windows - Environment setup: Sets
TERM=xterm-256color,COLORTERM=truecolor - Reader separation:
take_reader()allows moving reader to background thread - Graceful cleanup:
Dropimpl kills child if still running
Process Spawning
Basic shell spawn
use portable_pty::{native_pty_system, CommandBuilder, PtySize, PtySystem};
let pty_system = native_pty_system();
let size = PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
};
let pair = pty_system.openpty(size)?;
let mut cmd = CommandBuilder::new("bash");
cmd.env("TERM", "xterm-256color");
let child = pair.slave.spawn_command(cmd)?;
With environment and working directory
let mut cmd = CommandBuilder::new("/bin/zsh");
cmd.args(&["-l"]); // Login shell
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
cmd.cwd("/home/user/projects");
// Inherit specific env vars
if let Ok(path) = std::env::var("PATH") {
cmd.env("PATH", path);
}
I/O Handling
Reading output (blocking)
let mut reader = pair.master.try_clone_reader()?;
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => break, // EOF
Ok(n) => {
let output = &buf[..n];
// Process output bytes (may be partial UTF-8!)
}
Err(e) => break,
}
}
Writing input
let mut writer = pair.master.take_writer()?;
// Write command with carriage return
writeln!(writer, "ls -la\r")?;
writer.flush()?;
// Or raw bytes
writer.write_all(b"exit\r\n")?;
Non-blocking I/O pattern
Move reader to background thread:
let reader = pty_manager.take_reader().unwrap();
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
// Send to channel or process
}
Err(_) => break,
}
}
});
Resize Handling
Resize triggers SIGWINCH to child process:
let new_size = PtySize {
rows: 40,
cols: 120,
pixel_width: 0,
pixel_height: 0,
};
pair.master.resize(new_size)?;
script-kit-gpui pattern:
impl PtyManager {
pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
let new_size = PtySize {
rows, cols,
pixel_width: 0,
pixel_height: 0,
};
self.master.resize(new_size)?;
self.size = new_size;
Ok(())
}
}
Process Lifecycle
Check if running
match child.try_wait()? {
Some(status) => println!("Exited: {:?}", status),
None => println!("Still running"),
}
Wait for exit
let status = child.wait()?; // Blocks
if status.success() {
println!("Process succeeded");
}
Kill process
child.kill()?; // Sends SIGKILL on Unix
Anti-patterns
Taking writer multiple times
// WRONG - will panic or error
let writer1 = pair.master.take_writer()?;
let writer2 = pair.master.take_writer()?; // Error!
Forgetting carriage return
// WRONG - no line terminator
writer.write_all(b"ls")?;
// CORRECT - include CR or CRLF
writer.write_all(b"ls\r")?;
// or
writer.write_all(b"ls\r\n")?;
Not setting TERM
// WRONG - many programs won't work correctly
let cmd = CommandBuilder::new("vim");
// CORRECT
let mut cmd = CommandBuilder::new("vim");
cmd.env("TERM", "xterm-256color");
Blocking main thread on read
// WRONG - blocks UI thread
let mut buf = [0u8; 4096];
reader.read(&mut buf)?; // Blocks!
// CORRECT - read in background thread
std::thread::spawn(move || {
// reading here
});
Not handling partial UTF-8
// WRONG - may panic on invalid UTF-8
let output = String::from_utf8(buf.to_vec()).unwrap();
// CORRECT - handle lossy conversion
let output = String::from_utf8_lossy(&buf[..n]);
Forgetting cleanup
// WRONG - child may become zombie
drop(pty_manager);
// CORRECT - kill before drop (script-kit-gpui pattern)
impl Drop for PtyManager {
fn drop(&mut self) {
if self.is_running() {
let _ = self.kill();
}
}
}
Error Handling
All operations return anyhow::Result. Common errors:
- PTY creation: Resource exhaustion, permission denied
- Spawn: Command not found, permission denied
- Resize: Invalid dimensions, PTY closed
- I/O: Broken pipe (child exited), would block
Thread Safety
MasterPtyisSendbut notSync- Reader and writer can be used from different threads if separated
ChildisSend + Sync- Use channels to coordinate between reader thread and main thread
References
- docs.rs/portable-pty
- GitHub: wezterm/wezterm (portable-pty is part of WezTerm)
- script-kit-gpui:
src/terminal/pty.rs
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
Generate Component Documentation
Based on existing docs styles and specific API implementations, and referencing same name stories, generate comprehensive documentation for the new component.
Generate Component Story
Generate a comprehensive story for a new component for as example.
new-component
How to write a new component of GPUI Component.
troubleshooting
Diagnose and fix common Script Kit issues. Use when the user reports bugs, crashes, missing features, or unexpected behavior in Script Kit GPUI.
script-authoring
Create and manage TypeScript scripts for Script Kit. Use when the user wants to write a new script, edit an existing script, or understand Script Kit's SDK and metadata system.
agents
Create mdflow-backed agent files for Script Kit. Use when the user wants to create AI agents, configure agent backends (Claude, Gemini, Codex), or manage agent metadata.
Didn't find tool you were looking for?