RubyTUI

RubyTUI is a terminal user interface library written in pure Ruby with no runtime dependencies. It provides immediate-mode rendering with double buffering, constraint-based layouts, built-in widgets, keyboard and mouse input, an optional Model-View-Update application framework, and inline images through the Kitty graphics protocol.

It is suited both to full-screen interactive applications and to command-line tools that only need styled, one-shot output such as tables, boxes and progress bars.

Installation

gem install rubytui

Or add it to your Gemfile:

gem "rubytui"

Quick Start

require "rubytui"

RubyTUI.run do |terminal, reader|
  loop do
    terminal.draw do |frame|
      style = RubyTUI::Style.new.fg(RubyTUI::Color::GREEN).bold
      frame.buffer.set_string(0, 0, "Hello, RubyTUI! Press q to quit.", style)
    end

    event = reader.poll(timeout_ms: 50)
    break if event.is_a?(RubyTUI::KeyEvent) && event.char == "q"
  end
end

RubyTUI.run puts the terminal into raw mode, hides the cursor and switches to the alternate screen, then yields a RubyTUI::Terminal and a RubyTUI::Input::Reader. The terminal is restored when the block returns or raises.

Core Concepts

Rendering model

RubyTUI redraws the whole interface on every frame. Terminal#draw yields a RubyTUI::Frame; you render widgets into it, and when the block returns the terminal compares the new frame with the previous one and writes only the cells that changed.

  • frame.area is a RubyTUI::Rect covering the whole screen.
  • frame.render_widget(widget, rect) renders a stateless widget.
  • frame.render_stateful_widget(widget, rect, state) renders a stateful widget.
  • frame.buffer is the underlying RubyTUI::Buffer, a grid of cells (a character plus a style). Write to it directly with set_string(x, y, text, style) and set_style(rect, style).
  • frame.set_cursor_position(x, y) shows the terminal cursor at a position after the frame is drawn; otherwise the cursor stays hidden.

The terminal handles SIGWINCH (replacing any existing handler for it): after a resize, the next draw uses the new size and clears the screen. terminal.force_redraw! makes the next draw repaint everything.

RubyTUI.run accepts mouse: (default false), alternate_screen: (default true; pass false to keep the output in the scrollback after exit), input: and output: (default $stdin and $stdout; for example, pass input: File.open("/dev/tty") when standard input is redirected). For manual control use RubyTUI.init, which returns [terminal, reader], and RubyTUI.restore(terminal), passing the same alternate_screen: value to both.

Widgets

A widget is any object that responds to render(area, buf). A stateful widget responds to render(area, buf, state), where the state object belongs to your application and persists across frames (for example the selected row of a list). Most widgets are cheap to construct and are typically built inside draw from your current data; AsyncImage is the exception and must be kept across frames.

Styles and colors

RubyTUI::Style is immutable; each builder method returns a new style.

base    = RubyTUI::Style.new.fg(RubyTUI::Color::WHITE).bg(RubyTUI::Color.indexed(236))
warning = base.patch(RubyTUI::Style.new.fg(RubyTUI::Color.rgb(255, 170, 0)).bold)

warning.bold?    # => true
warning.italic?  # => false
  • Colors: the 16 named constants (RubyTUI::Color::RED, RubyTUI::Color::BRIGHT_BLUE, ...; GRAY and the LIGHT_* names are aliases for the bright variants), RubyTUI::Color::RESET for the terminal default, RubyTUI::Color.indexed(0..255) and RubyTUI::Color.rgb(r, g, b).
  • Modifiers: bold, dim, italic, underlined, reversed, strikethrough, each with a query method (bold?, ...). add_modifier and remove_modifier take RubyTUI::Modifier constants.
  • a.patch(b) layers b over a: colors set in b win, and modifiers are combined.

Styled text is built from RubyTUI::Span (text plus style), RubyTUI::Line (a sequence of spans) and RubyTUI::Text (a sequence of lines; a string is split on newlines):

line = RubyTUI::Line.new([
  RubyTUI::Span.new("Status: ", RubyTUI::Style.new.bold),
  RubyTUI::Span.new("OK", RubyTUI::Style.new.fg(RubyTUI::Color::GREEN))
])
paragraph = RubyTUI::Widgets::Paragraph.new(text: line)

Colors are sent to the terminal exactly as specified. To support terminals with fewer colors, use RubyTUI::ColorMode to detect the capability and convert colors yourself:

mode   = RubyTUI::ColorMode.detect   # => :truecolor, :color256 or :basic16
accent = RubyTUI::ColorMode.downgrade(RubyTUI::Color.rgb(255, 128, 0), mode)

detect inspects COLORTERM, TERM and TERM_PROGRAM and caches the result; RubyTUI::ColorMode.force(mode) overrides it. downgrade(color, mode) uses the detected mode when mode is omitted. For :color256 it maps RGB colors to the nearest 256-color index; for :basic16 it maps RGB and indexed colors to a named color; other colors are returned unchanged.

Layout

RubyTUI::Layout::Layout splits a Rect into rows or columns according to a list of constraints and returns one Rect per constraint.

area = RubyTUI::Rect.new(0, 0, 80, 24)

header, body, footer = RubyTUI::Layout::Layout.vertical([
  RubyTUI::Constraint.length(3),
  RubyTUI::Constraint.fill(1),
  RubyTUI::Constraint.length(1)
]).split(area)

sidebar, main = RubyTUI::Layout::Layout.horizontal(
  [RubyTUI::Constraint.percentage(25), RubyTUI::Constraint.fill(1)],
  spacing: 1
).split(body)
Constraint Size
length(n) n cells
percentage(n) n percent of the available space
ratio(num, den) num / den of the available space
fill(weight = 1) A share of the remaining space, proportional to weight
min(n) At least n cells, plus a weight-1 share of the remaining space
max(n) A weight-1 share of the remaining space, at most n cells

length, percentage and ratio are allocated first, in order; the remaining space is then divided among fill, min and max. spacing: inserts a gap between segments. When the constraints leave space unused, flex: decides where it goes: :start and :stretch (the default) leave it at the end, and :end, :center, :space_between and :space_around position the segments accordingly. The same values are available as constants in RubyTUI::Layout::Flex.

RubyTUI::Rect accepts positional (Rect.new(x, y, width, height), Rect[x, y, width, height]) or keyword (Rect.new(x:, y:, width:, height:)) arguments, and provides right, bottom, empty?, inner(margin) and inner_rect(top:, right:, bottom:, left:). Layouts nest by splitting the rects returned by another layout.

Widgets

All built-in widgets live in RubyTUI::Widgets. Most accept a block: argument (a Block) that draws a border and title around them.

Widget Kind Description
Block stateless Borders on all or selected sides, title aligned left, center or right, padding, background style. block.inner(area) returns the area inside the borders and padding.
Paragraph stateless Multi-line styled text with word, character or no wrapping, aligned left, center or right.
List + ListState stateful Scrollable, selectable list with a highlight symbol and style; scrolls to keep the selection visible.
Table + TableState stateful Rows with an optional header and separator, row selection, and fixed or equally divided column widths.
Gauge stateless Horizontal progress bar with eighth-of-a-cell precision and a centered label (the percentage by default).
Sparkline stateless Compact bar chart, one column per value, showing the most recent values that fit.
Scrollbar stateless Vertical or horizontal scroll indicator with a proportional thumb and optional arrows.
Tabs stateless Horizontal tab bar with active and inactive styles and a configurable divider.
Canvas stateless Points, lines and text labels in data coordinates, drawn with Braille characters (2x4 dots per cell).
Chart + Dataset stateless Line chart of one or more datasets with optional labeled axes and fixed or data-derived bounds. Dataset names are not displayed.
Popup stateless Overlay that clears its area and draws a block and an optional shadow; sized in cells or as a percentage of the parent, centered or placed at a fixed position. popup.inner(area) returns its content area.
InputField + InputState stateful Single-line text input with a cursor, horizontal scrolling and a placeholder.
Image stateless PNG or raw RGBA image drawn with the Kitty graphics protocol. See Images.
AsyncImage stateless Loads image data on a background thread and draws a placeholder until it is ready.

Block borders are chosen with borders: (:all, the default, :none, a single side such as :top, or an array of sides) and drawn with border_set:: RubyTUI::Symbols::ROUNDED (the default), LIGHT, DOUBLE, THICK or PLAIN (ASCII +, -, |).

Canvas keeps every shape added to it, so create a new canvas for each frame and give it both x_bounds: and y_bounds::

canvas = RubyTUI::Widgets::Canvas.new(x_bounds: [0, 10], y_bounds: [0, 10])
canvas.line(0, 0, 10, 10).point(5, 2).label(1, 9, "peak")

The following example renders several widgets once to standard output:

require "rubytui"

RubyTUI.render_once(width: 60, height: 12) do |frame|
  tabs_area, middle, bottom = RubyTUI::Layout::Layout.vertical([
    RubyTUI::Constraint.length(1),
    RubyTUI::Constraint.fill(1),
    RubyTUI::Constraint.length(3)
  ]).split(frame.area)
  left, right = RubyTUI::Layout::Layout.horizontal(
    [RubyTUI::Constraint.percentage(50), RubyTUI::Constraint.fill(1)],
    spacing: 1
  ).split(middle)

  tabs = RubyTUI::Widgets::Tabs.new(titles: ["Overview", "Logs", "Help"], selected: 0)
  frame.render_widget(tabs, tabs_area)

  table = RubyTUI::Widgets::Table.new(
    header: ["Host", "Status"],
    rows: [["web-1", "up"], ["db-1", "down"]],
    block: RubyTUI::Widgets::Block.new(title: " Hosts ")
  )
  frame.render_stateful_widget(table, left, RubyTUI::Widgets::TableState.new)

  sparkline = RubyTUI::Widgets::Sparkline.new(
    data: [3, 5, 2, 8, 6, 9, 4],
    block: RubyTUI::Widgets::Block.new(title: " Load ")
  )
  frame.render_widget(sparkline, right)

  gauge = RubyTUI::Widgets::Gauge.new(ratio: 0.62, block: RubyTUI::Widgets::Block.new(title: " Disk "))
  frame.render_widget(gauge, bottom)
end

Input

reader.poll(timeout_ms: 100) waits up to the given time and returns a RubyTUI::KeyEvent, a RubyTUI::MouseEvent, or nil when no recognized input arrived. reader.read blocks until an event arrives.

Keyboard

A KeyEvent has a code, a char and modifier predicates (ctrl?, alt?, shift?).

  • code is one of the symbols in RubyTUI::Input::Key: CHAR, ENTER, ESCAPE, BACKSPACE, DELETE, TAB, BACKTAB (Shift+Tab), UP, DOWN, LEFT, RIGHT, HOME, END_KEY, PAGE_UP, PAGE_DOWN, INSERT and F1 to F12.
  • For CHAR events, char holds the character. Ctrl+letter arrives as the lowercase letter with ctrl? true, except Ctrl+H, Ctrl+I and Ctrl+M, which the terminal sends as Backspace, Tab and Enter. Alt+key arrives with alt? true.
  • Arrow keys, Home and End also report Shift, Alt and Ctrl modifiers.
  • A lone Escape key press is reported after a 50 ms wait that separates it from escape sequences.
  • In raw mode, Ctrl+C does not send SIGINT; it arrives as a key event, so handle it if your application should quit on it.
require "rubytui"

items = ["Apples", "Bananas", "Cherries"]
state = RubyTUI::Widgets::ListState.new

RubyTUI.run do |terminal, reader|
  loop do
    terminal.draw do |frame|
      body, footer = RubyTUI::Layout::Layout.vertical([
        RubyTUI::Constraint.fill(1),
        RubyTUI::Constraint.length(1)
      ]).split(frame.area)

      list = RubyTUI::Widgets::List.new(
        items: items,
        block: RubyTUI::Widgets::Block.new(title: " Fruit ")
      )
      frame.render_stateful_widget(list, body, state)
      frame.buffer.set_string(footer.x, footer.y, "Up/Down to move, q or Ctrl+C to quit")
    end

    event = reader.poll(timeout_ms: 50)
    next unless event.is_a?(RubyTUI::KeyEvent)

    case event.code
    when RubyTUI::Input::Key::UP   then state.select_previous(items.length)
    when RubyTUI::Input::Key::DOWN then state.select_next(items.length)
    when RubyTUI::Input::Key::CHAR
      break if event.char == "q" || (event.ctrl? && event.char == "c")
    end
  end
end

Text input

InputState#handle_key(event) applies a key event to the input and returns true if it consumed the event. Printable characters are inserted; Backspace, Delete, Left, Right, Home and End edit and move the cursor; Ctrl+A and Ctrl+E move to the start and end; Ctrl+K and Ctrl+U delete to the end and to the start; Ctrl+W deletes the previous word. Other keys, such as Enter, Tab and Escape, are left to your application.

state = RubyTUI::Widgets::InputState.new
field = RubyTUI::Widgets::InputField.new(
  placeholder: "Search...",
  block: RubyTUI::Widgets::Block.new(title: " Query ")
)

# Inside terminal.draw:
frame.render_stateful_widget(field, area, state)

# In the event loop:
if event.is_a?(RubyTUI::KeyEvent) && event.code == RubyTUI::Input::Key::ENTER
  search(state.text)
elsif event
  state.handle_key(event)
end

Mouse

RubyTUI.run(mouse: true) enables reporting of button presses, releases, drags and the scroll wheel. A MouseEvent has:

  • button: :left, :middle, :right, :scroll_up, :scroll_down or :none (constants RubyTUI::MouseEvent::LEFT and so on)
  • action: :press, :release or :drag (scroll-wheel events are reported as :press)
  • x, y: zero-based cell coordinates
  • ctrl?, alt?, shift?
require "rubytui"

RubyTUI.run(mouse: true) do |terminal, reader|
  message = "Click or scroll; press q to quit"

  loop do
    terminal.draw { |frame| frame.buffer.set_string(0, 0, message) }

    case (event = reader.poll(timeout_ms: 50))
    when RubyTUI::MouseEvent
      message = "#{event.action} #{event.button} at #{event.x},#{event.y}"
    when RubyTUI::KeyEvent
      break if event.char == "q"
    end
  end
end

App Framework (Model-View-Update)

For structured applications, include RubyTUI::App and implement the hooks below. Every hook has a default, so implement only the ones you need.

Hook Purpose
init Returns the initial model (any object; a Hash by default).
handle_event(event) Converts an input event into a message, or returns nil to ignore it.
handle_tick(model) Called after every poll, whether or not an event arrived; returns a message or nil. Use it for animations and periodic work.
update(model, msg) Returns the next model. Call throw(:quit) or throw(:quit, value) to leave the loop.
view(model, frame) Draws the model into the frame.
on_start(terminal, reader) Called once after init, before the loop starts.
on_stop(value) Called once after the loop ends and before the terminal is restored, with the value given to throw(:quit, value) (or nil).
require "rubytui"

class Counter
  include RubyTUI::App

  def init
    { count: 0 }
  end

  def handle_event(event)
    return unless event.is_a?(RubyTUI::KeyEvent)

    case event.char
    when "+" then :increment
    when "-" then :decrement
    when "q" then :quit
    end
  end

  def update(model, msg)
    case msg
    when :increment then model.merge(count: model[:count] + 1)
    when :decrement then model.merge(count: model[:count] - 1)
    when :quit      then throw(:quit, model)
    else model
    end
  end

  def view(model, frame)
    frame.buffer.set_string(0, 0, "Count: #{model[:count]}  (+/- to change, q to quit)")
  end
end

Counter.run!

Counter.run! is shorthand for Counter.new.run!. run! accepts mouse: (default false), tick_ms: (the poll timeout per loop iteration, default 50), input: and output:, and sets up and restores the terminal like RubyTUI.run. Each iteration draws view, polls for an event, passes it through handle_event and update, then calls handle_tick.

One-Shot Output for CLI Tools

These functions render once and write ANSI-styled text to $stdout (or to output:), with no raw mode, alternate screen or event loop. The output stays in the terminal scrollback. When width: is omitted, the width of the output terminal is used (80 columns if it is not a terminal).

render_once

RubyTUI.render_once yields a Frame for any widgets and layouts, prints the result followed by a newline, and returns the frame. Pass height: explicitly; it defaults to the terminal height.

require "rubytui"

RubyTUI.render_once(width: 40, height: 3) do |frame|
  block = RubyTUI::Widgets::Block.new(title: " Report ")
  frame.render_widget(block, frame.area)
  frame.buffer.set_string(2, 1, "All checks passed", RubyTUI::Style.new.fg(RubyTUI::Color::GREEN))
end
require "rubytui"

RubyTUI.print_table(
  header: ["Name", "Price", "Change"],
  rows: [["AAPL", "150.00", "+2.3%"], ["GOOG", "2800", "-1.1%"]],
  title: " Stocks ",
  border: :rounded,
  width: 40
)

RubyTUI.print_box(
  title: " Summary ",
  content: "Total: $1,234\nProfit: +5.2%",
  border_style: RubyTUI::Style.new.fg(RubyTUI::Color::YELLOW),
  width: 30
)

RubyTUI.print_gauge(ratio: 0.75, title: " Progress ", width: 40)
  • print_table(header:, rows:, title:, border:, width:, ...) also accepts widths:, column_spacing:, header_style:, style: and border_style:. The border is drawn only when title: is given.
  • print_box(title:, content:, border:, width:, ...) takes a string (split on newlines) or an array of lines, and also accepts alignment:, text_style:, style: and border_style:.
  • print_gauge(ratio:, label:, title:, width:, ...) also accepts gauge_style:, border: and border_style:. The border is drawn only when title: is given.

border: is one of :rounded (the default), :light, :double, :thick or :plain, or a RubyTUI::Symbols border set.

Printing a buffer

require "rubytui"

buf = RubyTUI::Buffer.new(40, 1)
buf.set_string(0, 0, "Hello!", RubyTUI::Style.new.fg(RubyTUI::Color::GREEN).bold)
puts buf.to_ansi

Buffer.new(width, height) creates a buffer at the origin; Buffer.new(rect) uses an existing Rect. buf.to_ansi returns the content as a string with ANSI styles and one line per row; buf.print(io = $stdout) writes it.

Images

RubyTUI::Widgets::Image displays images in terminals that implement the Kitty graphics protocol, such as kitty, Ghostty and WezTerm. The specification lists other terminals that implement it.

Formats

  • PNG is sent to the terminal as is; its dimensions are read from the file header.
  • Raw 8-bit RGBA pixel data requires format: :rgba together with pixel_width: and pixel_height:.
  • JPEG, WebP, GIF and BMP data is detected and raises RubyTUI::UnsupportedImageFormat. Convert such images to PNG first, for example with ImageMagick: system("magick", "input.jpg", "output.png").
# From a PNG file
image = RubyTUI::Widgets::Image.new(path: "photo.png")
frame.render_widget(image, area)

# With a border and title
image = RubyTUI::Widgets::Image.new(
  path: "photo.png",
  block: RubyTUI::Widgets::Block.new(title: " Photo ")
)

# From raw RGBA pixels
image = RubyTUI::Widgets::Image.new(data: rgba_bytes, pixel_width: 800, pixel_height: 600, format: :rgba)

Sizing

fit: controls how the image is placed in its area:

  • :stretch (the default) scales the image to fill the area without preserving its aspect ratio.
  • :contain uses the largest centered part of the area that preserves the aspect ratio.
  • :cover computes a placement that would cover the area and limits it to the area. The image is not cropped, so the result is the same as :stretch; to show part of an image, use src_rect:.

Aspect-ratio calculations need the size of a terminal cell in pixels. Pass it as cell_size: [width, height]; the default is [9, 18]. terminal.cell_pixel_size asks the terminal for the real value, waits up to 100 ms for the reply, falls back to [9, 18], and caches the result. Call it once, before your event loop starts reading input.

cell_size = terminal.cell_pixel_size   # e.g. [9, 18]
image = RubyTUI::Widgets::Image.new(path: "photo.png", fit: :contain, cell_size: cell_size)

# Or compute the placement yourself
dims = RubyTUI::Widgets::Image.png_dimensions(File.binread("photo.png"))  # => [width, height] or nil
rect = RubyTUI::Widgets::Image.fit_area(area, dims[0], dims[1], cell_size: cell_size, mode: :contain)

Cropping and scrolling

src_rect: selects a rectangle of the source image, in pixels, to display. Changing its offset between frames scrolls through a large image:

image = RubyTUI::Widgets::Image.new(
  path: "tall_image.png",
  src_rect: RubyTUI::Rect.new(0, scroll_offset_px, image_width_px, visible_height_px)
)

Lifecycle

Images are tracked by position and content. When an image disappears, moves or changes between frames, the old image is deleted from the terminal and the following frame is repainted in full. terminal.clear_all_images! removes every image immediately.

RubyTUI::Widgets::Image.detect_format(data) returns :png, :jpeg, :webp, :gif or :bmp from the file signature, or :rgba when the data is not recognized.

AsyncImage

RubyTUI::Widgets::AsyncImage runs a loader on a background thread and renders the image once the loader returns PNG data. Create it once and keep it across frames.

image = RubyTUI::Widgets::AsyncImage.new(
  loader: -> { File.binread("large.png") },
  fit: :contain,
  on_error: ->(error) { warn error.message }
)

# Inside terminal.draw:
frame.render_widget(image, area)

image.loading?  # => true while the loader runs
image.ready?    # => true once the image can be drawn
image.error?    # => true if the loader raised or returned unsupported data; see image.error
  • While loading, Loading... is drawn centered in the area; after a failure, error_placeholder: (default [Error]) is drawn.
  • placeholder: accepts a widget that includes RubyTUI::Widget (all built-in widgets do), which is drawn instead until the image is ready, or a string, which is shown before loading starts when auto_start: false is given. With auto_start: false, call start_loading to begin; cancel stops the loader thread.
  • on_ready: is called on the loader thread when the loader has returned (not on a cache hit); on_error: receives the exception raised by the loader.
  • cache: (a Hash shared between instances) and key: reuse loaded data: when the cache already holds the key, the widget is ready immediately and the loader is not called.
  • block:, style: and cell_size: work as for Image.

Custom Widgets

Any object with a render(area, buf) method can be rendered. Including RubyTUI::Widget is optional and documents the protocol.

require "rubytui"

class Badge
  include RubyTUI::Widget

  def initialize(label)
    @label = label
  end

  def render(area, buf)
    text  = RubyTUI::Unicode.truncate_to_width(" #{@label} ", area.width)
    style = RubyTUI::Style.new.fg(RubyTUI::Color::BLACK).bg(RubyTUI::Color::CYAN)
    buf.set_string(area.x, area.y, text, style)
  end
end

RubyTUI.render_once(width: 20, height: 1) do |frame|
  frame.render_widget(Badge.new("beta"), frame.area)
end

Stateful widgets implement render(area, buf, state), may include RubyTUI::StatefulWidget, and are rendered with frame.render_stateful_widget.

Unicode Width

Text is measured and stored by grapheme cluster, the unit a terminal displays as one character:

  • CJK characters and emoji occupy two columns, including symbols with emoji presentation by default (such as ✅) and text symbols followed by the emoji variation selector U+FE0F (such as ⚠️).
  • Combining marks, such as Latin diacritics and Devanagari vowel signs, stay in the same cell as their base character.
  • Buffer#set_string stores each cluster in one cell and reserves the following cell for two-column clusters.

RubyTUI::Unicode exposes the same calculations:

RubyTUI::Unicode.string_width("Hi🔥")           # => 4
RubyTUI::Unicode.truncate_to_width("中文字", 5)  # => "中文"
RubyTUI::Unicode.cluster_width("⚠️")            # => 2
RubyTUI::Unicode.char_width("a")               # => 1

Testing

RubyTUI::TestBackend is an in-memory backend for testing rendering without a terminal.

require "minitest/autorun"
require "rubytui"

class TitleTest < Minitest::Test
  def test_block_title
    backend  = RubyTUI::TestBackend.new(width: 20, height: 3)
    terminal = RubyTUI::Terminal.new(backend: backend)

    terminal.draw do |frame|
      frame.render_widget(RubyTUI::Widgets::Block.new(title: " Hi "), frame.area)
    end

    assert_equal "Hi", backend.text_at(3, 0, 2)
    assert_equal "╰──────────────────╯", backend.row_text(2)
    assert_equal "╭", backend.cell_at(0, 0).symbol
  end
end
  • cell_at(x, y) returns the RubyTUI::Cell (symbol and style) at a position.
  • text_at(x, y, length) returns the characters of length cells starting at a position.
  • row_text(y) returns a whole row without trailing whitespace.
  • draw_count, total_changes and draw_history record what each draw sent; cursor_visible, cursor_x and cursor_y record the cursor; image_history records image placements; reset! clears the grid and history.

Widgets can also be tested without a terminal by rendering them into a Buffer and reading cells with buf[x, y].

Requirements

  • Ruby 3.2 or later
  • A terminal that supports ANSI escape sequences
  • For images, a terminal that implements the Kitty graphics protocol

License

Released under the MIT License. The full text is in the LICENSE file included with the gem.