feat: add Bun.Terminal API for pseudo-terminal (PTY) support (#25415)

## Summary

This PR adds a new `Bun.Terminal` API for creating and managing
pseudo-terminals (PTYs), enabling interactive terminal applications in
Bun.

### Features

- **Standalone Terminal**: Create PTYs directly with `new
Bun.Terminal(options)`
- **Spawn Integration**: Spawn processes with PTY attached via
`Bun.spawn({ terminal: options })`
- **Full PTY Control**: Write data, resize, set raw mode, and handle
callbacks

## Examples

### Basic Terminal with Spawn (Recommended)

```typescript
const proc = Bun.spawn(["bash"], {
  terminal: {
    cols: 80,
    rows: 24,
    data(terminal, data) {
      // Handle output from the terminal
      process.stdout.write(data);
    },
    exit(terminal, code, signal) {
      console.log(`Process exited with code ${code}`);
    },
  },
});

// Write commands to the terminal
proc.terminal.write("echo Hello from PTY!\n");
proc.terminal.write("exit\n");

await proc.exited;
proc.terminal.close();
```

### Interactive Shell

```typescript
// Create an interactive shell that mirrors to stdout
const proc = Bun.spawn(["bash", "-i"], {
  terminal: {
    cols: process.stdout.columns || 80,
    rows: process.stdout.rows || 24,
    data(term, data) {
      process.stdout.write(data);
    },
  },
});

// Forward stdin to the terminal
process.stdin.setRawMode(true);
for await (const chunk of process.stdin) {
  proc.terminal.write(chunk);
}
```

### Running Interactive Programs (vim, htop, etc.)

```typescript
const proc = Bun.spawn(["vim", "file.txt"], {
  terminal: {
    cols: process.stdout.columns,
    rows: process.stdout.rows,
    data(term, data) {
      process.stdout.write(data);
    },
  },
});

// Handle terminal resize
process.stdout.on("resize", () => {
  proc.terminal.resize(process.stdout.columns, process.stdout.rows);
});

// Forward input
process.stdin.setRawMode(true);
for await (const chunk of process.stdin) {
  proc.terminal.write(chunk);
}
```

### Capturing Colored Output

```typescript
const chunks: Uint8Array[] = [];

const proc = Bun.spawn(["ls", "--color=always"], {
  terminal: {
    data(term, data) {
      chunks.push(data);
    },
  },
});

await proc.exited;
proc.terminal.close();

// Output includes ANSI color codes
const output = Buffer.concat(chunks).toString();
console.log(output);
```

### Standalone Terminal (Advanced)

```typescript
const terminal = new Bun.Terminal({
  cols: 80,
  rows: 24,
  data(term, data) {
    console.log("Received:", data.toString());
  },
});

// Use terminal.stdin as the fd for child process stdio
const proc = Bun.spawn(["bash"], {
  stdin: terminal.stdin,
  stdout: terminal.stdin,
  stderr: terminal.stdin,
});

terminal.write("echo hello\n");

// Clean up
terminal.close();
```

### Testing TTY Detection

```typescript
const proc = Bun.spawn([
  "bun", "-e", 
  "console.log('isTTY:', process.stdout.isTTY)"
], {
  terminal: {},
});

// Output: isTTY: true
```

## API

### `Bun.spawn()` with `terminal` option

```typescript
const proc = Bun.spawn(cmd, {
  terminal: {
    cols?: number,        // Default: 80
    rows?: number,        // Default: 24  
    name?: string,        // Default: "xterm-256color"
    data?: (terminal: Terminal, data: Uint8Array) => void,
    exit?: (terminal: Terminal, code: number, signal: string | null) => void,
    drain?: (terminal: Terminal) => void,
  }
});

// Access the terminal
proc.terminal.write(data);
proc.terminal.resize(cols, rows);
proc.terminal.setRawMode(enabled);
proc.terminal.close();

// Note: proc.stdin, proc.stdout, proc.stderr return null when terminal is used
```

### `new Bun.Terminal(options)`

```typescript
const terminal = new Bun.Terminal({
  cols?: number,
  rows?: number,
  name?: string,
  data?: (terminal, data) => void,
  exit?: (terminal, code, signal) => void,
  drain?: (terminal) => void,
});

terminal.stdin;   // Slave fd (for child process)
terminal.stdout;  // Master fd (for reading)
terminal.closed;  // boolean
terminal.write(data);
terminal.resize(cols, rows);
terminal.setRawMode(enabled);
terminal.ref();
terminal.unref();
terminal.close();
await terminal[Symbol.asyncDispose]();
```

## Implementation Details

- Uses `openpty()` to create pseudo-terminal pairs
- Properly manages file descriptor lifecycle with reference counting
- Integrates with Bun's event loop via `BufferedReader` and
`StreamingWriter`
- Supports `await using` syntax for automatic cleanup
- POSIX only (Linux, macOS) - not available on Windows

## Test Results

- 80 tests passing
- Covers: construction, writing, reading, resize, raw mode, callbacks,
spawn integration, error handling, GC safety

## Changes

- `src/bun.js/api/bun/Terminal.zig` - Terminal implementation
- `src/bun.js/api/bun/Terminal.classes.ts` - Class definition for
codegen
- `src/bun.js/api/bun/subprocess.zig` - Added terminal field and getter
- `src/bun.js/api/bun/js_bun_spawn_bindings.zig` - Terminal option
parsing
- `src/bun.js/api/BunObject.classes.ts` - Terminal getter on Subprocess
- `packages/bun-types/bun.d.ts` - TypeScript types
- `docs/runtime/child-process.mdx` - Documentation
- `test/js/bun/terminal/terminal.test.ts` - Comprehensive tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
This commit is contained in:
robobun
2025-12-15 12:51:13 -08:00
committed by GitHub
parent e66b4639bd
commit d865ef41e2
17 changed files with 2983 additions and 36 deletions

View File

@@ -5697,6 +5697,44 @@ declare module "bun" {
* ```
*/
lazy?: boolean;
/**
* Spawn the subprocess with a pseudo-terminal (PTY) attached.
*
* When this option is provided:
* - `stdin`, `stdout`, and `stderr` are all connected to the terminal
* - The subprocess sees itself running in a real terminal (`isTTY = true`)
* - Access the terminal via `subprocess.terminal`
* - `subprocess.stdin`, `subprocess.stdout`, `subprocess.stderr` return `null`
*
* Only available on POSIX systems (Linux, macOS).
*
* @example
* ```ts
* const proc = Bun.spawn(["bash"], {
* terminal: {
* cols: 80,
* rows: 24,
* data: (term, data) => console.log(data.toString()),
* },
* });
*
* proc.terminal.write("echo hello\n");
* await proc.exited;
* proc.terminal.close();
* ```
*
* You can also pass an existing `Terminal` object for reuse across multiple spawns:
* ```ts
* const terminal = new Bun.Terminal({ ... });
* const proc1 = Bun.spawn(["echo", "first"], { terminal });
* await proc1.exited;
* const proc2 = Bun.spawn(["echo", "second"], { terminal });
* await proc2.exited;
* terminal.close();
* ```
*/
terminal?: TerminalOptions | Terminal;
}
type ReadableToIO<X extends Readable> = X extends "pipe" | undefined
@@ -5811,6 +5849,24 @@ declare module "bun" {
readonly stdout: SpawnOptions.ReadableToIO<Out>;
readonly stderr: SpawnOptions.ReadableToIO<Err>;
/**
* The terminal attached to this subprocess, if spawned with the `terminal` option.
* Returns `undefined` if no terminal was attached.
*
* When a terminal is attached, `stdin`, `stdout`, and `stderr` return `null`.
* Use `terminal.write()` and the `data` callback instead.
*
* @example
* ```ts
* const proc = Bun.spawn(["bash"], {
* terminal: { data: (term, data) => console.log(data.toString()) },
* });
*
* proc.terminal?.write("echo hello\n");
* ```
*/
readonly terminal: Terminal | undefined;
/**
* Access extra file descriptors passed to the `stdio` option in the options object.
*/
@@ -6102,6 +6158,154 @@ declare module "bun" {
"ignore" | "inherit" | null | undefined
>;
/**
* Options for creating a pseudo-terminal (PTY).
*/
interface TerminalOptions {
/**
* Number of columns for the terminal.
* @default 80
*/
cols?: number;
/**
* Number of rows for the terminal.
* @default 24
*/
rows?: number;
/**
* Terminal name (e.g., "xterm-256color").
* @default "xterm-256color"
*/
name?: string;
/**
* Callback invoked when data is received from the terminal.
* @param terminal The terminal instance
* @param data The data received as a Uint8Array
*/
data?: (terminal: Terminal, data: Uint8Array<ArrayBuffer>) => void;
/**
* Callback invoked when the PTY stream closes (EOF or read error).
* Note: exitCode is a PTY lifecycle status (0=clean EOF, 1=error), NOT the subprocess exit code.
* Use Subprocess.exited or onExit callback for actual process exit information.
* @param terminal The terminal instance
* @param exitCode PTY lifecycle status (0 for EOF, 1 for error)
* @param signal Reserved for future signal reporting, currently null
*/
exit?: (terminal: Terminal, exitCode: number, signal: string | null) => void;
/**
* Callback invoked when the terminal is ready to receive more data.
* @param terminal The terminal instance
*/
drain?: (terminal: Terminal) => void;
}
/**
* A pseudo-terminal (PTY) that can be used to spawn interactive terminal programs.
*
* @example
* ```ts
* await using terminal = new Bun.Terminal({
* cols: 80,
* rows: 24,
* data(term, data) {
* console.log("Received:", new TextDecoder().decode(data));
* },
* });
*
* // Spawn a shell connected to the PTY
* const proc = Bun.spawn(["bash"], { terminal });
*
* // Write to the terminal
* terminal.write("echo hello\n");
*
* // Wait for process to exit
* await proc.exited;
*
* // Terminal is closed automatically by `await using`
* ```
*/
class Terminal implements AsyncDisposable {
constructor(options: TerminalOptions);
/**
* Whether the terminal is closed.
*/
readonly closed: boolean;
/**
* Write data to the terminal.
* @param data The data to write (string or BufferSource)
* @returns The number of bytes written
*/
write(data: string | BufferSource): number;
/**
* Resize the terminal.
* @param cols New number of columns
* @param rows New number of rows
*/
resize(cols: number, rows: number): void;
/**
* Set raw mode on the terminal.
* In raw mode, input is passed directly without processing.
* @param enabled Whether to enable raw mode
*/
setRawMode(enabled: boolean): void;
/**
* Reference the terminal to keep the event loop alive.
*/
ref(): void;
/**
* Unreference the terminal to allow the event loop to exit.
*/
unref(): void;
/**
* Close the terminal.
*/
close(): void;
/**
* Async dispose for use with `await using`.
*/
[Symbol.asyncDispose](): Promise<void>;
/**
* Terminal input flags (c_iflag from termios).
* Controls input processing behavior like ICRNL, IXON, etc.
* Returns 0 if terminal is closed.
* Setting returns true on success, false on failure.
*/
inputFlags: number;
/**
* Terminal output flags (c_oflag from termios).
* Controls output processing behavior like OPOST, ONLCR, etc.
* Returns 0 if terminal is closed.
* Setting returns true on success, false on failure.
*/
outputFlags: number;
/**
* Terminal local flags (c_lflag from termios).
* Controls local processing like ICANON, ECHO, ISIG, etc.
* Returns 0 if terminal is closed.
* Setting returns true on success, false on failure.
*/
localFlags: number;
/**
* Terminal control flags (c_cflag from termios).
* Controls hardware characteristics like CSIZE, PARENB, etc.
* Returns 0 if terminal is closed.
* Setting returns true on success, false on failure.
*/
controlFlags: number;
}
// Blocked on https://github.com/oven-sh/bun/issues/8329
// /**
// *