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.

docker compose confined to a 5-line window

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-window renders the child's output itself. If your TTY::Command instance 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 uses TTY::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 :freeze on 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.

failure dump

Result capture (capture:)

  • :raw (default)Result#out is exactly what the child wrote, escape codes included (tty-command semantics). Result#err is 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 plain run/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)

three concurrent windows

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 pty library is unavailable;
  • the command instance is in dry_run mode.

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/SIGTERM are 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_exit hook if the process dies mid-run.
  • Result#out growth: capture: :raw keeps up to capture_max_bytes: (default 10 MiB) of the raw stream in memory, dropping the oldest bytes beyond that. For multi-hour firehoses prefer capture: :screen (bounded by scrollback:) plus output_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.