Skip to content

feat: draw posters as real images on kitty and Ghostty - #163

Draft
TechNapoleon wants to merge 25 commits into
baairon:mainfrom
TechNapoleon:pr3-kitty-graphics
Draft

feat: draw posters as real images on kitty and Ghostty#163
TechNapoleon wants to merge 25 commits into
baairon:mainfrom
TechNapoleon:pr3-kitty-graphics

Conversation

@TechNapoleon

@TechNapoleon TechNapoleon commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Stacked on #162, which is stacked on #161 — please review those first. This branch is based on
them, so their commits appear in this diff until they merge. Opened as a draft for that reason.


What and why

#162 draws the poster as coloured half-blocks, which is the best a terminal can do with text. Some
terminals can do better — kitty and Ghostty can draw a real image — and on those the poster stops
looking pixelated.

torlink running on a graphics terminal: the info pane shows the film poster as a real, sharp image rather than coloured blocks

The same pane on a terminal that can draw images. Here is the half-block fallback for comparison — same pane, same poster, every other terminal.

Ink's sanitizer drops an APC image escape and a sixel DCS alike, so the picture can't simply be
written into a cell. kitty's Unicode-placeholder path is text and survives: a cell is U+10EEEE
plus combining marks naming its row and column, and the image id rides in the foreground colour Ink
already emits. Every cell names both its row and column rather than inferring from its neighbour,
which is what lets the pane clip and scroll the art and still have it decode.

It's opt-out: TORLINK_GRAPHICS=off (also 0, none) gives half-blocks everywhere;
TORLINK_GRAPHICS=kitty forces it past the probe, which is how the path can be exercised on a
terminal that can't be asked. Documented in both the README and --help.

The detector writes nothing to the tty until every veto passes — no tty, CI, tmux (by TMUX or
by TERM, since torlnk attach runs the app inside it), anything short of certain truecolour, and
finally no positive marker for a terminal family that implements Unicode placeholders. A terminal
that can't parse an APC escape prints it, so guessing wrong costs a screenful of garbage.

That last veto is a placeholder list, not a graphics list, and this PR originally got it wrong.
WezTerm implements the graphics protocol and answers the query transmission with OK, so it cleared
the probe — but it has never shipped Unicode placeholders (asked for since 2023;
wezterm/wezterm#7924 is still open, and there are no
commits for it), which means it would have drawn a poster-shaped hole rather than a poster. The
protocol offers no query for placeholder support, so a family list is the only honest gate.
WezTerm is out, and gets the half-block tier like every other terminal; it is two lines and a
test to add back the day #7924 lands.

About the 129-entry Unicode table

A hand-transcribed Unicode table is exactly the kind of thing that rots silently, so it isn't taken
on trust: src/meta/kittyGraphics.test.ts re-derives the whole table from ICU via
Intl.Segmenter and asserts equality, with spot values checked independently of the derivation. If
the transcription were wrong, that test fails. The table is a deliberately truncated prefix — art
needing more than 129 cells on an axis returns null and the half-block tier answers.

One behaviour change worth stating plainly

A poster's source resolution is scaled to fit a fixed byte budget rather than being refused when it
exceeds it. That removes a real inversion — on a tall terminal, focusing the pane used to push the
poster over the cap and drop it back to half-blocks, so focusing made the picture worse. But it does
mean a large pane that previously sent zero image bytes now sends up to the budget, and sends it
again each time the cursor settles on a new row at that size. On a big terminal over SSH, that's the
trade; TORLINK_GRAPHICS=off opts out.

Teardown names the ids this process issued rather than the d=A that frees every image in the
terminal window — quitting torlink shouldn't drop a preview another tool drew there. The
alt-screen restore is emitted before those deletes: Node's write to a TTY is synchronous on POSIX
but not on Windows, and a truncated exit write must not take the restore with it and strand the user
in the alternate buffer.

Checklist

  • npm run typecheck is clean
  • npm test passes
  • New logic has a test (vitest; mock node built-ins for platform code)
  • If I added a key, I updated both HELP_GROUPS and footerHints in src/ui/keymap.tsno new keys in this PR
  • If I added a Store field, I updated makeStore in scripts/render-previews-impl.tsxno new Store fields in this PR
  • OS-touching code works on Windows, macOS, and Linux — the probe branches on terminal capability, never on platform; a Windows console never matches a marker and silently gets half-blocks. The exit-write ordering above is the Windows-specific consideration.
  • One concern, with a Conventional Commits title

64 test files, 700 tests. The capability probe was also verified against a real Ghostty session
rather than only in unit tests — the payload is the kind of thing that passes every test while
failing on hardware, so there's a test pinning the exact query byte string.

Comment thread src/ui/App.test.tsx
vi.mock("./hooks/useMouseWheel", () => ({ useMouseWheel: (): void => {} }));

vi.mock("../config/config", () => ({
loadConfig: async (): Promise<unknown> => ({ downloadDir: "/tmp/torlink-tests", trackers: [] }),
Comment thread src/ui/graphics.test.ts
});

it("refuses tmux, which is how torlnk attach runs the whole app", () => {
expect(graphicsMarker({ ...KITTY_ENV, TMUX: "/tmp/tmux-1000/default,123,0" }, true)).toBe(false);
Comment thread src/ui/graphics.test.ts
// being wrong here costs a screenful of garbage rather than a missing picture.
for (const env of [
{ TERM: "xterm-256color", COLORTERM: "truecolor" },
{ ...KITTY_ENV, TMUX: "/tmp/tmux-1000/default,1,0" },
@TechNapoleon

Copy link
Copy Markdown
Contributor Author

Three code-scanning annotations here, all false positives on string literals in tests:

  • src/ui/App.test.tsx:24/tmp/torlink-tests is a mocked loadConfig return value; nothing opens or writes it.
  • src/ui/graphics.test.ts:123 and :155 — that's a fake TMUX env var value, used to prove the graphics probe refuses to run under tmux. It isn't a path in use, it just looks like one.

ThreatCrush passes on all three. Happy to switch the first to path.join(os.tmpdir(), …) to match vitest.config.ts if you'd prefer it silenced.

@ralyodio

Copy link
Copy Markdown
Contributor

trying wez as its the only that is mac/win/nix

Search results arrive as scene and fansub release names, which carry the
title fused to quality, source, codec and group tags. Any metadata lookup
has to recover a plain title first, so this lands the reduction on its own
as a pure, import-free module ahead of the code that will consume it.

The parser is total by construction: it runs once per visible row during a
search, so it returns an empty title rather than throwing, and leaves the
decision about whether a title is worth querying to the caller. Sanitizing
for the terminal stays at the render boundary.

Three rules earn their odd shapes from real sampled names. Dots convert to
spaces on a tie with spaces, not only when they outnumber them, because a
name with equal counts is a dotted scene name whose title contains spaces,
and leaving the dots in fuses title and junk tokens. The bare "Show - 01"
fansub form backfills its season from the text before the marker, since that
form carries an episode only while the season sits just ahead of it.

The third is the trailing release group, which is taken only when the text
ahead of it corroborates a scene release — a junk token, a year, or an
episode marker. A trailing "-GROUP" and an ordinary hyphenated title are the
same shape, so matching on shape alone truncates "Spider-Man" to a title of
"Spider", and likewise "Ant-Man" and "X-Men". Those feed a title search,
where the truncation either misses or matches the wrong film, and a
junk-vocabulary guard does not help because a real title word is not
vocabulary. Requiring corroboration fails in the safe direction: a minimal
name keeps a slightly dirty title rather than a truncated one. Separator
normalisation runs ahead of the group strip to make the check possible at
all, since corroboration reads the name as tokens and a dotted scene name is
a single token until the dots become spaces.
A search row shows a release name and nothing else, which is the least
useful thing a torrent carries. Cinemeta is keyless and IMDb-keyed, so it
buys a title, year, rating, plot and poster with no account to create and no
key to ship in a public binary.

Two rules shape the code. The client is called from a render path, so every
mapper is total and every request fails soft to null or []: a dead provider
must cost a poster, not the TUI. And the IMDb id is remote input that ends up
in a URL path, so it is validated on the way out, not on the way in — the
same guard stripControl() applies to text bound for the terminal.

Totality has to hold in the helpers rather than rely on a caller's
try/catch, because searchUrl and metaUrl are exported and called directly.
encodeURIComponent throws URIError on an unpaired surrogate, and a lone
surrogate is remotely reachable: JSON.parse produces one from a "\ud800"
escape in a tracker payload. Half an astral character carries no meaning, so
it is dropped before encoding — one code path and one return shape, instead
of a swallowed error and a second, undocumented result. The lookbehind is
what catches an unpaired low surrogate as well; a plain \uD800-\uDFFF class
would mangle well-formed emoji, which the tests pin.

Remote fields are capped before they are cleaned rather than after, so a
hostile description does not pay the full cost of a code-point walk before
being cut down, and an oversized cast entry or genre never reaches a word
wrapper. The blank check runs on the capped string, so a field padded with
leading spaces up to the cap answers undefined rather than a placeholder.

The matcher abstains rather than guesses. A provider's search endpoint ranks
by popularity, not relevance, so "The Matrix" comes back with its sequels
attached; the score has to be positively convincing before a poster is
shown, because a wrong poster is worse than no poster and the user cannot
tell it is wrong.

Nothing imports this yet.
Metadata for a search row is looked up per selected row, so the same title is
otherwise re-fetched every time the user scrolls back onto it. A module-level
cache with a 30 minute TTL and an in-flight map collapses that: one film's
four YTS quality rows, and the same film arriving from three trackers, resolve
to a single entry and a single request. Misses are cached too, because
scrolling repeatedly past an unmatched row should stop costing anything after
the first pass.

The cache key carries the kind as well as the id. The same IMDb id can arrive
from a movies feed and from a TV feed that misclassifies it, and the two need
different Cinemeta URLs; Cinemeta answers HTTP 200 with its "unknown id" stub
for a wrong-type lookup rather than 404ing, so keying on the id alone lets one
feed's stub poison the other's entry for a whole TTL.

An in-flight request owns its own controller and counts the callers waiting on
it, rather than handing every joiner the first caller's promise. Two hooks can
sit on one row — the detail view and the info pane — and a shared promise
cancelled by whichever caller left first would tell the other there was no
metadata, with no way for it to notice: its effect deps have not changed. A
caller that cancels gets its own null immediately, and the request is aborted
only once nobody is left.

An aborted lookup deliberately does not write the cache: a cancelled request
is indistinguishable from a genuine miss at that point, and recording it would
pin "no metadata" on the row for the whole TTL because the user scrolled past
before the answer arrived. Negative entries carry their own two minute TTL,
since at this layer a dead network and a title the provider genuinely does not
have are the same empty answer, and the longer TTL would leave a session that
started before DNS settled without metadata long after the network came back.

YTS, EZTV and The Pirate Bay already know the IMDb id, so those rows skip the
search-and-guess round trip entirely — apibay carries it on both category
feeds, which makes it the largest source that can. The field is optional and
every other adapter is untouched.

Row dedupe carries the id across the merge. dedupeResults already folds a
losing row's numFiles and added into the winner where the winner has none, on
the rule that a field the winner never reported is a gap and not a decision.
imdbId is the same kind of field, and the case is the common one rather than
the corner: the healthiest row for a film is routinely the one from a source
that carries no id at all, so without this the merge quietly hands the lookup
back to guessing a title from the release name.

A row is refused before the network when its source is Games-only, so FitGirl
never queries at all, and when its release name carries no Latin-script title
to search with — Nyaa's Chinese fansub names parse to a long but entirely
unsearchable title, which a length check would wave through at one wasted
request per visible row.
normalizeImdbId and imdbFromNumeric are generic IMDb id validators, not
Cinemeta-specific, but living in cinemeta.ts made the lower sources/ layer
import the metadata client just to sanitise a field of its own payload. Move
both to a neutral src/meta/imdbId.ts and repoint yts, eztv, piratebay and
cinemeta itself at it.

Pure move: no behaviour or signature change, and the tests move with the
functions, assertion for assertion.

eztv and yts gain adapter-level tests for their id extraction while their
imports are being touched, following piratebay's precedent of exporting its
mapping function for exactly that purpose. The validators themselves are
covered in imdbId.test.ts; what these pin is the wiring, without a network
call.

eztv's suite keeps main's search tests and gains these beside them rather than
in place of them, so the module is now imported statically as well as through
the suite's own resetModules helper. Its mockFetch moves to vi.hoisted for
that: the vi.mock factory is hoisted above the file's top-level statements, so
a static import of ./eztv would otherwise reach the factory before mockFetch
was initialised.
The metadata pipeline built up to here — release parser, Cinemeta client,
cache, hook — has had no visible surface. The detail view opened with Enter
is the natural first home for it: rating, genres, director, cast and plot
render below the existing Magnet row when metadata resolves, and render
nothing extra otherwise, so a miss or a never-queried row looks exactly like
the view did before.

The rows are budgeted against the panel's real height rather than simply
appended. At a realistic terminal size the panel's fixed, overflow-hidden
content box has no slack once metadata sits on top of the existing torrent
facts, and handing Ink more rows than fit does not clip cleanly — Yoga's
flexbox shrink squeezes arbitrary rows to zero height instead, which drops
facts and can hide the download/copy/export hint row entirely, the only
on-screen way to discover those keys.

So the available rows are computed before rendering, and rating, genres,
director and cast are admitted in that priority order, with every row after
the first that does not fit dropped rather than leaving a gap partway through
the block. Plot is exempt from that cutoff and takes whatever is left over,
capped with a forced ellipsis when it does not fully fit — the one row
allowed to show less than it has.
Counting UTF-16 code units undercounts a CJK character — one unit, two
terminal columns — by half. Nyaa is an anime index and Cinemeta returns
Japanese, Korean and Chinese cast and plot text routinely, so the detail
panel's row budget has to measure display width or it overflows on a common
path rather than an edge case: rows dropped from the torrent facts, and the
action hint fused with stray metadata.

So the line-count and truncation math runs on an East Asian Wide/Fullwidth
range table, and raw string slicing gives way to code-point-safe wrapping and
ellipsizing, which also stops a surrogate-pair emoji being split in half.

The table has to carry astral ranges as well as BMP ones, and that is easy to
miss: an astral character costs two UTF-16 code units, which happens to match
its two-column display width, so a length-based count got it right by
accident. Iterating by code point removes the coincidence, and without the
astral entries every emoji would measure one column — the same undercount the
mechanism exists to prevent, moved from CJK text to emoji.

One range needs care rather than breadth. The U+231A-U+2B55 span contains
this app's own box-drawing border characters alongside genuinely wide symbols,
so it is enumerated by known-wide code point rather than swept wholesale; a
span-wide rule measures the panel's own chrome as double-width and corrupts
every frame regardless of content.

The helpers live in their own module because the detail panel is not the only
view that needs them — the info pane beside the results list wants the same
accounting, and a second copy of the width table is a second thing to get
wrong when a new emoji block or CJK range has to be added.
Results.tsx carries the results list, the detail panel and their key handling,
and had grown past 650 lines. The panel's row-budget planner is the part with
the least to do with rendering: a pure function over a metadata object and a
row count, with its own priority order and its own prefix-cut degradation
rule. Both are worth reading — and testing — without a render in the way.

Pure move. No behaviour change, and the tests that cover the budget through
the rendered panel are untouched, so they still pin it end to end.
Both files stood a fixed `tick(ms)` sleep in for "the mocked promise settled
and Ink flushed a frame". That guess holds on an idle machine and stops
holding on a busy one, where these tests fail reliably — reading LOADING
where the next row's title should already be, or counting one lookup where
the row change should have forced a second. A test that only passes on an
idle machine is not passing; CI runners are small and shared.

Every positive fact now waits on itself with `vi.waitFor`, which retries
until the fact is true rather than until a guessed interval expires.

The sleeps that remain are the ones a waitFor cannot express. A purely
negative step — the late resolution changed nothing, the re-sorted row did
not ask again — would satisfy a waitFor on its first attempt and prove
nothing, so it keeps a real sleep that gives the wrong behaviour room to
appear. So do the debounces, which are wall-clock facts by construction; a
sleep can only overshoot them on a loaded machine, which is the harmless
direction. Each is commented with which case it is.

Every converted wait was checked to need more than one attempt, so none of
them resolves on the frame the mount already showed.
The results list can say how big a release is and how well it is seeded, but
never what it actually is — which is the question a user scrolling twelve
near-identical release names is really asking. Answering it meant opening the
detail view on every candidate.

The pane follows the cursor and answers it in place: title, year, rating,
runtime, episode, genres, director and cast, on the row under the cursor. It
auto-hides below 92 columns, where the list needs every column it has, and
`i` toggles it anywhere else. The key is wired into both halves of keymap.ts,
so it appears in the footer and on the `?` sheet.

Fitting is measured before anything renders, not left to Ink: a panel that
overflows is clipped by squeezing rows, which drops and fuses lines elsewhere
in the block rather than cutting the one that overflowed.
The info pane can say what a release is in words, but a poster is the thing
the eye recognises before it reads anything, and Cinemeta already hands us a
URL for one. No terminal graphics protocol survives Ink's renderer — it
repaints whole frames, so a sixel written into a cell is overwritten,
mispositioned or measured as text on the next pass — so the art is drawn as
text: U+2580 with the upper pixel as the foreground and the lower one as the
background, which fits two pixel rows in one terminal row and comes out
square against a cell's own 1:2 shape.

jpeg-js is the one new dependency, pure JS with nothing under it. Both poster
hosts are covered: the gate for this work was decoding a real progressive
JPEG, because m.media-amazon.com serves baseline while images.metahub.space
serves progressive even when asked for ?format=jpeg, and a decoder that only
handled the first would have silently dropped every title that never went
through the catalog. Both renditions are inlined as base64 constants in the
tests rather than committed as fixtures.

The cache key carries the pane's cell budget, not just the URL, and both
resize directions are pinned on the rendered frame. Narrowing at least fails
visibly, since the pane refuses a grid larger than its budget and loses its
art; widening has no such backstop, and an undersized grid fits, draws, and
leaves a poster that quietly no longer matches the pane it is in.

Everything degrades to the text card that exists today. A URL off the host
allowlist, a body over a megabyte, a WebP served with a .jpg name, a
truncated download, a decoder that gives up, a pane too short, a grid left
over from a wider pane mid-resize: each is a null and a card that renders
exactly as it does without art. This is a React render path, so the
alternative to failing soft is an unmounted TUI.
The pane's vertical budget was the binding constraint: the poster came out
8x6 at a 130x26 terminal and only reached its intended 24x18 at height 44 and
above, and no split of a fixed pane fixes that. Rather than permanently trade
text rows for art rows, the pane becomes a place the keyboard can go.

Region gains "preview", filling the slot where → in the results list was a
no-op, so the existing horizontal model simply runs one column further:
sidebar ↔ list ↔ pane. Focused, the list falls back to MIN_LIST_WIDTH and the
pane spends the difference, the art is sized from the width and its own aspect
instead of from the rows the text card left over, and the whole card scrolls
under a window with a dim line saying which way there is more of it. Both new
keys are wired into HELP_GROUPS and footerHints alongside `i`.

Focus can never land on a pane that is not on screen: the results view is the
only place that knows the width tier, the `i` toggle and the section at once,
so it reports that up and one clamped step function answers for every
horizontal key — which also walks focus back out when a resize or a toggle
takes the pane away while it is holding the keyboard. That single function is
also what the rescue effect calls, so the recovery path cannot disagree with
the keys.

The focused pane stops widening once it has nothing left to spend width on:
the art is capped whatever the pane does, and a text measure past the
mid-fifties reads worse rather than better. At a 160-column content width that
hands the list 99 columns instead of leaving it at its 52-column minimum with
truncated names beside blank ones.

Games is excluded because no provider answers for it and every row would read
"No metadata"; `all` stays in, where those rows already say so one at a time.

App.test.tsx now drives the real App — boot, search, arrows, esc, tab, all
three modals, a live stdout resize and a section switch — with everything
below it stubbed so mounting starts no engine and makes no request. The four
ways this wiring can silently come apart (deleting the rescue effect,
hard-coding the → gate shut, routing ← past the list, dropping footerHints'
previewOpen argument) each fail a named test.
A literal ESC byte in a test string renders invisibly on GitHub, so a call
like `u.press` with a raw escape plus "[C" displays as `u.press("[C")` —
indistinguishable from a plain "[C", which Ink would actually deliver as
input "[C" with rightArrow false rather than as an arrow key. A maintainer
reading the diff has no way to tell the arrow-key tests are exercising the
right thing.

Add right/left/up/down to KEY in testHarness.ts, following the existing
enter/esc/ctrlU shape, and use them at the arrow-key call sites. The SGR
assertions in the poster and pane tests get the same treatment, with escaped
unicode sequences instead of raw bytes.
ansi-to-svg.ts's block-glyph branch drew only the foreground-coloured half of
a half-block character (upper for U+2580, and so on) and skipped the
background rect the general text path draws for every other run.

That is invisible for a progress bar's solid-colour fill, and the existing
previews are byte-identical either way — neither the wordmark nor the
progress bar sets a background colour on these glyphs, so the new rect never
fires for them. It matters for anything using the half-block trick, where the
background fills the cell and the foreground covers half of it: without the
rect, art drawn that way loses its bottom pixel on every row and renders as
banded stripes over the page background instead of a picture.

Paint boxFill (st.inverse ? fg : st.bg) under the glyph's own partial rect,
mirroring the existing boxFill handling in the general path one branch down.
Add a fourth preview scenario ("info") to render-previews-impl.tsx showing
the metadata pane beside the results list, with poster art and the facts
card, at a terminal width wide enough to reach the poster tier. previewLayout
needs a content width of 86 or more for art, and the other scenarios' shared
80 columns never gets there, so this one runs at 120 on its own constants
without touching them. README gains one line on the `i`/`→`/`←` keys plus the
new screenshot.

browse.svg and downloads.svg also change in this regeneration, and not
because of this feature. Both were last committed at 661f90f, before the
`f Filter` footer hint and the "Highlight Selected Items" (baairon#130) row styling
landed on main — that is, before 7b29746, the commit this branch forks from.
Running `npm run previews` on a clean checkout of main reproduces the
identical diff in both files, so the drift predates this work; regenerating
simply corrects it.
planPaneLines and the line builders around it move verbatim out of MetaPane
into src/ui/paneCard.ts, the same way the text-width helpers and the detail
panel's row planner moved out earlier: a pure module the component and its
tests read the same numbers from, with the row arithmetic now pinned without
a render.

The previews script was carrying a second, hand-rolled copy of the card and
calling posterBudget directly for it. It now draws the app's card, so the
screenshot cannot document a layout the app no longer has. Output is
byte-identical.
The plot was fetched, capped and cached but never rendered, so the pane held
a blank area under the cast credit — most of one when focused, where the card
is six rows in a window of nineteen.

planPaneLines now plans it last and outside the shared cutoff, the way the
detail panel already does: it is the one field with no natural length, so it
takes whatever the fixed-height facts above it underspent rather than being
refused whole because a two-row credit did not fit. Zero rows is a legitimate
answer on a short pane. Focused, the budget is infinite, so the whole synopsis
is built and the window scrolls it.

No spacer row above it — the pane is meant to read as one quiet block — and
the planner needs no focus flag, because an infinite budget already says
everything focus means here. The call is memoised on the row, the width and
the budget: the pane re-renders on every search tick, and the word wrapper is
linear in the plot.
A fixed 24-column cap bound the art in every configuration, so the poster
measured the same at every terminal width and at both focus states: widening
the pane bought text measure and nothing else. The tier table already caps how
wide a pane gets, so the second cap was only ever a proxy for "the art should
not eat the pane" — and fitCells does the aspect arbitration from the bounds
alone. Width is now the pane's full inner width, and a taller pane grows the
picture first, since unfocused art and plot compete for the same slack.

Focused, a poster that fitCells had to cap by rows comes back narrower than
the pane that asked for it, leaving columns empty beside it while the card
stacked underneath had one row to say anything in. So the pane spends those
columns on the card: the art takes the left column, the text flows down the
right. splitTextCols owns the decision and the component does no arithmetic of
its own; it answers null below MIN_TEXT_COLS, and null is the stacked layout
the pane has always drawn — which is also the only layout unfocused, where a
34-column pane split two ways is neither.

What bounds the text is the text measure, not the pane. MAX_TEXT_COLS wraps
the card at 56 however wide the terminal is, and a focused pane may grow to
artCols + gap + 56, so the picture is carried on top of the measure rather
than out of it. Bounding the whole pane instead would cap the card at whatever
the poster left over — fifteen columns at a 120-column terminal — and force
the stack at exactly the sizes the split exists for. The split engages from a
flat 113 terminal columns at every height, so a taller terminal is never less
likely to split than a short one.

Focusing guarantees MIN_FOCUSED_TEXT_ROWS of card on screen without scrolling
— the identity block and two rows of plot, which is the difference between
knowing a description exists and reading one. posterBudget decides which axis
pays for it: wide enough to seat the card beside the picture and the art gives
up columns while keeping the pane's whole height; otherwise it gives up rows
and the card sits underneath. seatsCardBeside settles that from dimensions
alone, before any byte is fetched, because previewLayout and posterBudget both
need the answer and deciding it from the decoded art would make the pane's
width depend on a decode that depends on the pane's width. previewLayout takes
paneInnerRows for the same reason; callers with no pane yet get the stacked
width, which is the width every caller had before.

Scrolling keeps one window over one row list: row i is art row i beside card
row i, the total is the taller column, and scrollStart slices it exactly as
before. The poster is still text and still scrolls with the pane.

The previews script now imports the poster rendition constants rather than
carrying its own copy, so the screenshot reasons from the same numbers the app
does. Results.test.tsx's usePoster mock calls the real fitCells rather than
returning a grid at the full budget width — the narrowing is what the split is
decided from, and a mock that skipped it would exercise only one of the two
layouts.
Give the preview fixture a plot and screenshot the focused split state, where
the poster and the full synopsis actually render side by side. This fixture's
credits alone saturate the unfocused pane's fixed text budget, so the
unfocused state would show no plot at all and document the feature as absent.

The script derives the split from the same shared layout functions the app
uses — previewLayout, posterBudget, fitCells, planPaneLines — and imports the
poster rendition constants rather than carrying its own copy, so a screenshot
cannot document a layout the app no longer has. It now also asserts the card
fits without scrolling, which is the guarantee the focused pane makes.

The stand-in poster ramps between colours already exported from theme.ts,
since the previews never touch the network and there is no JPEG to decode.
The poster hook's tests stood a fixed `tick(ms)` sleep in for "the mocked
promise settled and Ink flushed a frame". That guess holds on an idle machine
and stops holding on a busy one, where the test asserting the intermediate
loading state fails outright. Every positive fact now waits on itself with
`vi.waitFor`, which retries until the fact is true rather than until a guessed
interval expires, and each converted wait was checked to need more than one
attempt so none of them resolves on the frame the mount already showed.

The sleeps that remain are the ones a waitFor cannot express: a purely
negative step — the unmounted row's late response paid for no decode — would
satisfy a waitFor on its first attempt and prove nothing, so it keeps a real
sleep that gives the wrong behaviour room to appear.

The two exhaustive layout sweeps get an explicit timeout for the same reason.
At thousands of configurations each they run comfortably inside vitest's 5 s
default on an idle machine and exceed it on a loaded one, and a CI runner is
small and shared. Budgeting them deliberately is better than a global raise,
which would also hide a genuinely hung test.
The pane slices poster art with its scroll offset, which is what keeps a
half-scrolled poster on screen instead of abandoning it. That property was
expressed inline in MetaPane against one art shape.

Add a second shape beside PosterCells — a grid whose rows are strings a
graphics terminal resolves into a real image — and move the window into
sliceArt, written once against the union of both. PosterCells itself is
untouched, so every grid built by hand in the tests and the previews script
still compiles; `in`-narrowing supplies the discriminant instead.
Ink's sanitizer drops an APC image escape and a sixel DCS alike, so the
picture cannot be written into a cell. kitty's Unicode-placeholder path is
text and survives intact: a cell is U+10EEEE plus marks naming its row and
column, and the image id rides in the foreground colour Ink already emits.

Every cell names both its row and its column rather than inferring the column
from its neighbour, which is what lets the pane clip and slice the art and
still have it decode. The pixels are raw RGB deflated with node:zlib — kitty
takes PNG or raw and not JPEG, and sampleGrid already emits exactly the
payload format, so the tier costs no dependency.

The diacritic table is kitty's own derivation — combining class 230, no part
in canonical composition — cut at the last entry no Unicode version can move.
Transcribing a Unicode table by hand is exactly the kind of thing that rots
silently, so its test re-derives that rule from ICU via Intl.Segmenter and
asserts equality, with spot values checked independently of the derivation. A
wrong transcription fails the suite rather than producing quietly wrong art.
A terminal that cannot parse an APC escape prints it, so guessing wrong here
costs a screenful of garbage rather than a missing picture. The detector is
therefore a series of vetoes that writes nothing to the tty until every one
has passed: no tty or CI, tmux by TMUX or by TERM (torlnk attach runs the
whole app inside it), anything short of certain truecolour (the image id
travels as a 24-bit foreground colour, so a downgraded palette names a
different image), and finally no positive marker for a terminal family that
implements Unicode placeholders.

That last one is a placeholder list and not a graphics list, and the two are
not the same set. WezTerm implements the graphics protocol and answers the
query below with OK, but has never shipped placeholders — asked for since
2023, wezterm/wezterm#7924 still open — so a marker for it would clear every
veto here and then draw a poster-shaped hole. The protocol offers no query for
placeholder support, which is why this stays a family list rather than a
capability check, and why the list is as short as it is.

Only then does it ask, with a query transmission fenced behind a device
attributes request: every terminal answers DA1, and it answers after the
graphics reply, so a terminal that ignored the query gives a fast definite
negative.

The query's payload is the four bytes `AAAA` exactly. It is a one-pixel probe
image, and base64 of three zero bytes needs no padding — appending `=` makes
it invalid, which a strict terminal rejects outright: Ghostty logs
`error.Base64Invalid` and replies with DA1 alone, indistinguishable from a
terminal that has no graphics support at all. A test pins the whole query
string rather than a prefix and a suffix, because a byte in the middle is
precisely where this can hide, and the probe was checked against a real
Ghostty session as well — the failure mode here passes every unit test.

Both replies are consumed rather than left for Ink to read as keystrokes: the
probe holds the tier it saw until the DA1 fence arrives or its budget expires,
then drains whatever is still buffered, since pausing a stream does not
discard what it already holds. A device-attributes report is filtered out of
the search field too, which covers the one case the drain cannot — a reply
that arrives after the probe has given up. Raw mode, the listener and a
resumed stream are all handed back as they were found.

TORLINK_GRAPHICS=off is the user's escape hatch; =kitty forces the tier past
the probe, which is how the path can be exercised at all on a terminal that
cannot be asked.
The tier is settled once at startup, above render(). The poster hook keys its
cache by tier as well as by budget, asks for a larger source rendition when
the terminal draws real pixels, and transmits the image before the state
update that paints the placeholders addressing it — Node orders writes on a
stream, which is the whole synchronisation between the two halves.

Poster gains a branch that is cheaper than the one it already had: the image
id is one colour for the whole picture, so a row is one Text instead of up to
cols of them. MetaPane needed nothing but the widened type — cells are cells,
and the tier changes what one contains rather than how many there are.

Teardown names the ids this process issued, one escape each, rather than the
`d=A` that frees every image in the terminal *window*. Quitting torlink should
not drop a preview another tool drew there, and nothing else distinguishes
ours from theirs. `q=2` keeps the acknowledgement off stdin, where Ink's
parse-keypress would read it as input.

The pane's scroll is asserted against the art itself: with graphics art
mounted, one press of j moves the top visible row's row diacritic from 0 to 1.
The picture scrolled because a row of it says which row it is, not because
anything re-decoded. Frames carrying placeholders are measured by grapheme,
never by textWidth.ts, and Poster.test.tsx pins that divergence with the
reason it has to stay.

TORLINK_GRAPHICS is documented in `--help` alongside the two token variables,
not only in the README.
The teardown escape is now proportional to the session: one delete per image
id issued, so a long browse writes a larger string than a single fixed escape
ever did. Node's write to a TTY is synchronous on POSIX but not on Windows, so
a large write immediately before process exit can truncate — and with the
alt-screen restore trailing it, a truncated write takes the restore with it
and leaves the user staring at the alternate buffer with no prompt.

Emit the restore first. A lost tail then costs some images left in the
terminal's own store, which is invisible and bounded, instead of a terminal
the user has to fix by hand. Deleting by id is screen-independent, so nothing
about the ordering weakens it.
decodeGraphicsPoster sized the source at a flat 8x16 pixels a cell and
returned null once that passed MAX_RAW_BYTES, which is 3 125 cells — so the
bigger the pane, the likelier it got no native image at all. At a 220x64
terminal the unfocused info pane drew a real image (30x23 cells, 265 KB)
while the focused one exceeded the cap (68x51, 1332 KB) and fell back to
half-blocks: focusing a row *downgraded* its poster, the inverse of what the
tier is for.

Scale the pixels-per-cell down to fit the budget instead. The transmission
already declares the source size and the cell box separately and the terminal
scales one into the other, so a clamped poster keeps its footprint and is
only softer — 115 pixels a cell at 68x51, against the two that half-blocks
would have given it. The scale is one square root applied to both axes, so
nothing is stretched, and it is still computed from the cell dimensions
before sampling, so an oversized pane never allocates a megabyte to decide.
Both floors round down, so the buffer can only land under the ceiling.

MAX_PLACEHOLDER_CELLS is untouched: art past the diacritic table still falls
back to half-blocks.

Worth stating plainly, since it is a real change in what goes over the wire:
a pane past 3 125 cells used to send zero image bytes and now sends up to the
full budget, again each time the cursor settles on a new row at that size.
That is the trade the tier exists to make, and TORLINK_GRAPHICS=off opts out.
@TechNapoleon TechNapoleon changed the title feat: draw posters as real images on kitty, Ghostty and WezTerm feat: draw posters as real images on kitty and Ghostty Aug 30, 2026
@TechNapoleon

Copy link
Copy Markdown
Contributor Author

@ralyodio rebased onto main (918d3f6) too — conflicts resolved, so this applies cleanly now.

And your comment saved this PR from shipping something wrong, so thank you for it. You going to install WezTerm made me go and check what WezTerm can actually do, and the answer is: not this.

The tier here can't write an image into a cell directly — Ink's sanitizer drops an APC escape — so it uses kitty's Unicode-placeholder path instead: U+10EEEE cells with the image id carried in the foreground colour. WezTerm implements the kitty graphics protocol, but it has never shipped placeholders. They've been asked for since April 2023, wezterm/wezterm#7924 is still open, and there isn't a commit for it in the tree.

The nasty part is that the probe cannot see the difference. It sends a query transmission asking "could you accept this image?", and WezTerm truthfully answers OK, because it can accept images. The protocol has no query for placeholder support. So WezTerm was clearing every veto and then drawing a poster-shaped hole — exactly the failure this module's whole design exists to prevent, arrived at from the one angle the vetoes didn't cover.

So WezTerm is out: removed from the marker, the README and this PR's title, with the reasoning in the code and a test pinning the refusal so it can't quietly drift back. Two lines and a test to add back the day #7924 lands. On WezTerm you now get #162's half-blocks, which is the right answer rather than a broken pane.

If you'd rather confirm than take my word for it: TORLINK_GRAPHICS=kitty forces the tier past the probe, so you can see the hole on WezTerm for yourself, and TORLINK_GRAPHICS=off puts it back.

One thing worth saying plainly, since cross-platform was your reason for picking wez: there is currently no terminal that does this on all three. kitty and Ghostty are macOS and Linux; on Windows nothing implements it yet (microsoft/terminal#17309 is open). That's a fair argument for treating this tier as a bonus and judging the feature on #162's half-blocks, which is what most people will actually see.

If you do have a Mac or Linux box with kitty or Ghostty and still fancy a look:

git clone -b pr3-kitty-graphics https://github.com/TechNapoleon/torlink
cd torlink && npm install && npm run dev

Search for a film, arrow onto a result, i for the pane, to open it right up. One refusal that's by design and not a bug: tmux, including torlnk attach, which runs the whole TUI inside it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants