feat: show a live info pane beside the results - #162
Draft
TechNapoleon wants to merge 19 commits into
Draft
Conversation
7 tasks
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. 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.
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.
TechNapoleon
force-pushed
the
pr2-info-pane
branch
from
August 17, 2026 21:34
1cbdd37 to
f584ed0
Compare
| vi.mock("./hooks/useMouseWheel", () => ({ useMouseWheel: (): void => {} })); | ||
|
|
||
| vi.mock("../config/config", () => ({ | ||
| loadConfig: async (): Promise<unknown> => ({ downloadDir: "/tmp/torlink-tests", trackers: [] }), |
Contributor
Author
|
The code-scanning annotation on Happy to switch it to |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
#161 made the metadata available and put it behind Enter. That still means opening a row to find out
whether it's the one you want. This puts the answer beside the list, on whatever the cursor is on.
The pane follows the cursor.
itoggles it,→opens it up to read and scroll. The poster is drawn with half-block characters, which is the best a terminal can do with text.row under the cursor.
itoggles it; it auto-hides below 92 columns, where the list needs everycolumn it has.
whole frames, so a sixel written into a cell is overwritten or measured as text on the next pass.
So the art is text:
▀with the upper pixel as foreground and the lower as background, which fitstwo pixel rows per terminal row and comes out square against a cell's 1:2 shape.
→focuses the pane and←/escreturns. Focused, the list falls back to its minimum width,the pane spends the difference, and the whole card scrolls under a window.
rows visible without scrolling, which is the difference between knowing a description exists and
reading one.
Both new keys are wired into both halves of
keymap.ts, andkeymap.test.tsasserts it.iand→were unbound before, so nothing is retrained. ThepreviewOpen/setPreviewOpenStore fieldshave their
makeStoreentries in the previews script.Everything degrades to the text card: a URL off the host allowlist, a body over a megabyte, a WebP
served with a
.jpgname, a truncated download, a decoder that gives up, a pane too short — each isa
nulland a card that renders exactly as it does without art.A note on two preview images.
preview/browse.svgandpreview/downloads.svgchange in this PRand the changes are unrelated to this feature. Both were last committed before the
f Filterfooter hint and the "Highlight Selected Items" (#130) row styling landed, so they were already stale
on
main;npm run previewsregenerates all four from the real components and corrects them.Running it on a clean checkout of
mainreproduces the identical diff.Checklist
npm run typecheckis cleannpm testpassesHELP_GROUPSandfooterHintsinsrc/ui/keymap.tsStorefield, I updatedmakeStoreinscripts/render-previews-impl.tsx62 test files, 637 tests. The suite was also run under saturated CPU, because a test that only passes
on an idle machine isn't passing: the hook tests now wait on the fact rather than on a fixed sleep,
and the two exhaustive layout sweeps carry an explicit timeout, since at thousands of configurations
each they can exceed vitest's 5 s default on a loaded runner.