I write a lot of small CLI tools in Bun. The Terminal API just removed the last thing that was still annoying about it.

That last annoyance: spawning interactive programs. Any tool that checks isTTYfzf, vim, git add -p — would either crash or silently refuse to work when called from a Bun script. No real terminal, no party. The usual workaround was stdio: 'inherit', which technically works, but you lose all control over I/O. Can’t capture output. Can’t pipe programmatically. Can’t do anything clever.

The proper solution was node-pty. Now it’s just an option on Bun.spawn.

The problem it actually solves

// fzf detects no TTY and exits immediately
const proc = Bun.spawn(["fzf"], { stdout: "pipe" });
// Error: fzf requires a terminal

Bun.Terminal, shipped in v1.3.5 (December 2025), is a native PTY wrapper. The subprocess gets a real pseudo-terminal — process.stdout.isTTY === true, colors work, cursor movement works, arrow keys work. The subprocess has no idea it’s not in a real terminal.

How to use it

await using terminal = new Bun.Terminal({
  cols: process.stdout.columns,
  rows: process.stdout.rows,
  data(_, data) {
    process.stdout.write(data); // pipe subprocess output to your terminal
  },
});

// Forward your keypresses into the subprocess
process.stdin.setRawMode(true);
process.stdin.on("data", (c) => terminal.write(c));

const proc = Bun.spawn(["vim", "notes.txt"], { terminal });
await proc.exited;
process.stdin.setRawMode(false);

await using handles PTY cleanup automatically — no manual .close() calls. The subprocess gets a real PTY and does whatever it wants with the terminal. You stay in control of what happens before and after.

A real use case: interactive commit picker

I use this in a script that fuzzy-picks a recent commit for git commit --fixup. It’s one of those tools I reach for every day but would’ve needed node-pty + execa to build before:

#!/usr/bin/env bun
let selected = "";

await using terminal = new Bun.Terminal({
  cols: process.stdout.columns,
  rows: process.stdout.rows,
  data(_, data) {
    process.stdout.write(data);
    const text = new TextDecoder().decode(data)
      .replace(/\x1b\[[^m]*m/g, "")
      .trim();
    if (text) selected = text;
  },
});

process.stdin.setRawMode(true);
process.stdin.on("data", (c) => terminal.write(c));

const proc = Bun.spawn(
  ["bash", "-c", "git log --oneline -20 | fzf"],
  { terminal }
);

await proc.exited;
process.stdin.setRawMode(false);

if (selected) {
  const hash = selected.split(" ")[0];
  await Bun.$`git commit --fixup ${hash}`;
}

Spawns fzf with a real TTY. You get the full interactive UI. Select a commit, press Enter, and the hash goes straight into git commit --fixup. Zero external dependencies beyond Bun itself.

This is the kind of glue script I write constantly — and it pairs really well with the reusable module pattern I wrote about earlier. Wrap this in a bun link-able module and it’s available in every project.

What it replaces (and what it doesn’t)

Bun.Terminal replaces node-pty and the stdio: 'inherit' hack for the specific case of running interactive subprocesses that need a TTY.

It does not replace ink, blessed, or @clack/prompts. If you’re building your own TUI from scratch — components, layouts, styled prompts — those libraries still make sense. Bun.Terminal is for delegating I/O to a subprocess, not for building a UI out of primitives. The distinction matters: if you’re wrapping fzf, gum, lazygit, or any other interactive tool, this is exactly what you needed. If you want to build the next htop in TypeScript, you need something heavier.

With 2026 being the year of CLIs and agents consuming tools directly, getting your CLI scripts to feel polished without a dependency pile matters more than it used to. And if you’re curious where Bun is heading more broadly, the Anthropic acquisition post has some thoughts on what that means for the runtime’s direction.

This is the Bun-native way to write CLI tools that feel polished. If you’re still using process.stdout.write for everything, try this.