I spend 8+ hours a day in a terminal. Mostly running Claude Code, reviewing its output, copying bits of it, searching for "that error from 20 minutes ago." The experience is terrible.
Scrolling up through 2000 lines of AI output in tmux. Entering copy mode, carefully selecting text with arrow keys, accidentally losing the selection, starting over. Searching scrollback with Ctrl+R and finding the wrong thing. Giving up and re-running the command.
In 2026, we have AI writing entire codebases, and I'm still grep-ing scrollback with my eyes like it's a VT100 in 1978.
So I decided to build a better shell.
I was wrong about what to build, but the journey taught me something about terminals that most developers don't know.
The obvious idea: build a shell
I've been programming since 1979 — Z80 assembler, Forth, factory automation. I build my own tools. I maintain syslenz, a TUI that structures /proc and /sys data because cat /proc/meminfo is a 1970s experience. Why not apply the same idea to the shell itself?
I studied how bash, zsh, fish, and nushell work. I compared their completion systems, their scripting models, their prompt architectures. I designed a grammar-driven shell using my own parser combinator library, with UBNF definitions that would drive parsing, completion, syntax highlighting, and debugging — all from a single grammar file.
It was a beautiful design. It was also the wrong thing to build.
The question that changed everything
Before writing code, I asked myself: what is my actual problem?
It's not "bash's syntax is ugly." I've lived with it for decades. It's not "I want better tab completion." zsh already does that well enough.
My problem is: Claude Code outputs 2000 lines, and I can't navigate them.
And that's not a shell problem. Here's why.
After fork(), your shell is not on the data path
This is the thing most developers don't realize. When you type claude-code "fix the auth bug" and press Enter, here's what happens:
bash (pid=100)
│
├─ fork()
│ └─ child (pid=101)
│ └─ exec("claude-code", ...)
│ │
│ ├─ stdout ──→ PTY ──→ terminal emulator
│ └─ stderr ──→ PTY ──→ terminal emulator
│
└─ wait(101) ← bash is just sitting here. Sleeping.
The output from claude-code goes through the PTY (a kernel-level virtual terminal) directly to your terminal emulator. Bash never sees it. Bash is blocked on wait(), doing nothing until the child process exits.
This means no shell — not bash, not zsh, not fish, not any shell you could possibly write — can intercept or structure the output of a running command. The shell is simply not in the data path.
So building a better shell wouldn't fix my scrollback problem. Not even a little.
Where can you actually intervene?
If the shell can't see the output, who can? There are exactly four intervention points:
┌───────────────┐
│ Terminal │ ← A. Rewrite the terminal emulator (Warp did this)
│ emulator │
└───────┬───────┘
│ VT100 escape sequences
┌───────┴───────┐
│ PTY │ ← B. Sit on the PTY master side (tmux does this)
│ (kernel) │
└───────┬───────┘
│ read()/write()
┌───────┴───────┐
│ Child process │ ← C. Modify the program itself (can't, not my code)
│ (claude-code) │
└───────────────┘
D. Shell integration: have bash emit markers (OSC 133)
so the interceptor knows where blocks start/end
Option A is what Warp did — rewrite the entire terminal emulator from scratch in Rust with a GPU-accelerated renderer and block-based output. It works. It's also a massive undertaking, it's closed-source, requires an account, and doesn't work over SSH.
Option B is the tmux layer — a PTY multiplexer that sits between the terminal and the shell. tmux already intercepts all I/O. But tmux uses it for screen splitting and session persistence. It doesn't structure the output.
Option D is how iTerm2, Warp, and VS Code Terminal identify command boundaries. The shell emits special escape sequences (OSC 133) before and after each command, and the terminal reads them.
The answer is B + D: sit on the PTY master side (like tmux), use shell integration markers (like iTerm2) to detect block boundaries.
I realized I had built this before
I had already built syslenz — "Wireshark for /proc." It reads raw OS text interfaces and structures them into navigable, searchable, typed data with a TUI. The pattern was the same:
syslenz: /proc (raw kernel text) → structure it → TUI
ptylenz: PTY (raw byte stream) → structure it → TUI
Same motivation: "Why am I reading raw text dumps in 2026?"
Same solution: intercept → structure → present.
What ptylenz actually does
ptylenz is a PTY proxy. It creates a pseudo-terminal, runs bash inside it, and intercepts all I/O. Every byte passes through ptylenz, but in Normal mode, it does nothing visible — keystrokes go to bash, output goes to your screen. ptylenz is transparent.
The magic is Ctrl+]. Press it, and ratatui takes over with an alternate screen showing all your command output as structured, navigable blocks:
┌─ ptylenz ─ 3 blocks ──────────────────────────────┐
│ │
│ #1 ls -la 14:23:01 12 lines│
│ #2 cargo build 14:23:15 148 lines│
│ ▸#3 claude-code "fix auth" 14:24:02 2847 lines│
│ │
│ j/k navigate Enter expand / search y copy │
└───────────────────────────────────────────────────┘
Press v on a block to enter Detail view — full-screen vim-style navigation with linewise (v) and blockwise (Ctrl+v) selection. Copy gives you the raw output, without terminal-width line wrapping and without ANSI escape codes.
Press q or Ctrl+] again to go back. Bash is right where you left it.
The copy problem, solved at the right layer
Here's a concrete example of why the PTY layer matters.
Paste a long curl command into an 80-column terminal:
curl -X POST https://api.example.com/v1/users -H "Authorization: Bearer tok_very_long_token_xxx" -d '{"name":"test","email":"[email protected]"}'
The terminal wraps it for display. Now try to copy it.
With tmux: tmux copies what's on the screen grid. You get the command broken into 2-3 lines with \n inserted at the wrap points. Paste it somewhere and it's broken.
With mouse select: Same problem. You're selecting screen cells, not data.
With ptylenz: ptylenz copies from block.output — the raw bytes that passed through the PTY. Terminal width never entered the picture. You get the original one-liner.
This isn't a feature I added. It's a consequence of being at the right layer in the stack.
What I learned
Building the wrong thing taught me more than building the right thing directly would have.
Understand the data path before you design. I spent days designing a grammar-driven shell before realizing the shell isn't on the path that matters. Five minutes with a fork()/exec() diagram would have saved me.
"Where does the data flow?" is a better question than "what tool should I build?" The scrollback problem feels like a shell problem, looks like a terminal problem, but is actually a PTY problem.
Small scope wins. ptylenz doesn't try to be a better shell, a better terminal emulator, a session manager, or an AI tool. It does one thing: structure the PTY byte stream into blocks. The vim-style navigation, search, and copy follow naturally from having blocks.
Try it
cargo install --path .
# or grab a binary from GitHub Releases
ptylenz
Zero config. One binary. Your bash, your .bashrc, your aliases — all untouched. Just structured.
https://github.com/opaopa6969/ptylenz
ptylenz is part of the "lenz" family: syslenz structures /proc, ptylenz structures PTY streams, and claude-session-replay replays AI coding sessions. All Rust, all TUI, all zero-config.
SOCIAL SHARE CARD GENERATOR