term
term
term is a Crystal shard for building terminal applications on the kitty family of terminal protocols. One class, Term, puts the terminal into raw mode, turns everything the terminal sends into typed events on a channel, and gives you methods for everything you send back.
It covers:
- the Kitty keyboard protocol, with taps, holds, chords, key sequences and shortcuts derived from it
- pixel-precise mouse reporting, with clicks, drags, hover, scroll and swipe gestures
- resize, focus, visibility and color-scheme notifications
- pointer shapes (OSC 22)
- color stack and color set/query (OSC 21)
- MIME-typed clipboard and paste events (OSC 5522)
- the kitty graphics protocol, including animation and Unicode placeholders
- desktop notifications (OSC 99)
- drag and drop, including remote files and directories (OSC 72)
- a rendering layer,
Term::Render: styled cell planes, compositing with damage tracking and differential ANSI output, color palettes and themes, and grapheme-aware text layout
On terminals that lack these protocols, term falls back to legacy keyboard input, cell-based mouse reports and bracketed paste.
Requirements
- Crystal
>= 1.21.0 - Linux or macOS. macOS support is written but untested. Windows is not supported.
- Depends on
byte_builderandtermwidth(OpenSSL and zlib are linked from the standard library).
Installation
Add the dependency to your shard.yml:
dependencies:
term:
github: shpeckman/term
Then run shards install and:
require "term"
Quick start
require "term"
Term.open do |term|
term.print "\e[2J\e[HPress q to quit"
while event = term.events.receive?
case event
when Term::TextInput then break if event.text == "q"
when Term::Mouse then term.print "\e[2;1H#{event.col},#{event.row} "
when Term::Resize then term.print "\e[3;1H#{event.cols}x#{event.rows} "
end
end
end
Term.openprobes what the terminal supports, enables it, yields, and restores the terminal afterwards. The terminal is also restored on an exception, onexit, and onSIGINT,SIGTERM,SIGHUPandSIGQUIT.term.eventsis aChannel(Term::Event). Read it from one fiber;receive?returnsnilonce the terminal is closed.term.printandterm <<are safe to call from any fiber. At this leveltermdoes no drawing for you: cursor movement and text styling are your own escape sequences — or use theTerm::Renderlayer, which draws whole frames for you.- Queries such as clipboard reads block the calling fiber and return the answer, or
nilon timeout.
How it behaves
Support detection
At open, term asks the terminal about each feature and enables only what is supported. The result is in term.features:
term.features.keyboard? # Kitty keyboard protocol
term.features.pixels? # pixel mouse coordinates
term.features.paste? # MIME-typed paste events
term.features.resize? # in-band resize reports
The full set is Keyboard, Resize, Motion, Pixels, Focus, Visibility, ColorScheme and Paste.
| Missing feature | Fallback |
|---|---|
| Kitty keyboard | Typed bytes become key events, each as a press followed at once by a release. Holds and chords are unavailable; Tab and Ctrl-I, and Enter and Ctrl-M, cannot be told apart; a lone Escape is reported after about 100 ms. |
| Pixel mouse | SGR cell reports. col and row are exact; x and y are the cell's top-left corner. |
| MIME paste | Bracketed paste. The Paste event carries the text in its text field. |
| In-band resize | The window size is read from the terminal, and SIGWINCH produces a Resize event. |
| Focus, visibility, color scheme | None. These events are simply absent. |
A terminal that answers nothing delays Term.open by query_timeout (1 second by default). Set config.detect = false to skip probing and enable everything unchecked.
Signals and job control
While a Term is open the shard installs handlers for SIGINT, SIGTERM, SIGHUP, SIGQUIT, SIGTSTP, SIGCONT and SIGWINCH. They replace any handlers you installed and are not removed on close. Set config.signals = false to keep your own, and call Term.restore, Term.suspend, Term.resume and Term.refresh from them as needed.
Ctrl-Z suspends the process as a shell user expects: the terminal modes are left, the process group is stopped, and the modes are re-entered on continue. The key event is still delivered. Set config.job_control = false to handle Ctrl-Z yourself.
Events and back-pressure
Events are queued internally, so a slow consumer never blocks queries. If more than config.event_backlog events (65,536 by default) pile up, the oldest are dropped.
Blocking queries
Methods that ask the terminal a question return its answer, or nil if none arrives in time. Most accept a trailing limit : Time::Span; the default is config.query_timeout, or config.clipboard_timeout and config.transfer_timeout for clipboard and drag-and-drop transfers, since the terminal may prompt the user.
Configuration
config = Term::Config.new
config.app_name = "editor"
config.shortcut("save", 's', Term::Mods::Ctrl)
config.chord("jk", 'j', 'k')
config.sequence("leader-f", ' ', 'f')
Term.open(config) do |term|
# ...
end
| Option | Default | Meaning |
|---|---|---|
alternate_screen |
true |
Use the alternate screen |
detect |
true |
Probe support at open |
signals |
true |
Install the signal handlers |
job_control |
true |
Ctrl-Z suspends the process |
app_name |
nil |
Name shown in clipboard permission prompts; also enables a per-run password so the user is asked only once |
clipboard_id |
"term" |
Request id for clipboard reads, for multiplexer routing |
dnd_id |
0 |
Multiplexer id added to drag-and-drop codes |
query_timeout |
1 s | Default wait for queries and for startup probing |
clipboard_timeout |
30 s | Wait for clipboard transfers |
transfer_timeout |
30 s | Wait for drag-and-drop transfers |
busy_retries, busy_delay |
3, 100 ms | Clipboard retry when the terminal reports EBUSY |
event_buffer |
1024 | Capacity of the events channel |
event_backlog |
65536 | Internal queue cap before the oldest events are dropped |
hold_repeats |
3 | Repeats before HoldReached |
hold_after |
500 ms | Time before a press becomes a hold when no repeat arrives |
multi_tap_window |
300 ms | Window for counting consecutive taps |
sequence_timeout |
1 s | Reset time for key sequences |
click_radius |
4 px | Distance a click may move |
click_window |
300 ms | Longest press that counts as a click |
multi_click_window |
400 ms | Window for double and triple clicks |
long_press_after |
500 ms | Time before a mouse press becomes a long press |
hover_dwell_after |
500 ms | Time before a still pointer reports a dwell |
hover_radius |
4 px | Movement tolerated during a dwell |
scroll_window |
16 ms | Shortest time between two Scroll gestures while the wheel keeps turning |
scroll_pause |
200 ms | Gap between wheel steps after which scrolling counts as starting again |
swipe_velocity |
500 px/s | Speed at which a drag also reports a swipe |
Bindings:
chord(name, *keys)fires when all keys are held together.sequence(name, *keys)fires when the keys are pressed one after another.shortcut(name, key, mods = Mods::None, physical: false)fires on a key with modifiers. Withphysical: trueit matches the key's position on a standard layout, so it works regardless of keyboard layout.
A key is a Char, a Term::Named or a raw key code. Bindings arrive as Term::Binding events carrying the name.
Shortcuts without a binding
A Term::Shortcut can also be used on its own, to test keys yourself instead of registering a name at open:
save = Term::Shortcut.parse("ctrl+s")
quit = Term::Shortcut.parse("ctrl+q", physical: true)
# in the event loop
when Term::Key
save_file if event.press? && save.matches?(event)
Shortcut.parse(spec, physical: false)reads modifiers and a key joined by+. An unknown modifier or key raisesArgumentError.- Modifiers are the
Term::Modsnames (shift,alt,ctrl,super,hyper,meta), pluscontrol,cmdandcommand. Case does not matter. - Keys are a single character, a
Term::Namedname in any casing (enter,page_up,PageUp,f13,kp_5), orspace,escorreturn. A trailing+is the plus key, as inctrl++. matches?(key)applies the same rule as a configured shortcut: lock modifiers are ignored, andphysicalcompares the key's position on a standard layout.- Shift is consumed when the terminal reports the shifted character. Write
ctrl+S, notctrl+shift+s, for a letter, andctrl++, notctrl+shift+=. The same holds forconfig.shortcut.
Events
Everything on term.events is one of the following.
Keyboard
| Event | Fields | When |
|---|---|---|
Key |
code, action, mods, shifted, base, text |
Every press, repeat and release |
TextInput |
text |
A key produced text |
KeyCommand |
key |
A key did not produce text |
KeyGesture |
kind, key, count |
See kinds below |
Binding |
kind, name |
A registered Chord, Sequence or Shortcut fired |
Lock |
lock, active |
Caps lock or num lock toggled |
TypingMetric |
code, dwell, latency, overlap |
On each key release |
Key helpers: named (a Term::Named or nil), char, press?, repeat?, release?, shift?, alt?, ctrl?, modifier?, command?, and stroke(physical = false), the key code and modifiers that shortcuts are matched on.
KeyGesture kinds: Activate, AutoRepeat, Tap, MultiTap, HoldStart, HoldEnd, HoldReached, ModifierTap. For Tap and MultiTap, count is the number of consecutive taps.
Mouse
| Event | Fields | When |
|---|---|---|
Mouse |
action, button, mods, x, y, col, row |
Every report |
MouseGesture |
kind, mouse, count, dx, dy, velocity |
See kinds below |
Mouse actions: Press, Release, Drag, Hover, Scroll, Leave. Buttons: Left, Middle, Right, None, WheelUp, WheelDown, WheelLeft, WheelRight, Aux8 to Aux11. x and y are pixels, col and row are cells.
MouseGesture kinds: Click (count is 1, 2, 3…), DragStart, DragMove, DragEnd, Enter, HoverDwell, HoverEnd, Motion, Scroll (count is the number of lines), Swipe, Chord (count is the number of buttons held), LongPress. direction gives Left, Right, Up or Down from dx and dy.
Scroll reports the first wheel step at once, with count 1. While the wheel keeps turning it then reports once per scroll_window, with count the steps since the previous gesture. velocity is in steps per second, measured against the previous gesture in the same direction and never over less than one scroll_window; it is zero for the first gesture after scroll_pause without a step, and after a turn. The vertical and the horizontal wheel are tracked separately, so only the opposite direction on the same wheel is a turn. The raw Mouse scroll events still arrive for every step, so handle one or the other.
HoverDwell carries a Hover report with no button at the pointer's position, whatever the last report was.
Terminal
| Event | Fields | When |
|---|---|---|
Resize |
rows, cols, width, height |
The window was resized |
Focus |
gained |
Focus changed |
Visibility |
visible |
The window was minimised or restored |
ColorScheme |
dark |
Light or dark mode changed |
Paste |
mimes, primary, password, text |
The user pasted |
Protocols
| Event | Fields | When |
|---|---|---|
Notification |
kind, id, button, untracked |
A notification was Activated, a Button was clicked, or it Closed |
Drop |
kind, col, row, x, y, operations, mimes |
A drag Moves over the window, Leaves, or Lands |
Drag |
kind, col, row, x, y, index, operation, canceled, error |
See kinds below |
Ack |
image, number, placement, message |
A graphics reply nobody was waiting for |
Drag kinds: Gesture, Started, Accepted, Action, Dropped, Finished, DataRequest, FileRequest, Error.
API
Lifecycle and output
| Method | Returns |
|---|---|
Term.open(config, input = STDIN, output = STDOUT) { |term| } |
The block's value |
Term.new(config, input, output) |
A Term you must close |
close, closed? |
|
events |
Channel(Term::Event) |
features |
Term::Feature flags |
print(*objects), <<(object) |
|
suspend, resume |
Leave and re-enter the terminal modes |
refresh |
Re-read the window size and emit a Resize |
Term.restore, Term.suspend, Term.resume, Term.refresh |
The same for every open Term |
supports?(mode) |
Bool? for a DEC private mode |
window_size, cell_size |
Term::Size? in pixels |
query_visibility, query_color_scheme |
Nothing; the answer arrives as an event |
Pointer shape
term.pointer = :pointer
term.push_pointer(:wait)
term.pop_pointer
term.reset_pointer
term.pointer # => "pointer", or nil when none is set
term.pointer_support(:grab, :zoom_in) # => [true, false]
Term::Shape holds the thirty CSS-derived names, from Alias to ZoomOut. pointer(which) accepts :current, :default or :grabbed.
Colors
term.push_colors
term.color("foreground", "green")
term.color(1, Term::Color.rgb(255, 0, 128))
term.dynamic_color("cursor")
term.reset_color("background")
palette = term.colors("foreground", "cursor", 7)
palette.try &.["foreground"] # => Term::Color or nil
term.confirm_color("background", "#102030")
term.pop_colors
- A key is a number
0to255for the ANSI table, or a name such asforeground,background,cursor,cursor_text,selection_background,selection_foreground,visual_bell,transparent_background_color1to7. colorsreturns aTerm::Palettewith[],[]?,keys,sizeandunknown(keys the terminal did not recognise). A color with no defined value isnil.Term::Colorhas 16-bitred,green,blueand a floatalpha.Color.parseacceptsrgb:,#hex,rgbi:, an@alphasuffix and 658 color names.
Clipboard
term.copy("hello")
term.clipboard_write({"text/html" => "<b>hi</b>".to_slice, "text/plain" => "hi".to_slice})
term.clipboard_mimes # => ["text/plain", "image/png"]
term.clipboard_read("text/plain").try(&.text)
File.open("clip.png", "w") { |file| term.clipboard_read("image/png", into: file) }
Handling a paste:
when Term::Paste
text = term.clipboard_read(event, "text/plain").try(&.text)
copyandclipboard_writereturn aTerm::Status:Done, orEIO,EINVAL,ENOSYS,EPERM,EBUSY,EFBIG.clipboard_readreturns aTerm::Clipboardwithstatus,done?,data(a hash of MIME type to bytes) andtext.- Pass
primary: truefor the primary selection. Item values may beBytesor anIO. Withinto:, data is streamed to the sink anddatais empty.
Graphics
png = File.read("logo.png").to_slice
term.image(Term::Pixels.png(png), id: 1, placement: Term::Placement.new(columns: 20))
term.place(id: 1, placement: Term::Placement.new(id: 2, z: -1))
term.delete_images(:id, id: 1, free: true)
term.supports_graphics?
| Method | Returns |
|---|---|
image(pixels, id:, number:, placement:, quiet:, transient:) |
Term::Ack? |
place(id:, number:, placement:, quiet:) |
Ack? |
delete_images(target, free:, id:, number:, placement:, x:, y:, z:) |
|
frame(pixels, id:, number:, x:, y:, base:, edit:, gap:, replace:, background:, quiet:) |
Ack? |
animate(id:, number:, state:, current:, loops:, target:, gap:, quiet:) |
Nothing |
compose(source, target, id:, number:, width:, height:, source_x:, source_y:, x:, y:, replace:, quiet:) |
Ack? |
image_query(pixels, id: 31) |
Ack? |
supports_graphics? |
Bool |
Term.placeholder(id, columns, rows, placement = 0, compact: false) |
Array(String), one per row |
Term.place_code(id:, number:, placement:, quiet:) |
String |
Term.delete_code(target, free:, id:, number:, placement:, x:, y:, z:) |
String |
- Pixel data is a
Term::Pixels:Pixels.png(data),Pixels.rgb(data, width, height),Pixels.rgba(data, width, height)takeBytesor anIO, withcompress: truefor zlib.Pixels.at(path, medium)sends a file, temp file or shared memory name;Pixels.temp(data)andPixels.shared(data)create one for you. - Layout is a
Term::Placement:id,x,y,width,height(source rectangle),offset_x,offset_y,columns,rows,z,hold_cursor,placeholder,parent,parent_placement,shift_x,shift_y. - Replies. A call waits for the terminal's
Ackonly when you give anidornumberand leavequietat:none.Ackhasok?,error,image,numberandplacement. animatenever waits, because terminals do not acknowledge it on success. Errors arrive asAckevents.- Delete targets:
Visible,Id,Number,Cursor,Frames,Cell,CellZ,Range,Column,Row,Z.free: truealso frees the stored data. - Placeholders are limited to 297 rows and columns.
- Codes.
Term.place_codeandTerm.delete_codetake the arguments ofplaceanddelete_imagesand return the escape sequence instead of sending it. Use them to put a placement inside output you write yourself, such as a frame wrapped in synchronized output, where it must follow your own cursor movement. Passquiet: :all(or:ok); nothing waits for the reply, so it would otherwise arrive as anAckevent.
Desktop notifications
id = term.notify("Build finished", "2 warnings", buttons: ["Open", "Dismiss"], report: true)
term.close_notification(id)
| Method | Returns |
|---|---|
notify(title, body = "", **options), notify(notice) |
The notification id |
close_notification(id) |
|
notifications_alive |
Array(String)? |
notification_support |
Hash(String, Array(String))?, or nil when unsupported |
Options are the fields of Term::Notice:
| Field | Meaning |
|---|---|
id |
Your own id; reuse it to update a notification |
app, types |
Application name and notification types, for filtering |
icons |
Icon names, first match wins |
icon, icon_key |
Icon image data, and a cache key so it is sent only once |
buttons |
Button labels |
sound |
Sound name, such as "silent" |
urgency |
Low, Normal or Critical |
expires |
Auto-close after this span; zero means never |
occasion |
Always, Unfocused or Invisible |
focus |
Focus the window on click (default true) |
report |
Send Notification events for clicks and buttons |
closes |
Send a Notification event when it closes |
Ids and icon keys may contain only a-z A-Z 0-9 _ - + .; anything else raises ArgumentError.
Drag and drop: receiving
term.accept_drops("text/uri-list", "text/plain")
# in the event loop
when Term::Drop
case event.kind
when .move? then term.drop_reply(:copy, "text/plain")
when .land?
index = event.mimes.not_nil!.index!("text/plain") + 1
text = term.drop_data(index).try(&.text)
term.drop_finish(:copy)
end
| Method | Returns |
|---|---|
accept_drops(*mimes, remote: false), stop_drops |
|
drop_reply(operation, *mimes) |
|
drop_data(index, entry = 0, into:) |
Term::DropData? |
drop_entry(handle, index, into:) |
DropData? |
drop_close(handle) |
|
drop_save(index, entry, destination) |
Bool |
drop_finish(operation) |
- Indexes are 1-based positions in the
Dropevent's MIME list. Term::Operationis a flag set ofCopyandMove;Operation::Nonerejects.DropDatahasdata,text,error,ok?,remote?,symlink?,directory?,handle,entriesanduris.- With
remote: true, files dropped from another machine can be fetched: request thetext/uri-listtype, and ifremote?is true, usedrop_save(index, entry, destination)to download entryentryof the list, including whole directory trees.
Drag and drop: sending
term.offer_drags
# in the event loop
when Term::Drag
case event.kind
when .gesture?
term.drag_offer(Term::Operation::Copy, "text/plain")
term.drag_presend(0, "dragged text".to_slice)
term.drag_text(1, "T")
term.drag_start
when .data_request?
term.drag_data(event.index, "dragged text".to_slice)
end
| Method | Returns |
|---|---|
offer_drags(remote: false), stop_drags |
|
drag_offer(operations, *mimes) |
|
drag_presend(index, data), drag_data(index, data) |
|
drag_image(number, data, format, width, height, opacity = 0) |
|
drag_text(number, text, numerator = 1, denominator = 1, opacity = 0) |
|
drag_show(index) |
|
drag_start |
String?: "OK" or the error name |
drag_fail(index, name, description = nil) |
|
drag_abort(name, description = nil), drag_cancel |
|
drag_files(paths) |
The uri-list text |
drag_path(index, path), drag_entry(index, data, flag, parent, child) |
|
dnd_support, supports_dnd? |
Hash(String, String)?, Bool |
Term.uri_list(paths), Term.machine_id |
String, String? |
- MIME indexes are 0-based here; image numbers start at 1.
- Data may be
Bytesor anIO. - To drag files to another machine, call
drag_files(paths), pre-send the returned text astext/uri-list, and the terminal's file requests are answered for you.
Rendering
Term::Render is an optional layer on top of the protocol API: an off-screen cell buffer model with compositing, damage tracking and differential ANSI output. It has no opinion about your application loop — you hand it a ByteBuilder per frame.
compositor = Term::Render::Compositor.new
compositor.resize(cols, rows) # on Term::Resize
header = compositor.plane(20, 1) # an off-screen surface
header.put(0, 0, "hello", fg: Term::Render::Paint.index(4), attrs: Term::Render::Attr::Bold)
frame = ByteBuilder.new
compositor.enter(frame) # hides the cursor; call once
compositor.render(frame) # emits only what changed
term.print String.new(frame.written)
Compositorflattens all planes into a cell grid and emits the diff: SGR styling (including 256-color and truecolor), OSC 8 hyperlinks, cursor shape and position, kitty graphics placements and synchronized output (mode 2026, when you setsync = true). Scroll hints fromPlane#scrollbecome terminal scroll-region sequences where possible.leave(frame)restores the cursor and unplaces images.Planeis a clipped, nestable drawing surface withput,fill,erase,scroll, z-ordering (Layer), opacity, a text cursor and image sprites.Planeimplements theTerm::Render::Canvasinterface.Paint/Attr/Cell/Tileare the style vocabulary: foreground/background/underline colors (default, palette index or RGB) plus attribute flags.Paint.role(n)defers a color to the theme's role table.Palettemodels the 256 terminal colors with nearest-color lookup, ramps and generation from a base16 set;ColorSpacedoes the blending math in linear, Lab or Oklab space.Themesholds namedThemes, resolves role paints for the compositor, probes the terminal palette (via an injectedquerier), pushes palette changes with OSC 4/10/11 and animates transitions (via an injectedticker). It is framework-agnostic: output goes to an injectedoutput : Proc(ByteBuilder), change notifications toon_theme/on_palettecallbacks.Themes.survey(term)reads the palette from a liveTerm.TextLayoutmeasures, wraps and truncates text by grapheme cluster (TermWidth), with horizontal alignment helpers.GraphemesandLinksintern repeated grapheme clusters and OSC 8 targets so cells stay a fixed size.
TTY and PTY
Low-level helpers, useful for tests or for driving another program on a pseudo-terminal.
TTY is a set of functions on a file descriptor:
| Function | Returns |
|---|---|
mode(fd) |
LibC::Termios?, nil when fd is not a terminal |
apply(fd, mode) |
Bool |
raw(mode), raw?(mode) |
A raw copy; whether a mode is raw |
window(fd) |
TTY::Window? with rows, cols, width, height |
resize(fd, window) |
Bool |
signal_group(signal) |
Bool |
PTY is a pseudo-terminal pair:
pty = PTY.spawn("sh", ["-c", "stty size"], window: TTY::Window.new(24, 80))
puts pty.rest # "24 80\r\n"
puts pty.process.not_nil!.wait.exit_code
pty.close
| Member | Meaning |
|---|---|
PTY.open(window = nil) |
A pair with master, slave and name |
PTY.spawn(command, args, env, window) |
Runs a command on its own session with the PTY as its controlling terminal; process is a standard Process |
window, window= |
The terminal size; changing it notifies the child |
mode, mode= |
The terminal settings |
<<(text) |
Write to the master |
rest |
Read until the child hangs up |
close |
Limitations
- Verified against the protocol documents and kitty's source, not a running kitty. The test suite plays the terminal's side itself.
- macOS is untested; Windows is unsupported.
- Signal handlers are global. See Signals and job control.
drop_savetrusts the terminal. It creates symlinks with the target it is given, overwrites existing files, and leaves a partial tree behind on failure.- No multiplexer passthrough. Under tmux the kitty protocols are not wrapped and will be swallowed.
- Some data is held in memory: compressed image data, notification icons, and clipboard reads and dropped data when no
into:sink is given. PTY.spawnuses an undocumented Crystal API to adopt the forked child, which may change between Crystal releases.
Development
Run the suite once:
crystal spec
The suite drives Term over pipes and real pseudo-terminals. The child-process examples read /proc and so run on Linux only.
Run it thoroughly before a release:
spec/run.sh
spec/run.sh builds the suite three ways (default, -Dexecution_context and --release) and, for each build, runs it in defined order, in random order with several seeds, and one example at a time. Isolated runs catch examples that only pass because of what ran before them. A failing run prints the tail of its output, including the seed for random-order runs, and the script exits non-zero.
| Variable | Default | Effect |
|---|---|---|
ONLY |
all | Run a single build: default, execution_context or release. |
REPEATS |
3 |
Defined-order runs per build. |
SEEDS |
5 |
Random-order runs per build. |
LIMIT |
300 |
Seconds before a single run is killed as hung. |
ONLY=default SEEDS=20 spec/run.sh
Keep the spec file free of top-level local variables.
They are visible inside every describe and it block, and on Crystal 1.21.1 a top-level term next to a top-level config made an example read the wrong Term inside a nested captured block.
The child-process scenario lives in a method for that reason.
term
- 0
- 0
- 0
- 0
- 2
- about 2 hours ago
- October 6, 2026
MIT License
Tue, 06 Oct 2026 20:15:26 GMT