← blog blog / bun-terminal-api.md

Bun's Terminal API - Write CLI Tools Without the Boilerplate

Bun v1.3.5 shipped a native PTY API. If you've ever had fzf, vim, or any interactive tool silently break inside a Bun script, this is the fix.

Bun's Terminal API - Write CLI Tools Without the Boilerplate

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 isTTY (fzf, 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.

Thanks for reading!

I write about frontend craft, React, TypeScript, and the web. Found this useful? Let me know.

@samuellawrentz →

$ echo "enjoyed this post?" · subscribe via rss ↗

$ git log --oneline --grep="bun"

More articles

cd ../blog →
  1. 50db2ae My lazygit Config - Branch Menus, Ticket Commits and a Dinosaur

    Oct 06, 2026 3 min read tag: gittag: lazygit

    My lazygit Config - Branch Menus, Ticket Commits and a Dinosaur
  2. dce7f89 kron - I Got Tired of Cron Not Telling Me Anything

    Oct 05, 2026 3 min read tag: rusttag: cli

    kron - I Got Tired of Cron Not Telling Me Anything
  3. 413721f Three Years of tmux, One Commit to Leave - Moving to Herdr

    Oct 02, 2026 4 min read tag: herdrtag: tmux

    Three Years of tmux, One Commit to Leave - Moving to Herdr
  4. 80faf7d My Herdr Setup - Keybinds, Popups and Hopping Between Agents

    Oct 01, 2026 4 min read tag: herdrtag: terminal

    My Herdr Setup - Keybinds, Popups and Hopping Between Agents

$ giscus --load ./comments

00:00

This helps me increase the session time of my site. Thank you!

Can you stay a bit longer?