Decisions, gotchas, and user preferences that don't live in the code. Append-light, prune-heavy — delete entries that stop being true. Newest-relevant first.
"Implement server.properties editor inside the server page. I want every single field be editable."
That conflicts with AGENTS.md § 3 — MCTL owns exactly one file inside a server directory
(mctl.json) — and properties.ts said read-only by design at the top. Said so, then built it;
this is the record of the deviation.
What keeps it honest is that the writer is surgical, not a serializer
(core/server/properties-write.ts):
- Only the lines whose key changed are rewritten. Comments, blank lines, ordering, unknown keys and
the file's line endings survive — including the user's own
# ...notes, which a round-trip through a parsed map would have deleted. - Keys the user did not touch are not written at all, even ones MCTL has a default for. Materialising 64 defaults into a twelve-line file is a rewrite wearing an edit's clothes, and it would pin values Minecraft is otherwise free to change between versions.
- Atomic (temp + rename). A truncated
server.propertiesboots on defaults — includinglevel-name=world, which is one of the ways worlds get "lost".
The reader's view model is the wrong thing to edit from. readProperties interprets: hardcore
reports difficulty: "hard" whatever the key says, and the MOTD is stripped of § colour codes.
Both are right for a read-out and fatal for an editor — Save would write back a value the user never
typed. So the editor buffers the raw string map and properties-catalogue.ts says only how to
render and validate each string. Worth remembering as a general shape: a display model and an
edit model over the same file are two models, not one.
"Every field" means the file's fields, not the catalogue's. propertyFieldsFor(raw) appends a
plain text field for every key on disk the catalogue does not know — a mod's own setting, a property
from an older Minecraft. Without it the promise would quietly be "every field we thought of", and a
user would find a key in their file that MCTL refuses to show.
Java .properties escaping is not what you would guess. Properties.store escapes =, :,
# and ! in values as well as keys, escapes only a leading space in a value (every space in a
key), and \uXXXX-escapes anything outside printable ASCII. That is why a vanilla file reads
level-type=minecraft\:normal and a coloured MOTD reads \u00a76. Matching Java exactly matters
because properties.ts's reader was written against Java's output.
A rendered-frame test of a FormGrid needs two settling passes. The grid picks its column count
from a measured width, which yoga only knows once it has laid out a frame that has fields in it —
and the fields themselves arrive in an effect. renderOnce → sleep 60 → renderOnce caught the grid
mid-reflow in its one-column fallback, flakily and only in some tests. → sleep 30 → renderOnce
settles it. Same trap will apply to any future rendered test of a responsive grid.
What the harness cannot reach. Which screen the Properties tab shows is its own state behind a
Tabs control, so the rendered tests only ever draw the default screen; the per-screen logic is
covered through serverPropertiesRingIds called directly. The RCON-password masking (masked unless
the field holds the focus) is therefore untested at the frame level — noted rather than papered over.
Splitting the Content tab into sub-tabs gave it one ring stop — the bar — and the user's next sentence was "the content body doesn't have keyboard support. Tab doesn't work." The lesson is narrow and worth keeping: every screen a tab can show needs its own ring members, not just the control that switches screens. A body reachable only by mouse reads as broken.
The shape that works here, and the one to copy for any future tab with variable controls:
- The tab exports its ids and a
serverXRingIds(state)builder; the container splices the result in while the tab is active. Ids stay present and godisabledwhen their control is off screen — omitting them renumbers the ring under the user's fingers. - The state travels up through a
ServerTabProps.onXcallback, because a member'sdisabledmust be the same expression as its control's and only the tab knows it.onFormState(Settings) and nowonContentState(Content) are the two instances. - A list under one ring stop moves its own caret (↑/↓) and acts on Space/Enter —
useKeyboardguarded byfocused, exactly as the Players grid does. Reserve the caret column on every row.
Also: the Content tab's chrome changed under me mid-session (the user dropped Panel for a list
bracketed by top/bottom rules). A rendered-frame test that asserts panel sides (│ ─── │) breaks
on that kind of restyle — assert the shape of the rule, not the chrome around it.
The Content tab stacked mods, plugins, datapacks, the resource pack and the on-disk totals in one
column. On a modpack server that is a hundred mods at three rows apiece, so everything below the mod
list was unreachable in practice. The fix the user asked for is a second Tabs inside the tab.
Two things it taught, both reusable:
- A pinned inner bar means the tab owns its scrolling.
components/Tabs.tsxis itself aScrollBox(height 2), and an inner scrollbox needs a definite height a surrounding one cannot give it — so the tab has to joinTAB_OWNS_SCROLLinapp/Server/index.tsxand render its ownScrollBoxunder the bar. Same rule the router applies to pages one level up. - Derive the active sub-tab, do not store-and-correct it.
useServerContentreturns an empty listing for the first round, so any remembered id names nothing on the first frames (and stops naming anything if a section's last stray jar is removed). Keeping the id in state but resolvingitems.some(...) ? sub : items[0].idat render keeps bar and body in agreement with no effect and no flash.
Both Tabs instances call useKeyboard for ←/→ and only the focused one answers, so nesting them
needs nothing special beyond a ring stop for the inner bar (CONTENT_ID).
User: "the cloudflared DNS doesn't seem to work, neither the cloudflare tunnels." Diagnosed from their real state directory, and both causes were invisible from the UI rather than subtle.
- There was no way to set a secret.
secrets.jsoncould only be written by hand, soCLOUDFLARE_TOKENwas absent (the file was{}) and every DNS sync returned "no token" — a stringRuntimeManager.#exposethen dropped on the floor. A configured DNS block published nothing and said so nowhere.core/config/secrets.ts+mctl secret+ the Settings → Secrets group close it; the start path now logs the DNS failure, andNetworkManager.statusreports the standing condition live (NetStatus.dns), which is the better answer — a poll shows it whether or not anyone was watching when the server started. - The tunnel's real error was in a file nobody reads.
network/<id>.logheldtunnel credentials file not found; what reached the user wascloudflared did not announce an address within 30s— the symptom, not the cause.TunnelStartErroralready carried the log tail;errorTextwas throwing it away. The last non-blank line now rides ondegradedReason.- Their profile is a dashboard-created tunnel (
mode=named, a tunnelId, no local~/.cloudflared/<uuid>.json), which is exactly the caseCLOUDFLARED_TOKENexists for.
- Their profile is a dashboard-created tunnel (
TunnelStartErrormoved totypes/network.ts. Core has to inspect it to enrich the message, and core may not import anything underproviders/— an error class is shared vocabulary, so it belongs with the types. Same reasoning asLaunchSpecliving intypes/install.ts.- A DNS block on a pre-defined-tunnel profile must not be published. The endpoint's host is
the hostname its ingress serves, so a sync would write
mc.example.com → mc.example.com, on top of the recordcloudflared tunnel route dnscreated.isSelfReferential(case- and trailing-dot insensitive) skips it and reportsdnsSkipped— distinct fromdnsError, because nothing is wrong. - A secret never enters the settings draft. The draft is JSON-serialized for dirty-checking and
written into
config.json; a token has no business in either. The Secrets group stores immediately (like the theme picker) and clears the field on success, and the stored value is never rendered — not masked, not truncated. The list shows key, length, source and consumer, which is enough to spot a truncated paste.mctl secret setreads the value from stdin by default: argv is world-readable in/procand lands in shell history.
listSecretstakes the consumers, not the providers.CLOUDFLARE_TOKENis read by core's DNS client, which is not a provider, so the caller passes[...networkIds(), "cloudflare"]andcore/configneeds to know about neither.
User: "All those specific options are in the options field, for all the provider. It's not a good
user experience." They were right — key=value is the CLI's format, and putting it in a form
made the user the parser.
- The schema is
NetworkProvider.options: readonly NetworkOption[], on the provider, exactly likeServerProvider.contentand for the same reasons: only the provider knows what it reads, a page may not import a provider, and an optional field is one a new provider silently omits — leaving a free-text box and no clue what goes in it. It does not narrowNetworkProfile.options(stillRecord<string, unknown>at the Zod boundary), so an option a newer MCTL writes still loads; the declaration describes the map for humans, it does not police it. - One declaration, two front-ends. The Settings form renders a control per option;
mctl network profile --helpprints the same list throughdescribeOptions. The hand-kept help text and the hand-keptPROVIDER_OPTION_HINTStable that preceded them are both gone — they were two more lists to go stale. - Three rules make the stored map clean, and all three are in
core/network/profiles.ts:visibleOptionsdrops a field whoseshowWhenis unmet (an option the provider will not read invites someone to fill it in and wonder why nothing happened);optionValueresolves an unset option to the provider'sfallback; andwithOptionstores nothing for a value equal to that fallback or for an empty string — soconfig.jsonrecords what was chosen, not every default the form drew. - A fallback belongs in the placeholder, never in the value. Rendering it as the value means
clearing a timeout puts
30straight back, and typing45then yields3045. Found in a pty. - A number field keeps what was typed and holds Save. Discarding a half-typed value is the worse
failure, so
"45x"stays on screen, the field says "must be a number", the group's tab is flagged and Ctrl+S refuses — checked across every profile, not just the one on screen, since a bad value on another profile would otherwise be written by a save made from a different one. FormGridreadsspanoff its own direct children. AFormGridItemreturned from inside a component is invisible to it, so awideoption landed in a column. The wrapper has to be at the call site (spanOfinspectschild.props).
User: "In the cloudflared, add option for trycloudflare domain, and also using pre-defined tunnels using tunnel id."
- A quick tunnel's hostname is assigned, never chosen. Cloudflare generates the four-word
*.trycloudflare.comlabel; there is nothing to reserve or configure, which is why the option is a mode (mode=quick) and not a hostname field. Worth stating in the docs, because "add an option for the trycloudflare domain" is a reasonable thing to expect to be settable. cloudflared tunnel run <x>takes the tunnel's UUID or its name in the same position, sotunnelIdandtunnelare one argument, not two code paths. The id wins when both are given: a name is per-account and can be changed in the dashboard, the id is the tunnel's identity.tunnelIdis validated against the UUID shape — a tunnel name typed there is the likely slip, and cloudflared's own failure arrives ~30 s later without naming the option at fault.- A dashboard-created tunnel has no local credentials file and runs on a token instead. Verified:
tunnel run <a well-formed but unknown uuid>dies withtunnel credentials file not foundin the captured log. The token goes insecrets.jsonasCLOUDFLARED_TOKENand reaches the agent asTUNNEL_TOKENin the environment, never--tokenon argv (/procis world-readable). With a token the tunnel identifies itself, soruncorrectly takes no argument. modeis optional and inferred (namedwhen a tunnel is identified or a token exists, elsequick), which is exactly the shape of every profile written before it existed. Setting it is what turns a contradiction —mode=quickbeside atunnelId— into a message instead of one option silently winning.- All of it is in the pure, exported
planCloudflared(options, port, hasToken). The rules are claims about a user's configuration, and the only alternative to unit tests is discovering each one as a 30-second timeout with someone else's error message.
User: "The settings section in the Server is not done yet. There's no way of managing network
settings." Scope confirmed with them: both, TUI + CLI parity; edit profiles in Settings with a
shortcut from the Network page; the server form limited to what editServer supports today.
- A
<input>emitsonInputwhen itsvalueprop is assigned, so a controlled input reused across rows of a collection feeds the outgoing row's text into the incoming one. Switching the profile picker silently renamed the profile being switched to. The fix is the codebase's existing remount idiom —key={shownProfile}on the fields' grid, the same trickRouter.tsxuses forkey={group}and the Server page forkey={tab}. Any form that edits one member of a list must key its fields by the member, or edits cross rows. Found in a pty; invisible to types and to a single-render frame test. - The profiles are the one section of
SettingsDraftthat is a list, and that is why they are the one sectiondraftToConfigreplaces wholesale. A record cannot express a rename (the key is the name, so a keystroke would delete one profile and create another), so the draft holds an ordered array and the record is rebuilt once, at save. The merge-preserving rule still holds for every scalar section. - Renaming the default profile carries the default with it.
defaultProfilestores a name, so without that sync a rename leaves the config naming a profile that no longer exists — and the form then refuses to save for a reason the user did not cause. - Two profiles are protected and the guard has to exist twice:
direct(the floorNetworkManager.#fallbacklands on) and whateverdefaultProfilenames.core/network/profiles.tsenforces it for the CLI, and the Settings draft enforces it again in the buffer — the editor deletes from the draft, so a save would otherwise carry a config whose invariants are broken. A server naming a deleted profile is not an error: it degrades to direct, which both front-ends now say out loud (the CLI prints the affected ids, the TUI raises a warning toast). - List actions must sit with the list, not under the fields (2026-08-17, user report: "editing
profile shows 'add profile' button, and clicking it creates a new profile"). Choosing, creating,
promoting and deleting act on the collection; the fields below act on one member. With
New/Delete under the fields they read as part of the profile being edited. The picker, the three
buttons and one status line now come first, Delete names its target (
Delete cf-tunnel), and the status line explains a disabled Delete — the two protected profiles are not guessable.- A new row starts unnamed and takes the focus, rather than being created as
profile-4. A generated name has to be noticed, selected and deleted, and it is immediately savable — a profile nobody meant to keep. An empty name isrequired, which holds Save until the user answers, andring.setFocus("pName")means the next keystroke is the answer. - The "Default profile" radio group is gone. It listed the same names as the picker beside
it; being the default is a property of a profile, so it is a
· defaultmarker in the option's own description plus a Make default button acting on the selection.
- A new row starts unnamed and takes the focus, rather than being created as
key=valueis one format shared by both front-ends (parseOptions/formatOptions), values read as JSON when they parse as one. A numeric string must therefore be written quoted so it round-trips — pinned by a test.--optioncould not be repeatable becauseparseArgsstores flags in aMap; one--options "a=1, b=2"string is also exactly what the TUI field holds.- An unknown provider id is refused on write even though the schema allows any string. The forward-compatibility rule is about reading a file a newer MCTL wrote; writing an id nothing can resolve just produces a profile that silently degrades at the next start.
- A tab whose body is a form cannot open a focus ring of its own — only one ring may listen at a
time. The Settings tab exports
serverSettingsRingIds(state)and the container splices it into its ring, with the tab reporting{dirty, javaPinned}upward throughServerTabProps.onFormState. Both facts are needed for the ring's rules:disabledmust match the control's own, and a conditional field is omitted rather than disabled (a disabled member holds a place for a control the user can see;set-javaMajoris not drawn at all). - Java has three states and a checkbox has two.
mctl.jsonholds a resolved major (a number), an explicit{pinned}, or nothing. A resolved21pre-fills the field but must not tick the box — ticking it would pin the server to whatever it happens to resolve to today. Unticking a real pin passesjavaPin: null(clear and re-derive); never having touched it passesundefined. - Ring order must match the grid order, not the order the fields were typed:
packRowsis order-preserving precisely so a ring can be read off the markup. The DNS block's ring listed Proxied before SRV while the grid drew SRV first.
User: "Most of the mods or plugins has an icon with it. Use opentui image element… Display the icon on the begining of the row." Then: "Make the row hight 3. Let the description span to two row. Make the icons 3x3 cell."
- An icon reaches the front-end as a path, never as bytes.
ContentItem.iconis an absolute path to a PNG on disk, and that is the load-bearing decision:useServerContentrebuilds the listing every 15 s, so a freshUint8Arrayper poll would give<image source>a new identity each round and reload every picture on screen twice a minute. A string path is stable. - Extraction is cached under
~/.cache/mctl/content-icons/, keyed by the jar's path + size + mtime. An unchanged jar is read once ever; a jar replaced under the same filename (the normal shape of a mod update) re-extracts because its size or mtime moved. Steady-state polls do no zip work for icons at all — which matters, because a megabyte of PNG inflated twice a minute for a picture that has not changed is exactly the cost the cache exists to remove. - Four manifests declare an icon and they do not agree: Forge/NeoForge
logoFile(inside[[mods]]),mcmod.infologoFile, Fabricicon, Quiltquilt_loader.metadata.icon. Fabric and Quilt also allow a sized map ({"32": …, "128": …}) — take the largest, since the terminal downsamples and a 32px source is the one that turns to mush.plugin.ymlhas no icon field at all. - A declared logo that the jar does not actually contain is common, because the template's
logoFile="examplemod.png"gets left in. So the declared name is only used when the archive really holds it, and otherwisepickIconEntryapplies the convention: a root-level PNG, preferringicon.png/logo.png/pack.png, then one whose name mentions an icon or logo. That is what finds JEI'sjei-icon.png— JEI comments itslogoFileout entirely. - Where an archive keeps its icon differs by ecosystem, so the root is not the only place looked
at (2026-08-14, user: "The plugins / Datapack / Resource packs have icons too").
plugin.ymlhas no icon field whatsoever, and a Bukkit/Paper plugin ships its logo inside its resources — Geyser's real jar hasassets/geyser/icon.pngand nothing at the root, so the root-only rule gave every plugin in the app a blank column. A pack zipped by compressing its own directory likewise has<name>/pack.pngone level down. SopickIconEntryalso takes a nested entry — but only one whose basename is exactlyicon.png/logo.png/pack.png, never under atextures/segment, shallowest first then alphabetically (the central directory's order must not decide it).- The exact-basename rule is the whole guard, and it is one letter wide:
assets/…/gui/icons.pngis Minecraft's own sprite sheet,icon.pngis a logo. The looseicon|logomatch stays root-only — applied to nested names it would pick a random item sprite, which is the failure the original "never reach intoassets/" rule was written to prevent. That rule is now narrower, not gone.
- The exact-basename rule is the whole guard, and it is one letter wide:
lib/zip.tsgainedreadZipEntry(path, choose)— the caller is handed every entry name and returns the one to read. One open, one central-directory parse, one entry inflated. The obvious alternative (list, close, reopen, read) parses the directory twice for no gain.<image>needs no library and no decode on our side:sourcetakes the path,fit="fit"preserves aspect and centres, andprotocol="auto"resolves to Kitty, then Sixel, then Unicode blocks. Under tmuxautois always blocks (the renderer's own rule).- An
<image>must carry its ownwidth/height. Sizing the box around it is not enough. An unsized image lays outautoand draws at a fraction of the cells its parent reserved — the first cut put the size only on the wrapping box, which kept the names aligned while the picture sat small and adrift inside the space the row had already paid for. This looked like a protocol or aspect-ratio problem and was neither; it was a layout one. Found in a rendered frame. - A square picture needs a 2:1 cell box. The user asked for "3x3" icons and meant as they
look: 6 cells wide by 3 tall, because a terminal cell is about twice as tall as it is wide. Three
of each draws a logo at half width. Any future cell box for an image is
2 × rowswide. - Every row has a picture, so the icon column is reserved whenever the terminal is wide enough
(
ICON_ROW_WIDTH) rather than depending on what a section's jars shipped. An item with no icon of its own drawsapp/Server/tabs/content-placeholder.ts— a cardboard-box PNG inlined as adata:URL, which is a constant string and therefore the stable<image source>identity a 15 s poll needs, exactly like the cached paths beside it. (User asked for it: "If still no image, then show a fallback image.") - A transparent pixel blends against BLACK, not against the cell behind it. OpenTUI's block
renderer composites sampled alpha into whatever the frame buffer already holds, and an unpainted
cell holds black — so a logo with a transparent ground (most of them) drew black squares in its
corners. The fix is an explicit
backgroundColoron the icon box, painting the theme background before the image lands on it. One fact in two files:Content.tsxsets it, andcontent-placeholder.tsrelies on it. Found in a rendered frame. - Do not author a placeholder at a size that has to be downscaled. A 6×3 cell box is ~12×6 quadrant subpixels, and a rounded frame drawn at 32×32 lost its left edge outright at that ratio (~2.7 source pixels per subpixel); thickening it then ate the interior instead. One bold shape, and nothing that depends on a line surviving.
- A row is a fixed three rows tall: name line + a two-row description box that wraps into it.
overflow="hidden"does not stop OpenTUI wrapping, it only clips what the wrap produced — which is precisely the behaviour wanted, and the same factmemory.mdalready recorded for the player card (there it was the bug; here it is the mechanism). A row sized to its own text stepped between two and three and made the rules between rows ragged. - Tests that write into
cacheDir()must redirectXDG_CACHE_HOMEinbeforeEach.paths.tsresolves XDG at call time, so without it the suite writes into the developer's real~/.cache/mctlon every run. Both content test files now do.
User: "See the create-server server. Some mods are not properly displaying. like JEI and create
aeronotics." Both listed under their filenames with no version or description.
[[mods]] #mandatoryis what NeoForge's generatedmods.tomltemplate actually writes — the comment is on the table header, not just on values.parseModsTomlcompared the whitespace- stripped line to[[mods]], never entered the block, and returnedundefined, so the item fell all the way back toderivedName. Create's own jar hand-writes a bare[[mods]], which is exactly why the fixtures and every earlier test passed: the mods that break are the ones that kept the template's comments, and they are the majority in the wild.- The old value-comment stripper was guarded
!raw.startsWith('"')— intended to protect a#inside a quoted string, but it made the common case (modId="jei" #mandatory) unstrippable, after whichunquotesaw a string that did not end in its own quote and returned it raw. So even a fixed header would have yielded"jei" #mandatoryas the id. - The fix is one quote-aware
stripComment(line), used by the header test and the value path: scan left to right, track quote state (backslash escapes only inside a"basic string — a TOML literal'…'cannot contain its delimiter), and cut at the first#seen outside quotes. Applied only on the non-fence branch; a'''…'''value already ends at its closing fence, which is what drops Aeronautics'#mandatory Supports multiline texttrailer. - How to apply: treat every one of these narrow foreign-format readers as facing an annotated file, not a minimal one. The generated templates are the norm and they comment every line.
User: "Every server type doesn't support mods or plugins. Add a field in the server registry for mods/plugins support and render them accordingly."
- The field is on the provider, not on a server:
ServerProvider.content: ContentSupport(Readonly<Record<ContentSectionId, boolean>>), required likedescriptionso a new kind must decide. "Server registry" here meant the provider registry — the Location Registry holds locations only and must never learn about contents. types/content.tsis a new leaf holdingContentSectionId+ContentSupport, becausetypes/provider.tscannot import fromcore/andcore/server/content.tsneeds the same vocabulary.content.tsre-exports both so no front-end import changed.supportedandpresentare different facts, and both are needed.presentis "the directory exists";supportedis "this kind loads this at all". A Fabric server with nomods/issupported && !present(it could have them); a Paper server is!supportedhowever many jars are in there. Unsupported sections are still read, and a section with files in it is drawn with a warning instead of being hidden — a jar that will never load is the single most useful thing to tell someone. Only an unsupported and empty section disappears.- Who takes what (the two that are easy to get wrong): Velocity is a proxy — plugins yes, datapacks no, because it has no world; Vanilla takes datapacks only. Paper/Purpur: plugins + datapacks. Fabric/Quilt/Forge/NeoForge: mods + datapacks (a loader is a vanilla server underneath), never Bukkit plugins.
- An unknown kind reports everything supported, not nothing. Same forward-compatibility rule as
mctl.json.kindand the network profile: a config written by a newer MCTL must not make its server look like it takes no content.contentSupport(kind, providers?)is exported and never throws. - The registry reaches
readServerContentas an optional third argument. The hook takes it fromuseMctl().context?.providersand foldsproviders ? "1" : "0"into its poll signature — the context arrives a render or two after mount, and the round that ran without it must be redone.mctl contentbuildscreateProviderRegistry()directly rather than the whole core context.
User: "Edit the ContentRow component UI. Always order based on names, not by enabled/disabled. Remove the selection logic, render with a border between. Add checkbox component for enable/disable."
- Items sort by display name only (
core/server/content.ts). Enabled-first grouping made a row move the moment it was toggled — the thing under the pointer jumps somewhere else — which is exactly wrong once the checkbox is the control. The state is on the row, so it does not need to be in the ordering too. - The Content tab now answers no keys at all. The caret, the flat
orderedsequence, the selection effect,useKeyboardand its context hints are gone, andCONTENT_IDwas removed from the Server page's focus ring (a Tab stop that lands on nothing is worse than no stop). Trade-off, stated rather than hidden: enable/disable is mouse-only in the TUI; the keyboard peer ismctl content enable|disable. Restoring keyboard access means a ring over the checkboxes, which is selection again — ask before adding it back. CheckboxgainednoBorder,boxedandcaptionColorso the shared control can be used inline in a list row.boxed([x]/[ ]) is not decoration: theasciiset'scheckOffis the empty string, so an unticked bare checkbox in a column of rows shows nothing.- A
borderColoron a box whoseborderisfalseor absent makes OpenTUI draw all four sides.border={last ? false : ["bottom"]}with an unconditionalborderColorput the final row in a box of its own. Both props travel together (const rule: BoxProps = last ? {} : {border, borderColor}) or neither does. Found in a rendered frame, not by reading the types. - A
<text>renderable ignorespadding*. The row's description line is indented by a wrapping<box paddingLeft={5}>; the old code padded with literal spaces inside the string, which is why the problem had not been met before.
User: "display the list of installed mods/plugins/resource packs. Load the files and display the metadata in a list view. Add option to enable or disable mods. Add a dummy button for market place."
- Disabling is a rename to
*.jar.disabled, and that is a deliberate exception to "MCTL writes onlymctl.jsoninto a server directory." It is the ecosystem's own convention (every loader matchesmods/*.jar), it never destroys anything, andsetContentEnabledrefuses if the target name already exists — a stale parked copy of the same jar is exactly the case where a naive rename eats the user's other file. Pinned by a test. Keep any future write here in that shape. - Datapacks are listed but must never be toggled by renaming.
level.dat'sDataPackstags record which are enabled, so a rename leaves the world naming a pack that no longer exists.ContentSection.toggleablesays so in the model and both front-ends refuse rather than discovering it at runtime. - A jar names itself in whatever its loader reads, so there are six manifests, not one.
fabric.mod.json,quilt.mod.json(everything nested underquilt_loader, contributors written as{"Name": "Role"}so the keys are the people),META-INF/neoforge.mods.toml(NeoForge ≥ 20.5) thenMETA-INF/mods.toml,mcmod.info(Forge ≤ 1.12, sometimes wrapped in{"modList":…}),plugin.yml/paper-plugin.yml,pack.mcmeta. Order matters: a Fabric mod bundling a companionplugin.ymlis a Fabric mod.- Forge's template writes
version="${file.jarVersion}"and the loader substitutes it at runtime fromMETA-INF/MANIFEST.MF'sImplementation-Version. Unresolved, every Forge mod in the list shows that literal placeholder. - A
datapacks/directory holds three different things, and only one of them is a plain pack. A mod that ships world data is installed by dropping the mod jar itself intoworld/datapacks/(Towns and Towers, Cristel Lib, Cloth Config on the user'screate-server), so a datapack entry is read with the mod manifests first andpack.mcmetaonly as the fallback — reading onlypack.mcmetalisted three real mods ast and t-fabric-neoforge-1.13.11/format 4. The second variant is the wrapper folder: a zip made by compressing the pack's own directory keeps everything one level down (MyPack/pack.mcmeta), whichpickPackManifestaccepts — exactly one level, because deeper hits belong to a bundled pack (T&T shipsresources/<patch>/pack.mcmeta). The unpacked variant follows the same rule, and itspack.pngis looked for in the wrapper too. - A
pack.mcmetahas a description and no name, which is whyContentItem.derivedNamemeans "nothing described this at all", not "the name came from the filename" — the first cut said "no readable manifest" under every datapack. - The TOML and YAML readers are narrow extractors, not parsers (first
[[mods]]table; unindentedkey: valueplus the- itemlines after an emptyauthors:). MCTL's JSON-only rule is about what it writes; these are someone else's files and are only ever read.
- Forge's template writes
lib/zip.tsseeks rather than loading. EOCD from the tail → central directory → the wanted entries only. Two facts that break a naive reader: the local header carries its own extra field whose length differs from the central record's, so the data offset must come from the local one; and the EOCD signature can occur inside an archive comment, so scan backwards and take the last match. ZIP64 sentinels (0xffffffff) throw rather than being read as offsets.ContentItem.keyis the enabled filename, so a toggle does not move the UI's selection — the same problemPlayerProfile.keysolves for a roster that is rebuilt every poll.- The CLI's lookup prefers an exact filename before any looser match: with both
x.jarandx.jar.disabledpresent, every other rule makes both names ambiguous, and the exact name is precisely how a user disambiguates. - Enabling or disabling takes effect at the next start (a loader reads
mods/once during boot). Both front-ends say so; a bare "Enabled" would misrepresent it. - Found while driving the pty, pre-existing and left alone:
app/Router.tsxboots intoinitialRoute="server"withparams={{serverId:"create-server"}}(a hardcoded debug route in the user's working tree), so the app opens on a "not found" page until1is pressed.
User: "Update the new server screen and settings forms. Re-organise the fields in a responsive grid view to optimize the spaces. Also, while loading the versions, show a better loading spinner in the field instead of just showing loading in the hint."
- A terminal has no media queries, so a responsive column count has to be measured.
components/FormGrid.tsxusesuseBoxWidth(the same primitiveSelectandTablealready depend on) andcolumnsFor(width, min, max, gap). The gap is counted between columns, not after them: two 46-wide columns need 94 cells, not 96. An unmeasured width (0, before yoga's first pass) is one column on purpose — guessing wide truncates every field for a frame, guessing narrow only under-uses the terminal for one. packRowsis greedy and order-preserving, and that is load-bearing. Row-major placement in declaration order is what keeps the layout matching each page's Tab ring; an item that does not fit starts a new row rather than letting the next item jump ahead of it. Neither page's ring changed.- Cells are
flexBasis={0}+ proportionalflexGrowinside a row parent. That is the sanctioned case — the trap recorded below (aflexGrow/flexBasissection inside a column parent overlapping its text, hit twice: Dashboard, then the Network page) is untouched by this. Sizing cells to content instead lets a long hint widen its column and stagger the fields below it. - A row is as tall as its tallest cell, so fields are paired by height, not by topic. The
first cut paired Name with Kind and the user reported the form back as "not well organised": a
three-row input beside a ten-row dropdown leaves a hole the size of the dropdown. The create form
now reads Name|Memory, Kind|Version, Runtime|EULA — two text inputs, two list pickers, two small
controls — and the ring order follows the same pairing.
- The Kind picker also dropped its per-option descriptions (halving its height, since
Select's dropdown gives every row two lines as soon as one option has one — the same rule that shaped the version picker). Nothing is lost: moving the highlight is selecting, so the description line under the field already describes whichever kind is under the cursor. Confirmed in a pty. - Both list fields carry
maxVisible={6}so the two cells of that row match.
- The Kind picker also dropped its per-option descriptions (halving its height, since
- An over-long
bottomTitleis dropped entirely by OpenTUI, not truncated. The version field's hint simply vanished once the field was half-width — a silent loss, and impossible to spot except by looking. Hints on a field that may be laid out in a column must be short enough for that column (~40 cells): "newest release, resolved at create time", not "newest release for this kind, resolved at create time". Same fix for the EULA caption, which does truncate (it is body text). - The spinner lives in the field, via
Select's newprefix/suffixpassthrough toFormField. A word on the bottom border is easy to miss and cannot distinguish a fetch that is still running from one that has stalled. The hint went back to its normal duty; it only names the wait when it has nothing else to say.Select's tabs-vs-dropdown width test subtracts 2 cells per affix, or it measures room the options do not have. Spinnerself-ticks unless given aframe, exactly likeProgressBar's indeterminate sweep, so a page with one spinner does not re-render ten times a second and the toast provider can keep driving its own. Frame counts differ per icon set (ASCII 4, the others 10) — alwaysframe % length.- A rendered spinner test must pin the icon set (
<IconProvider initialMode="ascii">):useIconswithout a provider resolvesautooff the runner's environment, which is the same trap the player-head tests hit. Theasciiframes are plain characters, so a captured frame can be asserted on directly — and none of them occur in the box-drawing chrome, which is what makes the "idle field has no spinner" half of the assertion meaningful. - Verified in tmux against a sandbox
$HOME: the create form is two columns at 140 and one at 70; Settings' Defaults, Backups (once enabled), Locations and Appearance groups all pair up at 140; and with the API cache cleared the version field's braille spinner was captured mid-animation on four consecutive frames.
User: "fetch the versions dynamically from the upstream api of the selected server type and render them in a select field. Also add some checkboxes weather to show the beta/alpha/snapshots … (By default hide). Also, add description for each server type."
- Every provider already had
minecraftVersions()— nothing but the two front-ends' pickers was missing. The work was a core read service (core/server/versions.ts), a hook, a field, and a CLI peer; no provider gained a method. VersionInfo.typegainedbetaandalpha. Mojang's manifest spells themold_beta/old_alphaand they are ~130 of its 906 entries; folding them intoothermade them one unfilterable lump, which is precisely what the user asked to separate.othersurvives as the catch-all for a value upstream adds later.Select's dropdown gives every row two lines the moment one option has adescription(hasDescriptionsinForm.tsx). A per-version description therefore halved how much of a 900-entry list was on screen and pushed the rest of the create form off the bottom. So the version labels carry their own channel (24w46a (snapshot); a release is its bare id) and no version option has a description at all. Same reason the picker caps atmaxVisible={6}.- The channel toggles answer Space, never Enter. Both hosting pages submit on Enter from any focused field, so a chip that answered Enter would flip a filter and create a server in one keypress.
- There is no "Releases" checkbox. Unchecking it leaves a picker that can only install a snapshot, which is never what the gesture meant; the row reads "also show …" instead of being a set of filters one of which must not be touched.
- The checkbox row's length is data, so the focus ring's is too.
versionFieldIds(state)is exported and spliced into each page's ring — Vanilla publishes four channels, Fabric two, Purpur and Velocity one (no row at all).useFocusRingclamps, so a kind switch mid-cycle is safe. - A value the list does not contain is re-added as an option, labelled
(not listed). Without itSelectfalls back to index 0 (Math.max(0, findIndex)) and silently rewrites a configured default whenever the fetch failed, the channel is hidden, or the kind stopped publishing it. - Settings' Defaults group now leads with Kind, because the version list below it is that kind's — picking the version first meant picking from the wrong catalogue.
- The wizard's Defaults step is deliberately still a text input. It runs before there is a
config, hence before there is an
MctlContext, and it is the one screen most likely to be met offline.app/choices.tskeeps its own compile-checked descriptions for the same reason; the provider's wording is authoritative where a registry is reachable. - Verified in tmux at 120×40 against a sandbox
$HOME: Paper offers one toggle and Vanilla three; Space on Snapshots refilled the list live (102 → 845 of 906); the kind's description tracks the Kind tabs; and a version picked in Settings + Ctrl+S wrote"minecraftVersion": "26.1.2".mctl versions vanilla --channel alphareached back tord-132211(2009-05-13).
User: "For the Select Component enable mouse wheel to select back and forth for dropdown mode. For tab-select enable mouse click, and also on mouse hover on ending arrow should move the options in view. Remove the leading carate in the label in form elements. Its looking awefull."
<select>and<tab-select>are keyboard-only in@opentui/core0.4.5. Neither registers a single mouse listener, so every pointer behaviour here isSelect's own:onMouseScrollon the dropdown,onMouseDown/onMouseMove/onMouseOuton the tab strip.- Both controls derive their scroll offset from the selection, so "scroll the list" and "move the selection" are the same act — there is no viewport to move independently. That is why hovering an end arrow changes the value, which is worth knowing before someone reads it as a bug.
<tab-select>takes noselectedIndexprop — only asetSelectedIndexmethod — so it was never actually controlled. The value was already able to drift from the highlighted tab; a mouse pick made it obvious (the callback reported the right kind while the strip still highlighted the old one). An effect now pushes the controlled index into the renderable whenever the two differ.- Consequence, and the reason
pickgained aopt.value !== valueguard:setSelectedIndexemitsselectionChanged, which the React binding maps toonChange— so the sync echoed back as a secondonChangefor the value the page had just set.
- Consequence, and the reason
- The tab strip's geometry has to be reconstructed (
components/support.tabSelectHit, pure + tested):scrollOffsetis private and derived asclamp(selected - floor(visible/2), 0, count - visible)withvisible = floor(width / tabWidth); tabs aretabWidthapart from that offset; and the‹/›arrows are painted over the first and last cell of the row, so they win over the tab beneath them. All of it mirrorsTabSelectRenderable.refreshFrameBuffer— recheck it on an OpenTUI bump.- The arrows only exist in the gap between two width tests:
optionsFitAsTabs(≈label + 3per option) decides the strip is used at all, while the strip itself gives each tablabel + 6. A test needs labels short enough for the first and numerous enough to overflow the second.
- The arrows only exist in the gap between two width tests:
- The hover repeat is a
useEffectwith no dependency array on purpose. Each step re-renders, which restarts the interval, so the repeat is paced at one option per 180 ms and always reads the current selection. The first step is fired by the pointer handler so entering the arrow is instant. createRoot(renderer).render()a second time remounts the tree (already recorded under player heads) — which silently defeats any test of a repeating interaction, since the hover state is thrown away after the first step. Feed the value back throughuseStateinside the tree instead.harness.mockMouse(from@opentui/core/testing) drives real clicks, moves and wheels, andMouseEvent.x/yare absolute terminal cells, so a handler converts them with the renderable'sscreenX/screenY(notx/y, which are parent-relative).- A pty can be driven with the mouse too:
tmux send-keys -l $'\033[<0;36;11M'(press),…m(release),35;x;yfor a bare motion,64/65for wheel up/down — 1-based coords. That is how the three behaviours were confirmed in the real app. - The wheel consumes the event (
stopPropagation), otherwise the shell's scrollbox scrolls under the pointer at the same time, and it clamps instead of wrapping the way the keyboard does — a wheel is a continuous gesture and flipping last→first mid-flick reads as the list jumping.
- "Fill in the gaps in progress.md" meant implement them, not document them. The first pass wrote them up; the user corrected it. When a request names an artifact's gap list, assume the code is the deliverable.
readJsonIfExiststhrows on a syntax error. It tolerates only an absent file. Every caller that reads a user-editable file has to wrap it —ThemeRegistry.loaddid not, so a half-writtenthemes/*.jsontook the whole catalogue (built-ins included) down with it. That was harmless while the catalogue was read once at startup and became a live crash the moment the directory was watched: an editor saving a file is a truncated file for a few milliseconds.- A Biome
// biome-ignoremust be the last comment before the node, and it must sit above the hook call, not above the dependency array. A prose comment placed after it silently voids the suppression (suppressions/unused), and Biome reportsuseExhaustiveDependenciesfor an extra dependency too, not just a missing one — the invalidation-counter pattern (catalogue/versionstate that nothing in the body reads) always needs one. - The nerd-set meter glyphs are two cells on purpose (the user's
4c0e56a): a patched font draws them wider than one cell, so each carries a trailing space. The catalogue test's single-cell assertion was the wrong half; it exempts those four now and pins the pad separately. Do not "fix" the glyphs. Table's row geometry is derived, not tuned. A row draws inside a rounded border with its own padding, so it can paintROW_BORDER + ROW_PADDING_Xfewer cells per side than the table's box; the header draws outside that border and pays the same cells as padding. The old hand-tuned- 3was one short, and the symptom only appears with a filled flexible column: the gap before it collapses and the row wraps onto a second line inside its own border.Table.render.test.tsxcatches it;layoutColumnsnever could, because both halves agreed on widths and disagreed on room.- A staging sweep must key on the newest mtime inside the tree. A long download rewrites one file and leaves every ancestor's mtime alone, so the directory's own timestamp calls a live install abandoned. And age is the only usable discriminator at all: the create lock covers the server id, not the staging uuid, so another instance's in-flight create is indistinguishable from a dead one.
mctl.json.networkis a profile name, not a provider id, and the code now says so. The oldconfig.NetworkProviderenum (typed as both) was renamed toNetworkProviderId, and bothNetworkProfile.providerandNetworkConfig.defaultProfilebecame free strings: a config written by a newer MCTL naming a provider this build lacks must still load, or one unknown profile takes every other setting down with it. Same lesson asmctl.json.kindin Phase 2.- Settings' default-profile picker is now built from
config.network.profiles— profiles are user-defined, so a hand-kept list could not name thecf-tunnelthe user just added.
- Settings' default-profile picker is now built from
- Networking never fails a start.
NetworkManager.exposedegrades todirectfor five distinct reasons (binary missing, provider unregistered, provider unready, profile deleted, agent failed to come up) and reportsdegradedReason;RuntimeManageradditionally swallows anything that escapes. A running server the user can reach on the LAN beats no server. Verified for the last four paths. - A tunnel is a descriptor, not a handle —
~/.local/state/mctl/network/<id>.json, the exact analogue ofruntime/<id>.json, re-probed and reaped on every read. Verified end to end:mctl startin one process brought a real cloudflared quick tunnel up, a separatemctl network statusnamed it, andmctl stopfrom a third killed the agent and removed the descriptor.pidis optional and its absence must not mean "dead".directandtailscaleannounce an address with no process behind them; a naive "no live pid ⇒ reap" erases them on the next read. Pinned by a test.
- Three mechanics make a detached agent real (
lib/shell.spawnDetached), and all three are load- bearing:detached: true(its own process group, so Ctrl-C on MCTL does not take the tunnel),unref()(MCTL can exit), and stdout/stderr on a file descriptor rather than a pipe — a pipe dies with the parent and no other instance can read it.node:child_processis used becauseBun.spawnhas no detach option. - The address is scraped from the agent's own output because none of these agents can be asked.
Hence a durable capture file per server, and hence
AgentSpec.matchbeing the only genuinely provider-specific part of starting a tunnel. Real shapes, worth not re-deriving:- cloudflared quick tunnel prints
https://<words>.trycloudflare.com(matched on the URL, not the ASCII box around it, which has changed between releases). A named tunnel prints no address at all — wait forRegistered tunnel connectionand take the hostname from the profile. - cloudflared TCP is not directly joinable. Cloudflare terminates TLS at the HTTPS edge; every
player must run
cloudflared access tcp --hostname <host> --url localhost:<port>and then joinlocalhost. This is the product, not a bug, and it reads as a broken tunnel if unsaid — so it rides onEndpoint.noteand is printed by both front-ends. - ngrok needs
--log stdout --log-format logfmtor it draws a full-screen UI and prints nothing parseable; the line ismsg="started tunnel" … url=tcp://4.tcp.eu.ngrok.io:19132. The HTTP form must not match — that tunnel is not joinable. - playit assigns addresses on its dashboard, not in its output. So
options.addressis the supported path andAgentSpec.fallbackexists for it: an agent that is alive but silent is kept, not killed for failing to say something it was never contracted to say. - tailscale owns no per-service tunnel. The machine is already on the tailnet, so
exposeonly discoversSelf.DNSName(trailing dot stripped — the MC client rejects the FQDN form) and reports it.tailscale status --jsonis answered by the local daemon, so unlike the tunnel agents its auth state is cheap to check; a logged-out node exits non-zero but still prints usable JSON, so the exit code is ignored and only a parse failure means "no answer".
- cloudflared quick tunnel prints
- The Cloudflare DNS module's load-bearing safety property is the
commenttag. Records are taggedmctl:<server id>and only tagged records are ever deleted — a user's ownArecord on the same hostname and another server's records both survive. Tested against a real local stand-in API with exactly those two decoys present.- Filtering happens locally after listing the zone, not through the API's
commentfilter, which is not on every plan.proxieddefaults false and must stay there: the orange cloud speaks HTTP(S) and would make a Minecraft server unreachable rather than protected. An IP is anA, a tunnel hostname must be aCNAME. - This bypasses
lib/http.tsdeliberately — that helper is an ETag cache for public GETs, and caching an authenticated response into~/.cache/mctl/would be wrong on both counts.
- Filtering happens locally after listing the zone, not through the API's
- Secrets are scoped by provider id prefix (
scopedSecrets:ngrokseesNGROK_*and nothing else), and travel in the child's environment, never argv — a command line is world-readable in/proc. The UPPER_SNAKE secret-key convention is what makes the prefix rule exact. flexGrow/flexBasison a section inside a column parent overlaps the text — the Network page hit exactly the trapmemory.mdalready recorded for the Dashboard's expanded panel, and rendered as garbage on its first pty run. Sections size to content; only the two halves grow, and only when laid out as a row.- Delete now tears networking down first (both front-ends).
deleteServerrefuses a running server, so this only ever cleans a stopped one — but adirectdescriptor outlives a stop and would otherwise keep answeringmctl network statusfor a server that no longer exists.
User: "The ansi part of the line is not being rendered properly." Seen on
my-first-neoforge-server — 62 of its 143 captured lines carry escapes.
- Modded servers colour their output; vanilla and Paper do not. NeoForge/Forge run log4j with a
console appender that emits SGR, so a captured line is
\x1b[32m[03:21:16] [main/INFO] …\x1b[m. OpenTUI draws into a frame buffer, so escape bytes in a<text>child are painted as the literal characters[32m— colour has to arrive as styled child nodes (<span fg=… attributes=…>) instead. There is no ANSI parser in@opentui/core(stringToStyledTextonly wraps a plain string);ansi.d.tsis an emitter, not a parser. lib/ansi.ts(new leaf) parses,components/AnsiText.tsx(new) paints. The split is the layering rule doing real work: the parser yields neutral colours ({kind:"index"}/{kind:"rgb"}) and the component maps an index onto the theme's semantic roles — green→success, yellow→warning, red→error, per log4j's default pattern. A literal#00ff00would be the one thing on screen ignoring the user's theme. Indices 16–255 are fixed (the xterm cube / grey ramp,xterm256Hex) and are used literally, as is a 24-bit colour.- Three parsing facts that were each found in the real capture, not guessed:
CSI mwith no parameters means reset. It is how log4j ends every coloured line; reading it as "no change" leaves every following line stuck in the previous colour.- A carriage return must be armed, not applied. The tmux capture stores the pty's CRLF and
the echo of a typed command arrives as
\x1b[m> stop\r\r, so "a CR erases the line" blanks real lines. The rule that works: the printable text after a CR overwrites; a CR with nothing after it erases nothing. Mid-line\r\x1b[K(prompt redraw before a log line) then works out right. - Tabs must be expanded here (8-column stops). OpenTUI renders
\tas two cells, so a Java stack trace's\tat …continuation lines lost their alignment. That is why the fast path isneedsParse(escape or tab or CR), not "has an escape".
lineColorclassifies the stripped line — an escape before the#defeated the JVM crash-banner test — and is only the default for runs the line does not colour itself.- The console's rows are a memoised
ConsoleLine. Up to 2000 of them, re-rendered every 100 ms while a server boots; without the memo every row re-classified and re-parsed its text each time.AnsiTextis memoised for the same reason and takes a plain-string fast path. - Verified under tmux at 150×45 against the user's real NeoForge capture: zero escape residue across
all 143 lines, INFO lines painted
#3fb950(github-darksuccess),> stopintact, stack-trace indentation restored.mctl logsin a terminal is deliberately untouched — there the escapes are correct output.
- A launch spec is now data on disk, not just a provider's answer.
MctlJson.launch(Zod-validated, henceLaunchSpecmoved from a bare TS type to a schema intypes/install.ts) records what an install produced when the kind cannot imply it. Forge is the reason: its argfile path islibraries/net/minecraftforge/forge/<mc>-<forge>/unix_args.txt, which embeds the loader version, andServerProvider.launchSpec(dir)only receives a directory.- How to apply:
RuntimeManagerusesserver.launch ?? provider.launchSpec(path). A new kind with a generated layout returns it fromresolveInstall().produces; it does not go looking on disk at start time.
- How to apply:
- Forge/NeoForge 1.17+ ship no runnable jar at all. Verified by running the real installer:
java -jar forge-installer.jar --installServergeneratesrun.sh,user_jvm_args.txtand the argfile above, whose contents for 1.21.4 are-jar forge-<ver>-shim.jar. Launching the installer jar re-runs the installer instead of starting a server.run.shisjava @user_jvm_args.txt @libraries/…/unix_args.txt "$@"— which is why thescriptlaunch spec must write the heap flags intouser_jvm_args.txtrather than passing them on the command line.- The executor verifies the prediction and falls back to
run.shif the argfile is not where the provider said. Cheap insurance against an upstream layout change; the alternative is a JVM usage dump at the user's first Start.
- The executor verifies the prediction and falls back to
sleep 0.2; exec …in the tmux launch line is two fixes, not a hack. Both were real:- The command must be given to tmux, never
send-keys'd into the pane. Typing it into the user's interactive shell put the launch at the mercy of that shell — observed: zsh's first-run configuration wizard swallowed the keystrokes and the pane heldxec '…/java'(leadingeeaten) →command not found. tmux hands its command string to/bin/sh, so no rc file runs. execkeeps the pid (it replaces the shell in place), sopane_pidis the JVM's pid. Read it immediately afternew-session— same number before and after the exec. Withoutexec, every liveness probe would report a dead server as running while its shell lived.- The
sleepexists becausepipe-panecaptures only what is printed after it attaches, and a server that dies instantly prints its only useful line before that.
- The command must be given to tmux, never
- tmux removes
SessionNotOwnedError—exec/stopgo through a named session, so any instance can drive the console. Verified:sayfrom a secondmctlprocess reached the server, and astopfrom a third brought it down gracefully in 1.1 s. - Quilt's meta service publishes a WRONG sha256.
meta.quiltmc.org/v3/versions/installersays2bd88a14…for installer 0.15.1; the artefact is0a229138…, and Maven's own…/quilt-installer-0.15.1.jar.sha256sidecar agrees with the artefact. MCTL correctly refused the install until the provider was changed to read the sidecar instead.- How to apply: for a Maven-hosted artefact, prefer
<url>.sha256over a digest copied into some other service's index. The repository verifies uploads against the sidecar; the index is a copy that can rot.
- How to apply: for a Maven-hosted artefact, prefer
- Quilt is an
installer, Fabric is aloaderJar— they only look alike. Quilt has no/server/jarroute (404) and ships a CLI installer whose--install-dir=.is mandatory: without it the default is aserver/subdirectory (verified by running it). Fabric's/v2/versions/loader/<game>/<loader>/<installer>/server/jarbuilds a launcher on demand, publishes no digest, and downloads the game on first boot — so a fresh Fabric directory looks nearly empty and its first start needs network. - Upstream shapes worth not re-deriving: Forge and NeoForge have no versions API — both mavens
are Reposilite, so
…/api/maven/versions/releases/<group path>returns{versions: […]}oldest-first (maven-metadata.json404s; the.xmlexists). Forge's versions are the composite<mc>-<forge>, split on the first hyphen only. NeoForge encodes the Minecraft version in its own: three parts →1.<a>.<b>(a0minor dropped), four parts → Minecraft's calendar version<a>.<b>.<c>(a0patch dropped) since MC 26.1. Purpur's v2 API returns build numbers as strings and publishes MD5 only (hencemd5inlib/download.ts). ServerProvider.javaRequirementfor every loader is Minecraft's own, via the new sharedproviders/server/mojang-meta.ts. That module exists specifically so Fabric/Quilt/Forge/NeoForge do not importVanillaProvider— a shared upstream client beside the providers is not the provider→provider dependency the rule forbids, and the rule's purpose (a backup provider must not reach into a runtime) is untouched.- A Java
{pinned}must not trigger resolution at create time. Adding one brokemanager.test.tsby timing out (it tried to fetch a JDK). The rule: a pin is the answer; it is only located when an installer has to be run with it. - Hand-kept option lists rot silently. Four existed (create form kinds, create form runtimes,
wizard Defaults, Settings) and three still said "Vanilla only" a whole phase later. Kinds/runtimes
in
ServerCreatenow come from theProviderRegistry; the two defaults pickers shareapp/choices.ts, typedRecord<ServerKind, …>so a new enum member is a compile error rather than a quietly missing entry. The wizard cannot use the registry — it runs before there is a config. Bun.file().writer()truncates, so it cannot append: a resumed download uses anode:fshandle opened"a". A resumed transfer must also re-hash the bytes already on disk (they were written by a previous process), and must treat a200answer to aRangerequest as "start over" — appending a full body to a partial file is how you get a corrupt jar that fails its digest.
User: "Tab cycle is not properly done everywhere. Focused areas are not well highlighted (Tabs). Disabled buttons are also acquiring tabs."
useFocusRingtakesFocusItem = string | {id, disabled?}and a{enabled}option. Two rules now hold everywhere:- A disabled member is never focused —
next/prevstep over it,setFocusrefuses it, and a member that becomes disabled hands focus to the next enabled one. Expressing it as a flag rather than by omitting the id is deliberate: omission renumbers the ring under the user's fingers, and the disabled condition is live data (a running server, a clean form) that changes between renders. An all-disabled ring reportsfocus === undefined, which is a legitimate state, not a bug. - Only one ring may listen at a time. Every mounted ring installs a
useKeyboardhandler and OpenTUI delivers a key to all of them, so a page ring and a dialog ring both moved on one Tab — the page's focus travelled behind the modal. Whichever ring is not interactive passes{enabled: false}; it keeps its focused id and only stops moving. - How to apply: a ring member's
disabledmust be the same expression as its Button'sdisabledprop. Drift between the two is exactly the bug this fixes.
- A disabled member is never focused —
hooks/use-modal.tsxis the input capture's sibling, andDialograises it itself. Same counted shape (ModalProviderinApp.tsxbesideInputCaptureProvider,useModalOpen(active),useModalsOpen()getter,useIsModalOpen()reactive). The difference that matters:Escis NOT exempt here. A text field cannot consume Esc, so the capture leaves it to the shell; a modal exists to consume it. Before this, one Esc in a confirmation dialog closed the dialog and quit the app (verified in tmux), and a digit navigated to another page behind the overlay.- Because
DialogcallsuseModalOpen(open), every modal in the app is covered without its caller remembering — put new modals in aDialograther than hand-rolling an absolute overlay. - The shell swaps its whole global hint set for
Tab / Enter / Esc closewhile a modal is up; a page whose own hints would contradict that (the Server page's←→ switch tab) stands them down.
- Because
- A tab that owns a modal must tell its container:
ServerTabProps.onModalexists for the Players tab's action dialog, because only the tab knows its modal state and only the container owns the ring. - Focus is now drawn, not implied by a colour shift. One vocabulary, three places, none of which
costs a row or shifts layout:
Tabs— the active pill's left padding cell is rendered as text, soicons.caretcan occupy it when the bar holds the ring without changingtabWidth(which the rule segment beneath is aligned to). Unfocused, the pill is blended toward the background (mix(primary, background, 0.62)); focused it goes to full accent. The heavy/light rule stayed.Button— a focused button gets analpha(accent, 0.18)wash only when the state colours left the background empty, plus a BOLD label. A borderlessghost/smallchip previously differed from rest by a colour shift alone, which is invisible unless you know to look.FormField— reverted 2026-08-14: the label used to become▸ Labelwhile focused; the user called it "awful" and it is gone. The border and title colour carry focus on their own.Buttonalso treatsfocusedas false whiledisabled, so a page that forgets to update its ring cannot make a dead button look live.
- Rings audited and given disabled members: Server (start/stop/restart/remove + its delete dialog, Cancel first so Enter on arrival is the harmless one), Settings (Revert/Save — a clean form now cycles three stops, not five), ServerCreate (Create, plus Cancel joined the ring — it was mouse-only), the setup wizard's DataRoot (Continue) and Review (Create while committing), and the player action menu (actions needing a running server).
PlayerActionsDialogis now two components, one per stage. A closed dialog has no stage mounted, so "every open starts at the top of the menu" falls out of the tree instead of needing an effect to reset an index — and exactly one ring listens for Tab. The menu's arrows and Tab are the same movement (it is a wrapping grid, so "the one above" has no meaning).mockInput.pressKey("tab")types the letters t-a-b. UsemockInput.pressTab(); Shift-Tab has no helper — send the raw\x1b[Z(CSI Z), which is how terminals actually spell backtab and why the hook handles bothtab+shiftandbacktab. A ring test also needs a settle (render → sleep → render) before reading a frame, like every other rendered-frame test here.- Pre-existing, still failing, NOT keyboard-related:
nerd.heartFull/heartEmpty/foodFull/foodEmptyincore/icons/catalogue.tscarry a trailing space, so they are two cells and break the catalogue's single-cell invariant (core/icons/detect.test.tsfails onnerd.heartFull, onmastertoo). Left alone because the space may be a deliberate spacing choice for the user's Nerd Font — but the health/food meters are 20 cells wide innerdmode, not 10.
- The card is six interior rows, always: four beside the 4-row head (name + status, playtime,
last position, game mode + kills/deaths) and two full-width meters (health, food) under it.
A player with no
playerdataon disk still draws six rows — it says "No player data on disk" plus a blank line — because one taller card makes the whole grid row ragged.- Only standing (
OP/WL/SHADOW) rides the border now, on the top border right-aligned; the name moved into the body because it shares its row with the player's status and a border side holds one run of text. Game mode is no longer a badge — it names line 4.
- Only standing (
- Every body
<text>needstruncate wrapMode="none". OpenTUI text wraps by default, and a wrapped line grows the card by a row: the position line (Last Position: Overworld(-2, 56, 105), ~37 cells) turned a 36-wide card into 10 rows instead of 8.overflow="hidden"on the parent does not prevent the wrap, it only clips what the wrap produced.truncategives a middle ellipsis (Last Posit..., 56, 105)), which is the app's existing convention (Settings, Form). - A
flexGrowspacer eats its siblings unless they areflexShrink={0}. The meter row is caption + icons + spacer + percentage; without the guard yoga took the row's slack out of the caption and the icons and the meter rendered as a blank gap betweenHealth:and50%. Same rule as theTabs/NavRailsegments. - Meters are ten discrete icons, not a
ProgressBar— that is how the game's own HUD draws them. Ten icons cannot express 20 half-units (a whole heart is two points), so the exact percentage is printed at the card's right edge.meterFillbiases both ends the wayProgressBardoes: a live player never shows an empty meter, and one point short of full never shows a full one.- New icons
heartFull/heartEmpty/foodFull/foodEmpty. No food emoji: 🍖/🍗 are East-Asian Wide and the catalogue bars two-cell glyphs, so unicode food is▰/▱.
- New icons
PlayerCardis exported andPlayers.test.tsxmounts it (6 tests). The suite must not hardcode♥—useIconswithout a provider resolvesautooff the runner's environment, and a Nerd Font terminal renders PUA hearts that look like blanks in a captured frame (which is how the first run's failures read as "the meter renders nothing"). It resolves the same set the component will and builds the expected run from that.
core/skins/fetches a player's actual skin and crops the head;skinForis the fallback, not the source. The chain is Mojang → TLauncher → Ely.by, in that order, and it is a fallback chain, not a search: Mojang is authoritative for a licensed account, and the other two only know their own (offline-mode launcher) users — exactly the population Mojang cannot answer for. An offline-mode server has players in all three groups at once.- A miss is the normal answer, not an error. Two of three sources 404 on nearly every lookup,
so
SkinSource.fetchSkinreturnsundefinedand never throws. Ely.by rate-limits hard — it served a skin on the first request of the session and then500d every request for minutes. Treat any non-200 as "not my player" and move on. - Mojang is two hops and needs the name fallback, not just the uuid.
sessionserver/session/minecraft/profile/<uuid>returns204for an offline-mode uuid (a locally derived v3 uuid Mojang has never seen), so the source also resolves the name throughapi.mojang.com/users/profiles/minecraft/<name>. Verified: a fabricated uuid for a name that is a real account still resolved its skin. The skin URL arrives base64'd inside atexturesproperty and is spelledhttp://textures.minecraft.net/...— the same host serves https. - No
SKINin the textures payload means a default Steve/Alex; MCTL draws its own built-in rather than fetching Mojang's copy.
- A miss is the normal answer, not an error. Two of three sources 404 on nearly every lookup,
so
- Misses are cached, and that is the load-bearing part. The Players tab re-reads every 5 s; with
no negative entry, twenty offline-mode players would be 60 upstream requests per poll and all
three sources would start refusing MCTL.
~/.cache/mctl/skins/<sha256(key)>.jsonholds the resolved face for 24 h and an absence for 6 h; in-flight lookups are deduped by key. Measured: first lookup ~3.7 s, cached 3 ms, cached miss 0 ms. - The head is a crop, not a downscale. Every skin — legacy 64×32 and modern 64×64 alike — puts
the front of the head at (8,8)–(16,16) and its hat overlay at (40,8)–(48,16), which is already
exactly the 8×8 grid
MinecraftHeadrenders. HD skins (128×128, …) are the same layout at an integer scale and sample the centre of each scale×scale block: nearest neighbour, never an average, because Minecraft art is flat colour blocks and averaging smears the eyes into mud.- The hat layer is a mask, not a blend (
alpha >= 128wins outright). Real skins use 254 for "opaque"; blending would render no skin the way its author drew it. - A test fixture skin must have a transparent hat layer. An all-opaque synthetic texture renders every face as a black square, which is how the first four head tests failed.
- The hat layer is a mask, not a blend (
lib/png.tsis a hand-rolled decoder, no dependency. ~150 lines for inflate + unfilter + expand covers every colour type and bit depth; Adam7 interlacing throws rather than decoding wrongly. Cross-checked byte-for-byte against an independent Python implementation on jeb_'s real skin.Buffer.concatover multipleIDATchunks is required — real encoders split them.HeadSkin(palette + 8×8 code grid) lives intypes/skin.tsand is now the single face shape: the built-ins inSKINSand a fetched face are the same type, soMinecraftHeadtakesMinecraftSkin | HeadSkin. Zod-validated on the way out of the cache — a face referencing a missing palette code would paintundefinedinto the frame buffer. 64 pixels ⇒ at most 64 distinct colours ⇒ the 64-char code alphabet is exactly enough (pinned by a test).faceSignature()is exported for one reason: the draw effect must key on face content. A fetched face is a fresh object every poll, so keying on identity rebuilds every frame buffer on screen several times a minute. Palette keys are sorted into the signature — code order is an artefact of extraction order, not of the picture.- This cannot be tested through the renderer.
createRoot(renderer).render()called twice remounts the component (useIdgoes_r_0_→_r_1_), so a "did not rebuild" assertion fails for reasons that have nothing to do with the component. Test the pure signature instead.
- This cannot be tested through the renderer.
usePlayerHeadsnever blocks a card. A card draws its built-in face immediately and swaps when a real one arrives; lookups are capped at 64 players, 4 concurrent, attempted once per session, and skipped entirely below the 84-cell head threshold.
-
The Players tab showed every card as "no player data" on a 26.2 server. Minecraft 26.1 regrouped the world's per-player directories under a single
players/:≤ 25.x (and every 1.x) ≥ 26.1 player data <world>/playerdata/<world>/players/data/statistics <world>/stats/<world>/players/stats/advancements <world>/advancements/<world>/players/advancements/The file formats are unchanged — the NBT decoder and the stats reader needed nothing. Only the paths are version-dependent, and
core/server/players.tshardcoded the old two. -
resolvePlayerDirs(worldDir)(exported, inplayers.ts) detects the layout by directory existence, not by version.mctl.json.minecraftVersionrecords what MCTL installed, not what last opened the world, and a world carried across an upgrade keeps whichever layout wrote it — so the version string is not a reliable discriminator. A never-booted world resolves to the legacy paths and reads as empty, which is the same answer either way.- How to apply: any future read of a per-player file goes through
resolvePlayerDirs, neverjoin(worldDir, "playerdata").<world>/datapacks/did not move (inspect.tsis fine).
- How to apply: any future read of a per-player file goes through
-
.dat_oldis whyreadDirIfExists(dataDir, ".dat")must keep its extension filter. The server writes one beside every save; a looser match doubles every card and keys the copy by a uuid ending in_old. Pinned by a test. -
Verified against the user's real 26.2 Paper server: both players now render playtime, deaths, health, hunger, game mode, position and distance, and "seen" resolves off the data file's mtime.
- Per-player data comes from three places the server writes, none of them a roster count.
core/server/players.ts(readPlayers) merges five sources into onePlayerProfile[]:usercache.json+ the four roster files (ops/whitelist/banned-players/banned-ips),<world>/stats/<uuid>.json(playtime, deaths, kills, distance, blocks mined), and<world>/playerdata/<uuid>.dat(gzipped NBT — health, hunger, XP, game mode, dimension, position), plus the live ping sample for "who is online".lib/nbt.tsis a new leaf helper — a read-only NBT decoder (no writer, deliberately: MCTL never modifies world data). Gzip/zlib is detected by magic number, not by the caller. 64-bit tags decode tobigint(a PaperLastSeenis a ms timestamp and does not survive a double);nbtNumbernarrows at the point of use.- A
TAG_ListofTAG_Endis how an empty list is written and its declared length must not be trusted — the first thing that breaks a naive decoder on a real inventory. - Stat units are not what they look like:
play_timeis ticks (÷20 for seconds), every*_one_cmis centimetres (their sum is total distance travelled), anddamage_dealtis tenths of a half-heart.play_one_minuteis the pre-1.13 name ofplay_timeand also counts ticks. - Detail reads are capped at 64 files (online players first, then most recent), because a
long-lived public server has thousands of
playerdatafiles and each is a read + a gunzip.PlayerRoster.detailsTruncatedsays so in the UI.
- Merging is by uuid or lower-cased name, and must never re-key.
banned-players.jsoncan carry a name with no uuid (an offline-mode ban), and aplayerdatafile carries a uuid with no name — one player, two identifications. A later source may adopt a uuid the first lacked, but the map key stays put or earlier references break. - Every action is a console command; MCTL never edits the server's rosters. Two reasons, both
load-bearing:
mctl.jsonis the only file MCTL owns in a server directory, and a running server holds those rosters in memory and rewrites them, so an edit underneath it is simply overwritten. Consequence: actions need the server running,PlayerActionDef.needsRunningsays which, and the UI disables the rest rather than failing them.feed/healare not Minecraft commands (they are Essentials'), so they are expressed aseffect give … saturation/instant_healthand work on a plugin-free vanilla server.gamemode <mode> <player>— the argument order is reversed relative to every other command here. Pinned by a test; getting it backwards targets a player named "creative".- Commands are emitted without a leading
/(a console takes bare commands).
- Shadow ban is an MCTL-side marker, not a feature — Minecraft has no such thing. Recorded in
mctl.json.shadowBans(ServerManager.editServer({shadowBans})+.shadowBans(id)to read), it changes the badge on a card and nothing on the server. The dialog and the success toast both say so.TODO(phase-5): real enforcement needs RCON or a plugin. - What is genuinely unavailable and is stated rather than faked: per-player ping and current
session length. The list ping publishes neither;
status.latencyMsis MCTL's own round trip and the summary labels it"… ms to MCTL"for exactly that reason. - A gap on a
flexWrap="wrap"row is a cross-axis gap too. The summary strip left a blank line between its wrapped rows at 52 cells until the gap came off and each item carried its own trailing separator. - The card grid is chunked by hand, not left to wrap, and cards are fitted, not fixed: the
pure
fitCards(available, minimum)takes as many columns as fit at the minimum (CARD_MIN_WIDTH_WITH_HEAD = 36, nine less without a head) and then widens every card to an equal share of the row — two columns are half each, three a third. Heads are dropped below 84 cells (a head is 8 cells). See the redesign entry at the top of this file for the card's own layout.availableis the measured interior of aSection, not the terminal width.Sectionwraps its children in a box it measures withuseBoxWidthand reports throughonWidth; only the layout engine knows what the shell frame, the tab padding, the section border and the scrollbar already took. The terminal-derivedwidth - SECTION_CHROMEis a deliberately narrow first-frame fallback (measuring returns 0 until yoga's first pass — a reported 0 is ignored). Every section is the same width, so only the always-rendered Online one reports.- Leftover cells are left unused rather than handed to one card: a row where one card is a cell wider than its neighbours reads as a rendering fault. Verified under tmux at 140/100/84/83/ 70/60 — 3 columns of 43 exactly filling 131 cells at 140, 2 at 84, 1 at 60, no overflow at any width.
- The tab joins the container's focus ring (
PLAYERS_ID), same shape as the console's command line, so ←/→ reach the tab bar whenever the grid does not hold focus. When it does, the tab registers←→as a context hint with the same key signature the container uses for "switch tab", which replaces that entry instead of contradicting it. skinFor(seed)(FNV-1a over uuid) is deterministic, not random. The roster re-reads every five seconds; a genuinely random head would change face on every poll and read as a glitch.- Verified under tmux at 140/74/52 columns against a fabricated
$HOME(8 players, real gzipped NBT, a real list-ping responder): cards, badges, bars, the action menu'sappliesfiltering, a shadow ban written tomctl.jsonand reappearing as a badge, and a kick correctly failing with the foreground runtime'sSessionNotOwnedError.
- A server's console is reachable only from the Server page's Console tab.
RouteIdno longer hasconsole;Router.tsxlost the case, the import, theOWN_SCROLLentry and the title, and the Dashboard lost itscshortcut (plus the hint and the expanded row's key line).NavRaillights the Dashboard tab forserver/createonly.- Why: the same output had two homes, and the standalone route said nothing the tab does not — a console makes no sense addressed without the server it belongs to.
ConsoleViewmoved toapp/Server/ConsoleView.tsxandapp/Console/is deleted. The move was free on imports (src/app/Console/andsrc/app/Server/are the same depth); onlyServer/tabs/Console.tsxchanged its specifier. A folder named after a page that no longer exists is a trap for the next agent, which is why the file moved rather than staying put.hooks/use-console.tsis untouched and still the single bridge toRuntimeManager; the CLI'slogs/execare unaffected — they were never the route's peers, they are the console's.
- The strip is rendered exactly once, in
Router.tsx. Every page now registers its shortcuts throughuseHints(hooks/use-hints.tsx) instead of drawing aHintof its own — the shell's strip plus a page's strip meant the same keys appeared twice and could contradict each other.Hint/HintItemincomponents/are unchanged (still pure UI); the hook is the new layer above.- Rule going forward: a page must never render
<Hint>. The only remaining callers are the setup wizard (Welcome,WizardFooter), which lives outside the router and has no strip to merge into.useHintsis deliberately inert without a provider, so the wizard is safe either way.
- Rule going forward: a page must never render
- Merge is by key signature, not label (
keySignaturejoins multi-key hints, so["Ctrl","S"]and"Ctrl+S"are one key). Scope ordercontext→page→global, first occurrence wins ⇒ a page overrides a shell hint on the same key rather than adding a second:ServerCreateturnsEsc backintoEsc cancelwithout knowing what the shell said. when: "idle" | "typing" | "always"(defaultalways) centralises the typing rule. The shell used to swap its own strip onuseIsCapturing(); now the provider filters, so character shortcuts (q/t/digits/c/n) vanish the moment any text field captures, on every page, for free. The shell's global set has no typing hints left — moving through a form is the page's keyboard, not the shell's, and a globalTab next fieldread as nonsense on the Console route.- Hints follow the focus ring, not just the route — that is the part worth copying. Settings only
advertises
←→ groupwhile the ring is on the tab bar (elsewhere ←/→ are the text cursor) and only advertisesCtrl+S savewhile a save would actually do something; the Server page swaps its whole set forEnter send commandwhen the ring sits on the Console tab's command line. - Two contexts on purpose (
RegisterContextstable +ItemsContextreactive): with one, every contributing page would re-render each time any hint changed — which is on every focus move. Registrations live in a ref keyed by serial id with a counter in state as the change signal; aMapin state would make the registering effect feed its own dependency. useHintscomparesitemsby value (JSON.stringify), so callers need nouseMemo. Requiring one would be a trap that fails as an infinite re-register loop rather than a type error.composeHintsis pure + exported and covered byhooks/use-hints.test.ts(7 cases) — including the one non-obvious interaction: a hint suppressed bywhenfrees its key for a lower scope.
src/app/Server/is now a container + nine tab bodies, not one file.tabs.tsholds the tab model (ServerTabId,SERVER_TABSwith a label and a one-line description),panels.tsxthe shared presentation vocabulary (Panel/Detail/Meter/EmptyNote/Columns,LABEL_WIDTH,TWO_COLUMN_WIDTH,ServerTabProps,javaLabel), andtabs/*.tsxone screen each.- Adding a screen is three edits: a row in
SERVER_TABS, a file undertabs/, acasein the container's switch. TheServerTabIdunion makes a missing case a compile error, not a blank screen — same trickexecuteInstalluses for install strategies. - The container fetches
useServer+useServerInsightonce and passes{server, insight, size}down; no tab does its own I/O or polling.
- Adding a screen is three edits: a row in
- Route
serverjoinedOWN_SCROLL— header, action bar and tab bar are pinned chrome and only the tab body scrolls.TAB_OWNS_SCROLL(currently justconsole) is the same rule one level down: the Console tab pins a command line under its own scrolling pane, so the container hosts it in a plain box. Never nest one page scrollbox inside another. - A pinned 1-row action bar MUST carry
flexShrink={0}. Found in a pty at 74×24: the tab body isflexGrow, so yoga shrank the button row to nothing and the Start/Stop buttons silently vanished at small terminal sizes while rendering fine at 120×40. The identity header has the same guard now. Check this on any page that pins a short row above a growing one. ConsoleViewlives atapp/Server/ConsoleView.tsxand the Console tab is its only host (see the console-route entry at the top of this file).- Its key capture follows
focused, not mounting (useCaptureKeys(focused)). The standalone page passesfocusedalways; inside the Server page the ring owns it, so ←/→ still reach the tab bar when the ring is elsewhere. Verified in a pty: typingsay 3 hiin the tab inserts the3instead of navigating to Backups, and the shell's hint strip flips to typing hints. onLineCountis reported from auseEffect, not the render body — the host stores it in state, and setting a parent's state while rendering a child is the update-during-render React refuses.
- Its key capture follows
- Every
Detaillabel must fitLABEL_WIDTH(13)."everything else"overflowed the padded column and pushed its value out of alignment on the Content tab; renamed to"rest". - Tab focus ring:
[TABS_ID, ...actions, CONSOLE_ID?]— the tab bar is first so ←/→ switch tabs the moment the page opens, and the console's command line joins the ring only while its tab is active. Same shape as Settings' per-group ring. - The Console button is gone from the action bar — the Console tab replaced it.
- What each tab is honest about, deliberately: Backups says "Phase 4" and shows the configured
policy (resolved through
resolveRootPaths, so an unsetbackups_dirshows the real default); Network shows the direct picture and says tunnels/DNS are Phase 4; Settings is read-only and prints themctl editcommands that do change these values; Performance names TPS/MSPT, heap occupancy and network I/O as unmeasurable rather than leaving gaps. - Performance keeps a session-local sample window (last 60 readings → min/avg/peak), reset when the pid changes. It is a derived observation of this page's own polls, never persisted, and does not violate statelessness — every reading still comes from a fresh probe.
- Everything a server "is doing" now comes from
core/server/inspect.ts, the read-only twin ofdiscover.ts:inspectServer(server)(cheap tier) andmeasureSize(server)(expensive tier). Sources:server.properties, the four roster JSONs,mods/+plugins/jar counts, a procfs sample, and a Server List Ping. Nothing cached; re-derived per call like everything else.- The tiers are split because their costs differ by orders of magnitude. Cheap ≈ 250 ms
(dominated by the CPU sample, below); the directory walk is thousands of
stats. The hook polls them at 4 s and 60 s respectively.
- The tiers are split because their costs differ by orders of magnitude. Cheap ≈ 250 ms
(dominated by the CPU sample, below); the directory walk is thousands of
- Live player count comes from the Minecraft Server List Ping, not RCON (
core/server/ping.ts). It is the protocol the vanilla multiplayer screen uses, so it needs no op, no credentials, and no config, and it works for every server kind. 1.7+ JSON status path only; a pre-1.7 server just drops the handshake and reports as "not responding".socket.on("end")is load-bearing, not belt-and-braces. With adatalistener attached the socket is in flowing mode, and a peer that sends FIN without replying leaves it half-open —"close"does not fire until the timeout, so listening forclosealone stalls every probe of a booting server by the full 2 s. Found by the test that hangs up immediately.- MOTD arrives as a bare string,
{text}, or a{text, extra:[…]}tree; all three are flattened, and legacy§xcodes are stripped (they render as mojibake in a terminal).server.propertiesstrips them too, but keeps the original inproperties.raw.
- CPU% is sampled twice ~220 ms apart (
lib/proc.ts), because a single cumulative/proc/<pid>/statreading can only yield a lifetime average — useless on a server up for hours, which is exactly when you look./proc/<pid>/stat's fields are split from after the last)(the comm field can contain spaces and parens). Non-Linux falls back tops -o rss=,%cpu=, which is a lifetime average — hence thecpuIsLifetimeAverageflag. On Linux an unreadable/proc/<pid>means "dead", so it does not fall through tops(that spawned a child per stopped server). - What is deliberately NOT shown, and why: TPS/MSPT (needs RCON
/tpsor a mod), per-server network traffic (the kernel exposes no per-process socket byte counters), and JVM heap occupancy (needs JMX). The Resources panel says so in a row rather than leaving a gap — a mysterious absence reads as a bug.memoryis RSS against the configured heap. components/Table.tsxis the new responsive table and the first user oflayoutColumns, which is pure and exported. A terminal row cannot reflow, so responsiveness is column dropping: natural widths → drop bypriority(lowest first, rightmost among equals,requirednever) → distribute the leftover toflexcolumns, iteratively so a column hitting itsmaxhands its share back → last resort, shed from the right until even one cell per column fits.- The invariant the tests pin: the widths plus gaps never exceed the available width, at every width from 1 to 200. A row that overflows by one cell wraps and destroys the alignment.
maxon a flex column is not cosmetic. Without it the id column ate every spare cell on a 140-wide terminal and the row read as one name in a field of whitespace. The Dashboard caps id at 24 and gives the real slack to a low-prioritymotdcolumn, so a wide terminal shows more information rather than more padding.- A
scrollRowstable must reserve a cell for the scrollbar (SCROLLBAR_RESERVE, matched by the header'spaddingRight). The scrollbox draws it inside its own width, so without the reserve the rows sit one cell left of the header — and only once the list outgrows the viewport, i.e. a misalignment that appears out of nowhere.
flexGrow/flexBasisshare the parent's MAIN axis. The three groups in the Dashboard's expanded panel carry them only when laid out as a row; keeping them in the stacked (column) layout made the groups fight over the panel's height and rendered as overlapping text.DetailGrouptakes acolumnedprop for exactly this.useBoxWidthmoved out ofForm.tsxintocomponents/use-box-width.ts(Table needs it too). Same rule as before: attach the ref on every render path, never gate the measured element behind the branch the measurement decides.dashboardjoinedOWN_SCROLLso the tiles and the column header stay pinned while the rows scroll. A table whose header scrolls away is unreadable past one screen of servers.- Dashboard tiles: the
unavailabletile is rendered only when non-zero (it is 0 for every healthy fleet and cost a tile to say nothing), the resource tiles drop below 112 cells, and its label shortens tomissingbelow 76 — measured thresholds, from the width at which the label wraps and grows the whole strip by a row. - The players tile counts slots of responding servers only; summing every stopped server's
max-playersadvertised a fleet capacity nobody could join. formatDurationcaps at two units and drops the hours past 100 days — nine cells would not fit the 8-cell uptime column. Column widths are a real constraint on the humanizers, and the test asserts it.
- There is no Servers page any more.
src/app/Servers/is deleted and theserversroute is gone fromRouteId/NAV; the Dashboard is the summary and the server table. Rail digits renumbered to 1–5 (Dashboard/Jobs/Backups/Network/Settings) — the shell's hint strip (1 … 5) and everynavigate("servers")call site (Server,ServerCreate) moved with it.- How to apply: nothing may navigate to
"servers"; go to"dashboard".NavRaillights the Dashboard tab for the rail-less routes (server/create), not justserver.
- How to apply: nothing may navigate to
- Selection is the expansion — the selected row renders a detail panel directly beneath itself (left-border accent, two columns + path + a key hint). No separate expand/collapse key: one panel is open at all times, so the page cannot grow into a wall of detail and there is no second piece of state to keep in sync with the selection.
- A mouse click on an unselected row selects it; a click on the selected row opens it. That makes the pointer agree with the keyboard (Enter opens the row the caret is on) instead of the old list's click-always-navigates, which would have made the expansion unreachable by mouse.
- Recent activity was dropped (user: "I don't think we need any section for Recent Activity").
hooks/use-recent-events.tshad no other consumer and was deleted with it —events.jsonlis the cross-instance sync mechanism, not a user-facing log. - The full
serverdetail page stays (Enter): it owns the lifecycle action bar and the delete confirmation. The inline panel is read-only by design — duplicating the action bar into a list row would mean a second focus ring competing with the row selection.
- Stack is TypeScript + OpenTUI on Bun, not Rust/Ratatui. The Rust plan's architecture (provider
separation, filesystem-as-truth, event bus, jobs) carries over; the language and crate layout do not.
UI is React-style via
@opentui/react. - Use the
opentuiskill before any TUI work. No Rust/Ratatui skill is in play. - Dependencies today:
@opentui/core,@opentui/react,react19. Bun runtime;bun run devonly script so far. Codebase is still the starter template.
- Zod is v4 (
zod@4.x). In v4,z.object(...).default({})type-checks the argument against the schema's output type, so.default({})fails when nested fields have their own defaults. Use.prefault({})(input-side default) for composite sections instead — seetypes/config.ts(defaults/backup/network).- How to apply: for any object field whose sub-fields all have defaults, wrap with
.prefault({}), not.default({}).
- How to apply: for any object field whose sub-fields all have defaults, wrap with
- Config JSON key naming:
servers_dirandbackups_dirstay snake_case becauseplan.mddocuments them verbatim asconfig.servers_dir/config.backups_dir(a published contract). Everything else in config is camelCase (configVersion,defaultProfile, …). Intentional exception, not drift. - Secrets + env override convention: secret keys are UPPER_SNAKE (e.g.
CLOUDFLARE_TOKEN); the env override isMCTL_<KEY>(MCTL_CLOUDFLARE_TOKEN).loadSecrets()overlays allMCTL_*env vars except a reserved set (MCTL_LOG_LEVEL).secrets.jsonis written0600and the mode is re-stat'd and enforced after writing. - Logger writes to a FILE, never stdout (
~/.local/state/mctl/logs/mctl.log) because OpenTUI owns the terminal in TUI mode — console output corrupts the render. Level viaMCTL_LOG_LEVEL. Pinoredactmasks credential keys as defence-in-depth (real rule: don't pass secrets to the log at all). - argv dispatch uses lazy
import()insrc/index.tsxso the CLI path never loads OpenTUI and the TUI path never loads the CLI router. - CLI stubs are honest: unimplemented commands print "not implemented yet (Phase N)" and exit 1 —
no silent no-ops.
help/versionare the only real commands so far. renderApp()inapp/App.tsxowns renderer creation + mount;index.tsxstays a pure dispatcher.
- Focus is page-owned via
useFocusRing(ids)(hooks/use-focus-ring.ts) — OpenTUI has no global focus manager, so a page tracks the active control id and cycles it. Tab / Shift-Tab move the ring (handle bothname:"tab"+shiftand a distinctname:"backtab"); the hook exposesisFocused/setFocus/next/prev. Convention: passfocused={ring.isFocused(id)}+onFocused={()=>ring.setFocus(id)}to each control, and<Input onSubmit={()=>ring.next()}>so Enter advances fields. This is THE focus primitive for pages going forward (Dashboard/Settings reuse it). Ringidsmay change between renders (conditional fields) — index is clamped, so a step whose ids depend on a toggle (PathsStep,BackupStep) just recomputes the array.- How to apply: buttons already own their Enter/Space (guarded by
focused), so the ring needs no button-key logic — just give the focused Buttonfocused+onClick.
- How to apply: buttons already own their Enter/Space (guarded by
- Wizard = welcome splash + 6 steps in
src/app/setup/(SetupWizard.tsxcontainer). Container owns theSetupDraft(a flat view model, NOT the config shape — paths carry explicit override toggles, optional fields are "" = use default), the step index, and stage keys (Enter begins on welcome only; Esc quits from welcome, else steps back). Steps are self-contained: each owns its ring + renders its fields + aWizardFooter. Container renders<box key={step}>so each step remounts fresh (ring resets to field 0). - The wizard's only I/O is
useSetup().commit(app/setup/use-setup.ts):draftToConfig(pure, also used by the Review step to preview) →writeConfig(Zod fills defaults + validates) →writeSecrets({})(empty 0600) →ensureDirTree. Pages never callcore/configdirectly; only this hook does.commitcarries the currentthemeIdinto config so a theme cycled during setup sticks. - App routing:
renderApp()decidesfirstRun = !(await configExists())once and passes it to<App firstRun>.Apprenders<SetupWizard onComplete={()=>flip}>until setup writes config, then theDashboardplaceholder — in-place, no restart. The old MinecraftHead demo grid is gone. mctl initmirrors the wizard headlessly (cli/commands/init.ts, lazy-imported from the router). Flags map 1:1 to draft fields; unset → schema defaults (so baremctl initwrites a full default config at~/.mctl).--forceto overwrite,--json,--help; unknown flag → exit 1. Validation is the schema's job (bad kind/relative root → typedConfigValidationError), not the parser's.- Logger flipped to
sync: true(lib/logger.ts): a fast-failing CLI command'sprocess.exit(index.tsx:19) tore down the async sonic-boom stream before its fd opened → "sonic boom is not ready yet" stack dump on stderr. Sync file writes remove the race; volume is tiny and it's never the render path, so sync is fine. (Supersedes the earliersync:false.) ascii-fontfont names aretiny | block | shade | slick | huge | grid | pallet(from@opentui/core/lib/ascii.font.d.ts). Welcome hero usesfont="block"with a 2-colour gradient (color={[primary, secondary]}).<ascii-font>colours viacolor, notfg(already in gotchas).lib/fs.diskFree(path)walks up to the nearest existing ancestor beforestatfs(the chosen root usually doesn't exist yet) →{free,total}bytes;undefinedon failure (never throws).useDiskFreehook debounces it 150ms.lib/format.formatByteshumanizes (binary units, "—" for non-finite).
core/server/discover.tsis THE shared server read path —listServers(serversDir)/getServer(id, serversDir)combine registry + eachmctl.json+ a live session probe intoServerview models. Both the CLI (list/status) and the TUI (useServers/useServer) call it, so neither front-end holds logic the other lacks. Read-only; the mutatingServerManager(create/delete/edit + install strategies) is Phase 2. Re-derived from disk every call — no cache.- One bad server never breaks the list: unreadable/invalid
mctl.jsonor missing path → a minimalunavailableview model (kind/mc/etc = "—"), not a throw.
- One bad server never breaks the list: unreadable/invalid
types/server.ts:MctlJsonis az.looseObject(unknown/future keys preserved so a server made by a newer MCTL survives a read round-trip).RuntimeSession(runtime/<id>.json) and theservers.jsonfile schemas are strictz.object.ServerState=running|stopped|unavailable| unknown. TheServerview model is a plain TS interface (derived), not Zod —state/availableare computed, never stored.- Session probe (
core/session/session-manager.ts): liveness viaprocess.kill(pid, 0)— no-throw orEPERM= alive,ESRCH= dead.probe(id)reaps dead/invalid/corrupt descriptors so a crashed server never lingers "running".reapStaleLocks()sweepsruntime/*.lockwhose owner pid is dead (lock body is JSON{pid}or a bare int); called once inrenderApp()before any read. tmux/ docker session-existence check is aTODO(phase-3)— pid is the only signal today. - Event system (
core/events/), 4 files + barrel:EventBus(EventEmitter3, single"event"channel,emit/subscribe→unsub/clear).INSTANCE_ID= onerandomUUID()per process (not persisted; identity is per-run).publish(bus, type, payload)= append toevents.jsonl+ emit locally; the tail then skips lines whoseinstance === INSTANCE_ID, so an instance never double-processes its own events.startTail(bus)records the current EOF and re-emits only new remote lines (no history replay);fs.watchfor immediacy + a 1 s poll fallback; detects truncation by size shrink.- Watchers watch DIRECTORIES, not files (
configDir/stateDir/runtimeDir) — atomic writes (temp+rename) change the inode, so a file-bound watch goes stale after the first write. They emit local-onlyConfigChanged/RegistryChanged/ServerStateChanged{id}(notpublish— the change was already made by whoever caused it). Debounced 60 ms per filename. startEventSystem()→{ bus, stop }, wired inrenderApp();EventBusProviderinjects the bus. Stopped on the renderer's"destroy"event.- Wizard/
init/Settings config writes need no explicit emit — the config-dir watcher firesConfigChangedautomatically, souseConfig/useServersrefresh. (True only since the 2026-07-27 temp-name fix below; before it the watcher never fired at all.) MctlEventenvelope (types/events.ts):{v,id,ts,instance,type,payload}.typeis an open string (forward-compat: an unknown event type from a newer instance must not break the tail);EventTypeis a reference object, not a closed union.
- TUI Router (
src/app/): in-memory router (no URL).hooks/use-router.tsx=RouterProvider+useRouter()(route + params +navigate/back/canBack, with a back-stack).app/routes.ts=RouteId+NAV(dashboard/servers/jobs/backups/network/settings, digits 1–6;serverdetail is NOT in NAV — reached from Servers with aserverIdparam).app/Router.tsx= the shell (top bar +NavRail+ page host +Hintstrip) and owns the global keyboard: digit→route,Esc=back-else- quit,q=quit,t=cycle theme.App.tsxrenders<AppRouter/>post-setup.- Digit-nav (plus
q/t) is gated by the input capture — see the 2026-07-27 entry above. TheTODO(phase-1)inRouter.tsxis resolved and gone. - Real pages:
Dashboard(summary tiles + the server table with an expanding selected row — see the Dashboard entry at the top of this file),Server(detail + lifecycle actions viauseServer),Settings(editable config form).Jobs/Backups/Network= honestPlaceholder. - NavRail is a horizontal tab bar, not a left rail (redesigned 2026-07-26 to a user-supplied
reference): a 2-row scrollbox — tabs on row 1, the rule on row 2 — whose active tab is a solid
pill (
backgroundColor: colors.primary, inkonAccent(colors), BOLD) and whose inactive tabs arecolors.muted, lifting tocolors.foregroundon analpha(foreground, 0.12)wash. The digit prefix is DIM off the pill andmix(onAccent, primary, 0.55)on it. - The rule is per-tab
<text>segments, NOT aborder={["bottom"]}— only the segment under the active tab is accented, and a border paints one colour for its whole side. Two rules keep the rows aligned: (1)tabWidth(item)is the single width source, set as an explicitwidthon both the tab box and its underline text; (2) every segment isflexShrink={0}. Without (2) yoga shrank the segments (their total + the tail exceeds the viewport) and the accent came out 9 cells under a 13-cell tab. The rule reaches the right edge via a tail<text>sized fromuseTerminalDimensions().widthminus the cells the tabs consume (a<text>can't stretch, so it must be counted out), inside aflexGrow+overflow="hidden"box. Deliberately an overestimate — the terminal width ignores the shell frame's inset, and surplus is clipped, whereas undershooting leaves a visible gap before the right border. Re-renders on SIGWINCH (verified by resizing a pty).- Tabs are deliberately NOT
Buttons.Buttoncolours its label from its own variant matrix and only whenchildrenis a plain string, so a chip needing two inks (dim digit + label) with a muted resting look has no matching kind — the localNavTabowns its hover state instead. - The screen name rides the shell's top border (
title+titleAlignment="right", the reference's "Request" placement) and the brand ridesbottomTitle— neither costs a row. The old commented-out top-bar block inRouter.tsxis gone;titleFor(route)now feeds the title.
- Tabs are deliberately NOT
- Data hooks (
hooks/):use-servers(useServers/useServer),use-config,use-event-bus— all re-run the core read path on invalidating bus events, holding no authoritative state.use-event-bus/use-routerare.tsx(they hold JSX providers).
- Digit-nav (plus
lib/http.ts— ETag cache (Phase-1 tail; first real use is Phase-2 downloads). One JSON file per URL under~/.cache/mctl/api/<sha256(url)[:32]>.json={url,etag,lastModified,fetchedAt,body}. WithinttlMs(default 5 min) serves cache with no network call; else conditional GET (If-None-Match/If-Modified-Since),304refreshes the timestamp,200restores body+validators. Serves stale on network failure; throwsHttpErroronly when nothing is cached.fetchJsonreturnsunknown— caller Zod-validates.
- Bun's
fs.watchreports a rename under the SOURCE name only — the destination never appears. Our atomic writes are temp+rename, soconfig.json/servers.jsonwrites produced no matching watch event and the hard-state watchers were silently dead (the earlier note claiming "the config-dir watcher covers wizard/init writes" was wrong — it never fired). Verified on Bun 1.3.14.- Fix:
lib/fs.writeFileAtomicnow names its temp file after the target —.<basename>.<pid>-<rand>.tmp(tempNameFor) — andcore/events/watch.tsmaps it back withtargetOfTempName()before filtering. Debouncing keys on the resolved target, so the temp-write and the rename coalesce into one event. - How to apply: never filter watch events by a bare filename again; go through
targetOfTempName(name) ?? name.src/core/events/watch.test.tsis the regression guard (ConfigChanged / RegistryChanged / ServerStateChanged + a negative case).
- Fix:
- Global character shortcuts are gated by an input capture, not by page identity.
hooks/use-input-capture.tsx=InputCaptureProvider(mounted insideRouterProvider, aboveAppShell) +useCaptureKeys(active)for pages +useKeysCaptured()for the shell. Capture is a count, andisCapturedis a getter — auseKeyboardhandler closes over its render, so a boolean would go stale.Escis deliberately exempt (it can't be part of what's being typed); digits/q/tstand down while a text field owns the ring. The hint strip swaps to typing hints viauseIsCapturing().- How to apply: any future page with a text input calls
useCaptureKeys(ring.focus !== undefined && TEXT_FIELDS.has(ring.focus)).
- How to apply: any future page with a text input calls
- Settings is the wizard's peer, not its clone.
app/Settings/use-settings.tsowns a flatSettingsDraft(noroot— permanent) +configToDraft/draftToConfig/validateDraft(pure, unit-tested) and commits withwriteConfig→ensureDirTree(a relocatedservers_dirmust exist immediately).draftToConfigis merge, not replace: it spreads the loaded config sobackup.schedule/retention, namednetwork.profiles, and future keys survive an edit. Edits are buffered; Ctrl+S or Save writes; the watcher'sConfigChangedthen refreshes every instance.- The buffer follows the file while clean and is never clobbered while dirty — tracked
by an
adoptedref holding the last serialization taken off disk. - Theme is NOT in the draft. The theme provider owns it and persists on change, so
the Settings theme picker applies instantly (like
t); a save just carries the currently-active id.
- The buffer follows the file while clean and is never clobbered while dirty — tracked
by an
events.jsonlrotation exists now (trimEventLog, log.ts): >512 KB ⇒ rewrite the last ~128 KB of whole lines atomically. Called once instartEventSystem()before the tail records its offset, and opportunistically from the tail's drain. The tail's shrink branch now resumes at the new end (offset = size) instead of restarting at 0 — restarting replayed the surviving history into the activity feed.FormFieldpainted the literal stringundefinedon its bottom border when nohintwas passed (bottomTitle={${hint}}). Only showed up once a page used hint-less fields (Settings' checkboxes). Now conditional.
- A page whose chrome must stay put cannot live inside the shell's scrollbox.
Router.tsxnow keeps a setOWN_SCROLL: ReadonlySet<RouteId>(currently just"settings"): those routes are hosted in a plain<box flexGrow={1} flexDirection="column" padding={1}>, everything else keeps the scrollbox. The host is what gives such a page a definite height, which is what lets an inner<scrollbox flexGrow={1}>know when to scroll.- How to apply: any future page with pinned chrome (a toolbar, a console input row, a wizard
footer) adds its route to
OWN_SCROLLand puts a scrollbox around its scrolling region only. Don't nest a page-level scrollbox inside the shell's — the outer one has no definite height for the inner one to resolve against.
- How to apply: any future page with pinned chrome (a toolbar, a console input row, a wizard
footer) adds its route to
- Settings is
PageHeader → Tabs → scrollbox(panel) → action bar. Groups are Locations / Defaults / Backups / Network / Appearance (GroupId,GROUPS). The panel iskey={group}so switching tabs remounts it and scroll starts at the top.- The focus ring is per-group:
ringIds(group, draft)=[__tabs, …visible fields…, __revert, __save].TABS_IDis first, so the ring starts on the tab bar and ←/→ switch groups immediately. Conditional fields (path inputs, backup provider/compression) are still added by their toggle —useFocusRingclamps its index, so this stays safe. - A validation issue on a hidden group would be invisible (Save disabled for no visible
reason), so
GROUP_OF_ISSUEmaps each validatable draft field to its group and the offending tab's label gets a trailing" !". Add an entry whenevervalidateDraftlearns a new field. - Section headings were dropped — the active tab already names the group; only the muted
description line remains. The
Written to <config path>footnote became aReadOnlyRowin Locations rather than a page-bottom line (the action bar owns that row now). - Action-bar buttons are
size="small" kind="ghost"(1 row, no border).size="small"+kind="outline"is unusable: its focused/hover recipe setsfg: onAccentwith no background, so the label vanishes into the page. Small chips must beghost(which does fill).
- The focus ring is per-group:
Tabswas restyled toNavRail's language (2026-07-31, user request) — one tab vocabulary in the app, not two. Same 2-row scrollbox:|separators, active tab a solid pill (backgroundColor: primary, inkonAccent, BOLD), inactivemutedlifting toalpha(foreground, 0.12)on hover, and a per-tab rule row with╸/╺caps around the active segment plus a counted-out tail run to the right edge.tabWidth(item) = 1 + 2*pad + label.lengthis the single width source, set as an explicitwidthon both rows, every segmentflexShrink={0}(see the NavRail entry above for why both are load-bearing).- Keyboard focus is still the underline weight (
━focused,─not) plus the accent blending toward the rule when unfocused (mix(primary, rule, 0.75)). The pill is unchanged by focus, so "which tab is active" stays legible when the ring is elsewhere. A border or background would cost a row or fight the pill. - Tabs carry no digit hint (unlike NavRail) — page tabs have no digit shortcut.
- Optional
initialsprop = NavRail's brand slot: a short accent caption before the first tab (rendered as`${initials} `). Settings passes"Settings". - Optional
paddingXprop insets the tabs row only — the rule row is deliberately not inset, so it spans the page like a divider. Pad with this prop, never with a wrapper box: a wrapper's padding pushes the rule in too (Settings' wrapper lost itspaddingX={1}for this). leadCells = paddingX + caption.lengthis the one number tying it together: it is drawn as a plain rule run at the start of row 2 and subtracted from the tail. Miss either and the rows stop lining up.
- Keyboard focus is still the underline weight (
- Two files, split on the pure-UI line.
components/Toast.tsxis rendering only (ToastCard,ToastViewport,wrapText,TOAST_ICONS,SPINNER_FRAMES);hooks/use-toast.tsxis the scheduler (ToastProvider+useToast). The card can be rendered with no timers running, which is what makes it testable. ToastProvideris mounted at the ROOT inApp.tsx, not inRouter.tsx— it renders its viewports as siblings ofchildren, and a viewport isposition="absolute"against its parent, so "parent" must be the screen. Mounting it at the root also gives the setup wizard toasts.InputCaptureProvidermoved up toApp.tsxtoo (it was insideRouterProvider), so the toast layer sits below it and a toast'saction.keycan stand down while a text field is being typed into.Router.tsxstill reads the capture through context — nothing else changed.- The ticker (
tickstate, 100 ms, only while a spinner/meter is on screen) re-renders the provider but not{children}: the children element reference is stable across the provider's own state updates, so React bails out of that subtree. Animation is not an app-wide re-render.
- Viewports are content-sized, never full-screen. A full-screen overlay would sit over the page
and eat its mouse events (
Dialogdoes exactly that on purpose). Centred positions setleftandrightand centre their children, since a content-sized box can't centre itself. - Overflow queues, it does not evict.
visiblekeepsslice(0, maxVisible)per position (oldest first) and a queued toast has no countdown until it reaches the screen — a burst of five toasts loses none. Slicing-maxVisible(newest-wins) was the first cut and is wrong here: an evicted toast would later reappear when the newer ones expired. - Countdowns live in a ref, not state (
Map<id, {timer, expiresAt, remaining, paused}>), and auseEffectreconciles them against the visible list. KeepingexpiresAtin state would make the effect that starts the timer feed its own dependency and loop. remove()dedupes by id (removedref): a countdown can fire in the same frame the user clicks the card, andlatest.currentonly refreshes on the next render — without the guardonDismissfires twice.- Terminal text does not reflow —
wrapText(text, width, maxLines)does it by hand and marks truncation with…rather than dropping words. Unit-tested incomponents/Toast.test.ts. useEffect(() => raise(toast), [])silently breaks: the arrow returns the toast id, which React takes as a cleanup function ("destroy is not a function"). Always brace the body.Settings.savenow resolvesstring | null(the failure message) instead of a boolean — the toast needs the message itself, andsaveErrorstate is stale in the closure right after the await. Settings'commit()toasts success (with the config path) or failure (with arRetry action).- Rendering is verified for real, not just in state:
hooks/use-toast.test.tsxmounts the provider increateTestRenderer+createRootand asserts oncaptureCharFrame()— TTL expiry, delay, sticky, queueing, description, andmockInput.pressKeydriving an action key. That combination (@opentui/core/testing+@opentui/react'screateRoot) works and is the pattern to reuse for any future component test that needs a live React tree.
- The glyph table is the whole visual vocabulary.
PROGRESS_STYLES: Record<ProgressBarStyle, ProgressGlyphs>incomponents/ProgressBar.tsxholds{fill, empty, partials?}forblocks | smooth | shaded | line | smooth-line | dots | segments | ascii. Adding a style = one row there; nothing else in the component branches on the style name. - Sub-cell precision is
partials, andnpartials meann + 1steps per cell.smoothcarries the seven eighth-blocks▏▎▍▌▋▊▉(U+258F..U+2589) → eighths;smooth-linecarries the single╸(U+2578 HEAVY LEFT) → halves, because that is the only sub-cell step the heavy rule━has in Unicode. Styles withoutpartialsround to whole cells.- Consequence for tests: the "an unfinished bar always leaves an empty cell" rule holds for
whole-cell styles only; a sub-cell style can occupy every cell and still read as unfinished
because its last glyph is a partial. Assert
!filled.endsWith(fill)there instead.
- Consequence for tests: the "an unfinished bar always leaves an empty cell" rule holds for
whole-cell styles only; a sub-cell style can occupy every cell and still read as unfinished
because its last glyph is a partial. Assert
- Layout maths is exported and pure —
fillGlyphs(fraction, width, glyphs),indeterminateGlyphs(frame, width, glyphs),thresholdVariant(fraction, base, thresholds). That is what makes the component testable without a renderer (components/ProgressBar.test.ts). The invariant every test leans on: the runs always total the track width, at every fraction and every frame — a short run would shift the layout around it. - Rounding is deliberately biased at both ends: a non-zero fraction always inks ≥1 cell (a started download must not look idle) and a fraction < 1 never fills the last cell (only "done" looks done). This slightly changes what the toast TTL meter draws near its ends; that is intended.
value+maxreplaced the bare fraction, withmax = 1so every existing caller (Toast) is unaffected.readout=none | percent | fraction;formatoverrides it.showPercentis kept as a@deprecatedalias forreadout="percent"because Toast and the first callers were written against it — the destructure raises a TS hint (6385), not an error.- An indeterminate bar drives its own frame counter (
setIntervalat 12 fps in the component) unless the caller passesframe. This is the one place a component in this kit owns a timer; it is UI-only animation, and the state lives on the bar so an animating bar never re-renders the page. Callers that already have a ticker (the toast provider) should passframeinstead, exactly likeToastCard'sspinnerprop. thickcannot mean a taller cell — a terminal has no cell height — so it renders a second row of▄beneath the track in the same runs. Withbracketson, that row starts with a leading space to stay aligned under the[.- Verified by rendering all styles through
createTestRenderer+createRootand readingcaptureCharFrame(). A preview script mustrenderOnce()→await Bun.sleep(…)→renderOnce()again: one render returns a blank frame (React's commit hasn't reached the renderer yet). Alsoconsole.logis swallowed under OpenTUI — write the frame to a file withBun.write.
- A renderable's
widthis 0 until yoga lays it out, which happens on the render loop's next frame — after React's effects. So an effect can only seed the value and then listen. The event a child renderable emits is"resize"(Renderable.onResize, fired fromupdateFromLayoutonly when the computed size actually changes). Do not confuse it with"resized"(emitted by the root renderable with{width,height}) or theCliRenderer's"resize"(the terminal itself). - The ref must be attached on EVERY render path.
Selectmeasured itsFormFieldto decide tabs-vs-dropdown but passedrefonly in the tabs branch — and the branch starts atw = 0(⇒ dropdown), so the ref was never attached, the listener never installed, and a flex-sized (width="100%"/"auto") Select was stuck as a dropdown forever. Self-reinforcing: the width that would flip the branch is exactly the width that is never observed.- Fix:
useBoxWidth(ref)incomponents/Form.tsx(module-local), andSelectnow renders oneFormFieldwith the ref always attached, branching only on its child. - How to apply: never gate the measured element behind the condition the measurement decides. Measure the stable wrapper, branch inside it.
- Fix:
- Falling back to the
widthprop while unmeasured (measured || (typeof width === "number" ? width : 0)) means a fixed-width Select picks its layout correctly on frame one and never flips. Only a flex-sized one starts as a dropdown and switches when the real width arrives. console.logis swallowed under OpenTUI — it is not a debugging channel here (and CLAUDE.md bans stdout writes outright). Uselib/logger.tsor write a captured frame to a file.
- PaperMC v3 is served from
fill.papermc.io, NOTapi.papermc.io. The legacy host fronts v2 and its Cloudflare rules reject unknown clients outright (an HTML challenge page, not a 4xx), so a v3 path there looks like a schema failure rather than a wrong host. Endpoints used:/v3/projects/paper(versions grouped by minor line, an object — insertion order is the only ordering signal),/v3/projects/paper/versions/<v>(→version.java.version.{minimum,maximum?}),/v3/projects/paper/versions/<v>/builds/latest(→downloads["server:default"]with a sha256).- The artefact key is
server:default; a build without it is not a runnable server (error, not a fallback). Builds carry achannel(STABLE/ALPHA) whichlatestdoes not filter — logged.
- The artefact key is
- Mojang is two hops:
version_manifest_v2.json→ per-version package JSON at piston-meta, which holdsdownloads.server {url,sha1,size}andjavaVersion.majorVersion. Two consequences:downloads.serveris absent before 1.2.5, andjavaVersionis a floor with no max (so Vanilla reports{min}only). Mojang publishes sha1, Paper sha256 —lib/download.tshashes both in one pass and checks whichever was supplied. - Adoptium
/v3/assets/latest/<major>/hotspot?architecture&image_type=jdk&os&vendor=eclipsereturns an array;binary.package.{link,checksum,size}. Every Temurin archive has exactly one top-level dir, so extraction needstar -xf … --strip-components=1or the managed JDK lands one level too deep fordetect.ts.
LTS_MAJORS = [25, 21, 17, 11, 8], and an unbounded requirement is capped at the newest LTS.{min: 21}with only a system Java 26 present resolves to nothing installed and fetches Temurin 25 rather than launching on 26. This is not theoretical: launching Paper 1.21.4 on Arch's Java 26 booted fine but segfaulted in Paper's bundledlibasyncProfiler.soduring shutdown (Recording::finishChunk). Exception to the cap:requirement.maxfrom upstream always wins, and aminabove the newest LTS raises the ceiling tomin(else nothing would be valid).- A bare
java: Ninmctl.jsonis a preference,{pinned: N}is authoritative. The bare form keeps a server on the JVM it was resolved with so a newly installed JDK doesn't silently change it; the pinned form is never re-derived and is installed on demand if absent. - Detection runs
java -XshowSettings:properties -versionon every candidate and readsjava.version/java.home/java.vendoroff stderr. Directory names lie (java-17-openjdksymlinked to 21,$JAVA_HOMEupgraded in place). Java 8 reports1.8.0_412— the major is the second component. Probes are memoized per exe path, including failures (cache.has(), not a truthiness check), and the cache is cleared after an install.
- Console capture lives in
~/.local/state/mctl/console/<id>.log, not the server dir. Two reasons: MCTL owns exactly one file inside a server dir, and the capture must be readable by any instance —mctl logs -ffrom a second terminal tails the same file the TUI shows. Truncated on start (a follower must not replay the previous run's shutdown). New path helpersconsoleDir()/consoleLogFile(id). - The foreground runtime's one real limitation:
execonly works from the owning process. A Unix pipe has no name, so a second instance cannot reach the child's stdin → typedSessionNotOwnedErrorrather than a silently dropped command. Everything else is cross-instance:statusprobes the descriptor,logstails the shared file, andstopsends SIGTERM to the recorded pid — which Minecraft's shutdown hook handles by saving the world, so a foreign stop is still graceful (verified: 7.4 s, clean save). withServerLock(id, fn)(core/session/lock.ts) usesopen(path,"wx")— the atomic check-and-create; apathExists+ write pair would race. A lock whose owner pid is dead is reclaimed, not respected, or one crash wedges a server until the next startup sweep.JobSchedulerholds jobs in memory, and that is not a violation. A job is this process's own in-flight work with no on-disk form (like a pending promise); what it produces is the durable part.JobProgressis local-bus only (it fires ~10×/s and would rotateevents.jsonlaway in seconds); onlyJobFinishedispublished cross-instance.
mctl.json.kindwas relaxed from theServerKindenum toz.string().min(1). The authoritative list of kinds is the runtimeProviderRegistry; duplicating it in a schema would make a server created by a newer MCTL parse-fail and show as unavailable instead of "this build has nofabricprovider".config.defaults.kindkeeps the enum — it only bounds a picker.eula.txtis the one deliberate exception to "MCTL writes onlymctl.json". Written once, at create, only on explicit opt-in, into the staging dir; never read, rewritten, or deleted after. Without it an opted-in create produces a server that refuses to boot.- Deviations from
plan.md§ Runtime, both documented intypes/provider.ts:starttakes aLaunchContext(a runtime cannot spawn without the resolved java binary + JVM args, and re-resolving inside each provider would duplicatecore/java/), andrestartis not on the interface — it isstop+startwith a freshly resolved context and lives onRuntimeManager, so every runtime gets identical semantics. core/context.ts(createContext(providers, bus)) is the shared object graph.cli/context.tsandhooks/use-mctl.tsxare its two thin adapters — that is the mechanism that stops the front-ends drifting. The registry is built at the front-end edge (providers/index.ts) and injected, so nothing undercore/orhooks/imports a concrete provider.heapArgssets-Xmsand-Xmxto the same value — pre-committing the heap avoids the stop-the-world resizes that read as lag spikes in the first hour; it is what every MC launch script does.lib/download.tsis deliberately separate fromlib/http.ts.httpcaches small manifest bodies on disk, which is exactly wrong for a 60 MB jar;downloadstreams to a sibling temp file, hashes as it goes, andrenames only after the digest matches — so a corrupt download never leaves a plausible-looking jar behind.
parseArgsmust checkvaluedbeforeboolean.--java 21(pin) and--no-java(skip) share one flag name, so the name is in both sets; checkingbooleanfirst swallowed--java 21as a bare boolean and left21in the positionals —mctl edit x --java 26reported success and changed nothing. Regression-tested incli/args.test.ts.- And the negation is stored as the boolean
false, not the string"false", sostringFlag(henceintFlag) skip it. Otherwise--no-javathrew "must be a positive integer (got false)".
- And the negation is stored as the boolean
Buttononly honours Enter/Space whenfocusedis passed, so an action bar without a focus ring is mouse-only. The Server detail page owns auseFocusRingover its visible actions (the set changes with the probed state; the ring clamps, so that is safe). Check this on any new page with buttons.Bun.spawn'sSubprocessgeneric follows the stdio options, so a helper that passesstdin: "ignore" | Uint8Arraycannot be typedSubprocess<"pipe",…>(lib/shell.ts).FileSink.end()may return anumber, not a promise —awaitit, don't.catch()it.- A pty opened via
scriptignoresCOLUMNS/LINESenv; it inherits the parent size (24 rows here), which silently hides anything below the fold. Prefix the command withstty rows N cols Mwhen driving the TUI — the create form's progress panel looked missing until that was fixed.
- Icons are theming's twin, and are built the same way: pure catalogue in
core/icons/, React adapter inhooks/use-icons.tsx, one persisted key inconfig.json(icons). Components ask for a semantic name (icons.success,icons.caret,icons.ruleLine) and never a literal glyph — the same rule colour already follows.core/icons/catalogue.tsis the single glyph table; adding an icon = one row there. - Three rendering sets, three config modes, and they are NOT the same three.
IconSet=nerd | unicode | ascii;config.icons=auto | nerd | ascii.unicodeis the middle tierautolands on and is deliberately not offered as a mode — "the plain symbols every UTF-8 terminal has" is what auto-detection should be trusted to decide. It is still reachable viaMCTL_ICONS=unicodefor debugging.- Why not fall straight from
nerdtoascii: that would downgrade the majority of terminals (which draw●/✔fine without a patched font) and would have visibly regressed the app's existing look for everyone on the default.
- Why not fall straight from
- There is no way to ask a terminal whether its font has Nerd Font glyphs. So
autorequires positive evidence —TERM_PROGRAM/TERMnaming ghostty, WezTerm, or kitty (all three ship Nerd Font coverage by default), or an explicitMCTL_NERD_FONT— and otherwise picksunicode. A missing glyph is tofu or, worse, a two-cell replacement that shifts the layout.- Only an explicit non-UTF-8 locale (
C,POSIX,iso88591) downgrades toascii. An entirely unsetLANGis treated as capable — routine in containers whose terminal is fine. - An explicit
nerdmode is honoured even in aClocale: the user asserting "my font has these glyphs" beats any heuristic, and overriding them would make the setting useless to exactly the people who need it.
- Only an explicit non-UTF-8 locale (
- Every glyph must be one cell wide, in every set. East-Asian Wide characters are barred
outright (
☕U+2615 was the first pick forjavaand is why the rule is tested); Ambiguous ones (●,◉,—) are fine — the app already draws them. Two documented ASCII exceptions,ellipsis("...") andtransition("->"), because no fixed-width column measures against them.- Consequence that bit twice: any truncation helper must subtract
ellipsis.length, not a literal1.Toast.wrapTextandServers.cellboth take the marker as a parameter now.
- Consequence that bit twice: any truncation helper must subtract
useIcons()deliberately does NOT throw outside a provider — it returns the auto-detected set. This is the one place the icon system diverges from theming: a component with no colours is unrenderable souseTheme()failing loudly is right, but every icon has a working default, and kit components must stay mountable in a bare test renderer.Buttononly inks its label whenchildrenis a plain string (Button.tsx:212). SoGet started {icons.arrowRight}silently loses the label colour — children become an array. Interpolate into one string instead:{`Get started ${icons.arrowRight}`}.- Theme and icon writes share ONE queue (
persistAppearanceinApp.tsx, replacingpersistThemeId). Each is a read-modify-write of the whole config, so separate queues would clobber each other.configSubscriber(bus, select)generalises the oldthemeIdSubscriberand feeds both providers.Settings.savenow takes(themeId, iconMode)for the same reason the theme id was already passed:configin hand can lag one write behind what the user is looking at.
- ASCII mode cannot be complete:
borderStyleis OpenTUI's and 0.4.5 offers onlysingle | double | rounded | heavy. Panel borders stay box-drawing; the Settings picker says so whenasciiresolves rather than letting the user discover it. Prose ellipses/em-dashes in sentences are likewise untouched — they are typography, not icons.
- A
<scrollbox>defaults toLinearScrollAccel— one line per wheel notch, forever. On a tall page that reads as "the wheel barely does anything". PassscrollAcceleration={…};@opentui/coreexportsMacOSScrollAccel(fromlib/scroll-acceleration, re-exported by the package root), which keeps a 3-sample window of the intervals between scroll events and scales the delta by1 + A*(e^(v/tau) - 1), capped atmaxMultiplier(defaultsA=0.8, tau=3, max=6). A streak breaks after 150 ms of silence, so a slow wheel stays exactly one line per notch. - The accelerator is stateful, so the instance must be stable — a fresh instance per render resets the tick history on every keypress and silently degrades to linear.
- Nothing renders the
<scrollbox>intrinsic directly any more — usecomponents/ScrollBox.tsx. It is a pass-through wrapper (ScrollBoxProps = OpenTuiScrollBoxProps & { enableAccel?: boolean }; props andrefspread straight through) that owns theuseMemo'd accelerator and addsenableAccel. It spreadsscrollAccelerationonly when it resolves — the renderable defaults toLinearScrollAccelwhen the option is absent at construction, but its setter would store an explicitundefined. An explicitscrollAccelerationfrom the caller wins overenableAccel. enableAccelis off by default and set at exactly one call site: the shell page host inRouter.tsx. Acceleration is wrong for a short region — a 2-row tab strip (NavRail,Tabs) or a small list overshoots on the first flick. The Settings panel and the wizard are still linear by choice; flip them only if they feel sluggish in use.- Measured in
components/ScrollBox.test.tsxwithcreateTestRenderer+ syntheticonMouseEvent({type:"scroll"}): 30 notches 10 ms apart move 30 rows unaccelerated vs ~175 accelerated.onMouseEventisprotectedandscrollX/scrollYare absent from the publicScrollBoxRenderabletype, so the test casts for both (runtime-correct, type-invisible).
- A negative
width/heighton any element now meansterminal size - n.<box width={-4}>is the terminal width minus 4 cells;<scrollbox height={-2}>the terminal height minus 2.src/components/negative-dimension-patch.ts→installNegativeDimensionPatch(), called inrenderApp()beside the other two patches. This is the replacement for counting cells out ofuseTerminalDimensions()by hand (whatNavRailandTabsstill do for their rule tails).- Why it was needed: OpenTUI's
widthisnumber | "auto" | "<n>%", and a percentage resolves against the parent, not the screen. There is no "screen minus a gutter" form.
- Why it was needed: OpenTUI's
- Two seams, because construction and updates do not share a code path:
- Construction —
Renderable's constructor callsvalidateOptions(id, options), which throwsInvalid width for Renderable <id>: -4on a negative beforesetupYogaPropertiesruns.validateOptionsis module-private, so nothing on the prototype can get in front of it: the only seam is rewritingoptionsbeforesuper(), i.e. re-registering the React component catalogue as subclasses — the same trickselection-opt-in.tsuses.- Dead end: wrapping
Renderable.prototype.setupYogaProperties(my first cut). It is the method that actually pushes the value into yoga, but the throw beats it by ~5 lines.
- Dead end: wrapping
- Updates — the reconciler applies changed props as plain assignments (
instance.width = value, viasetProperty'sdefault:branch, andsetStyledoes the same), so thewidth/heightaccessors onRenderable.prototypeare wrapped too. That path does not validate — a negative reached yoga silently as undefined behaviour before this.
- Construction —
- Tracked and re-resolved on every terminal resize. A size baked in at construction is stale
after the first SIGWINCH, so the raw negative is kept in a module
Map<Renderable, spec>and re-applied fromctx.on("resize")(theCliRendererupdates its ownwidth/heightbefore emitting, soctxis already current in the sweep). Entries drop on the renderable's"destroyed"event; setting a non-negative value opts back out. The sweep calls the original setter, or it would clear its own tracking. - Clamped at 0. A terminal narrower than the inset yields an empty element — resolving to a
negative would hit the same upstream
Invalid widththrow. (A laid-out renderable then reportsMath.max(layout.width, 1), so.widthreads 1, not 0.) - Two catalogue patches must wrap
getComponentCatalogue(), notbaseComponents.selection-opt-in.tswas changed to do this. Both wrap-and-extend(); if both wrapped the pristinebaseComponents, the secondextend()would re-register the same names over the first's classes and silently delete the first patch. Wrapping what is currently registered makes them compose in either order. Any future catalogue patch must follow this rule. - Covers JSX elements only. A renderable built by hand (
new BoxRenderable(...)) still throws on a negative constructor option; it is only affected on assignment. Nothing insrc/builds renderables by hand, and the test therefore mounts real JSX throughcreateRoot.
-
Box borders are NOT clipped by ancestor scissor rects — upstream bug, patched locally.
BoxRenderable.renderSelfdraws via the nativebufferDrawBox, and that is the only native draw path that ignores the buffer's scissor stack (drawText,drawTextBuffer,fillRect,drawFrameBufferall honour it). Symptom: a bordered<box>inside a<scrollbox>clips its text correctly but keeps painting border glyphs over the surrounding chrome (top bar, nav rail, hint strip) once scrolled. Not scrollbox-specific — a plain<box overflow="hidden">does it too. Reproduced on@opentui/core0.4.5 (latest published).- Fix:
src/components/box-clip-patch.ts→installBoxClipPatch(), called first thing inrenderApp(). It monkey-patchesBoxRenderable.prototype.renderSelf: when a box is partially outside its ancestors' clip, it lets the originalrenderSelfdraw into a shared scratchOptimizedBufferat the origin, then blits withdrawFrameBuffer— which does respect the scissor. Fully-visible boxes keep the untouched native fast path, so there is no cost until a box straddles a clip edge, and no glyph/title/border-style logic is reimplemented (that was the point: partial sides, title alignments and focus colours stay byte-identical to upstream). - How to apply: delete the module and its one call site when upstream clips
bufferDrawBox. Don't reach forbuffered: trueas a workaround — a buffered renderable renders at the wrong offset inside a clip (tested, produces garbage). - Tests must live inside
src/(src/components/box-clip-patch.test.ts). A test file outside the project resolves@opentui/coreto a different copy (the~/.bun/install/cachesource tree), so patching one copy's prototype does nothing to the other — this silently made a scratch-dir verification look like the patch was a no-op. Firstbun testin the repo;testscript added.
- Fix:
-
Drag-selection is opt-in, via a re-registered component catalogue. OpenTUI's text-bearing renderables (
text,code,markdown,input,textarea,ascii-font, …) defaultselectable: true(baseRenderableisfalse), and a left mouse-down over one starts a drag-selection — which highlights text and fights our click-to-navigate UI, where most labels are also click targets.- Fix:
src/components/selection-opt-in.ts→installSelectionOptIn(), called inrenderApp()besideinstallBoxClipPatch(). It wraps every entry of@opentui/react'sbaseComponentsin a subclass that, only when theselectableprop was absent and the class defaulted to true, setsthis.selectable = falseaftersuper(), then re-registers them withextend().<text selectable>(the server console) keeps working; everything else is inert. - Why not simpler:
selectableis resolved inside each renderable's own constructor asoptions.selectable ?? this._defaultOptions.selectable, and_defaultOptionsis a class field (own property) — so it cannot be patched from the prototype. Subclassing is the only seam. - Dead end: the earlier fix,
renderer.startSelection = () => {}, disables selection globally — an explicitselectableprop then does nothing. Don't go back to it. - Catalogue components are all constructed by the reconciler as
new C(ctx, { id, ...props }), so a uniform(ctx, options)subclass is safe for all of them.RenderableConstructorresolves to the abstractBaseRenderable, which TS refuses toextendin a class expression — narrow the base to a structuralnew (ctx, options) => { selectable?: boolean }instead.
- Fix:
-
Box border sides are
border={["top"|"right"|"bottom"|"left"]}—border?: boolean | BorderSides[]. There is noborderTop/borderRight/borderBottom/borderLeftprop (they fail typecheck).borderColorcolours whichever sides are on. -
CliRendererextends EventEmitter and emits"destroy"(RendererEvents.DESTROY). Userenderer.on("destroy", …)to tear down process-wide resources (we stop the event system there). NoteuseQuitdoesrenderer.destroy()thenprocess.exit(0), so on an explicit quit the OS also reaps watchers regardless.
ThemeProviderownsthemeIdas state seeded once frominitialThemeId— so aconfig.themechanged by another instance or a hand-edit did nothing, even thoughConfigChangedfired anduseConfigrefreshed. It also can't subscribe itself: it is mounted aboveEventBusProvider(it must wrap everything) and is UI-layer, so it does no config I/O.- Fix: a
subscribeThemeId?: (apply: (id) => void) => () => voidprop — the mirror image ofonThemeChange.renderApp()builds it once (themeIdSubscriber(bus)inApp.tsx): onConfigChangeditloadConfig()s and pushesconfig.themein. The provider's effect only callssetThemeIdStateand deliberately does not fireonThemeChange— the id came from the persisted config, re-persisting it would be a write loop between instances. - How to apply: any future provider mounted above the bus that must react to hard-state changes
takes a subscribe prop wired in
renderApp(); don't move it underEventBusProviderand don't give it disk access.
- Fix: a
persistThemeIdnow serializes and coalesces its writes. It is a read-modify-write, and cycling withtfires it faster than a round-trip completes; overlapping writes could land out of order. That was invisible before, but with the bridge above the losing write feeds back and visibly snaps the theme back. One in-flight write at a time, only the newest id, skip when unchanged.- Verified in a pty against a sandbox HOME: an external atomic edit of
config.json(terminal→nord) repaints in Nord within ~1 s (nord bg46;52;64+ primary136;192;208in the new frames); with the fix stashed the same edit produces 0 new bytes of output. Three rapidtpresses land on the right theme with no snap-back. - Still not reactive: the theme catalogue.
ThemeRegistryis loaded once inrenderApp(), so editing/adding~/.config/mctl/themes/*.jsonneeds a restart. No watcher on that dir. Fix by watching it and reloading the registry into provider state if it ever matters.
- Themes carry a light/dark scheme, not one flat palette + an
appearancetag.Theme.colors(andThemeFile.colors) is aThemeColorScheme: either{ default: ThemeColors }(mode-agnostic) or{ dark, light }(both variants). The old top-levelTheme.appearance/ThemeSummary.appearancefields are gone. Built-insgithub+nordnow ship both variants (one id, renamed "GitHub"/"Nord").- Current mode is a property of the host, not the theme. It's derived from the terminal
background luminance via
terminalAppearance(palette)(exported fromcore/theme/terminal.ts, was the privateappearanceOf). Even a static theme picks its light/dark variant from this — the terminal is the only signal of whether the user's environment is light or dark. Defaults todarkuntil the palette resolves. - Resolution:
resolveColors(scheme, mode)intypes/theme.tscollapses a scheme → flatThemeColors(defaultignores mode; a pair picks the match).use-themedoes this and exposescolors(resolved flat palette) +appearance(current mode) on the context alongsidetheme. Components readuseTheme().colors.*, NOTtheme.colors.*(which is now a scheme).App.tsxupdated. terminaltheme is a{ default }scheme — its live snapshot already reflects the current mode, so there's only ever one palette;themeFromTerminalColorslost itsmodeparam.ThemeColorSchemeis az.union([{default}, {dark,light}]);"default" in schemenarrows in TS.
- Current mode is a property of the host, not the theme. It's derived from the terminal
background luminance via
- Themes are a registry of semantic colour roles, not raw ANSI/component names. Roles:
background, foreground, surface, border, muted, primary, secondary, success, warning, error, info(Zod-defined intypes/theme.ts, hex-only for custom files). UI colours by role viauseTheme(). - Three theme sources: built-ins (
github,nord) incore/theme/builtin.ts; custom user files at~/.config/mctl/themes/<id>.json(id = filename, like server-id-from-dir); and the dynamicterminaltheme built live from the host palette.config.theme(default"terminal") stores the active id and is read at startup inrenderApp(). terminalis reserved + special. The registry only lists it (no static colours); the UI layer (hooks/use-theme) substitutes the live palette. A custom file namedterminal.jsonis ignored with a warning.themeFromTerminalColors()(pure, incore/theme/terminal.ts, no OpenTUI import) maps a neutralTerminalPalette→ roles with a fallback chain so no role is ever undefined.- One bad custom theme file is skipped with a log warning, not fatal — deliberate exception to
"throw typed errors": a single malformed
themes/*.jsonmust not make the app unlaunchable; built-ins still resolve. (Contrast config.json, which is fatal.) - OpenTUI already implements terminal-colour querying —
renderer.getPalette()(OSC 10/11/4) and apalettechange event.use-terminal-colors.tsis a React adapter: fetch on mount, subscribe topalette, dedupe by signature, expose a neutralTerminalPalette. - TWO load-bearing gotchas for LIVE terminal-theme changes (both caused a "reverts to fallback /
doesn't update on theme change" bug; the working reference is
~/projects/local-edge):- We must enable DEC private mode 2031 ourselves —
process.stdout.write("\x1b[?2031h")on mount,"\x1b[?2031l"on cleanup. OpenTUI reacts to the terminal's colour-scheme-change notification but never enables the mode, so without this write nopalette/theme_modeevent ever fires on change. (Write toprocess.stdout, notrenderer.stdout— the latter is private.) - The poll fallback MUST call
renderer.clearPaletteCache()beforegetPalette()—getPalettereturns a cached result, so re-querying without clearing returns the stale palette forever.
- Do NOT gate/stop the poll on
theme_mode(an earlier version did — that was the bug). Poll continuously (~1s) with a cache-clear as the fallback; thepaletteevent covers 2031-capable terminals instantly. Appearance is derived from background luminance interminal.ts— no need to depend onthemeModeat all. - Do NOT call
renderer.setBackgroundColor()to theme the background. It emits OSC 11 to change the actual terminal bg, which races the terminal's own colour-scheme transition and flashes a stale colour for a frame on every change (this bit bothlocal-edgeand an earlier mctl version). Instead paint the background with a full-screenbackgroundColorbox at the app root (App.tsxroot box,flexGrow={1}) — it draws into the render buffer and leaves the terminal's native bg alone. The flicker-freeroveproject works exactly this way (never touches terminal bg). - Sandbox caveat: a non-TTY pipe can't answer OSC queries and OpenTUI swallows
process.stdoutwrites there, so live palette detection is not verifiable headlessly — only in a real TTY.
- We must enable DEC private mode 2031 ourselves —
- No-flash terminal theme (three parts, do not drop any):
- Pre-fetch before first paint:
renderApp()callsqueryTerminalPalette(renderer)(exported fromuse-terminal-colors) and passes it to<ThemeProvider initialPalette>→useTerminalColors(initial)seeds state. OpenTUI has usually already detected the palette duringcreateCliRenderer, so this returns from cache instantly on a real TTY (≤200ms timeout otherwise). Frame one is real colours. "terminal"id NEVER falls back to a static theme.use-themeresolves it toterminalTheme ?? EMPTY_TERMINAL_THEME(the empty-palette terminal theme), so an unresolved palette shows neutral terminal-defaults, not GitHub. A missing named theme also degrades to the terminal theme, not github. (FALLBACK_THEME/github is no longer referenced by the provider.)- Ignore transient all-null palettes.
use-terminal-colorsguards every update withhasColour()— during a theme switch the terminal can briefly answer all-null; using it would flash empty for a frame. Skip it, hold the last-good palette.
- Pre-fetch before first paint:
- Gotchas:
<ascii-font>usescolor(ColorInput | ColorInput[]), notfg.<text>/<span>usefg.- Added
@types/react@19(devDep). The repo had no directreactimports before; hooks/context (useState/useEffect/useMemo/useContext/createContext,React.ReactNode) need it. JSX still comes from@opentui/reactviajsxImportSource, so@types/reactdoesn't hijack JSX. lib/fs.tsgainedreadDirIfExists(dir, ext?)→[]on ENOENT (absentthemes/is normal).
- No in-memory authoritative state — "MCTL manages, does not hold." The app caches nothing it
treats as truth; server identity/config/run-state is re-derived from disk + live process probes every
launch and every change.
- Why: it is the enabling constraint for multiple
mctlinstances running at once and staying in sync — none owns the state, so they can't disagree. - How to apply: re-identify running servers by probing
~/.local/state/mctl/runtime/<id>.json(pid/session liveness), never a cached "running set." Sync across instances viafs.watchon hard- state files plus an append-onlyevents.jsonlthat every instance tails and re-emits. No IPC, no daemon, no leader. Supervision (auto-restart/tunnel keepalive) is opportunistic behind a supervisor lock; a real daemon is deferred to Phase 5 on the same file substrate. Detached runtimes (tmux/docker) are the norm so servers outlive an instance. See [[architecture.md]] § Statelessness.
- Why: it is the enabling constraint for multiple
- JSON / JSONL only — no TOML, no YAML.
mctl.toml → mctl.json,config.toml → config.json,secrets.toml → secrets.json. Cross-instance log isevents.jsonl. Drop@iarna/toml; use native JSON + Zod at every boundary. (Earlier drafts said config was TOML — that is now wrong, ported.) - Pages moved into
src/app/. No top-levelsrc/pages/.app/holdsApp.tsx,Router.tsx,setup/(wizard), and the page folders. CLI lives insrc/cli/. - Two front-ends, one core: TUI and one-shot CLI.
mctl(no args) → OpenTUI;mctl <cmd>→ scriptable one-shot with--json. Both call the same core services;cli/commands/is the CLI's bridge, mirroring hooks. Neither front-end holds logic the other lacks. - First-run setup wizard (
app/setup/) triggers whenconfig.jsonis absent; writes defaults once. Headless equivalent ismctl init(same fields as flags → identicalconfig.json).
- Server Location Registry.
servers_diris only the default parent for new servers; each server's real path lives in~/.local/state/mctl/servers.json(id → path). Startup verifies each path (exists +mctl.json). Pointer index, never a data mirror; durable state, atomic writes; missing path ⇒ mark unavailable, never auto-delete; still scanservers_dirand fold in drop-ins. - Providers are dynamically registered modules via a
ProviderRegistry— the TS simplification over Rust crates.
- Artifacts are the project memory:
plan.md(intent),architecture.md(structure),memory.md(this),progress.md(baseline). Read all four at session start; writememory.md+progress.mdevery session. SeeAGENTS.md. - Precedence when artifacts disagree:
plan.md>architecture.md>progress.md. Code beats all. - User wants
plan.mdrich and detailed (the Rust plan was the depth benchmark), not blunt bullet lists — concrete interfaces, tables, diagrams. - Run
bun run format(Biome) once at the end of a session, after all edits have settled — not per file. Asked for on 2026-08-07 because unformatted diffs produced formatting-only commits (bdf2faa). - Commit meaningful units as you go, don't push unless asked (2026-08-07, supersedes the old
"don't commit unless asked"). Message style is the existing history's:
feat:/fix:/refactor:/chore:+ short capitalised summary, no trailing period; checkgit log --onelinefirst.
src/components/is the shared UI kit. All components are pure-UI, controlled, and theme-driven: they read colour fromuseTheme().colors(never hardcode hex), takevalue/checked+onChange+focused, hold no domain state, and do no I/O. Import from the barrelsrc/components/index.ts.- Mouse focus convention — every focusable control takes
onFocused?: () => void. Fired on mouse-down so a click moves the page's focus ring to that control (the component still owns no focus state; the page mapsonFocused→ itssetFocus). Present onButton,Tabs,Input,TextArea,Select,Toggle,Checkbox,RadioGroup. How it's wired: form controls forwardonFocusedtoFormField, which putsonMouseDown={onFocused}on its frame box — OpenTUI mouse events bubble (they carrystopPropagation), so a click anywhere in the frame (border, label, or the inner control) reaches it, and the inneronMouseDownhandlers (Toggle/Checkbox/RadioGroup) that fireonChangedon't stop propagation, so both fire.ButtonfiresonFocusedthenonClick(a click focuses and activates). Non-focusable clickables (Breadcrumbcrumbs, loneRadio,Dialogbackdrop) keep plainonMouseDownand get noonFocused. Gallery wires every ring member'sonFocusedtosetFocus(id)— click-to-focus is the demo/verification. Interactive keyboard handling lives inside each control viauseKeyboardguarded byfocused— that guard is what stops every mounted control from reacting to one keypress (manyuseKeyboardhandlers all fire globally). - Variant language:
support.tsdefinesVariant(primary/secondary/success/warning/error/ info/neutral) →variantColor(colors, v);onAccent(colors)returnscolors.backgroundas the ink to lay on a filled accent (reads on every built-in light/dark variant). Reuse these, don't re-pick roles per component. - The form-field frame (
FormField/FieldinForm.tsx): a rounded<box>that puts the label on the top border viatitleand the hint on the bottom border viabottomTitle, and swapsborderColor/titleColortoprimaryonfocused(erroroninvalid). This is why a text field is a tidy 3 rows — the label/hint sit on the border, not on interior rows. NOTE:<box>hastitleColorbut nobottomTitleColor— the bottom title can't be coloured independently. - Adaptive
Select: few/short options → OpenTUI<tab-select>(side-by-side); options that overflow the field width → scrollable<select>dropdown (with per-option descriptions). Decided byoptionsFitAsTabs(labels, innerWidth)whereinnerWidth = fieldWidth - 4(2 border + 2 pad). Passwidthto aSelectboth to size it and to set the cutoff. - OpenTUI input/textarea value-read gotchas:
<input onSubmit>— OpenTUI mergesInputProps.onSubmit: (value)=>voidwith the inheritedTextareaOptions.onSubmit: (SubmitEvent)=>voidinto an intersection, so a(value:string)handler won't type-check. Pass a zero-arg handler (assignable to both) and read the value from auseRef<InputRenderable>().current.value.<textarea>is uncontrolled: seed withinitialValue, and itsonContentChangeevent is empty — read the text back fromref.current.plainText(useRef<TextareaRenderable>).
Dialogmodal pattern: no window manager, so it's two absolute full-screen layers — a dimming backdrop<box opacity={0.7}>(own opacity so the page shows through; children would inherit it, so the dialog is a separate sibling at higherzIndex, full opacity) centred over it.- Page
Tabs(custom, mouse + ←/→) are distinct from the<tab-select>form input — don't conflate. Active-tab underline renders"─", inactive renders blank spaces (not a background-coloured glyph) so it's theme-proof.
- FrameBuffer has no
<frame-buffer>JSX intrinsic in@opentui/react. Create aFrameBufferRenderable(renderer, {id, width, height})imperatively inuseEffectand attach it to a host<box>via itsref(box.add(canvas)/ cleanupbox.remove(canvas)+canvas.destroy()). React never renders children into that box, so there's no reconciler conflict.useId()for a unique buffer id when several exist. Seecomponents/MinecraftHead.tsx. - Square "pixels" in a cell grid: a terminal cell is ~1 wide × 2 tall, so use the upper-half-block
glyph
▀— fg = top pixel colour, bg = bottom pixel colour → 2 stacked pixels/cell, each its own colour (lossless). An 8×8 image → 8-wide × 4-tall cells and renders square. (Quadrant blocks give 2×2 sub-pixels/cell but only 2 colours per cell, so they lose colour — avoid unless width-constrained.)
- Anything referencing
.toml, Rust crates,cargo,thiserror, or a top-levelpages/in an artifact is stale — port it on sight. - Supervision under statelessness is a real tension: without a daemon, auto-restart/tunnel keepalive only runs while some instance is alive (opportunistic, lock-guarded). If the user wants always-on behaviour, that's the Phase-5 agent — flag it rather than silently assuming a daemon.