# tuika terminal features
Cross-cutting terminal capabilities that sit outside the [component
gallery](components.md): the *out-of-band* escape sequences tuika speaks to the
terminal itself — clickable links, native progress, the system clipboard — and
the mouse model that turns pointer input into selections and clicks.
These are terminal features, not widgets. Where a component paints cells,
several of these emit an OSC (Operating System Command) sequence the terminal
interprets directly. That has one consequence worth stating up front: a
terminal that understands the sequence acts on it; one that does not silently
ignores the unknown OSC, so the feature degrades to plain text or a no-op
rather than leaking escape codes onto the screen.
## Capabilities
`Capabilities` is the one place to ask what the terminal can do —
`graphics` (an `ImageSupport`, the Images section below), `hyperlinks`,
`clipboard`, `progress`, and `truecolor`.
[API](https://docs.rs/tuika/latest/tuika/term/capabilities/index.html)
```rust
use tuika::term::capabilities::Capabilities;
let caps = Capabilities::from_env(); // instant, no terminal I/O
if caps.supports_images() { /* … */ }
```
Two tiers. `Capabilities::from_env()` is an **advisory** guess from `TERM`,
`TERM_PROGRAM`, `COLORTERM`, `KITTY_WINDOW_ID`, and the Ghostty marker — instant,
never blocks. Because the OSC features above degrade harmlessly, you emit them
regardless; the flag only decides whether to *show an affordance* (a "copy" hint,
a link underline) the terminal can't act on, so those flags lean conservative.
For accuracy — mainly to confirm **Sixel**, which has no reliable environment
signal — `Capabilities::query(timeout)` also does a **Device Attributes** probe:
it writes `DA1_REQUEST` (`ESC [ c`), reads the reply, and upgrades `graphics`
when the terminal reports Sixel (DA1 feature `4`). Call it once at startup, in
raw mode, before the event loop reads stdin (Unix ttys only; elsewhere it is just
`from_env`). The pieces are also exposed standalone — `DA1_REQUEST` and
`DeviceAttributes::parse` — so a host with its own read strategy can do the
round-trip itself. Kitty and iTerm2 keep their reliable environment detection
(DA1 doesn't report them).
## Inheriting the terminal's colors
The user already chose a palette — in Ghostty, kitty, WezTerm, wherever they
work. An application can adopt it instead of imposing its own, so it looks like
it belongs in the terminal it was launched from.
[API](https://docs.rs/tuika/latest/tuika/term/palette/index.html)
**This never happens on its own.** tuika ships a default theme and changes it for
nobody; inheriting is something a host asks for, explicitly, with one of the two
calls below. Nothing here runs unless you call it.
There are two ways, and they trade accuracy against cost.
### Without asking the terminal anything
`themes::TERMINAL` is a `const Theme` whose every slot is `Color::Reset` or a
`Color::Indexed` ANSI slot, so the terminal resolves the palette itself:
```rust
use tuika::themes;
let theme = themes::TERMINAL; // that's it — no I/O, no timeout, no failure
```
It cannot be wrong about the user's setup and cannot fail — it works on a
terminal that answers nothing at all, and on a light background as readily as a
dark one. The limit is that ANSI has no tone *between* two slots, so tuika's
raised and faint roles collapse: `surface` and the code background come out as
the plain background, and `dim`, `border`, and `muted` all land on bright black.
Panels and rules read flatter than a designed palette.
### By asking
For real 24-bit values — and therefore real in-between tones — ask the terminal
what its colors are and derive a theme from the answer:
```rust
use std::time::Duration;
use tuika::{Theme, themes};
use tuika::term::capabilities::Capabilities;
// In raw mode, at startup, before the event loop reads stdin:
let (caps, palette) = Capabilities::query_with_palette(Duration::from_millis(100));
let theme = Theme::from_terminal(&palette).unwrap_or(themes::TERMINAL);
```
The queries are the xterm ones: `OSC 10` for the default foreground, `OSC 11` for
the background, `OSC 4` per ANSI entry. Ghostty, kitty, WezTerm, foot, alacritty,
contour, iTerm2, and xterm answer them; others answer nothing, and
`from_terminal` returns `None` so you fall back.
`Theme::from_terminal` uses the reported foreground and background **verbatim** —
that is the whole point — and derives everything the terminal has no notion of:
- The in-between tones (`surface`, `muted`, `dim`, `border`, the code
background) are blends between the foreground and the background, so a light
palette and a dark one both work without a special case.
- The hues (`accent`, and the syntax roles) come from the ANSI palette, taking
the bright half on a dark background and the normal half on a light one. Blue
leads and cyan follows: a terminal reports no notion of a *brand* color, so
tuika takes the conventional interactive hue rather than inventing one.
- Every derived color is held to a minimum contrast against the background. A
user's palette is free to put two colors on top of each other; a UI built out
of it is not.
Any ANSI slot the terminal does not report falls back to xterm's default for that
slot, so a terminal that answers `OSC 10` and `OSC 11` but not `OSC 4` still
yields a complete theme.
### Why the query is fenced
A terminal that does not implement `OSC 11` does not say so — it stays quiet, and
a reader cannot tell *not yet* from *never*. So tuika appends the Device
Attributes request (`DA1_REQUEST`), which every terminal answers, as a **fence**:
once its reply arrives, every color reply that was ever coming has arrived. The
probe therefore costs one round-trip rather than one timeout, and because that is
the same request the capability probe already makes,
`Capabilities::query_with_palette` answers both questions at once.
Call it **once at startup, in raw mode, before the event loop reads stdin** — the
replies arrive on stdin, and a running event loop would deliver them to your
application as keystrokes. The read stops exactly at the fence, so input typed
behind the replies is left for the application.
The [`inherit`](../examples/inherit.rs) example does all of this end to end —
including `cargo run --example inherit -- --probe`, which prints what your
terminal actually answers without taking over the screen.
## Screen modes: alternate screen & split footer
`ScreenMode` decides which part of the terminal a frame owns. The default takes
the whole window on the alternate buffer. `ScreenMode::split_footer(rows)`
instead reserves rows at the bottom of the *main* screen and leaves everything
above as the terminal's own scrollback — the shell prompt, the wheel, mouse
selection, and the output the app publishes, which is still there after it
exits.
<p align="center">
<img src="demos/split-footer.svg" width="880" alt="A terminal running the split_footer example: a bordered status box pinned to the last rows while published build lines accumulate above it as ordinary scrollback; after the example exits the lines remain and the box's rows are gone.">
</p>
Because the footer owns the cursor, a host publishes through tuika instead of
`println!`: `Scrollback` is a cloneable, `Send + Sync` queue for background
producers, and `screen::publish_block` commits a view straight from a host's own
render loop. Either way a block is rendered once and handed over — it is the
terminal's content afterwards, not a frame tuika will repaint.
```rust,ignore
use tuika::prelude::*;
let runner = Runner::new(RunnerConfig {
tick_rate: Duration::from_millis(80),
screen_mode: ScreenMode::split_footer(5),
});
let scrollback = runner.scrollback();
```text
ESC ] 8 ; ; https://docs.rs/tuika ST the tuika docs ESC ] 8 ; ; ST
```
**Supported terminals:** Ghostty, iTerm2, WezTerm, Kitty, recent VTE-based
terminals. Others render the visible text unchanged.
**Limits & safety.** The scheme allowlist is the safety boundary: by default
only `http://` and `https://` are accepted, and a host that opts into `mailto:`
gets it sanitized separately — the query (`?cc=`, `?body=`, …) is dropped so a
click can't be steered into pre-filled headers. Every accepted target has its
control characters stripped (including the `ESC`/`BEL` that could break out of
the sequence), so a crafted URL can't escape the OSC. Under tmux, passthrough
must be enabled: `set -g allow-passthrough on`.
## Mouse: selection, highlight & clicks
Once an app captures the mouse, the terminal stops doing its own click-drag text
selection. The `mouse` module rebuilds those affordances over the grid you
already rendered — selection, copy, and hit-testing — from tuika's enriched
event model (`MouseKind` carries `Down`/`Up`/`Drag`, `Moved`, and scroll, with
`shift`/`ctrl`/`alt`).
[API](https://docs.rs/tuika/latest/tuika/mouse/index.html)
<img src="demos/mouse.gif" width="880" alt="Mouse demo: a left-drag selection highlight growing across the phrase 'the quick brown fox jumps over the lazy dog', and a row of clickable Run / Diff / Cancel regions with one highlighted as active.">
- `SelectionState` turns a left-button `Down → Drag → Up` gesture into a
`SelectionRange`, and a same-cell double click selects the word under the
pointer. Call `resolve(buffer, area)` after rendering to resolve pending word
boundaries; `selected_text(buffer, area, range)` then reads the selected text
back out of the rendered buffer (wide glyphs intact) and `mouse::paint_selection(buffer,
area, range, style)` paints it in.
- `ctrl_click_url(event, buffer, area)` returns the URL under a Ctrl+left-button
release: first an OSC 8 target embedded in the cell run (labeled markdown
links after `apply_buffer_links`), then a visible bare `http(s)://` run. The
host remains responsible for opening it; Yolop's fullscreen host opens it in
the system browser.
- `HitMap<T>` maps screen rects to values (a button, a link, a row); the
last-pushed match wins, so children and overlays registered after their
parents take precedence. `ClickTracker` turns a same-cell `Down`/`Up` into a
`Click` and lets an intervening drag cancel it.
```rust
use tuika::mouse::{SelectionState, selected_text};
let mut sel = SelectionState::new(); // held by the host across frames
sel.handle(&mouse); // feed each mouse event
if let Some(range) = sel.range() {
let text = selected_text(&buffer, area, range);
// …copy `text` to the clipboard (see below)
}
```
Selection integrates with the clipboard feature below: the text a drag selects
is exactly what `clipboard::write` copies. **Shift-drag** is deliberately left to
the terminal so its native selection still works as an escape hatch.
## System clipboard (OSC 52)
Copy text to the system clipboard by writing an OSC 52 sequence — the *terminal*
does the copy, so there's no platform clipboard library and it works over SSH.
That makes it the natural partner to a mouse selection.
[API](https://docs.rs/tuika/latest/tuika/term/clipboard/index.html)
- `osc52(text)` — pure encoder; returns the sequence, or `None` when `text` is
empty or exceeds `MAX_LEN`.
- `clipboard::write(out, text)` — encode and write in one step, returning whether
a sequence was emitted.
```rust
use tuika::term::clipboard;
let mut out = std::io::stdout();
let copied = clipboard::write(&mut out, "selected text")?; // Ok(true) when emitted
```
The emitted bytes are the base64-encoded payload, terminated by `ST` (`ESC \`):
```text
ESC ] 52 ; c ; <base64-of-text> ST
```
**Supported terminals:** Ghostty, iTerm2 (with clipboard writes enabled),
WezTerm, Kitty, recent VTE, xterm.
**Limits.** Terminals cap the sequence near 100 000 bytes, so a single copy is
bounded at `MAX_LEN` (74 994 bytes); longer text is refused rather than sent
truncated — a truncated clipboard is worse than a failed copy. Under tmux it
needs `set -g allow-passthrough on` (or tmux's own `set-clipboard`), the same
passthrough caveat as OSC 8.
## Native progress (OSC 9;4)
Drive the terminal's *own* progress indicator — a bar across the window in
Ghostty, the taskbar in Windows Terminal and ConEmu. It's fully out-of-band: it
moves no cursor and paints no cells, so it works in both the inline and
full-screen renderers and composes with an in-grid
[`ProgressBar`](components.md#progressbar).
[API](https://docs.rs/tuika/latest/tuika/term/progress/struct.TerminalProgress.html)
`TerminalProgress` owns the indicator and clears it on drop, so a dropped or
panicking session never leaves a stuck bar in the taskbar. Its methods map to
the indicator states: `percent` (normal), `error`, `indeterminate`, and
`clear`.
```rust
use tuika::term::progress::TerminalProgress;
let mut progress = TerminalProgress::new();
progress.percent(60); // 60% on the terminal's own bar
// progress.indeterminate(); // busy, percent ignored
// progress.error(60); // error state at 60%
// …dropping `progress` clears the indicator.
```
The emitted bytes (`state` is 0–4, `percent` is 0–100; terminated by `BEL`):
```text
ESC ] 9 ; 4 ; <state> ; <percent> BEL
```
**Supported terminals:** Ghostty (bar across the top of the window), Windows
Terminal and ConEmu (taskbar), WezTerm, Konsole, mintty. Others swallow the
unknown OSC. Writes are best-effort — a failed progress write never disrupts the
session.
## Images (Kitty, iTerm2 & Sixel graphics protocols)
Paint real pixels — an avatar, a chart, a rendered diagram — over the cells a
view reserves for them, using the **Kitty**, **iTerm2**, or **Sixel** graphics
protocol, whichever the terminal speaks.
[API](https://docs.rs/tuika/latest/tuika/term/image/index.html)
<img src="demos/image.svg" width="880" alt="Image demo, side by side: on a Kitty/Ghostty/WezTerm/Konsole terminal the Image view renders a red/green gradient in place; on every other terminal the same view shows a dimmed italic '[image: a red/green gradient]' placeholder.">
Unlike the other demos, this one is generated from the real render rather than
recorded — VHS captures through xterm.js, which doesn't implement the Kitty
graphics protocol, so it can't show the pixels. The picture above is the actual
RGBA the component transmits; the fallback panel is the exact placeholder string
it paints.
Images are the one out-of-band feature that does **not** degrade harmlessly on
its own: a terminal that can't read the graphics escape may paint the payload as
garbage rather than swallowing it. So this is the only feature gated on real
capability detection — `ImageSupport::detect()` reads the environment (`TERM`,
`TERM_PROGRAM`, `KITTY_WINDOW_ID`, the Ghostty marker) and defaults to *no*
graphics, where the same `Image` view falls back to an alt-text placeholder.
Decoding stays in the host (it's a heavy dependency, kept out of tuika like
syntax highlighting is): a host hands in raw RGBA via `ImageData::from_rgba`,
and tuika encodes it for whichever protocol the terminal wants — raw RGBA for
Kitty, an in-tuika PNG for iTerm2 — with no image-codec dependency. Because a
graphics escape paints at the cursor — unlike the cursor-neutral OSC sequences
above — emission is a two-step draw:
- `Image` reserves a `cols × rows` cell footprint and, on render, records its
placement into a shared `ImageLayer` (the ownership shape of `RectProbe`).
- **After** `terminal.draw()` flushes the frame, `ImageLayer::emit(out)` writes
each image's escape at its cell origin, bracketed by a cursor save/restore so
ratatui's cursor model is undisturbed. Then `ImageLayer::clear()` resets it
for the next frame.
```rust
use tuika::prelude::*;
use tuika::term::image::{ImageData, ImageLayer, ImageSupport};
let data = ImageData::from_rgba(2, 2, vec![0u8; 2 * 2 * 4]).unwrap();
let layer = ImageLayer::new();
let _image = Image::new(data, 20, 10) // 20×10 cells on screen
.support(ImageSupport::detect())
.in_layer(&layer)
.alt("a 2×2 swatch"); // shown where graphics aren't supported
```
The emitted bytes per protocol (base64 payload; `ST` is `ESC \`, `BEL` is
`0x07`). Kitty carries raw RGBA (`f=32`), chunked, with `q=2` suppressing the
terminal's replies so they aren't read as input:
```text
ESC _ G f=32,s=<px_w>,v=<px_h>,a=T,c=<cols>,r=<rows>,q=2,m=<more> ; <base64> ST
```
iTerm2 carries a PNG file sized in cells:
```text
ESC ] 1337 ; File=inline=1;width=<cols>;height=<rows>;preserveAspectRatio=0;size=<bytes> : <base64> BEL
```
Sixel carries a palette-quantized bitmap (it has no cell-based sizing, so tuika
resamples the pixels to an assumed cell geometry first):
```text
ESC P q "1;1;<px_w>;<px_h> <color registers> <band data> ESC \
```
**Supported terminals:** Kitty, Ghostty, WezTerm, Konsole (Kitty protocol);
iTerm2 (its own protocol); foot, xterm +sixel, mlterm, contour (Sixel). Others
show the alt-text fallback. Sixel has no reliable environment signal, so a host
on a Sixel terminal usually sets `ImageSupport::Sixel` explicitly. See the
`image` example (`cargo run --example image`).
Markdown `` renders too, in both the one-shot `Markdown` view and the
streaming `MarkdownState`. A host `ImageResolver` decodes each URL to `ImageData`
(markdown carries only the URL, never pixels, exactly like the code `Highlighter`
seam); a resolved image reserves a block and is painted with the same `Image`
machinery — real pixels where supported, alt text otherwise. The view's
`.images(resolver, support, layer)` overlays them for you; a host driving
`MarkdownState::lines` reads `MarkdownState::images()` and paints each
`MarkdownImage` at its `rect(area)`. Unresolved images stay a marked, link-styled
inline placeholder rather than dropping the URL.
## See also
- [Component gallery](components.md) — the widgets that paint the grid.
- [Markdown guide](markdown.md) — where hyperlinks and images meet rendered
markdown.
- [API documentation](https://docs.rs/tuika) — the complete reference for the
`capabilities`, `hyperlink`, `mouse`, `clipboard`, `native`, and `image`
modules.
- [Runnable examples](../examples/) — `mouse` records the selection/clipboard
workflow live; quit with `q`/`esc`.
- [README](../README.md) — the model behind the toolkit.