class

Phosphor::Terminal

Inherits Reference < Object

Owns the terminal connection: raw mode, alternate screen, dimensions, and the root Frame that every higher layer renders relative to.

A thin, RenderMode-aware layer over a Backend chosen at construction (AnsiBackend by default — a real terminal) — Phase 27's pluggable-backend refactor. Terminal holds a @backend : Backend rather than inheriting from AnsiBackend, so App#run can inject a TestBackend (or any other Backend) in its place for fast, deterministic specs, while Terminal itself keeps every bit of RenderMode-aware coordination logic that isn't part of a Backend's own job: mode branching, @origin_row bookkeeping, auto-height resolution, scroll-drift detection, and so on.

#size, #write(diff), and #read_event are pure one-line delegations to @backend — those three never differ between Fullscreen and Inline. #enter/#exit are more involved: for Fullscreen, entering/exiting is exactly what @backend.enter/@backend.exit do (raw mode, alternate screen, mouse/paste toggling), so those two delegate wholesale. For Inline, the alternate-screen sequence bundled into a real Backend#enter would be wrong — an inline session must never touch it — so Inline mode never calls @backend.enter/@backend.exit at all. It instead uses its own private helpers (setup_raw_mode, restore_termios, write_mouse_and_paste_enable/disable) — a small, deliberate duplication of AnsiBackend's own private methods of the same name, unavoidable now that composition (not inheritance) means Terminal can no longer reach them directly. See AnsiBackend's own doc comment for the same point from its side.

Constants

AUTO_HEIGHT = AutoHeight.new

The one AutoHeight instance — pass this as height: to request auto-height, e.g. App.new.run(state, Screen, mode: RenderMode::Inline, height: Terminal::AUTO_HEIGHT).

DEFAULT_AUTO_HEIGHT = 8

Row count reserved for an AUTO_HEIGHT session before its first #auto_resize call (#enter_inline needs some height to reserve immediately, before any screen has mounted widgets to measure), and the fallback #auto_resize itself uses whenever View#preferred_height returns nil — no mounted widget declares a preference.

Log = ::Log.for("phosphor.terminal")

Constructors

new(mode : RenderMode, height : Int32 | AutoHeight | Nil = nil, tty : IO | Nil = nil, backend : Backend | Nil = nil)

Creates a new Terminal in the given mode.

height is required for RenderMode::Inline#enter, #exit, and #frame all raise ArgumentError if it's missing. Unused in RenderMode::Fullscreen. Pass AUTO_HEIGHT instead of an Int32 to have #enter/#auto_resize size the block from its mounted widgets' declared content instead of a fixed row count — see #auto_resize.

tty overrides the terminal device Inline-mode coordination writes go to (see @tty's own doc comment) — for tests only; production code should never pass this. Defaults to a real /dev/tty handle only when backend is also omitted (see below) — never opened at all when a backend is given without an explicit tty, since a session driven entirely by an injected Backend (App#run's usual backend: path) has no real terminal to open in the first place, and would otherwise fail (or hang, mid-Inline-mode CPR query) on a machine with no /dev/tty at all — exactly the machines specs need to run on.

backend is the Backend #enter (in Fullscreen), #exit (in Fullscreen), #size, #write(diff), and #read_event delegate to. Defaults to a fresh AnsiBackend sharing this session's own tty — the real-terminal behavior every existing caller already gets with no changes needed. Pass a TestBackend here (typically via App#run(backend: ...)) to drive a session without a real terminal.

Source

Class methods

detect_color_profile(tty : Bool = STDOUT.tty?) : ColorProfile

Detects the terminal's color rendering capability from environment variables and whether STDOUT is a real terminal — no round-trip query needed, unlike .detect_dark_background.

Checked in order: $NO_COLOR (any value at all forces NoColor, per https://no-color.org) — non-TTY tty (piped/redirected output never gets color codes) — $COLORTERM of truecolor or 24bit (case-insensitive) for TrueColor$TERM containing 256color for Indexed256 — otherwise Ansi16.

A class method, not an instance method — it only inspects ENV and tty, no Terminal instance state involved. tty defaults to STDOUT.tty? (the real check, for production use) but is a plain Bool parameter rather than reading STDOUT directly, so a spec can pass tty: true to exercise the $COLORTERM/$TERM branches below without a real terminal — a non-interactive crystal spec process never has one, and there's no other way to reach that code otherwise.

Source
detect_dark_background

Detects whether the terminal has a dark or light background via an OSC 11 query (\e]11;?\a), returning true for dark.

A class method, not an instance method — it opens its own /dev/tty handle and must run before any Terminal#enter call puts the real terminal into raw mode. OSC 11 queries only get a reply in normal mode on some terminals.

Parses the reply's rgb:RRRR/GGGG/BBBB color (terminated by \a or \e\\) and computes perceptual luminance (0.2126*R + 0.7152*G + 0.0722*B); below half of 65535 counts as dark. Returns true on timeout (100ms), a parse failure, no reply at all, or any other error opening/reading /dev/tty — dark is the safe default, since most terminals are dark-themed.

Source

Instance methods

auto_resize(view : View(S, M)) : Void forall S, M

Resizes an Inline session's block to fit view's mounted widgets' declared content, via #resize_inline — a no-op unless this session was constructed with AUTO_HEIGHT (including Fullscreen mode, or Inline with a fixed height).

App#run calls this once, right after the initial screen's on_mount (before any widget has had a chance to render, so there's a real height to reserve for the first frame), and again whenever a ResizeEvent arrives, since View#preferred_height depends on the terminal's current width (ParagraphMock rewraps at the new width, for one).

Queries View#preferred_height at the terminal's current width, falling back to DEFAULT_AUTO_HEIGHT when it returns nil — no mounted widget declares a preference.

Source
block_visible?

Whether the Inline block's assumed position (@origin_row through @origin_row + height - 1) is still plausible, given the terminal's current dimensions and how much output this session has printed. Always true in Fullscreen mode — there's no fixed block position to lose track of there.

Two independent checks, either one enough to report false:

  1. Structural: @origin_row no longer fits within the terminal's current row count at all (reachable after a SIGWINCH shrink big enough that even the block's own top row is off-screen — #inline_minimum_height's guard catches most shrinks before this, but App#run also calls this after every render as a backstop).
  2. Heuristic: @lines_printed has exceeded @origin_row + height. Every newline Terminal itself ever prints (#print_newlines) either advances @origin_row in lockstep (#scroll_down) or claims already-available blank space below the block (#enter_inline, #resize, #resize_inline's expand) — unless the terminal happened to already be at its bottom edge when one of those printed, in which case a real scroll occurred that @origin_row was never told about. There's no ioctl for "did a write I already made scroll the terminal" — this cumulative count is the closest approximation available, and it's deliberately biased toward eventually reporting false rather than silently drifting forever undetected. See TASKS.md § "Phase 24: Inline mode completion" — precisely tracking external scrollback (the user's own terminal emulator, not this session's own output) remains a known, documented limitation; this only catches drift this session's own writes could plausibly have caused.
Source
dismiss_inline_block

Removes an Inline session's block entirely, for Cmd::InlineDismiss. Unlike normal exit (#exit / exit_inline), which preserves the block's last rendered frame in scrollback, this leaves no trace of it at all — not even blank rows.

Uses Delete Line (\e[{n}M), not per-row Erase Line (\e[2K) — Erase only blanks a row's content, leaving it in place occupying vertical space, so anything printed afterward (e.g. a later Inline session started in the same process) would end up with a gap of blank rows where the dismissed block used to be. Delete Line actually removes the rows, scrolling whatever's below them up to close the gap — same fix, same reason, as #resize_inline's contract path.

Delete Line doesn't move the cursor, so after this the cursor sits at @origin_row, column 1 — precisely where the block's first row used to start, now the first free row since everything below it just shifted up to fill the gap. Doesn't leave raw mode — #exit still runs afterward through the normal quit path and handles that.

Source
enter(resize_channel : Channel(Event) | Nil = nil, mouse : Bool = false) : Nil

Enters the terminal session: switches STDIN to raw mode and, for Fullscreen, switches to the alternate screen buffer, clears it, and moves the cursor to the home position.

Registers SIGINT, SIGTERM, and at_exit handlers that call #exit before the process terminates, ensuring the terminal is always restored. The handlers capture self as a closure — no global state is used.

Pass resize_channel to receive a ResizeEvent whenever the terminal is resized (SIGWINCH). Omit it to ignore resize signals. The event carries the new dimensions from #size; the main loop re-renders automatically on the next iteration because #frame reads dimensions fresh each call.

Set mouse to true to enable mouse and touch tracking: VT200 button press/release (\e[?1000h), button motion/drag (\e[?1002h), and SGR extended coordinates (\e[?1006h, required for touch and for any column/row past 223). Events arrive as TouchEvent or MouseEvent values on the main event channel via StdinPort — see TouchEvent.from_sgr? for how the two are told apart on the wire. Disabled automatically on #exit.

Bracketed paste (\e[?2004h) is enabled unconditionally, regardless of mouse — pasting into a text input is a common need independent of mouse support, so it isn't gated behind that flag. Once enabled, the terminal wraps pasted text in \e[200~...\e[201~, which StdinPort parses into a single PasteEvent instead of one KeyEvent per character. Disabled automatically on #exit.

Call #exit to undo every change made here.

For Fullscreen, this is @backend.enter(mouse) — raw mode, the alternate screen, and mouse/bracketed-paste toggling, all in one Backend call. For Inline, @backend.enter is never called at all (see this class's own doc comment): raw-mode setup and the mouse/bracketed-paste writes go through this class's own private helpers instead, and #enter_inline reserves the block in place of ever touching the alternate screen.

Source
exit

Exits the terminal session: deregisters signal handlers, leaves the alternate screen buffer, and restores the original termios settings.

Idempotent — safe to call multiple times. In Inline mode, after the first call the saved termios is cleared, so subsequent calls (e.g. from at_exit after a signal handler already ran) are no-ops for the termios restore. In Fullscreen mode, idempotency is @backend.exit's own responsibility — AnsiBackend#exit has the identical property.

For Fullscreen, this is @backend.exit — the exact inverse of #enter's @backend.enter(mouse) call. For Inline, same as #enter, @backend.exit is never called: mouse/paste-disable and termios-restore go through this class's own private helpers instead.

Source
frame

Returns the render region as a Frame.

In Fullscreen mode this covers the entire terminal: origin (0, 0), width and height from #size. In Inline mode it's a fixed-height block starting at @origin_row, spanning the terminal's current width. Every higher layer renders relative to this frame and never uses absolute terminal coordinates.

Re-reads #size on every call rather than caching it, so a SIGWINCH resize is picked up on the next render without any special handling.

Source
mode

Which mode this Terminal was constructed with — App#print checks this to raise outside Inline sessions.

Source
read_event

Non-blocking read of the next input event from @backend — a pure delegation, unconditionally regardless of @mode. Not currently called anywhere in the framework — App#run still polls STDIN through its own StdinPort rather than through this — but part of Backend's contract this class delegates in full regardless.

Source
reanchor

Attempts to recover from #block_visible? reporting false by asking the terminal where the cursor actually is right now (the same CPR round-trip #enter_inline uses at startup) and adopting that as the new @origin_row — but only if a block of the current height would actually fit on-screen starting there. Declines (returns false, leaving @origin_row untouched) rather than relocating to a position that would just fail #block_visible? again on the very next render. Always false in Fullscreen mode.

Resets @lines_printed to 0 on success — the old count is meaningless once @origin_row has just been corrected to a fresh, confirmed-accurate reference point.

Source
resize(new_height : Int32) : Void

Changes an Inline session's block height, clearing the old block and re-reserving the terminal rows for the new size at the same @origin_row. Content is not preserved — the caller (App#run, via Cmd::InlineResize) is expected to force a full redraw afterward.

Not to be confused with the no-arg #resize below, which reacts to the terminal window being resized (SIGWINCH) — an unrelated event that happens to share this method's name.

Erases each of the old block's rows individually via absolute CUP (\e[{row};1H\e[K) rather than clearing then stepping down, so it doesn't depend on the cursor's position between writes. Re-reserving the new rows reuses enter_inline's technique: print new_height newlines, then move back up — except the cursor must first return to @origin_row, since the clearing loop left it at the old block's last row.

Source
resize

Acknowledges that the terminal window itself was resized (SIGWINCH, delivered to App#run as a ResizeEvent). Not to be confused with #resize(new_height) above, which changes an Inline block's height on purpose — this one reacts to the user resizing their terminal.

#size/#frame already re-read dimensions from the kernel on every call — nothing on Terminal caches them — so there's no dimension state here to update. This method exists so "the terminal just resized" has one explicit place to react, in case a future need arises; for now it only confirms the current size is queryable.

Source
resize_inline(new_height : Int32) : Void

Adjusts an Inline session's block from its current height to new_height in place, without disturbing rows the block keeps — unlike #resize, which always erases and re-reserves the whole block for a deliberate phase transition (Cmd::InlineResize) where discarding old content is the point. #auto_resize uses this instead, since a terminal-width-driven height change should feel like the block growing or shrinking in place, not flashing to blank and back.

No-op if new_height equals the current height.

Expanding (new_height greater): moves the cursor to the block's current last row, writes new_height - @height newlines to claim that many additional rows below it, then moves back up to @origin_row — the origin itself never changes. If the block is already flush against the terminal's bottom edge, those newlines scroll the whole terminal (shell history included) upward to make room, exactly like a normal shell prompt would; that's expected, not a bug.

Contracting (new_height smaller): moves the cursor to the first row the block no longer needs (@origin_row + new_height) and deletes the remaining @height - new_height rows with ANSI Delete Line (\e[{n}M) — not \e[2K (Erase Line). Erasing only blanks a row's content; the row itself still occupies vertical space, so the block would still visually span its old height with empty rows at the bottom instead of actually shrinking. Delete Line removes the rows outright, scrolling whatever sits below them up to fill the gap — the sole mechanism that actually shrinks a block's on-screen footprint. The block shrinks upward from the bottom; @origin_row stays fixed either way.

Source
scroll_down

Scrolls an Inline session's block down one row to make room for a persistent line above it, and returns the absolute row that scroll vacated — the row App#print writes that line at.

Writes a bare \n, then advances @origin_row so every later @origin_row-based write (#frame, #exit) tracks the block's new, one-row-lower position. Returns the pre-scroll row, not the new one — that's the row the scroll just freed up, not where the block moved to.

Source
size

Returns the output target's current {width, height} — a pure delegation to @backend, unconditionally regardless of @mode. Never cached; see Backend#size's own doc comment for why.

Source
translate_mouse(event : MouseEvent) : MouseEvent | Nil

Confines event to #frame's row range, or returns nil if the click falls outside the render region entirely (only reachable in Inline mode — see below).

MouseEvent#x/#y come off the wire already 0-based (see MouseEvent.from_sgr?), measured from the terminal's own top-left corner — the same coordinate space #frame.origin_row itself is expressed in (see #frame's own doc comment: it's @origin_row - 1, not 0, in Inline mode). That's also the space every widget already writes cells in (buf.set_string(frame.x, frame.y + local_row, ...)) and reads back in MockWidget#hit_test — so a click's coordinates need no further adjustment once confirmed to land inside the block; subtracting #frame.origin_row here would only shift them out of the space widgets actually compare against.

In Fullscreen mode #frame.origin_row is always 0 and covers the whole terminal, so this always passes the event through unchanged — #App calls this unconditionally rather than special-casing the mode. In Inline mode, a click outside `#frame.origin_row...#frame.origin_row

  • #frame.heightis a click above or below the block (on shell history, or on nothing at all) —niltellsApp#dispatch_event` to drop it rather than dispatching a position no mounted widget's own frame could ever contain.

Known limitation: this assumes @origin_row still reflects the block's true on-screen row. #scroll_down (App#print) keeps it correct for scrolls Phosphor itself causes, but if the user's terminal scrolls shell history off-screen independently (e.g. scrolling up in the terminal emulator to read earlier output), there's no signal that tells Phosphor its saved origin is now stale — clicks can mis-map in that case. Tracked as a known gap in TASKS.md § "Phase 24: Inline mode completion".

Source
write(diff : Array(Diff)) : Void

Writes a set of changed cells to the output target — a pure delegation to @backend, unconditionally regardless of @mode. This is Renderer#draw's output path; distinct from the raw-bytes #write(bytes : String) overload below, which App#print uses instead.

Source
write(bytes : String) : Nil

Writes bytes directly to the terminal output.

This is the single output path for the renderer. No buffering, no diffing, no ANSI interpretation — the renderer owns all of that.

Source

Nested types