lllm3090.tui#

The control panel on a text console, for a machine with no browser on it.

The panel binds loopback and speaks HTML, which is exactly right until the machine is sitting on multi-user.target with no compositor – the state that hands a 24 GB card back most of a 35B model’s KV cache. There is then no browser within reach of 127.0.0.1:8080 and no way to start a model except by remembering its name.

This module is that panel drawn with curses. It shows the same things: engine state, VRAM, one list of every model – what is on disk and what is merely available, with what fits and what it will do – downloads with progress, and the tail of the engine log.

Two decisions are worth stating, because both could reasonably have gone the other way.

It talks to the panel over HTTP, and falls back to the library. Reading the machine’s state has a local answer that is always correct – the catalogue is arithmetic, the engine is a pidfile – so status, the model list and start/stop work with the panel stopped. Downloads do not divide so cleanly: the registry of in-flight downloads is state inside the panel process, and a second process fetching into the same .part file would append to bytes the first is still writing. downloads.resume_interrupted would then cheerfully start a third. So downloads are the panel’s job alone, and when it is not running the terminal UI says so and offers to start it.

Everything it draws is ASCII. The Linux framebuffer console renders whatever glyphs its font holds, and the default fonts are Latin-1: box-drawing characters, block elements and the panel’s typographic dashes are not reliably there. A UI whose entire purpose is to work where a browser cannot is not the place to gamble on a font.

curses is in the standard library, so this costs the project no dependency at all – which matters for something installed with uv tool install. It is imported inside run() rather than at module scope so that importing this module, as the documentation build does, needs no terminal and no ncurses.

lllm3090.tui.MIN_WIDTH = 60#

Below this there is no useful layout left, only a lie about one.

lllm3090.tui.REFRESH_SECONDS = 1.5#

How often the machine is re-read. The engine log is a local file and the catalogue is arithmetic; the expensive call is nvidia-smi, which is why this happens on a worker thread and not in the keyboard loop.

lllm3090.tui.KEYS: tuple[tuple[str, str], ...] = (('s', 'start'), ('x', 'stop'), ('d', 'download'), ('c', 'cancel'), ('P', 'panel'), ('q', 'quit'))#

What each key does, in the order the footer lists them.

class lllm3090.tui.Line(text: str, style: str = '')[source]#

Bases: object

One rendered row: the text, and which of the styles to draw it in.

text: str#
style: str = ''#
class lllm3090.tui.Ui(row: int = 0, message: str = '', busy: str = '')[source]#

Bases: object

What the user is looking at – everything the snapshot does not say.

row: int = 0#
message: str = ''#
busy: str = ''#
clamp(snap: dict[str, Any]) None[source]#

Pull the cursor back inside a list that may have shrunk.

The poller replaces the snapshot on its own thread, so a model deleted from disk – or a catalogue that has not loaded yet – can leave the cursor pointing past the end of the list it was placed in. Every read of that cursor is then an IndexError, and an exception out of handle_key closes the UI on a keypress.

class lllm3090.tui.Control(url: str | None = None)[source]#

Bases: object

The panel if it is up, this process if it is not.

reachable records which of the two answered last, because it changes what the UI may offer: without a panel there is no download.

snapshot() dict[str, Any][source]#

The machine, from the panel if it answers and from here if not.

The log is always read from the file. It is on this machine either way, and re-reading its last lines is cheaper and simpler than an SSE client – the panel streams it because a browser cannot tail a file.

start(model: str) str[source]#
stop() str[source]#
download(model_id: str) str[source]#

Ask the panel to fetch a model. There is no local fallback.

A download is a thread writing to a .part file, and the panel resumes any .part it finds when it starts. Downloading from here as well would give one file two writers, which is not a slow download but a corrupt one.

cancel(model_id: str) str[source]#
start_service() str[source]#

Start the panel’s user unit, so downloads become possible.

lllm3090.tui.bar(fraction: float, width: int) str[source]#

An ASCII progress bar. fraction outside 0..1 is clamped, not trusted.

lllm3090.tui.fmt_ctx(tokens: int) str[source]#
lllm3090.tui.speed_label(row: dict[str, Any], brief: bool = False) str[source]#

What may be claimed about a model’s speed on the card in this machine.

An entry nobody has benchmarked has no number, and no number is the honest thing to print: the browser panel once rendered such an entry as ~null tok/s, which reads as a broken panel rather than as an absence. A number measured on another card is shown as measured elsewhere, never scaled to this one.

brief is for a window with no room for the whole qualifier. It drops only (measured), which says the ordinary thing; a figure taken on some other card never loses the words that say so, because without them it reads as a measurement of this machine.

lllm3090.tui.window(count: int, index: int, rows: int) tuple[int, int][source]#

The slice of a list to draw so that index is inside it.

lllm3090.tui.model_rows(snap: dict[str, Any]) list[dict[str, Any]][source]#

One list: the catalogue, plus any GGUF on disk it has never heard of.

The same eight models used to appear twice – once as INSTALLED and once as CATALOGUE – which left the reader to do the join and made s mean two slightly different things depending on which copy the cursor happened to be on. There is one row per model now, and what it offers depends on the state that row is in.

On-disk rows come first and catalogue order is kept inside each group, so the models that start without waiting are at the top. The sort is stable and reads only the snapshot, which means a finished download moves its row once, when the poller hands over a new one, and not under a held j.

lllm3090.tui.footer(snap: dict[str, Any], ui: Ui, width: int) list[Line][source]#

The message line and the key hints – the only help this UI has room for.

lllm3090.tui.render(snap: dict[str, Any], ui: Ui, width: int, height: int) list[Line][source]#

The whole screen, as at most height lines of at most width.

A curses window raises rather than clipping when something is written past its last column, so every line this returns is already inside the window.

lllm3090.tui.selected_row(snap: dict[str, Any], ui: Ui) dict[str, Any] | None[source]#

The row the cursor is on, or None when there is no list to be on.

lllm3090.tui.handle_key(key: str, snap: dict[str, Any], ui: Ui, control: Control, submit) bool[source]#

Act on one keypress. Returns False when the user asked to quit.

submit(label, action) runs action somewhere that is not the drawing loop: stopping an engine waits for the VRAM to come back, which is seconds, and a UI that stops repainting for seconds looks broken.

lllm3090.tui.run(url: str | None = None) int[source]#

Draw the panel in this terminal until the user quits.