tty-command-window
[!WARNING] This library has been built with Claude Code.
Run shell commands with tty-command, but show only the last N lines of output as a live, fixed-height block in your terminal — like CI log folding, but local and real-time.
The trick: the child process runs in a PTY that reports N rows, so
cursor-driven programs (docker compose, installers, progress bars) lay
themselves out as if the terminal were N lines tall. A built-in pure-Ruby
terminal emulator interprets every escape sequence the child emits — cursor
movement, erase, scroll regions, colors, alternate screen — and paints the
resulting screen into a static block that streams in place.

Installation
Add to your Gemfile:
gem "tty-command-window"
Requires Ruby >= 3.1 and a Unix-like OS for windowed rendering (see Degradation for what happens elsewhere).
Usage
require "tty-command-window"
cmd = TTY::Command.new(printer: :null)
# Everything docker compose draws stays inside 5 lines:
cmd.run_windowed("docker compose up -d", lines: 5)
# Same semantics as tty-command: raises TTY::Command::ExitError on failure,
# run_windowed! doesn't. Returns a TTY::Command::Result.
result = cmd.run_windowed!("make -j8", lines: 8, title: "building")
puts result.out if result.failure?
run_windowed accepts everything run does (env:, chdir:, timeout:,
input:, a streaming block, ...) plus the window options below.
Use
printer: :null.tty-command-windowrenders the child's output itself. If yourTTY::Commandinstance uses a printer other than:null(:pretty,:progress,:quiet), the printer will also write to the terminal in parallel with the window, double-logging every line. Every example in this README usesTTY::Command.new(printer: :null)for that reason.
Options
| Option | Default | What it does |
|---|---|---|
lines: |
5 |
Window height; also the row count the child's PTY reports. |
title: |
the command | Title-bar text. false hides the title bar. |
on_exit: |
:freeze |
What the block does when the command ends (see below). |
capture: |
:raw |
What Result#out contains (see below). |
capture_max_bytes: |
10 MiB |
Cap on raw bytes kept for Result#out; the head is dropped, the tail kept. nil disables. |
scrollback: |
10_000 |
Plain-text history lines kept for dumps and capture: :screen. |
output_log: |
nil |
Tee the full raw child output to this file while rendering. |
interactive: |
false |
Forward your keystrokes to the child's PTY. |
output: |
printer output | IO to render on. |
window: |
auto-detect | Force (true) or forbid (false) windowed rendering. |
on_unavailable: |
:fallback |
:fallback degrades to plain run; :raise raises TTY::Command::Window::Unavailable. |
width: |
auto-detect | Fixed render width. |
End-of-run behavior (on_exit:)
:freeze(default) — the final frame stays in the terminal and the cursor moves below it. The title bar turns into✔ title • 3.1s(green) or✖(red).:dump_on_failure— like:freezeon success; on failure the block is replaced by the full plain-text output history, so the error that scrolled away is right there.:collapse— the block shrinks to a single status line when done.

Result capture (capture:)
:raw(default) —Result#outis exactly what the child wrote, escape codes included (tty-command semantics).Result#erris always"": a PTY merges the child's stderr into the same stream at the OS level, so windowed rendering cannot separate them without destroying the layout fidelity that is the point of the gem. If you need stderr independently, use plainrun/run!.:stripped— the raw stream with ANSI sequences removed.:screen— the emulator's plain-text history (scrollback + final screen): what a human saw, in order, without any escape codes.
Concurrent windows
run_windowed is thread-safe; blocks from concurrent calls stack in the
terminal, each rendering independently:
%w[api worker assets].map do |name|
Thread.new { cmd.run_windowed("bin/build #{name}", lines: 3, title: name, on_exit: :collapse) }
end.each(&:join)

Step windows
A step is a window you open yourself, feed several commands (and plain log lines), and close when the logical unit of work is done — the shape of a step in a CLI progress checklist:
step = TTY::Command::Window::Step.open(title: "starting containers", lines: 8)
cmd = TTY::Command.new(printer: :null)
cmd.run_windowed("docker compose pull", window: step)
step.log "images pulled, starting"
result = cmd.run_windowed!("docker compose up -d", window: step)
step.finish(success: result.success?)
The title bar doubles as the progress line: spinner while open, then
✔ starting containers • 12.3s. Steps default to on_exit: :collapse_or_dump:
on success the window is replaced by that one-line summary as permanent
output (sequential steps leave a compact checklist behind); on failure the
full output history is dumped — cap it with dump_lines: 200. step.run /
step.run! are sugar for run_windowed(..., window: step), and a block form
finishes the step automatically:
TTY::Command::Window::Step.open(title: "deps") do |step|
step.run(cmd, "bundle install")
step.run(cmd, "yarn install")
end
Geometry and end-of-run behavior (lines:, title:, on_exit:,
scrollback:, dump_lines:) are fixed at Step.open time; per-run calls
accept only capture:, capture_max_bytes: and output_log:. Without a
TTY, Step.open returns a PlainStep with the same interface — plain
start/summary marker lines with full streamed output between them, so the
same code produces a readable CI log.
Interactive commands
cmd.run_windowed("bin/deploy", lines: 5, interactive: true)
With interactive: true, the terminal goes into raw mode and your
keystrokes are written to the child's PTY, so Continue? (y/N) prompts work
inside the window. When several interactive windows run at once, Ctrl-O
cycles the keyboard focus between them — the focused window shows a ▸
marker and an inverse title.
Because raw mode delivers Ctrl-C to the focused child as a keystroke
(0x03) rather than signalling your process, the child decides what an
interrupt means while an interactive window is open.
Resize
Terminal resizes are propagated: the child PTYs get the new width (rows stay
at lines:), the child re-renders — as any full-screen program does on
SIGWINCH — and the blocks repaint.
Degradation
Windowed rendering needs a real terminal and PTY support. run_windowed
falls back to a plain run (full streamed output through your configured
printer, no window, no PTY) when:
- stdout (or the given
output:) is not a TTY — CI, pipes, cron; - the OS is Windows, or Ruby's
ptylibrary is unavailable; - the command instance is in
dry_runmode.
The fallback keeps the same raise/no-raise contract, so calling code never
needs a branch. If you need windowed rendering — for example, a test that
asserts the child laid itself out at 5 rows — pass on_unavailable: :raise
to opt out of degradation:
cmd.run_windowed("bin/deploy", lines: 5, on_unavailable: :raise)
# raises TTY::Command::Window::Unavailable when there's no PTY / TTY.
Notes and limitations
- Signals: while windows are active,
SIGINT/SIGTERMare forwarded to the children's process groups (previous handlers are chained and restored afterwards). - Cursor: hidden while windows render, restored afterwards — also via an
at_exithook if the process dies mid-run. Result#outgrowth:capture: :rawkeeps up tocapture_max_bytes:(default 10 MiB) of the raw stream in memory, dropping the oldest bytes beyond that. For multi-hour firehoses prefercapture: :screen(bounded byscrollback:) plusoutput_log:, or raise/disable the cap.- Emulator coverage: the common VT100/xterm repertoire (cursor movement, EL/ED, IL/DL/DCH/ICH/ECH, SU/SD, DECSTBM scroll regions, SGR colors incl. 256/truecolor, alternate screen, autowrap, wide characters, DSR/DA reports). Exotic sequences are ignored rather than leaked to your terminal.
Development
bundle install
bundle exec rake # specs + rubocop
ruby examples/compose_demo.rb # see it live
The examples/ directory doubles as a manual test rig; examples/fake_compose.rb
simulates docker compose's cursor choreography without needing Docker.
License
MIT. See LICENSE.txt.