term-codec

term-codec

A Crystal library for decoding and encoding terminal escape sequences. Incoming CSI, OSC, DCS and APC sequences are parsed into typed packets; outgoing packets are serialized back to their canonical wire form.

Installation

Add the dependency to your shard.yml:

dependencies:
  term-codec:
    github: shpeckman/term-codec

Then run shards install. Requires Crystal >= 1.21.

Usage

Decoding

Route a single sequence to a typed packet:

require "term-codec"

packet = Term::Codec.route("\e[I")
# => Term::Codec::FocusTracking::Report(Term::Codec::FocusTracking::Focus::In)

Unrecognized or malformed sequences decode to Term::Codec::Unknown, which keeps the raw bytes.

Stream a byte flow through the router; it handles concatenated sequences, chunk boundaries and interleaved plain text:

router = Term::Codec::Router.new
router.feed(bytes) do |packet|
  case packet
  when Term::Codec::Mouse::Press    then handle_press(packet)
  when Term::Codec::Keyboard::KeyEvent then handle_key(packet)
  when Term::Codec::Unknown         then forward(packet.raw)
  end
end

# or straight from an IO
Term::Codec::Router.each(STDIN) { |packet| handle(packet) }

Each protocol module also parses on its own:

Term::Codec::Mouse.parse("\e[<0;10;5M")  # => Mouse::Press
Term::Codec::Mouse.parse?("garbage")     # => nil

Encoding

Every codec serializes its packets back to the wire — either directly:

Term::Codec::Mouse.encode(Term::Codec::Mouse::Press.new(
  Term::Codec::Mouse::Button::Left, Term::Codec::Mouse::Mods::None, 10, 5))
# => "\e[<0;10;5M"

or through the central dispatcher, which routes on the packet's type:

Term::Codec.encode(packet)        # => String
Term::Codec.encode(packet, io)    # writes to an IO

Term::Codec.encode accepts Term::Codec::EncodablePacket — a compile-time union of every encodable packet type plus Unknown (which passes its raw bytes through). Passing a packet type no codec can encode is a compile error, not a runtime failure.

Encoding is canonical: OSC sequences terminate with ST, base64 payloads are padded, hex output is uppercase. Decoding remains lenient (BEL or ST, relaxed base64, either hex case), so the round-trip guarantee is semantic:

Term::Codec.route(Term::Codec.encode(packet)) == packet

Multi-sequence operations (chunked clipboard sessions, notification bodies split across chunks) stay available as module-level helpers, e.g. Term::Codec::Clipboard::Osc5522.write(io, data) and Term::Codec::Notify.notify(io, title, body).

Protocols

Module Sequences
Term::Codec::Clipboard OSC 52, OSC 5522 clipboard read/write
Term::Codec::ColorControl OSC 21 dynamic colors, OSC 4/10x legacy palette
Term::Codec::ColorScheme dark/light scheme query and report
Term::Codec::DeviceAttributes DA1/DA2/DA3 requests and reports
Term::Codec::DeviceStatus DSR, CPR, DECXCPR, DECRQPSR (DECCIR/DECTABSR)
Term::Codec::Dnd OSC 73 drag-and-drop
Term::Codec::FileTransfer OSC 5113 file transfer
Term::Codec::FocusTracking focus in/out reports
Term::Codec::Glyph APC glyph protocol (PUA glyph registration)
Term::Codec::Graphics APC graphics protocol
Term::Codec::InbandResize in-band size reports
Term::Codec::Keyboard kitty keyboard protocol
Term::Codec::Mouse SGR mouse events
Term::Codec::MultipleCursors multiple cursor shapes and colors
Term::Codec::Notify OSC 9/99 notifications
Term::Codec::PointerShape OSC 22 pointer shapes
Term::Codec::Visibility window visibility query and report
Term::Codec::XtermQuery XTVERSION, XTGETTCAP, XTGETXRES
Term::Codec::Xtwinops CSI t window operations, OSC title stack

Defining a codec

Protocols are declared with the Term::Codec.codec macro: route declarations tell the router which sequences belong to the codec, a decode block parses bytes into a packet, and an optional encode block serializes packets back:

module Term::Codec::FocusTracking
  abstract struct Packet
    abstract def write(io : IO) : Nil
  end

  record Report < Packet, focus : Focus do
    def write(io : IO) : Nil
      io << (@focus.in? ? "\e[I" : "\e[O")
    end
  end

  Term::Codec.codec do
    route :csi, final: {'I', 'O'}

    decode do |bytes|
      # Bytes -> Packet?
    end

    encode do |packet, io|
      packet.write(io)
    end
  end
end

Route kinds: :csi (by final char, optional prefix), :osc (by command number), :dcs and :apc (by prefix string). A codec without an encode block is left out of EncodablePacket at compile time.

Development

shards install   # no runtime dependencies
crystal spec     # run the test suite

License

MIT

Repository

term-codec

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 0
  • about 4 hours ago
  • September 7, 2026
License

MIT License

Links
Synced at

Tue, 08 Sep 2026 01:07:37 GMT

Languages