Baseline state for the next session. What's done, what's half-done and where it stopped, what to pick up next. Updated at the end of every session that changes code or decisions.
Last updated: 2026-08-21 (Properties tab: a full editor for server.properties)
-
A Properties tab, editing every field of
server.properties(2026-08-21, user request). "Implement server.properties editor inside the server page. I want every single field be editable." This is MCTL's first write to a file in a server directory other thanmctl.json— a deliberate deviation from AGENTS.md § 3, recorded inmemory.mdwith the mitigations.src/core/server/properties-catalogue.ts(new) — pure data: the 64 documented keys with label, kind (boolean / int+range / enum / string), Minecraft's default, a one-line hint and the screen each belongs on, plusnormalizeProperty(legacy numericgamemode=0→survival, and nothing else),validateProperty, andpropertyFieldsFor(raw)— which appends a plain text field for every key on disk the catalogue does not know, so "every field" means the file's fields and not the catalogue's.src/core/server/properties-write.ts(new) — a surgical editor: rewrites only the lines whose key changed, preserves comments / blank lines / ordering / unknown keys / CRLF, replaces all occurrences of a duplicated key, appends genuinely new keys at the end, writes atomically, and creates the file with Minecraft's own two header lines when the server has never booted.src/core/server/properties.ts— unchanged in intent (still reads and never writes); now exportsseparatorIndexandunescapeValueso the writer locates a key exactly as the reader does, and its header points at the new module.src/hooks/use-server-properties.ts(new) — buffers the raw string map off the polledinsight(never the coercedServerProperties, which interprets), follows disk while clean and never while dirty via theadoptedref, tracks achangedset, validates per field, and commits only the changed keys.src/app/Server/tabs/Properties.tsx(new) — the form is generated from the catalogue: a kind becomes a control. Nine screens behind a nestedTabs(so"properties"joinedTAB_OWNS_SCROLL), bar pinned above and Revert/Save pinned below the scrolling fields. Fields are labelled by their key (what the wiki calls them), changed ones get a trailing*, the bar's labels carry a per-screen pending count, and the RCON password is masked unless the field holds the focus. Ctrl+S saves; the toast says a running server keeps what it booted with and may overwrite these when it saves.- Ring:
serverPropertiesRingIds({keys, dirty, invalid}), reported up through the newServerTabProps.onPropertiesState. Unlike the Content tab's fixed stops the members are the active screen's fields — a screen switch legitimately renumbers, because it is as deliberate as a tab switch. src/app/Server/tabs.ts— new"properties"tab after World; World's description now says it shows the rules, since Properties owns editing them.- Tests (679 total, +36): the writer's preservation guarantees (comments, ordering, CRLF,
duplicates, continuations,
:/space separators, a value containing its own key), Java escaping round-tripped through the real reader, catalogue consistency and the unknown-key path, and eight rendered/ring cases for the tab. - Verified:
bunx tsc --noEmitclean,bun test679 pass / 0 fail,bun run formatclean. The live TUI was not driven by hand this session — the rendered-frame tests are the evidence.
-
The Content tab's body answers the keyboard (2026-08-20, user report). "The content body doesn't have keyboard support. Tab doesn't work." The nested bar was the tab's only ring stop, so Tab reached nothing else on the screen.
src/app/Server/tabs/Content.tsx— three exported ring ids (CONTENT_TABS_ID,CONTENT_MARKET_ID,CONTENT_LIST_ID) andserverContentRingIds(state), on the Settings tab's precedent: the ids are always present and merelydisabledwhere the control is not on screen, so Tab does not renumber as the user moves between sections. The tab reports{market, rows}up through the newServerTabProps.onContentState.- The list gained a caret: a 2-cell column reserved on every row (an inserted caret would shift the row sideways as the selection moved), seeded and clamped on the item keys exactly as the Players grid does. ↑/↓ (j/k) move it, Space and Enter toggle the item, a click both selects and toggles. The caret is drawn muted when the list does not hold the ring — where the keyboard would land stays useful while the focus is on the bar.
- Context hints follow the ring: "switch section" on the bar, "select item / enable / disable / leave list" on the list.
- Tests (643 total, +2): the keyboard case drives ↓ then Space through a stand-in
FocusRingand asserts only the caret's jar was parked; a second covers the caret being drawn unfocused. The rule assertion in "rows are in name order" was updated for the panel-less list chrome. - Verified:
bunx tsc --noEmitclean,bun test643 pass / 0 fail,bun run formatclean.
-
The Content tab is a nested tab bar (2026-08-20, user request). "Currently mods, datapacks, etc is showing on single place. This will be difficult if we have a lot of mods." Mods, Plugins, Datapacks, Resource pack and On disk are now five screens behind a second
Tabsinside the tab, instead of one stacked column.src/app/Server/tabs/Content.tsx— sub-tab id typeContentSubTabId, bar entries derived from the listing each render (a section with no panel gets no entry; the label carries the item count for a present directory), and the active id falls back to the first entry rather than being corrected in an effect — the sections land a round after mount, so a remembered id names nothing on the first frames. The bar is pinned and the body scrolls under it; theColumnslayout that paired Resource pack with On disk is gone now that each is its own screen, and the rename/.jar.disabledfootnote is shown only on a present, toggleable section.src/app/Server/index.tsx—"content"joinedTAB_OWNS_SCROLL(an inner scrollbox needs a definite height), andCONTENT_IDjoined the focus ring while the tab is active so ←/→ switch sections. The tab previously claimed no ring stop at all.- Tests (641 total, +1): the marketplace-placeholder case walks the four screens instead of counting four buttons in one frame, the datapack case opens its section first, and a new case covers the split itself (counts on the bar, Sodium not visible from Datapacks and vice versa, bar still pinned).
- Verified:
bunx tsc --noEmitclean,bun test641 pass / 0 fail,bun run formatclean.
-
Datapacks that are mod jars, or zipped with a folder around them, are read properly (2026-08-20, user report). "The datapack have some variants, that is not well retrieved. Similar to what happened to the mods." On the user's own
create-server,world/datapacks/holds three mod jars — the way Towns and Towers, Cristel Lib and Cloth Config are actually installed — and the listing showedt and t-fabric-neoforge-1.13.11with no version at all,cristellib-…with nothing, andformat 4for the one that had apack.mcmeta.src/core/server/content-meta.ts— new exportedpickPackManifest(names): the rootpack.mcmeta, else the one exactly one directory down (the zip-of-the-pack's-folder shape), alphabetical on a tie. Deeper hits are deliberately ignored — T&T bundlesresources/<patch>/pack.mcmetafor its compatibility packs.src/core/server/content.ts—readPackMetabecamereadDatapackMeta: for an archive it reads the six jar manifests and a rootpack.mcmetain one pass, prefers the mod manifest (parseJarMeta), and only falls back to a second pass for a nested one. NewunpackedPackRoot(path)resolves the wrapper folder for an unpacked pack, and thepack.pngicon is now looked for there rather than only at the entry's root.- Tests (640 total, +7): four over
pickPackManifest, and three incontent.test.ts(a mod jar indatapacks/, a jar whosepack.mcmetamust not outrank its mod manifest, and the wrapper-folder shape zipped and unpacked including its icon). - Verified:
bunx tsc --noEmitclean,bun test640 pass / 0 fail,bun run formatclean.mctl content create-servernow lists Cloth Config v15 API 15.0.140, Cristel Lib 3.1.7 and Towns and Towers 1.13.11 under DATAPACKS, and the same three (with icons and descriptions) render in the TUI's Content tab at 140×44.
-
The reported tunnel/DNS failures, fixed at the cause (2026-08-17, user report). "The cloudflared DNS doesn't seem to work, neither the cloudflare tunnels." Diagnosed from the user's real
~/.local/state/mctl:secrets.jsonwas empty (so DNS could never publish, silently) and the tunnel agent's log saidtunnel credentials file not foundwhile the user was shown only a 30s timeout.src/core/config/secrets.ts(new) —listSecrets/setSecret/unsetSecret/secretKeyIssue/KNOWN_SECRETS. No value is ever returned, logged or printed; the file stays0600and anMCTL_*environment override is never persisted into it.src/cli/commands/secret.ts(new) + router —mctl secret list|set|unset, reading the value from stdin by default.src/hooks/use-secrets.ts(new) + a Secrets group in Settings: pick a key, paste a value, Store/Remove, and a list of what is set (key, length, source, consumer). Stored immediately, never part of the settings draft, field cleared on success.src/types/network.ts—TunnelStartErrormoved here fromproviders/network/agent.ts(core must not import a provider) and gainedNetStatus.dns+DnsStatus.src/core/network/index.ts—errorTextnow appends the agent's own last line todegradedReason;#syncDnsskips a self-referential hostname (a pre-defined tunnel already serves it) and reportsdnsSkipped;status()reports the standing DNS state, including the missing token, with the command that fixes it.src/core/runtime/index.ts— a DNS failure at start is no longer dropped; it is logged.src/app/Server/tabs/Network.tsx— a DNS panel (hostname, state, reason) and a Re-apply button, the TUI peer ofmctl network up, joined to the container's ring and disabled unless the server runs.useNetworkStatusgainedrefresh().- Tests (633 total, +15):
core/config/secrets.test.ts(10, against a real 0600 file) and five incore/network/index.test.ts(the self-referential skip, the no-token error, the two status states, and the agent-output enrichment). - Verified:
bunx tsc --noEmitclean,bun test633 pass / 0 fail,bun run formatclean, biome clean bar the pre-existingTable.tsxwarning. CLI: set/list/unset round-tripped with mode 600 and a rejected lower-case key. In tmux at 140×44: the Secrets group stored a token (field cleared, list showing24 chars · secrets.json · used by cloudflare, no value on screen), and the Server page's Network tab showed DNSpublished on startwith a token andnot published — no CLOUDFLARE_TOKEN …without one. - Not verified against a real account: an actual record write. The user's own next step is
mctl secret set CLOUDFLARE_TOKEN(for DNS) andCLOUDFLARED_TOKEN(for their dashboard tunnel).
-
Provider options are fields, not a
key=valuebox (2026-08-17, user report). "All those specific options are in the options field, for all the provider. It's not a good user experience."src/types/network.ts(newNetworkOption,NetworkOptionKind,NetworkOptionChoice) +src/types/provider.ts—NetworkProvider.options, required. All five providers declare theirs: direct (host, publicAddress), cloudflared (mode + the three tunnel fields behindshowWhen, timeout), playit (address, timeout), ngrok (region as a choice, remoteAddr, timeout), tailscale (preferIp).src/core/network/profiles.ts—visibleOptions(drops a field whose condition is unmet),optionValue(unset ⇒ the provider's fallback),withOption(a value equal to the fallback, or empty, is stored as nothing) anddescribeOptions(the same declaration as help text).src/app/Settings/index.tsx— the single Options input is gone;OptionFieldrenders each declared option as anInput,CheckboxorSelect, values stored typed. A fallback shows as the placeholder, a non-numeric number is flagged and holds Save (checked across every profile), and the ring splices in one id per visible option.PROVIDER_OPTION_HINTSdeleted.src/app/Settings/use-settings.ts—ProfileDraft.optionsis aRecord<string, unknown>instead ofkey=valuetext; nothing is parsed on the way to disk, and undeclared keys are carried through untouched.src/cli/commands/network.ts— the hand-written provider-options section of--helpis now generated from the registry.src/components/Form.tsx—Selectgainedinvalid, whichInputalready had.- Tests (618 total, +12): the three core helpers and the help formatting (6), and an invariant
over what the shipped providers declare — unique keys, every
showWhenresolving to a choice/boolean, every choice having options (6). - Verified:
bunx tsc --noEmitclean,bun test618 pass / 0 fail,bun run formatclean, biome clean bar the pre-existingTable.tsxwarning. In tmux at 140×44: switching Provider swapped direct's two fields for cloudflared's; choosing pre-defined revealed tunnel id / hostname / name; a save wrote only{"mode":"named"}(the untouched timeout stayed absent) andmctl network profile showread it back; a typed45stored as the number 45; and45xflagged the field, marked the tabNetwork !, printed the reason and made Ctrl+S a no-op.
-
cloudflared profiles pick a mode, and can run a pre-defined tunnel by id (2026-08-17, user request). "Add option for trycloudflare domain, and also using pre-defined tunnels using tunnel id."
src/providers/network/cloudflared.ts— new pure, exportedplanCloudflared(options, port, hasToken)turning a profile into argv. Options gainedmode(quick|named, inferred when absent so existing profiles are unchanged) andtunnelId(validated as a UUID;tunnelstays as the name alias, and the id wins when both are set). A dashboard-managed tunnel runs onCLOUDFLARED_TOKENfromsecrets.json, passed asTUNNEL_TOKENin the child's environment — with a token,runtakes no argument at all.preflightnow reports a token the way ngrok's does.src/app/Settings/index.tsx+src/cli/commands/network.ts— the Options field's hint names the new keys, andmctl network profile --helpgained a provider-options section.- Tests (606 total, +9):
providers/network/matchers.test.tscovers both modes, the id/name precedence, the token path, and all four refusals. - Verified:
bunx tsc --noEmitclean,bun test606 pass / 0 fail,bun run formatclean, biome clean bar the pre-existingTable.tsxwarning. Against real cloudflared:mode=quickbrought up a live tunnel (chancellor-requires-automobiles-distribute.trycloudflare.com), reportedup, and tore down cleanly; all four bad profiles were refused before anything was spawned; and a well-formed but unknown tunnel id failed inside its timeout leaving no descriptor, withtunnel credentials file not foundinnetwork/<id>.log. - Not verified against a real account: a named tunnel actually carrying traffic, and the token
path end to end — both need a Cloudflare account this machine does not have. The argv and the
environment are unit-tested; the
Registered tunnel connectionmatcher is unchanged from the last session.
-
Network profiles are manageable, and the Server Settings tab is a form (2026-08-17, user request). "The settings section in the Server is not done yet. There's no way of managing network settings." Both landed, with CLI parity; scope agreed with the user up front.
src/core/network/profiles.ts(new) — the write side ofconfig.network.profiles, whose read side was alreadyNetworkManager.profiles(). PureConfig → Configtransforms (withProfile/withoutProfile/withDefaultProfile) plus one-linewriteConfigwrappers, and the sharedparseOptions/formatOptionskey=valueformat both front-ends use.directand the configured default may not be deleted.src/cli/commands/network.ts—mctl network profile [list] | show | set | rm | default, with--provider,--options, the five--dns-*flags and--no-dns. A partialsetmerges over the profile it edits; an unknown provider id is refused with the list this build has;rmnames the servers it just stranded.src/app/Settings/— the Network group is now an editor: a profile picker and the three list actions (New profile / Make default / Delete ) above, then that profile's Name / Provider / Options / Cloudflare DNS (zone, hostname, TTL, SRV, proxied). The list actions were moved up out of the field stack after the user reported "Add profile" reading as part of the profile being edited; a new row starts unnamed with the cursor in its Name field; and the separate "Default profile" radio group was dropped for a· defaultmarker on the profile itself.Selectgained theinvalidpropInputalready had.SettingsDraftgainedprofiles: ProfileDraft[](an array, not a record — a record cannot express a rename),profileIssuesfor per-field marks, andvalidateDraftrolls them up so the Network tab is flagged from any group. A save that strands a server raises a warning toast.src/app/Network/index.tsx— a Manage profiles button andp, both navigating tosettingswith the newRouteParams.group. The page stays read-only; the editor has one home.src/app/Server/tabs/Settings.tsx— rewritten from a read-only panel to a form overServerManager.editServer: Name, Memory, Runtime, Network profile and the Java pin, with Revert/Save and Ctrl+S. Identity, kind, version and path stay read-only — changing kind or version is an update, which core does not have.src/hooks/use-server-settings.ts(new) is its bridge, buffering likeuse-settings.ts(a dirty buffer is never clobbered by a poll).src/app/Server/panels.tsx+index.tsx—ServerTabPropsgainedfocus,onFormStateandonRefresh; the container splicesserverSettingsRingIds(state)into its own ring while the tab is active, because only one ring may listen at a time.- Tests (594 total, +43):
core/network/profiles.test.ts(19), the profile half ofapp/Settings/use-settings.test.ts(+11),hooks/use-server-settings.test.ts(8) andapp/Server/tabs/Settings.test.tsx(5, real frames). - Verified:
bunx tsc --noEmitclean,bun test594 pass / 0 fail,bun run formatclean, biome clean bar the pre-existingTable.tsxwarning. Driven for real in a sandbox$HOME: the CLI created/edited/showed/removed profiles, refused an unknown provider (exit 2), a bad name,rm directandrmof the default, and reported the strandedsurvival. In tmux at 140×44:pon the Network page landed on the Settings → Network group; switching profiles loaded each one's fields; Add wroteprofile-4toconfig.jsonand Delete removedcf-tunnelfrom disk with the orphan warning toast; and on the Server page the form wrotememory 2G → 6G, a network profile change and a{"pinned": 21}Java pin tomctl.json, then cleared the pin on untick. - One real defect found in the pty, recorded in
memory.md: switching the profile picker renamed the profile, because OpenTUI emitsonInputwhen an<input>'s value prop is assigned and the same renderable was reused across rows. Fixed by keying the field grid. - Not done, deliberately: changing a server's kind or Minecraft version (an update operation
core does not have), and per-provider typed option fields — the Options field is one
key=valueline with the provider's own keys named in its hint.
-
Plugins and zipped packs draw their icons too (2026-08-14, user request). "The plugins / Datapack / Resource packs have icons too. Try to render them." Nothing in the UI or the extraction path needed changing — the one rule that decides which entry is the icon was root-only, and no plugin keeps its icon at the root.
src/core/server/content-meta.ts—pickIconEntrynow also accepts a nested PNG whose basename is exactlyicon.png/logo.png/pack.png, excluding anything under atextures/segment, shallowest first then alphabetically. Root entries still win, and the looseicon|logoname match stays root-only (nested, it would pick an item sprite). NewICON_BASENAMES.- This covers all three the user named: a Bukkit/Paper plugin (
plugin.ymlhas no icon field at all, soassets/<plugin>/icon.pngis the only way one is ever found), and a datapack or resource pack zipped by compressing its own folder (<name>/pack.png). Both go through the existing per-archive cache and the existing<image>column, which were already section-agnostic. - Tests (550 total, +8): five over
pickIconEntry(the plugin path, the wrapped pack, exact basename vsicons.png, shallowest-then-alphabetical, root still winning) and three end-to-end over real archives in a real server directory (a plugin jar, a wrapped datapack zip, and a jar holding only sprites getting no icon). - Verified:
bunx tsc --noEmitclean,bun test550 pass / 0 fail,bun run formatclean, biome clean bar the pre-existingTable.tsxwarning. Against the user's realfirst-paper-server,mctl content --jsonextracted Geyser'sassets/geyser/icon.png(a real 512×512 PNG) where it previously had none, and in tmux at 120×45 the Plugins panel draws it beside the row. - Not done, and it needs a decision: the Resource pack panel has no icon, because it is not a
file —
server.propertiesholds a URL, and drawing itspack.pngwould mean downloading a user-configured archive (routinely tens of MB) on the render path. Ask before adding it.
-
An item with no icon draws a fallback (2026-08-14, user request). "If still no image, then show a fallback image." Plenty of jars ship no logo at all — every
plugin.ymldeclares none — so a blank box was the common case, not the exception.src/app/Server/tabs/content-placeholder.ts(new) —PLACEHOLDER_ICON, a cardboard-box PNG inlined as adata:URL. A constant string is the stable<image source>identity a 15 s poll needs (the same reasonContentItem.iconis a path), and nothing has to be resolved at runtime. The user replaced the agent's first grey-frame asset with this one mid-session.src/app/Server/tabs/Content.tsx— the row's<image>falls back to it, and the icon column is now reserved wheneverwidth >= ICON_ROW_WIDTHrather than when something in the section has a picture. The box gained an explicitbackgroundColor, without which transparent corners draw black (the block renderer blends alpha into an unpainted, i.e. black, buffer cell).fitiscover(the user's change), so a non-square logo fills the column instead of letterboxing.- Tests (551 total, +1 net): the "an icon indents the whole section" case was replaced — the column no longer depends on any jar having one — by two: names lining up whether or not a jar ships a logo, and a jar with none still drawing block glyphs in the icon cells. The parked-jar assertion stopped matching the whole line, which now leads with the picture.
- Verified:
bunx tsc --noEmitclean,bun test551 pass / 0 fail,bun run formatclean, biome clean bar the pre-existingTable.tsxwarning. In tmux at 120×45 against the realfirst-paper-server: Geyser draws its own icon, floodgate and thebukkitdatapack draw the box, and the escape sequences confirm the corners now take the theme background rather than#000000. - Known: two dead ends worth not repeating are recorded in
memory.md— a transparent-ground placeholder, and authoring one at 32×32 for a ~12×6 raster.
-
Content rows lead with the mod's own icon (2026-08-14, user request). "Most of the mods or plugins has an icon with it. Use opentui image element… Display the icon on the begining of the row." Followed by: "Make the row hight 3. Let the description span to two row. Make the icons 3x3 cell."
src/lib/zip.ts—readZipEntry(path, choose): the chooser is handed the archive's own entry names and returns the one to read, so an icon whose name is not known in advance costs one open and one inflated entry.src/core/server/content-meta.ts—ContentMeta.icon, filled from Forge/NeoForgelogoFile,mcmod.infologoFile, Fabriciconand Quiltquilt_loader.metadata.icon(a sized{"128": …}map yields its largest). New pure, exportedpickIconEntry(names)for the convention a jar that declares nothing still follows: a root-level PNG, neverassets/.src/lib/paths.ts—contentIconCacheDir()(~/.cache/mctl/content-icons/).src/core/server/content.ts—ContentItem.iconis an absolute path, not bytes (a poll rebuilds the listing, and fresh arrays would reload every image on screen). Extracted once, cached by jar path + size + mtime, capped at 4 MB, never throwing; an unpacked datapack uses its ownpack.pnguncopied.src/app/Server/tabs/Content.tsx— an<image source={item.icon} fit="fit" />at the head of each row in a 6×3 cell box (square: a cell is ~2:1, so a three-row picture needs six columns), the column reserved for the whole section when anything in it has one and dropped belowICON_ROW_WIDTH(64). Rows are a fixed three tall: name line plus a two-row description that wraps into the space instead of being truncated to one line.- Two sizing corrections after the first cut (
50fc1f6): the box was 3×3, which draws every logo at half width; and the size was only on the wrapping box, so the image itself laid outautoand drew at a fraction of the cells the row had reserved. An<image>needs its ownwidth/height. - Tests (542 total, +20):
pickIconEntryand the four manifest icon fields,readZipEntry(3), extraction into the cache including the declared-but-absent logo, the cache actually being reused, and a datapack's ownpack.png(6), plus rendered frames for the three-row height, the wrap onto the second row, the section-wide indent and the narrow-terminal drop (3). Both content test files now redirectXDG_CACHE_HOME—paths.tsresolves XDG at call time, so without it the suite wrote into the developer's real~/.cache/mctl. - Verified:
bunx tsc --noEmitclean,bun test542 pass / 0 fail,bun run formatclean, biome clean bar the pre-existingTable.tsxwarning. Against the user's realcreate-serverall six mods extracted a valid PNG (128², 256², 1080², and JEI's 32² found only by the root-PNG fallback), and in tmux at 120×45 every row draws its icon with three-row spacing and two-line descriptions; at 50 cells the column is dropped. - Known: under tmux
protocol="auto"always resolves to Unicode blocks, so the logos are drawn as half-block glyphs — legible and correctly proportioned, but coarse. A Kitty- or Sixel-capable terminal outside tmux renders them as real graphics. Only the tmux path has been seen; the user's own terminal is unconfirmed.
-
Mods whose
mods.tomlannotates every line are named again (2026-08-14, user-reported defect). "See thecreate-serverserver. Some mods are not properly displaying. like JEI and create aeronotics." Both showed their filename with no version, description or loader.src/core/server/content-meta.ts— new purestripComment(line), quote-aware, used by both the[[mods]]header test and the value path. NeoForge's generated template writes[[mods]] #mandatory, so the reader never entered the block at all; and the old value stripper's!raw.startsWith('"')guard mademodId="jei" #mandatoryunstrippable and then unquotable. The'''…'''branch is untouched — it already ends at its closing fence.- Tests (522 total, +2): the fully-annotated NeoForge template parsed end to end, and a
#inside a quoted value kept as content. - Verified:
bunx tsc --noEmitclean,bun test522 pass / 0 fail,bun run formatclean, biome clean bar the pre-existingTable.tsxwarning. Against the user's realcreate-server:mctl content create-servernames all six mods (Cloth Config v15 API, Create, Create Aeronautics, Ferrite Core, Jade, Just Enough Items) with versions and descriptions, noderivedNameleft; confirmed in the Content tab in tmux at 120×45. - Noticed, not changed: every row's checkbox glyph renders blank in the user's working tree —
boxedis commented out on theCheckboxinapp/Server/tabs/Content.tsx(an uncommitted local edit), and without it the unticked/ticked state has no visible mark. Seememory.md§ The Content list lost its caret for whyboxedwas added.
-
A kind declares what content it loads (2026-08-14, user request). "Every server type doesn't support mods or plugins. Add a field in the server registry for mods/plugins support and render them accordingly."
src/types/content.ts(new) —ContentSectionId(moved here from the content service, which re-exports it) andContentSupport, a completeRecordso a new section id is a compile error in every provider.src/types/provider.ts—ServerProvider.content: ContentSupport, required. All eight providers carry one (FillProviderdeclares it abstract; Paper and Velocity differ), plus the two test stubs. Velocity is plugins-only (a proxy has no world, so no datapacks); Vanilla is datapacks-only; the four loaders are mods + datapacks.src/core/server/content.ts—ContentSection.supported, andreadServerContenttakes an optionalProviderRegistry. Unsupported directories are still read; the exportedcontentSupport(kind, providers?)never throws and reports an unknown kind as supporting everything.src/hooks/use-server-content.ts— resolves the registry fromuseMctl()and re-runs the poll once the context lands.src/cli/commands/content.ts— buildscreateProviderRegistry()and prints one line for an unsupported empty section, a warning header for one with files in it.src/app/Server/tabs/Content.tsx— an unsupported empty section draws no panel at all; one with files draws a warning line and no marketplace button.- Tests (520 total, +3): the section-vs-directory distinction, files in an unsupported directory still being listed, and the unknown-kind/no-registry fallback.
- Verified:
bunx tsc --noEmitclean,bun test520 pass / 0 fail,bun run formatclean, biome clean bar the pre-existingTable.tsxwarning. Driven for real in a sandbox$HOMEholding four fabricated servers (paper/fabric/vanilla/velocity) with real fixture jars:mctl contentprinted the right line for each, and in tmux at 120×40 the Paper server showed the Mods panel with its warning and no marketplace button, Vanilla showed only Datapacks, and Velocity only Plugins. - Noticed, not changed: the Resource pack panel is still drawn for Velocity, which has no
server.propertiesat all. Same class of problem, different field — worth a look next.
-
The Content list is checkboxes in name order (2026-08-14, user request). "Always order based on names, not by enabled/disabled. Remove the selection logic, render with a border between. Add checkbox component for enable/disable."
src/core/server/content.ts— a section's items sort by display name alone; the enabled-first grouping is gone (a toggled row used to jump out from under the pointer).src/components/Form.tsx—CheckboxgainednoBorder(drop the field frame for inline use),boxed([x]/[ ], needed because theasciiset's unchecked glyph is the empty string) andcaptionColor.src/app/Server/tabs/Content.tsx—ContentRowis aCheckboxcarrying the item's name, the facts right-aligned, and the description/filename lines indented under the name; rows are separated by a bottom-border rule, with none under the last. The caret, the selection state and effect, the keyboard handler and the context hints are all gone.src/app/Server/index.tsx—CONTENT_IDremoved from the page's focus ring; the tab takes no keys, so a stop there would be a Tab that lands on nothing.- Tests (517 total, 53 files, +2): the tab's name-ordering + one-rule-between-rows frame, and a
real mouse click on a row's checkbox renaming
sodium.jarto.disabledon disk. The core ordering test now asserts name order. - Verified:
bunx tsc --noEmitclean,bun test517 pass / 0 fail,bun run formatclean,bunx biome check srcclean bar the one pre-existingTable.tsxwarning. Frames read at 100 cells over four real jars (one parked, one corrupt). - Known trade-off: enabling/disabling is now mouse-only in the TUI —
mctl content enable|disableis the keyboard path. Restoring keys means a focus ring over the checkboxes, which is the selection logic this removed.
Every entry is dated. Dates are the date of the commit that landed the work, not the order of this
list — the first six entries are the most recent, the rest run oldest-first below them. "user request"
/ "user report" marks work the user asked for mid-session; entries with neither were driven by the
roadmap in plan.md.
-
The Content tab lists what is installed, and can park it (2026-08-14, user request). "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."
src/lib/zip.ts(new) — a read-only ZIP reader (readZipEntries/readZipText), stored and deflated members, ZIP64 and encrypted entries throwZipError. It seeks: EOCD from the tail, then the central directory, then only the wanted entries' local headers — a mod jar is tens of MB and its manifest is a few hundred bytes.src/lib/zip.fixture.ts(new, test-only) builds real archives (real CRC-32s, real deflate) so both test files can make fixtures without importing each other.src/core/server/content-meta.ts(new) — pure manifest readers:fabric.mod.json,quilt.mod.json,META-INF/neoforge.mods.toml,META-INF/mods.toml,mcmod.info,plugin.yml/paper-plugin.yml,pack.mcmeta, plusparseJarMeta(the precedence) andmanifestVersion(Forge's${file.jarVersion}resolved fromMETA-INF/MANIFEST.MF). The TOML and YAML readers are deliberately narrow — MCTL still writes JSON only.src/core/server/content.ts(new) —readServerContent(server, levelName)(the expensive twin ofinspect.ts's counts) andsetContentEnabled(server, item, enabled), which renames to and from*.jar.disabled. Refuses an existing target, a path outside the server directory, and datapacks (a world records which are on, so a rename would corruptlevel.dat's view).src/hooks/use-server-content.ts(new) — 15 s self-chaining poll, immediate refresh on a toggle,togglereturningnull | messagelikeusePlayers.act.src/app/Server/tabs/Content.tsx— rewritten: a section per list with its counts, its directory and a Browse marketplace placeholder (Phase 5), one row per item (checkbox, name, description, and version/loader/size above 72 cells), then the unchanged Resource pack and On disk panels. Joins the container's ring asCONTENT_ID: ↑/↓ select, Space toggles,mopens the placeholder.src/cli/commands/content.ts(new) + router —mctl content <id> [--json]andmctl content enable|disable <id> <file>, the tab's CLI peer over the same core functions.- Tests (515 total, 53 files, +53):
lib/zip.test.ts(9),core/server/content-meta.test.ts(23),core/server/content.test.ts(15, real jars in a real server directory — including the overwrite, outside-the-directory and datapack refusals),app/Server/tabs/Content.test.tsx(6, real frames over real jars). - Verified:
bunx tsc --noEmitclean,bun test515 pass / 0 fail,bun run formatandbunx biome check srcclean bar one pre-existing warning (components/Table.tsxunused import). Driven in tmux at 120×42 and 70×30 against a sandbox$HOMEholding real fixture jars: a Fabric mod, a Forge mod whose${file.jarVersion}resolved to19.21.0.247, a Bukkit plugin, a zipped datapack, a parked jar and a deliberately corrupt one. Space renamedlithium-0.14.jarto.disabledon disk, the row moved to the disabled group and the caret stayed on it;mand a datapack toggle both raised their toasts; at 70 cells the version column drops with no wrap. CLI exercised for list,--json, enable by display name, disable by filename, an unknown name (exit 2), a datapack (exit 1) and the overwrite guard (exit 1). - Not done, deliberately: installing anything. The marketplace buttons report that they are Phase-5 placeholders rather than doing nothing silently.
-
Forms became a responsive grid; the version field spins (2026-08-14, user request).
src/components/FormGrid.tsx(new) —FormGridmeasures its own width and lays children out in as many columns as fit (two by default, from 94 cells), plusFormGridItem span="full"and the pure, exportedcolumnsFor/packRows. Packing is row-major and order-preserving, so both pages' focus rings are unchanged.src/components/Spinner.tsx(new) — the shared work-in-flight glyph over the active icon set's frames; self-ticking at 10 fps unless the caller suppliesframe.src/components/Form.tsx—Selectgainedprefix/suffix, forwarded toFormField; the tabs-vs-dropdown width test subtracts the cells an affix occupies.src/app/VersionField.tsx— aSpinnersits in the field while the list is being fetched; the hint no longer spends its line on "loading versions…" and only names the wait when it has nothing else to say.src/app/ServerCreate/index.tsx— the six fields moved into aFormGrid, paired by height after the user reported the first cut as badly organised: Name|Memory, Kind|Version, Runtime|EULA, with the ring order following. Kind dropped its per-option descriptions (halving a dropdown row's height; the line under the field already describes the highlighted kind) and both list fields cap at six rows. The version hint and EULA caption were shortened — an over-longbottomTitleis dropped by OpenTUI, not truncated.src/app/Settings/index.tsx— Locations (the two overrides), Defaults (Kind/Version, Memory/Runtime, full-width EULA), Backups (full-width switch, then Provider/Compression) and Appearance (Theme/Icons) all grid; the hardcodedwidth="50%"fields are gone.- Tests (462 total, 49 files, +12):
components/FormGrid.test.tsx(8 — the packing rules plus real frames proving the reflow at 90 and 40 cells),components/Spinner.test.tsx(3, including that it animates with no caller-supplied frame), and one more inapp/VersionField.test.tsx(the field spins while loading and does not while idle; its mount now pins theasciiicon set). - Verified:
bunx tsc --noEmitclean,bun test462 pass / 0 fail,bun run formatandbunx biome check srcclean. Driven in tmux against a sandbox$HOME: the create form at 140×40 (two columns, whole form on screen) and 70×30 (one column); all five Settings groups at 140×40, including Backups with the switch on; and, with~/.cache/mctl/apicleared, the version field's spinner captured mid-animation on four consecutive frames.
-
The version field became a picker; kinds describe themselves (2026-08-14, user request).
src/types/install.ts—VersionInfo.typegainedbetaandalpha;providers/server/mojang-meta.tsmapsold_beta/old_alphaonto them instead ofother.src/types/provider.ts—ServerProvider.description, required. All eight providers (plus the abstractFillProviderand the manager test's stub) carry one.src/core/server/versions.ts(new) —listMinecraftVersions(providers, kind)plus the pureavailableChannels/filterVersions,VERSION_CHANNELS,CHANNEL_LABELS,DEFAULT_CHANNELS(releases only). Nothing cached;lib/http.tsalready ETag-caches the manifests.src/hooks/use-server-versions.ts(new) — fetches for the selected kind, holds the shown channels, never blocks a render. Per-kind memo in a ref so flipping the Kind select back does not blank the picker for a round trip.src/app/VersionField.tsx(new) — the picker + the "also show" channel row, taking the fetched list as a prop (hence fully testable offline).versionFieldIds(state)is exported so each page splices the variable-length ring into its own.src/app/ServerCreate/index.tsx— version input →VersionField; the Kind select's options carry the provider description and the selected kind's is drawn under the field.src/app/Settings/index.tsx— same field in the Defaults group, which now leads with Kind (the version list belongs to it);ringIdstakes the channel ids.src/cli/commands/versions.ts(new) + router —mctl versions [kind] [--channel …] [--all] [--json], the picker's CLI peer over the same core functions.- Tests (450 total, 47 files, +18):
core/server/versions.test.ts(10, the channel rules + the registry delegation) andapp/VersionField.test.tsx(8, real frames: which channels get a toggle, the hint's three states, a snapshot naming its channel, and a value that outlived its list staying selected). - Verified:
bunx tsc --noEmitclean,bun test450 pass / 0 fail,bun run formatandbunx biome check srcclean. Driven in tmux at 120×40 against a sandbox$HOME: the create form's picker filled from the live APIs, Paper showed one toggle and Vanilla three, Space on Snapshots refilled the list (102 → 845 of 906), the kind description tracked the Kind tabs, and a version picked in Settings + Ctrl+S wrote"minecraftVersion": "26.1.2". CLI checked against live upstream for vanilla/fabric/velocity,--channel alpha, an unknown channel (usage error) and an unknown kind. - Not done, deliberately: the setup wizard's Defaults step is still a text input — it runs
before there is a config (so no
MctlContext) and is the screen most likely to be met offline. A full TUI create with a picked version was not run (it is a real download); the value reachescreateServeras the same string the input produced.
-
Selectanswers the mouse; the field label lost its caret (2026-08-14, user request).src/components/support.ts—tabSelectHit+TabSelectHit/TabSelectGeometry(new, pure and exported): a pointer offset inside a<tab-select>resolved to the tab or end arrow drawn there, reconstructing the scroll maths the renderable keeps private.src/components/Form.tsx—Selectgained the whole pointer vocabulary: wheel over the dropdown walks the selection (clamped, and consumed so the page does not scroll too), a click picks the tab under the pointer, and resting on an end arrow walks toward the hidden options at one per 180 ms. An effect pushes the controlled index into the<tab-select>renderable (it has noselectedIndexprop, so the strip could highlight a tab the value had moved off), andpicknow ignores a pick that lands on the current value — otherwise that sync echoes back as anonChange.src/components/Form.tsx—FormFieldno longer prefixes its label with▸while focused.- Tests (432 total, 45 files, +11):
components/support.test.ts(7, the tab geometry: scroll offset, arrows winning over the tab beneath them, empty slots, a strip narrower than one tab) andcomponents/Form.mouse.test.tsx(4, the real control driven withharness.mockMouse). - Verified:
bunx tsc --noEmitclean,bun test432 pass / 0 fail,bun run formatandbunx biome check srcclean. Driven in tmux with real mouse escape sequences against a sandbox$HOME: at 120 cols a click on Fabric selected it; at 60 cols (dropdown layout) three wheel-downs walked Paper→Forge and scrolled the list, two wheel-ups walked back, and the page behind it never scrolled; at 90 cols (strip overflowing) hovering›walked the strip to the end and stopped,‹walked it back, and moving off the cell froze it.
-
The standing gaps, closed (2026-08-13, user request: "implement all the gaps"). Everything in Known gaps that did not need credentials, RCON, or a whole phase behind it.
core/icons/detect.test.ts— the single-cell assertion exempts the four nerd meter glyphs (deliberately two cells, see the entry above) and a second test pins the pad, so a stray space still fails. The suite has no failing test for the first time since 2026-08-12.- Biome is clean. Unused imports in
setup/Welcome.tsxandcomponents/Form.tsx, an unused variable inlib/png.test.ts, and a documented suppression for the toast test's raise-once effect. src/lib/colors.test.ts(new, 25 tests) — parse/format across all four hex lengths, the HSL round trip's one-channel tolerance, the transforms' clamping,mix's weight direction (reading it as "how much ofto" inverts every blended border and still looks plausible), and the WCAG reference ratios.Tablerow geometry is derived, not tuned —ROW_BORDER + ROW_PADDING_Xdrives both the outer-width subtraction and the header's padding. The old- 3was one cell short: with a filled flexible column the gap before it collapsed and the row wrapped inside its own border.components/Table.render.test.tsx(new, 6 tests) drives real frames and fails 3-of-6 against the old constant.core/server/sweep.ts(new) —sweepDownloads(paths, {maxAgeMs, partialMaxAgeMs, now})removes abandoned staging trees (6 h) and stale partial downloads (14 d). Called fromcreateContext, deliberately not awaited. Age is the discriminator because another instance's in-flight create looks identical to a dead one, and the age is taken from the newest file inside the tree — a long download leaves every ancestor's mtime alone.core/server/sweep.test.ts(10 tests) includes the case that proves it never reaches into a siblingservers/directory.- Theme files apply live.
startWatcherswatches~/.config/mctl/themes/and emitsThemesChanged;ThemeRegistry.reload()re-reads from scratch so a deleted file stops resolving (load()only ever merges);ThemeProvidergained asubscribeCataloguebridge, mirroringsubscribeThemeId, wired inApp.tsxbycatalogueSubscriber.core/theme/registry.test.ts(new, 6 tests) + one watcher test. - One real defect found on the way:
readJsonIfExiststhrows on a syntax error (it tolerates only an absent file), so a half-writtenthemes/*.jsontook the whole catalogue down with it — harmless while the catalogue was read once at startup, a live crash the moment the directory is watched.ThemeRegistry.loadnow skips an unparsable file, as its doc comment always claimed. - Verified:
bunx tsc --noEmitclean,bun test421 pass / 0 fail (43 files, +49),bun run formatclean,bunx biome check srcclean. Not driven under a pty this session — the theme-reload path is covered by unit tests over the real watcher and the real registry, but saving a theme file and watching the app repaint is unconfirmed on screen.
-
Hand-made UI passes by the user (2026-08-03 → 2026-08-13). A dozen commits the artifacts never recorded, because they were the user's own work between agent sessions. They are decisions, not drift — do not revert them while "restoring" something an older entry below describes.
components/Table.tsx— a row is now a bordered card, not a line (f442c93, on top of9a40104): every row is arounded-bordered box whose border turnsprimarywhen selected, the header keeps its bottom border only when the table is empty, header ink moved fromsecondarytoprimary, cells gainedpaddingX, and the outer width lost a hand-tuned3to pay for the row borders. (That subtraction was one cell short and is now derived — see the gap-closing entry below.)components/Form.tsx—FormFieldPropsbecameBoxProps & {…}with...restpassthrough, plusprefix/suffix/noBorder(d9cae5e), which is what lets a control be embedded chromeless (the console command line).Select's tab layout now sizestabWidthfrom the longest option label (ea5e24b).- Colours (
a71d9c7): the terminal theme'sborderis derived atalpha(…, 0.6)and the NavRail / Tabs rules moved0.6 → 0.8; Nord'smutedwas lightened#4c566a → #708abdbecause nord3 was unreadable as body text.src/lib/colors.tsis the helper behind all of it. - Dashboard (
c233a97, superseding the "uncommitted tweak" this file used to note): the expanded row dropped its left border andmarginLeftforpaddingLeft={3}over the tinted background. - Server page: identity row and action bar share one
space-betweenrow with a bottom border so the lifecycle buttons stay on one line (29aa06b); horizontal padding moved off the tab container intoColumns(paddingXprop) so the console and table tabs own their own edges (b9333c5); the tab description line under the tab bar is gone andplayersjoinedTAB_OWNS_SCROLL(ce8c680). - Players:
CARD_MIN_WIDTH_WITH_HEAD36 → 52 (4c0e56a) — the head plus the six-row wireframe never fit 36 in practice, so the entry below describing 36 is history. The action menu is a wrapping row ofmedium/outlinebuttons instead of a left-aligned column of small ghosts (de6fb7a). - The nerd meter glyphs are deliberately two cells (
4c0e56a):heartFull/heartEmptycarry a trailing space andfoodFull/foodEmptymoved to\u{f141f}/\u{f1420}. This settles the open question below —core/icons/detect.test.ts's single-cell assertion is what is now wrong, and the fix is to add these four to the test'sexemptset, not to narrow the glyphs. HintgainedflexShrink={0}(5252344); pages gainedpaddingX={1}(2dc1c76);RouterProvideraccepts initialparams(315089e) — which is what made the temporary "boot straight into a server" shortcut possible, since reverted (ea5e24b).
-
Phase 4a — networking (2026-08-13). Roadmap bullets 1 and 2 of Phase 4 are done; bullet 3 (backups, supervision) is not started — see Next up.
- Types.
types/network.ts(new):RequiredBinary,Readiness(a tagged union, because "missing binary" wants an install command and "logged out" wants a login command),Endpoint+TunnelSession(Zod — the descriptor is on-disk data),NetState,NetStatus,ExposeRequest.types/provider.tsgainedNetworkProvider.types/config.ts:NetworkProvider→NetworkProviderId(picker only),NetworkProfile.providerandNetworkConfig.defaultProfilerelaxed to strings, newCloudflareDnsConfigon a profile.types/events.ts:TunnelUp/TunnelDown/DnsChanged. core/network/index.ts—NetworkManager:profiles(),readiness(),expose(),teardown(),status(), plus the exported purescopedSecrets. Degrades todirectfor five distinct reasons rather than ever failing a start.core/network/cloudflare-dns.ts: A/CNAME +_minecraft._tcpSRV, zone-name or zone-id, idempotent, and deletion restricted to records taggedmctl:<server id>.- Five providers under
providers/network/:direct,cloudflared,playit,ngrok,tailscale, over the sharedagent.ts(detached spawn → scrape the announced address → write / read / reapnetwork/<id>.json).ProviderRegistrygainedregisterNetwork/network/networks/networkIds; all five are wired inproviders/index.ts. lib/shell.spawnDetached(own process group,unref, output on an fd not a pipe),lib/net.publicAddress(two echo services, validated, 10-minute cache, never throws),lib/pathsnetworkDir/networkFile/networkLogFile.- Wiring:
RuntimeManagerholds theNetworkManagerand exposes after a successful start / tears down after a stop, swallowing failures both ways;core/context.tsbuilds it. Delete (CLI and TUI) tears networking down first. - CLI:
mctl network(provider readiness + profiles),network status [<id>],network up <id>,network down <id>, all with--json.cli/format.renderTableexported for it. - TUI:
hooks/use-network.ts(useNetworkOverview30 s,useNetworkStatus5 s, both self-chaining);app/Network/is a real page (providers with install hints, profiles, per-server endpoints); the Server page's Network tab now shows the live profile/provider/state/endpoint plus provider readiness beside the unchanged direct picture. - Tests (372 total, 39 files, +47):
providers/network/agent.test.ts(11, driving real detached shell-script agents: scraping, survival past the parent, a failing agent leaving no descriptor, a silent agent being reaped — proved via a pid the script writes — thefallbackpath, log truncation, descriptor reaping, pid-less descriptors surviving,stopAgent),core/network/cloudflare-dns.test.ts(13, against a real local stand-in API, including the two decoy records that prove tag-scoped deletion),core/network/index.test.ts(17, every degradation path +scopedSecrets),providers/network/matchers.test.ts(6, the real log lines). - Verified for real, not just typed:
bunx tsc --noEmitclean;bun test371 pass / 1 pre-existing fail (nerd.heartFull);bun run formatclean;bunx biome check srcclean bar four pre-existing warnings (plus one from the user's own uncommitted Dashboard tweak). In a sandbox$HOME:mctl networklisted all five providers with real readiness (tailscale correctlyunauthenticated — sudo tailscale up); a real Paper 1.21.4 server on the tmux runtime was created and started, and the start brought up a real cloudflared quick tunnel (supporters-freight-erik-monetary.trycloudflare.com) which a separatemctl network statusprocess then described, andmctl stopkilled the agent and removed the descriptor; direct exposure reported LAN + real public address; degradation to direct was confirmed for both a logged-out tailscale and an unregistered provider; delete removed the descriptor. TUI driven under tmux at 130×40: the Network page and the Server page's Network tab both render. - One real defect found in the pty: the Network page's sections carried
flexGrow/flexBasisinside a column parent and rendered as overlapping text — the same trapmemory.mdalready recorded for the Dashboard's expanded panel.
- Types.
-
The Console tab renders ANSI (2026-08-13, user-reported defect). A modded server's output (NeoForge/Forge run log4j with a colouring console appender) reached the frame buffer with its escape bytes intact and drew as literal
[32mmid-line.src/lib/ansi.ts— new leaf helper:parseAnsi(SGR → styled spans, everything else dropped),stripAnsi,needsParse,xterm256Hex. Handles the bareCSI mreset, carriage returns (armed, not applied — the capture stores CRLF and> stop\r\r), and tab expansion to 8-column stops.src/components/AnsiText.tsx— new component: maps a palette index onto the theme's semantic roles and renders<span>children; plain lines take a string fast path. Exported from the components barrel alongside the pureansiColor.src/app/Server/tabs/Console.tsx— rows became a memoisedConsoleLine;lineColornow classifies the stripped text and is only the default for uncoloured runs.- Tests:
lib/ansi.test.ts(17) +components/AnsiText.test.tsx(4, rendered frames). Suite is 324 pass / 1 pre-existing fail (nerd.heartFull, see memory.md);bunx tsc --noEmitclean; verified in a tmux pty against the user's real NeoForge capture.
-
Phase 3 — loaders, installers, runtimes (2026-08-12). All four roadmap bullets landed.
- Types.
InstallStrategygainedloaderJar(a meta service's pre-built launcher) andinstaller(download a program, run it);LaunchSpecgainedargFileandscriptand became a Zod schema, because a launch spec is now persisted —MctlJson.launchrecords the one the install produced when the kind alone cannot imply it.jargained optionalargs(Velocity takes nonogui).buildFromSourcedeliberately still absent — no provider needs it. core/runtime/launch.ts— the purelaunchCommand(spec, javaPath, jvmArgs)+launchInputs, shared by both runtimes.core/runtime/console-log.ts— the capture-file tail, likewise shared.core/server/install.ts— runs installer jars (javaPathis a required input for that strategy), verifies what they produced, falls back to the generatedrun.shwhen the predicted argfile is missing, cleans up the installer, and returns the resolvedLaunchSpec.- Six providers:
fabric(loaderJar),quilt+forge+neoforge(installer),purpur(directJar, MD5 only),velocity(directJar, a proxy).providers/server/mojang-meta.tsis the new shared upstream client — four of the six need Minecraft's own Java requirement, and reaching it throughVanillaProviderwould be provider→provider.fill.tsis the shared PaperMC v3 client behind Paper and Velocity;forge-common.tsholds the two facts Forge and NeoForge share. providers/runtime/tmux.ts— detached, re-attachable, and the runtime whereexecandstopwork from any instance.RuntimeManagerverifies the launch files exist before spawning and writesuser_jvm_args.txtfor ascriptlaunch.- Install resume — artefacts land in
$ROOT/downloads/partial/(keyed by URL) and move into staging once verified; JDK downloads resume too.lib/download.tsgainedmd5andresume. - UI: the create form's Runtime select and the wizard/Settings Kind+Runtime pickers now come from
the registry or one shared
app/choices.tstable — there were four hand-kept lists, three of which still said "Vanilla only". - Tests (303 total, 33 files, +41):
core/runtime/launch.test.ts,core/server/install.test.ts(installer stubbed by a shell script standing in forjava; resume driven against a local server that honoursRange),lib/download.test.ts,providers/server/forge-family.test.ts,providers/runtime/tmux.test.ts(including a quoted launch line round-tripped through a realsh). - Verified end-to-end in a sandbox
$HOME, not just typed:bunx tsc --noEmitclean;bun test302 pass / 1 pre-existing fail;bun run formatclean;bunx biome checkclean bar four pre-existing warnings. Created and booted toDone (…): fabric 1.21.4, forge 1.21.4 (via its generated argfile), neoforge 21.1.248, quilt 1.21.4, purpur 1.21.4 — five kinds, plus vanilla/paper/velocity resolution checked against the live APIs. Under tmux:logs,exec([Server] tmux exec works) and a gracefulstopall from separatemctlprocesses. The TUI driven at 120×40 shows every kind and both runtimes. - Three real defects found by doing it for real, all fixed: the tmux launch was originally
send-keys'd into the user's interactive shell (zsh's first-run wizard ate the leadingeand leftxec …: command not found); a Java pin was triggering resolution at create time; and Quilt's meta service publishes a wrong SHA-256 (seememory.md).
- Types.
-
App-wide keyboard pass (2026-08-12, user request). "Tab cycle is not properly done everywhere. Focused areas are not well highlighted (Tabs). Disabled buttons are also acquiring tabs."
src/hooks/use-focus-ring.ts— members are nowFocusItem = string | {id, disabled?}and the hook takes{enabled}. Disabled members are skipped bynext/prev, refused bysetFocus, and never hold focus (including on the first render, and when a focused member becomes disabled);enabled: falsestands the ring's keyboard down without losing its focused id, so only one ring answers Tab at a time.src/hooks/use-modal.tsx— new, the input capture's sibling: a counted modal signal (ModalProvidermounted inApp.tsx,useModalOpen,useModalsOpen,useIsModalOpen).components/Dialog.tsxraises it for every dialog in the app;app/Router.tsxreturns early from its global keyboard while one is up — Esc included — and swaps its hint set forTab / Enter / Esc close.- Focus affordances:
components/Tabs.tsx(caret in the active pill's left padding cell, pill blended back when unfocused),components/Button.tsx(accent wash + bold label when focused;focusedignored whiledisabled),components/Form.tsx(▸before the field label). - Rings audited:
app/Server/index.tsx(lifecycle buttons carry their live disabled state; a ring for the delete dialog with Cancel first; stands down forconfirmDeleteor a tab's modal),app/Settings/index.tsx(Revert/Save disabled in the ring — a clean form cycles three stops, not five),app/ServerCreate/index.tsx(Create disabled until valid, Cancel joined the ring — it was mouse-only),app/setup/steps/DataRootStep.tsx+ReviewStep.tsx(Continue/Create). app/Server/PlayerActionsDialog.tsxsplit into two stage components, each mounted only while showing: the menu's ring skips actions that need a running server, the argument stage's ring is field → Back → run (disabled while a required argument is empty), and "every open starts at the top" now falls out of the tree instead of an effect.ServerTabProps.onModal+tabs/Players.tsxreport the dialog upward so the container's ring stands down.src/hooks/use-focus-ring.test.tsx— new, 6 tests throughcreateTestRenderer+createRoot+ real keypresses: cycling and wrapping, stepping over a disabled member, a disabled first member, an all-disabled ring,setFocusrefusing a disabled id, and a disabled ring ignoring Tab.- Verified:
bunx tsc --noEmitclean;bun test261 pass / 1 pre-existing fail (see below);bun run formatclean. Driven under tmux at 130x40 against the user's real config: the Server page cycles tabs → Start → Remove → tabs; Settings cycles three stops clean and five once dirty (Revert/Save join); the create form cycles seven stops empty and eight with a name (Create joins); the player action menu opened straight onto Shadow ban (the only action a stopped server can run); Tab inside both dialogs moved only the dialog's buttons;5no longer navigates behind an open dialog; and oneEsccloses a dialog without quitting the app. - Pre-existing failure, untouched:
core/icons/detect.test.tsfails onnerd.heartFull— four nerd glyphs incore/icons/catalogue.tscarry a trailing space, so they are two cells and the health/food meters are 20 cells wide innerdmode. Fails onmastertoo; left for the user to decide, since the space may be deliberate.
-
Player card redesigned to a wireframe (2026-08-12, user request). Six interior rows: the 4-row head beside
name ● status/<playtime> Played/Last Position: Overworld(x, y, z)/<GameMode> n Kills n Deaths, then full-widthHealth:andFood:meters — ten game-style icons each with the exact percentage at the right edge. Standing (OP/WL/SHADOW) moved to the top border; the name moved into the body; game mode stopped being a badge.src/types/icons.ts+src/core/icons/catalogue.ts— four new icons (heartFull/heartEmpty/foodFull/foodEmpty) in all three sets.src/app/Server/tabs/Players.tsx—PlayerCardrewritten and exported for its test;StatBar(aProgressBar) replaced byStatMeter+ the puremeterFill; helpersdimensionLabel,titleCase,counted,gameModeColor.src/app/Server/tabs/Players.test.tsx— new, 6 tests mounting the card throughcreateTestRenderer+createRoot: the wireframe's fields, singular/plural counts, the meters' icons agreeing with their percentage, the both-ends fill bias, six rows with data / without data / without a head, and a banned player's card.- Two real defects the tests caught: every body line needed
truncate wrapMode="none"(an unwrapped position line grew a 36-wide card to 10 rows), and the meter row's caption and icons neededflexShrink={0}beside theflexGrowspacer (they were being shrunk to nothing). - Verified:
bunx tsc --noEmitclean;bun test256/256 (+6);bun run formatclean; the card rendered throughcreateTestRendererat widths 50/43/36 in theunicodeandasciisets (full player, a long-named creative player in the Nether, and a player with no data at all) — six rows in every case, meters aligned, long lines middle-ellipsised. - Not verified: the tab has not been driven in a real pty since the redesign — the card is checked frame-by-frame in tests, the grid around it is unchanged code.
-
Player heads are real skins (2026-08-08, user request). "Fetch the head from the API, prefer 8×8, convert it to the Head Skin type, then render it. Official Minecraft first, then TLauncher, then Ely.by; fall back to the defaults."
src/lib/png.ts— a hand-rolled, read-only PNG decoder (inflate → unfilter → expand to RGBA). Every colour type, bit depths 1–16,tRNS, multipleIDATs; Adam7 interlacing throws. No new dependency.src/lib/png.test.ts— 14 tests over a minimal in-test encoder.src/types/skin.ts—HeadSkin(palette + 8×8 code grid) +HeadSkinSchema+HEAD_SIZE. Now the single face shape:components/MinecraftHead.tsx's built-inSKINSare typed asHeadSkinand the component acceptsMinecraftSkin | HeadSkin.src/core/skins/—head.ts(headSkinFromPng/headSkinFromImage: crop (8,8)–(16,16), composite the hat overlay as a mask, nearest-neighbour centre sampling for HD skins),sources.ts(SKIN_SOURCES= Mojang → TLauncher → Ely.by, each returning bytes orundefined, never throwing),index.ts(resolveHeadSkin+ the hit/miss disk cache under~/.cache/mctl/skins/+ in-flight dedupe).src/lib/paths.tsgainedskinCacheDir().src/core/skins/head.test.ts— 11 tests.src/hooks/use-player-heads.ts—usePlayerHeads(players, enabled): display order, 64 players max, 4 concurrent, once per session, inert below the 84-cell head threshold.src/components/MinecraftHead.tsx— exportedfaceSignatureso the draw effect keys on face content (a fetched face is a fresh object every poll).src/components/MinecraftHead.test.tsx— 5 tests against real rendered spans.src/app/Server/tabs/Players.tsx—PlayerCardtakes ahead, falling back toskinFor.- Verified:
bunx tsc --noEmitclean;bun test250/250 (+25);bun run formatclean. The decoder cross-checked byte-for-byte against an independent Python implementation on jeb_'s real skin. End-to-end in a sandbox$HOME: Mojang resolved jeb_'s skin (first lookup 3.7 s, cached 3 ms), a nonexistent name resolved to a cached miss (0 ms), eight concurrent asks made one lookup. Under tmux at 140×40 against a fabricated server with fiveusercache.jsonplayers: four heads resolved through Mojang (including one whose uuid was fake but whose name is a real account, proving the name fallback) and the fifth fell back to a built-in; jeb_'s skin tone and eye colour verified in the raw escape sequences. At 70×30 the heads drop as before. - Known upstream flakiness: Ely.by served one skin then answered
500for every subsequent request — treated as a miss, as designed. No TLauncher hit was observed in testing (its endpoint is live and 404s for names it does not own), so that source is wired and reached but not confirmed against a real TLauncher account.
-
Repo scaffolded from
create-tui: Bun +@opentui/core+@opentui/react+ React 19. -
opentuiskill available and vendored under.claude/skills/opentui/. -
Planning artifacts written for the TypeScript/OpenTUI stack (plan/architecture/memory/AGENTS).
-
Phase 1 foundation groundwork (2026-07-25):
- Deps added:
zod(4.x),eventemitter3,pino(+pino-prettydev).typecheckscript added. src/lib/paths.ts— XDG +$ROOTresolution. Config/cache/state path helpers (known before config loads) +rootPaths(root, overrides)for data dirs. All path building goes through here.src/lib/fs.ts—writeFileAtomic/writeJsonAtomic(temp +rename),readJsonIfExists,pathExists,ensureDir,appendLine(forevents.jsonl). Atomic writes supportmode(0600).src/lib/logger.ts— Pino to~/.local/state/mctl/logs/mctl.log(NOT stdout — OpenTUI owns the terminal). Redacts token/secret/password/*key keys.log(mod)for tagged child loggers.src/types/config.ts— Zod schemas (source of truth) forconfig.json+secrets.json;CONFIG_VERSION = 1. Composite sections use.prefault({})(see memory).src/core/config/index.ts—configExists(first-run =config.jsonabsent),loadConfig,loadSecrets(+MCTL_*env overrides),writeConfig,writeSecrets(0600, mode verified),resolveRootPaths,ensureDirTree. TypedConfigNotFoundError/ConfigValidationError.src/index.tsx— argv dispatch: no args →app/App.tsx renderApp();mctl <cmd>→cli/router.ts runCli()(lazy imports keep the paths independent).src/app/App.tsx— minimal OpenTUI shell (renderApp()owns renderer creation; quit on q/Esc).src/cli/router.ts—help/versionreal; other commands are honest "not implemented (Phase N)" stubs. No fake functionality.- Verified:
tsc --noEmitclean; CLI dispatch paths exercised; a runtime smoke test round-tripped config write/reload, first-run detection, 0600 secrets + env override, and the full dir tree.
- Deps added:
-
Theming — light/dark schemes (2026-07-25):
Theme.colors/ThemeFile.colorsis now aThemeColorScheme={ default }or{ dark, light }. RemovedappearancefromTheme/ThemeSummary/ThemeFile.resolveColors(scheme, mode)added.- Built-ins
github+nordship both light and dark palettes. Terminal theme is a{ default }. - Current mode from
terminalAppearance(palette)(exported);use-themeexposescolors(resolved flat) +appearance(current mode) on the context.App.tsxreadsuseTheme().colors. - Verified:
tsc --noEmitclean; headless smoke (both builtins differ light vs dark; terminal default resolves identically both modes; appearance light/dark from bg luminance).
-
Theming system (2026-07-25, earlier the same day):
src/types/theme.ts— ZodThemeFile/ThemeColors(11 semantic roles, hex-only) +Theme,ThemeSummary, neutralTerminalPalettetypes.src/core/theme/—builtin.ts(GitHub Dark + Nord,FALLBACK_THEME),terminal.ts(themeFromTerminalColors, pure, no OpenTUI import;TERMINAL_THEME_ID),registry.ts(ThemeRegistry: built-ins +~/.config/mctl/themes/*.json;load/get/has/list/isDynamic; reserved-id + invalid-file skipping).config.themefield (default"terminal") added totypes/config.ts; read at startup.src/hooks/use-terminal-colors.ts— implemented: adaptsrenderer.getPalette()+theme_mode/paletteevents intoTerminalPalette; 5s poll fallback that self-cancels on first live event.src/hooks/use-theme.tsx—ThemeProvider+useTheme(); resolves active id (terminal=live, else registry, fallback chain).App.tsxthemed;tcycles themes, persists toconfig.theme.src/lib/fs.ts— addedreadDirIfExists(dir, ext?). Dep added:@types/react@19(dev).- Verified:
tsc --noEmitclean; headless smoke test (registry list/get, custom+reserved+broken files, terminal mapping + null fallbacks, config default/override); TUI mounts and renders themed.
-
Component library — shared UI kit (2026-07-25):
src/components/now holds the full primitive set, all pure-UI (no I/O), theme-driven viauseTheme().colors, and controlled (value + onChange +focused):support.ts—Variant/SemanticColortypes,variantColor/onAccent,clamp,optionsFitAsTabs(the tabs-vs-dropdown width heuristic).Label.tsx,Kbd.tsx(1-row filled keycap),Hint.tsx(row of[key] label).Button.tsx— variants (primary/secondary/success/warning/error/info/neutral) × kinds (solid/outline/ghost); outline fills onfocused; Enter/Space when focused.ProgressBar.tsx— determinate block bar,showPercent.Breadcrumb.tsx,Tabs.tsx(page tabs, underline marker, ←/→ when focused).Form.tsx—FormField/Field(the rounded frame: label on top border viatitle, hint on bottom border viabottomTitle, accent border when focused),FormGroup,Input,TextArea,Select(adaptive:<tab-select>when options fit, scrollable<select>dropdown when not),Toggle(segmented),Checkbox,RadioGroup/Radio.Dialog.tsx— modal overlay (absolute backdrop withopacity+ centred box, Esc/ backdrop-click closes).index.ts— barrel for all of the above (+ re-exports MinecraftHead).Gallery.tsx— living showcase of every component with a Tab focus ring; mounted inApp.tsx(replaced the MinecraftHead placeholder demo).
- Verified:
tsc --noEmitclean;bun run src/index.tsxmounts and renders the gallery (breadcrumb/tabs/buttons/form frames all draw; terminal theme active). - Mouse focus (2026-07-25): every focusable control gained an
onFocused?: () => voidprop, fired on mouse-down so a click moves the page's focus ring to it (Button,Tabs,Input,TextArea,Select,Toggle,Checkbox,RadioGroup). Form controls forward it toFormField, which owns theonMouseDownon its frame (clicks bubble).ButtonfiresonFocusedthenonClick. Gallery wires each ring member'sonFocused → setFocus(id).tsc --noEmitclean. Seememory.md§ Component library for the convention.
-
Leaf helpers the artifacts never listed (2026-07-25):
src/lib/colors.ts(c020c32) — pure colour maths, the layer every theme and component builds on:parseHex/toHex(#rrggbband#rrggbbaa),alpha,fade,mix,lighten/darken,saturate/desaturate/grayscale,rotateHue/setHue,luminance,contrastRatio,readableOn. HSL transforms are lossy at 8-bit, so the doc comment tells callers to compose one call rather than chain many. No test file — the largest untested pure module inlib/, and the easiest to test (see Known gaps).src/hooks/use-quit.ts(f4efa62) —useQuit(): destroy the renderer beforeprocess.exit, or the terminal is left in the alternate screen in raw mode. Used byRouter.tsxand the setup wizard. Its comment still mentions releasing "the public port" / a proxy listener — that subsystem does not exist; the comment is stale, the code is right.scripts/png-to-skin.ts(1eca01f) — dev tool, outsidesrc/: samples an 8×8 grid of cells out of a PNG into theHeadSkinpalette + code-grid shape. Zero dependencies (node:zlib), 8-bit colour types 2/3/6 only. Not part of the app and not covered bybun test.
-
First-run setup wizard +
mctl init(2026-07-26):src/lib/format.ts—formatBytes(binary units).src/lib/fs.ts—diskFree(path)→{free,total}viastatfs, walking up to the nearest existing ancestor (root may not exist yet).src/hooks/use-focus-ring.ts— reusableuseFocusRing(ids): tracks the focused id, Tab/Shift-Tab (andbacktab) cycle;isFocused/setFocus/next/prev. The one focus primitive pages reuse (wizard now, Dashboard later).src/hooks/use-disk-free.ts— debounced hook overdiskFree.src/app/setup/— the wizard flow:types.ts—SetupDraft(flat view model),StepProps,STEP_TITLES,initialDraft().use-setup.ts—draftToConfig()(pure map, reused by Review preview) +commitSetup()+useSetup()hook (commit/committing/error). Commit =writeConfig→writeSecrets({})→ensureDirTree. The wizard's ONLY I/O goes through this hook (pages stay UI-free).Welcome.tsx(branded splash,ascii-font font="block"hero + preview panel),Stepper.tsx(left progress rail ○/●/✔),WizardFooter.tsx(Hint + Back/Continue, buttons own their Enter),StepScaffold.tsx(title/desc/fields/footer layout).steps/— DataRoot (path + live free-space + permanence warning), Paths (optional servers/backups overrides, ring adapts to toggles), Defaults (mc/kind/memory/runtime/eula), Backup (enable + provider + compression), Network (direct only + pointer to Network page), Review (summary panel + Create, shows commit error inline).SetupWizard.tsx— container: welcome→6 steps, owns draft + step index + stage keys (Enter begins, Esc backs/quits).index.tsbarrel.
src/app/App.tsx— split intoApp({firstRun})router +Dashboardplaceholder; first run (config absent, decided inrenderApp) routes to<SetupWizard onComplete>which flips to the dashboard in-place. Dropped the MinecraftHead demo grid from the shell.src/cli/commands/init.ts+ router dispatch —mctl init(flags mirror the wizard;--force/--json/--help; unknown flag → exit 1; refuses to clobber existing config). Lazy- imported so the CLI stays cheap.src/lib/logger.ts— pino destination flipped tosync: true(was async): a fast-failing CLI command'sprocess.exitwas tearing down the async sonic-boom stream before its fd opened ("sonic boom is not ready yet"). Sync file writes remove the race (tiny volume, never render path).- Verified:
tsc --noEmitclean;mctl initround-trip in a sandbox HOME (config written, secrets 0600, full dir tree, re-run refused, bad flag → exit 1,--help/--json); TUI under a pty renders the Welcome screen and Enter→step-1 with no runtime errors.
-
Phase 1 completion — registry, session, events, CLI, router (2026-07-26):
src/types/server.ts—MctlJson(z.looseObject, future-key safe),ServerRegistryFile/ServerRegistryEntry,RuntimeSession,ServerStateenum,JavaPin, and theServerview model (plain TS interface —state/availableare derived, not stored).src/types/events.ts—MctlEventenvelope ({v,id,ts,instance,type,payload};typeopen string for forward-compat) +EventTypereference object.src/core/session/session-manager.ts—probe(id)(pid liveness viakill(pid,0), reaps dead/ invalid/corrupt descriptors) +reapStaleLocks()(sweepsruntime/*.lockwith dead owner pid).src/core/registry/server-registry.ts—loadRegistry(serversDir)(read/verifyservers.json, fold inservers_dirdrop-ins, persist additions atomically, mark unavailable never delete) +addServer/removeServer+mctlJsonPath.src/core/server/discover.ts— the shared read path:listServers/getServer→Server[]view models (registry +mctl.json+ probe). Read-only;ServerManagermutations are Phase 2.src/core/events/—bus.ts(EventBus),instance.ts(INSTANCE_ID),log.ts(publish= append+emit-local;startTailre-emits remote lines, skips self),watch.ts(directory watchers → localConfigChanged/RegistryChanged/ServerStateChanged),index.ts(startEventSystem() → {bus, stop}).src/lib/http.ts— ETag/conditional-GET cache under~/.cache/mctl/api/;fetchText/fetchJson(returnsunknown), TTL fast-path, stale-on-failure,HttpError.src/cli/— reallistandstatus(+format.tstable/--json), wired inrouter.ts(removed from the PLANNED stubs). First-run steers tomctl init.- TUI router —
app/routes.ts(NAV, digits 1–6),hooks/use-router.tsx(RouterProvider/useRouter, back-stack),app/Router.tsx(shell: top bar +NavRail+ page host +Hint; owns global keyboard),app/NavRail.tsx, and pagesDashboard/Servers/Server/Settings(real)Jobs/Backups/Network(Placeholder). Data hooksuse-servers/use-config/use-event-bus.App.tsxnow:renderAppreaps stale locks + starts the event system + injects the bus (EventBusProvider), and routes to<AppRouter/>post-setup.
- Verified:
tsc --noEmitclean; CLI e2e in a sandbox HOME (first-run steer→init, empty list, drop-in auto-discovery folded intoservers.json,list/status/--json); headless smoke (8/8: probe alive/dead + reap, unavailable server, stale-vs-live lock reaping, local-publish-once + foreign-event-tailed); TUI mounts under a pty (router + Servers nav + quit, no stderr) and the first-run wizard still mounts with no config.
-
Box border clipping fix (2026-07-26):
src/components/box-clip-patch.ts—installBoxClipPatch()works around an upstream@opentui/core0.4.5 bug where the nativebufferDrawBoxignores the scissor stack, so bordered boxes inside a<scrollbox>painted their borders over the top bar / nav rail / hint strip when scrolled. Partially-clipped boxes now render through a scratch buffer blitted withdrawFrameBuffer(which respects the scissor); fully-visible boxes keep the native fast path. Installed first thing inrenderApp()(src/app/App.tsx).src/components/box-clip-patch.test.ts— first tests in the repo (bun test, script added topackage.json): unclipped boxes render byte-identically (glyphs and colours, viacaptureSpans) before vs after patching across 10 border/title/background configs; bordered boxes in a scrollbox paint nothing outside the viewport at 5 scroll offsets. Verified the second test fails without the patch (not vacuous).- Verified:
tsc --noEmitclean;bun test2/2; real app under a pty at 14×80 — Settings scrolled with the mouse wheel leaks a stray│into the hint strip without the patch, clean with it.
-
NavRail redesign — horizontal tab bar (2026-07-27):
src/app/NavRail.tsxrewritten to match a user-supplied reference: a row of tabs where the active route is a filled primary pill (on-accent bold ink) and the rest are muted text with a faint hover wash; digit shortcuts stay as a DIM prefix. LocalNavTabcomponent owns hover state (aButtoncan't do a two-ink chip with a muted resting look). Dividers (|) and the inlineMCTLlabel are gone; the row still scrolls horizontally on narrow terminals.- The underline is a second row of per-tab
<text>segments (accent only under the active tab, plain elsewhere) rather than a bottom border, which can only be one colour.tabWidth(item)sizes both the tab and its segment, and segments areflexShrink={0}; seememory.mdfor the alignment traps. AflexGrow+overflow="hidden"tail carries the plain rule to the right edge, its run length computed fromuseTerminalDimensions().widthminus the cells the tabs consume. src/app/Router.tsx— the shell frame now carries the screen name on its top border (title, right-aligned, via the existingtitleFor(route)) andbottomTitle=" mctl ", replacing the commented-out top-bar block (deleted, along with the then-unusedTextAttributesimport).- Verified:
bunx tsc --noEmitclean; app rendered under a pty at 100×24 and 60×14 and the frames replayed — active pill emits a real background SGR, rule and border titles draw, tabs scroll rather than wrap when narrow.
-
Phase 1 tail — Settings, key gating, log rotation, watcher fix (2026-07-27):
src/hooks/use-input-capture.tsx—InputCaptureProvider+useCaptureKeys(active)+useKeysCaptured()/useIsCapturing(). A counted capture;isCapturedis a getter because auseKeyboardhandler closes over its render. Mounted insideRouterProviderinRouter.tsx.src/app/Router.tsx—Eschandled first (always live), then all character shortcuts (digits/q/t) return early while captured. The hint strip swaps to typing hints. TheTODO(phase-1)is resolved and removed.src/app/Settings/use-settings.ts—SettingsDraft+configToDraft/draftToConfig(merge, not replace) /validateDraft(pure) + theuseSettingshook (buffered edits, dirty tracking that a backgroundConfigChangedcan't clobber,writeConfig→ensureDirTree).src/app/Settings/index.tsx— rewritten as the editable form: read-onlyroot/configVersion, servers/backups override toggles + path fields, server defaults, backup policy, network profile, theme picker (applies instantly), Revert/Save + Ctrl+S, inline validation and save errors.src/core/events/log.ts—trimEventLog()(>512 KB ⇒ keep the last ~128 KB of whole lines, atomic rewrite), called fromstartEventSystem()and opportunistically in the tail's drain; the tail's shrink branch now resumes at the new end instead of replaying history.- Watcher fix (real defect): Bun's
fs.watchreports a rename under the source name only, so our atomic writes never matchedconfig.json/servers.jsonand the hard-state watchers never fired at all.lib/fs.writeFileAtomicnow names its temp file.<target>.<pid>-<rand>.tmp(tempNameFor) andcore/events/watch.tsresolves it back (targetOfTempName). src/components/Form.tsx—FormFieldno longer paints the literalundefinedon its bottom border when a field has no hint.- Tests added (now 22, 4 files):
core/events/watch.test.ts(the watcher regression + a negative case),core/events/log.test.ts(rotation keeps whole lines / tail doesn't replay / self-events emit once),app/Settings/use-settings.test.ts(draft mapping, merge-not-replace, validation). - Verified:
bunx tsc --noEmitclean;bun test22/22; CLI e2e in a sandbox HOME (first-run steer →init --json→ drop-in discovery →list/status --json/help); TUI under a pty at 120×40 and 60×20 — Settings renders, Tab reaches the fields, typing6/qedits instead of navigating or quitting, Ctrl+S writesconfig.json(schedule/retention and the extra network profile preserved) and the header flips to "saved" via the watcher'sConfigChanged.
-
Theme reactivity fix (2026-07-31):
src/hooks/use-theme.tsx— newsubscribeThemeIdprop (mirror ofonThemeChange): a bridge for theme ids changed outside the provider. Its effect updates local state only, never re-persists.src/app/App.tsx—themeIdSubscriber(bus)built once inrenderApp()and passed in: onConfigChangedit re-readsconfig.themeand applies it.persistThemeIdrewritten to serialize and coalesce writes (one in-flight write, latest id wins, skip when unchanged) — otherwise a rapidtcycle's out-of-order write feeds back through the bridge and snaps the theme backwards.- Verified:
bunx tsc --noEmitclean;bun test22/22; pty run in a sandbox HOME — an external atomicterminal→nordedit repaints in Nord, and the same run with the fix stashed produces zero new output (non-vacuous). Rapidtcycling lands correctly with no snap-back. - (The catalogue's restart requirement was closed on 2026-08-13 — see the gap-closing entry.)
-
Settings regrouped into tabs with a pinned action bar (2026-07-31):
src/app/Router.tsx— addedOWN_SCROLL(aReadonlySet<RouteId>, currently{settings}): those routes render in a plain padded box instead of the shell's<scrollbox>, so a page can pin its own chrome and own its scrolling. Every other route is unchanged.src/app/Settings/index.tsx— restructured toPageHeader → Tabs → scrollbox(panel) → action bar. Five groups (GroupId): Locations / Defaults / Backups / Network / Appearance; the panel iskey={group}so a tab switch resets scroll. Focus ring is now per-group viaringIds(group, draft)with the tab bar first (←/→ switch groups).GROUP_OF_ISSUEflags a group's tab with" !"when one of its fields fails validation, so a hidden invalid field can't silently disable Save. Section headings dropped (the tab names the group); the config-file path moved into Locations as a read-only row. Revert/Save are 1-rowsize="small" kind="ghost"buttons in the bottom bar.src/components/Tabs.tsx— restyled toNavRail's design (2026-07-31): 2-row scrollbox,|separators, filled-pill active tab with hover wash, per-tab rule segments with╸/╺caps and a counted-out tail to the right edge. Focus still shows as underline weight (━/─), now with the accent blending toward the rule when unfocused. Details inmemory.md. Type-checks clean; not yet driven in a pty since the restyle — worth a visual pass on Settings at a narrow width (the bar scrolls horizontally rather than wrapping).- Verified:
bunx tsc --noEmitclean;bun test22/22; driven under a pty in a sandbox HOME at 100×30 and 100×24 — tabs render and ←/→ switch groups, the panel scrolls while the tab bar and action bar stay pinned, the focus underline thickens/thins with the ring, emptying Memory flagsDefaults !from another tab, toggling EULA + Ctrl+S writeseula: trueand the header flips to "saved", and Dashboard (the scrollbox path) still renders.
-
Toast notifications (2026-07-31):
src/components/Toast.tsx— pure UI:ToastCard(variant-tinted bordered card: icon or spinner, bold title, wrapped description, optional action chip with a keycap, optional time-to-live meter) andToastViewport(an absolutely-positioned, content-sized stack for one of six screen anchors).wrapTextwraps by hand and marks truncation with…— terminal text does not reflow. Exported from the components barrel.src/hooks/use-toast.tsx—ToastProvider+useToast(). API:show(message or options object),info/success/warning/error/loading,update,dismiss,dismissAll, andpromise(work, {loading, success, error}). Per-toast options:description,variant,icon,duration(0/∞ = sticky; errors and warnings default longer),delay,position,dismissible,progress,loading,action {label, key, onAction},width,onDismiss(reason), andid(re-raising a live id updates it in place). Provider defaults:position,duration,maxVisible,width,margin,dismissible,progress. Hovering a card pauses its countdown; overflow queues rather than evicting; an action key stands down while an input capture is held.src/app/App.tsx—InputCaptureProvidermoved up here fromRouter.tsx(so the wizard is covered too) andToastProvidermounted below it, wrapping<App/>at the root.src/app/Settings/—savenow resolves the failure message (string | null) instead of a boolean, and the page'scommit()toasts "Settings saved" (with the config path) or "Settings not saved" with the error and anrRetry action.- Tests (34 total, 6 files):
components/Toast.test.ts(wrapping/truncation edge cases) andhooks/use-toast.test.tsx— the provider mounted increateTestRenderer+createRoot, asserting on real frames: TTL expiry, delay, sticky, queueing pastmaxVisible, description, dismissal reasons, andmockInput.pressKeydriving an action key. - Verified:
bunx tsc --noEmitclean;bun test34/34; a rendered-frame preview of three stacked toasts (spinner, wrapped description + action, progress meter) at two positions; and the real app under a pty in a sandbox HOME — toggling a Settings field and pressing Ctrl+S wroteconfig.jsonand painted the "Settings saved / Written to …" toast, no errors.
-
ProgressBar styles & variations (2026-07-31):
src/components/ProgressBar.tsxrewritten around a glyph table: eight track styles (blocks | smooth | shaded | line | smooth-line | dots | segments | ascii,PROGRESS_STYLES;smooth-linesteps the thin rule in halves via╸),value+max(default1, so old fraction callers are unchanged),readout(none|percent|fraction) with aformatoverride andreadoutFirst, alabelcaption,brackets,tintTrack,bold,thick(a second▄row), colourthresholds(a bar that goes success→warning→error as it fills), and anindeterminatesweep that self-animates at 12 fps unless the caller suppliesframe.showPercentstays as a deprecated alias. Layout maths is exported and pure:fillGlyphs,indeterminateGlyphs,thresholdVariant.src/components/index.ts— the new types and helpers are re-exported from the barrel.src/components/ProgressBar.test.ts— 13 new tests (47 total, 7 files): runs always total the track width for every style/fraction/frame, sub-cell steps forsmoothandsmooth-line(with the whole-celllinerounding the same fractions up as the contrast), the started/unfinished rounding rules, clamping, the sweep bouncing rather than wrapping, and threshold selection.- Verified:
bunx tsc --noEmitclean;bun test46/46; every style rendered throughcreateTestRendererand read back fromcaptureCharFrame()(the preview script was temporary and is deleted). No existing caller changed — Toast's TTL meter still passesvalue/width/variant.
-
Selectwidth measurement fixed (2026-07-31):src/components/Form.tsx— the adaptiveSelectnever measured itself: therefit watched was attached only in the tabs branch, which the initialw = 0never selects, so a flex-sized (width="100%"/"auto") Select was permanently a dropdown. It also listened for the wrong thing via a strayconsole.log(swallowed under OpenTUI).- Added module-local
useBoxWidth(ref)(documented:"resize"is the renderable's event;"resized"is the root's) and rewroteSelectto render oneFormField— ref always attached — branching only on the child control. While unmeasured it falls back to a numericwidthprop, so fixed-width fields pick the right layout on frame one. - Verified:
bunx tsc --noEmitclean;bun test47/47; rendered throughcreateTestRendererat outer widths 60 and 30 — 60 ⇒ tabs, 30 ⇒ dropdown, for both a fixed-width and a flex-sized field. Non-vacuous: with the fix stashed, the flex-sized field at width 60 still rendered as a dropdown.
-
ScrollBoxwrapper + shell scroll acceleration (2026-08-01):src/components/ScrollBox.tsx(+ barrel export) — a pass-through wrapper around the<scrollbox>intrinsic: every prop and therefare forwarded untouched, and it adds one prop,enableAccel, which supplies a stableMacOSScrollAccel. Every<scrollbox>insrc/was replaced by it —Router.tsx,NavRail.tsx,Settings/index.tsx,components/Tabs.tsx, and both insetup/SetupWizard.tsx. Nothing renders the intrinsic directly any more.- Acceleration is enabled only on the shell page host in
src/app/Router.tsx; the tab strips, the Settings panel and the wizard stay linear (seememory.md§ Scroll acceleration for why). src/components/ScrollBox.test.tsx— 3 tests (50 total, 8 files): props/children/refreach the realScrollBoxRenderable, the default isLinearScrollAccelvsMacOSScrollAccelwithenableAccel, and a 30-notch synthetic wheel burst travels 30 rows linear vs ~175 accelerated.- Verified:
bunx tsc --noEmitclean;bun test50/50; driven under a pty at 100×30 in two sandbox HOMEs — the first-run wizard's welcome renders, and with a config the NavRail bar, Servers, and Settings (itsTabsstrip + scrolling panel) all draw with no errors.
-
Icon sets — Nerd / Unicode / ASCII (2026-08-03):
src/types/icons.ts—IconSet(nerd | unicode | ascii),ICON_SETS, theIconNameunion (~40 semantic names: status, server state, selection controls, stepper, chrome, arrows, rules, domain),IconMap.src/core/icons/—catalogue.ts(ICONS, the exhaustiveIconName × IconSetglyph table;SPINNERS; memoizediconsFor/spinnerFor),detect.ts(resolveIconSet(mode, env)— pure over an env record;detectIconSet,hasNerdFont,hasUtf8Locale,parseIconSet;MCTL_ICONSoverride),index.tsbarrel. Nerd glyphs are\u{…}escapes with their upstream Font Awesome names in comments, so the table is readable without a patched font.src/types/config.ts—IconMode(auto | nerd | ascii) +config.icons(default"auto"), sitting besidetheme.core/config/index.ts—MCTL_ICONS/MCTL_NERD_FONTadded toRESERVED_ENVso they are settings, not secrets.src/hooks/use-icons.tsx—IconProvider+useIcons(), mirroringuse-theme'sonModeChange/subscribeModeprop pair.useIcons()returns the auto-detected set instead of throwing when no provider is mounted (seememory.mdfor why it diverges fromuseTheme).src/app/App.tsx—IconProvidermounted besideThemeProvider;loadThemeId→loadAppearance(),themeIdSubscriber→ genericconfigSubscriber(bus, select), andpersistThemeId→persistAppearance(patch): one shared write queue for theme + icons (each is a read-modify-write of the whole config, so separate queues would clobber).src/app/Settings/— Appearance group gains an IconsRadioGroup(auto/nerd/ascii), a hint naming the resolved set, a live glyph preview row, and an honest note about panel borders in ascii mode.save(themeId, iconMode)now carries both live provider-owned values.- Call sites converted off hardcoded glyphs:
Toast(variant icons →TOAST_ICON_NAMES, close, spinner,wrapTextellipsis param),use-toast(spinner frames from the set),Form(Checkbox/RadioGroup/Radio markers, option-description separator),Hint,Tabs+NavRail(the rule/cap glyphs —BORDER_CHARSdeleted from both),Stepper,Welcome(feature icons areIconNames now),WizardFooter,DefaultsStep,ReviewStep,SetupWizard,Router,Dashboard,Servers(+cell()takes the ellipsis),Server, andshared.tsx(newserverStateIcon(state)besideserverStateColor). - Tests (76 total, 9 files):
core/icons/detect.test.ts— 26 tests over locale/font heuristics, override precedence, and catalogue invariants (every set defines every name; ASCII is 7-bit; every glyph is single-cell bar the two documented exceptions; nothing is East-Asian Wide;iconsForis memoized).Settings/use-settings.test.tsupdated for the newsavearity plus a case proving the icon mode comes from the argument, not a stale config. - Verified:
bunx tsc --noEmitclean;bun test76/76; the real app driven under a pty at 110×30 in a sandbox HOME at all three sets —unicodedraws━╸╺rules /▸ survival/○ stopped/1 … 6 · Enter,asciidraws==-==rules /> survival/. stopped/1 ... 6 | Enter/^/v move, andnerdemits the PUA codepoints.mctl initwrites"icons": "auto". - Not verified: the Settings Appearance picker itself was never driven to completion under a
pty — the scripted run hung and was killed, so
config.iconswas still"auto"afterwards. The wiring type-checks and the persist path is shared with the theme picker, but picking a mode in the UI and seeing it written is unconfirmed. Do this first next session.
-
Phase 2 — server lifecycle (2026-08-03): all four roadmap bullets landed.
- Types:
types/install.ts(InstallStrategy—directJartoday, tagged for Phase 3;LaunchSpec;VersionInfo/LoaderVersion/InstallRequest),types/java.ts(JavaRequirement,JavaInstallation,LTS_MAJORS = [25,21,17,11,8]),types/provider.ts(ServerProvider,RuntimeProvider,LaunchContext).MctlJson.kindrelaxed to a free string (the registry is the authority);ServerKindenum grewpaperand now bounds only the settings/wizard picker. New event types:ServerCreated/Deleted/Edited,JobProgress,JobFinished,JavaInstalled. core/registry/provider-registry.ts—ProviderRegistry(instance, not singleton) + typedUnknownProviderError.providers/index.tscreateProviderRegistry()is the single wiring point, called by both front-ends.- Providers:
providers/server/vanilla.ts(Mojang manifest → per-version package JSON; sha1; no server jar before 1.2.5) andproviders/server/paper.ts(fill.papermc.io/v3— notapi.papermc.io; sha256; the only Phase-2 kind declaring a real Java range). lib/shell.ts(run,which) andlib/download.ts(streaming download → sibling temp file, sha256+sha1 hashed in one pass,renameonly after the digest matches, throttled progress).core/java/—detect.ts(probes every candidate withjava -XshowSettings:properties -version; managed/$JAVA_HOME/$PATH/system; memoized incl. failures),adoptium.ts(Temurin resolve + download +tar --strip-components=1into$ROOT/java/temurin-<major>),java-manager.ts(resolveJava, plus the pure, exported policychooseInstalled/preferredMajor).core/jobs/—JobScheduler:run(spec, work)→{job, result},list/active/cancel,JobContext.step/progress/signal. Progress local-bus only;JobFinishedpublished.core/server/install.ts(executeInstall+writeEulaAcceptance) andcore/server/manager.ts(ServerManager: staged create, merge-not-replace edit, guarded delete;idFromName; typedServerOperationError).core/session/lock.ts—withServerLockvia atomicopen(…, "wx"), stale-owner reclaim.providers/runtime/foreground.ts+core/runtime/index.ts— spawn withcwd= server dir, capture to~/.local/state/mctl/console/<id>.log, descriptor write, three-tier stop (consolestop→ SIGTERM → SIGKILL), cross-instancelogs/stop/status,SessionNotOwnedErrorfor foreignexec.RuntimeManagerowns provider+Java resolution, the lock,heapArgs, andrestart.core/context.ts—createContext(providers, bus), the shared object graph.- CLI:
cli/args.ts(flag parser),cli/context.ts, and commandscreate,edit,delete,start,stop,restart,logs,exec,java list|install. Router rewired; onlybackup/restoreremain honest Phase-4 stubs. - TUI:
hooks/use-mctl.tsx(the mutating-core bridge, rebuilt onConfigChanged),hooks/use-jobs.ts,hooks/use-console.ts; pagesapp/ServerCreate/(form + live job progress) andapp/Console/(auto-scrolling output + command input);app/Server/gained a focus-ringed action bar (Start/Stop/Restart/Console/Remove) and a delete confirmationDialog;app/Jobs/is now real; the server list gainedn(new) andc(console) — that list now lives on the Dashboard, see the entry above. Routescreate/consoleadded (not inNAV);consolejoinedOWN_SCROLL. - Tests (127 total, 13 files, +51):
core/java/java-manager.test.ts(selection policy incl. the LTS ceiling),cli/args.test.ts(incl. the--java 21/--no-javaregression),core/session/lock.test.ts(exclusion, stale reclaim, release-on-throw),core/server/manager.test.ts(19 cases: create/edit/delete end-to-end against a temp$HOMEwith a stub provider overfile://— no network). - Verified for real, not just typed:
bunx tsc --noEmitclean;bun test127/127. In a sandbox$HOME:mctl create --kind paper --mc 1.21.4downloaded and sha256-verified the 51 MB Paper jar, wrotemctl.json+eula.txt, and registered the location;mctl startbooted Paper toDone (16.955s);mctl logs -ntailed it;mctl execfrom a second instance correctly refused withSessionNotOwnedError;mctl stopfrom a second instance stopped it gracefully in 7.4 s;mctl java install 21fetched, verified and extracted Temurin 21.0.12. Guards checked: duplicate id,--fileswithout--yes(exit 2), unknown flag (exit 2),execon a stopped server, idempotentstop. Under a pty at 120×44: the create form filled and submitted, paintedResolving · paper 1.21.4/Writing configurationwith a progress bar, toastedCreated tui-made, and navigated to the detail page; Start (keyboard) launched it on the managed Java 21, the Console page streamed live output, and Stop brought it down.
- Types:
-
Drag-selection made opt-in (2026-08-03).
src/components/selection-opt-in.ts→installSelectionOptIn(), called inrenderApp()next toinstallBoxClipPatch(). Replaces the blanketrenderer.startSelection = () => {}, which had disabled selection everywhere including where it was wanted. Now<text selectable>(the console log lines) selects and everything else ignores drag. Verified at runtime against the real catalogue:text→falseby default,truewith the prop,falsewithselectable={false};box/inputunaffected.bunx tsc --noEmitclean. -
Dashboard absorbed the Servers screen (2026-08-03, user request).
src/app/Dashboard/index.tsxrewritten: summary tiles → column header → server rows, with the selected row expanding in place (name/loader/java/memory/network/path + pid/port/startedAt when running, and anEnter/c/nhint). Keeps the old list's keyboard (↑/↓ or j/k, Enter open,cconsole,nnew). The Recent Activity feed is gone.- Deleted:
src/app/Servers/andsrc/hooks/use-recent-events.ts(its only consumer). app/routes.ts—serversremoved fromRouteIdandNAV; digits renumbered 1–5.app/Router.tsx— page switch + import dropped, hint strip now1 … 5.app/NavRail.tsx—server/console/createall light the Dashboard tab.navigate("servers")→navigate("dashboard")inapp/Server/andapp/ServerCreate/.- Verified:
bunx tsc --noEmitclean;bun test127/127 (no test referenced the Servers page); driven under a pty at 110×40 in a sandbox$HOMEwith two discovered servers — the rail shows the five renumbered tabs, the table renders,jmoves the caret and the expansion follows it,2reaches Jobs and1returns to the Dashboard.
-
Terminal-relative dimensions — negative width/height (2026-08-03, user request).
src/components/negative-dimension-patch.ts→installNegativeDimensionPatch(), installed inrenderApp()beside the other two patches. A negativewidth/heighton any JSX element now meansterminal size - n(<box width={-4}>= terminal width minus 4), clamped at 0 and re-resolved on every terminal resize. Two seams: the React component catalogue (upstream's constructorvalidateOptionsthrows on a negative before any prototype method runs) and theRenderable.prototypewidth/heightaccessors (the reconciler applies prop updates as plain assignments). Tracked renderables are dropped on"destroyed"or when set to a non-negative.src/components/selection-opt-in.ts— now wrapsgetComponentCatalogue()instead ofbaseComponents, so the two catalogue patches compose in either order instead of the secondextend()silently replacing the first. This is a rule for any future catalogue patch.src/components/negative-dimension-patch.test.tsx— 7 tests (134 total, 14 files) mounting real JSX throughcreateRoot+createTestRenderer: construction, prop-update assignment, resize tracking in both directions, opting back out, the positive/auto/%control, the clamp, and an unmounted renderable leaving the sweep. Installs both catalogue patches together, so it also guards their composition.- Verified:
bunx tsc --noEmitclean;bun test134/134; non-vacuous — with the install commented out 6 of the 7 fail (the untouched-dimensions control still passes, as it should). A runtime check through the real reconciler confirmed both patches at once (width={-4}→ 36 at a 40-cell terminal,<text>non-selectable,<text selectable>selectable). The real app driven under a pty at 100×30 in a sandbox$HOMErenders the rail, tiles and table with no stderr.
-
Server inspection + a responsive Table; richer Dashboard and Server pages (2026-08-03, user request). "Make the table look good (full width), make it responsive, add more columns and info."
- New core read path —
src/core/server/inspect.ts(read-only twin ofdiscover.ts):inspectServer(server)(cheap tier:server.properties, roster JSONs,mods/+plugins/jar counts, process sample, list ping) andmeasureSize(server)(expensive tier: the directory walk). Nothing cached; every field optional.src/core/server/properties.ts— Java.propertiesparser + coercion to a typedServerPropertieswith Minecraft's documented defaults (numeric pre-1.13 gamemode/difficulty,\uXXXXescapes, line continuation, hardcore's effective difficulty,§codes stripped for display and kept inraw).src/core/server/ping.ts— Server List Ping (1.7+ JSON status): varint framing, handshake → status request → JSON response, chat-component MOTD flattening, 2 s timeout. The only way to a live player count without RCON.src/lib/proc.ts—sampleUsage(pid): two procfs snapshots ~220 ms apart for a real CPU rate, RSS, thread count;psfallback off Linux (flagged as a lifetime average).src/lib/fs.ts—dirSize(dir, {maxEntries, exclude}): level-by-level concurrent walk, does not follow symlinks, never throws, reportstruncated.src/lib/net.ts—lanAddress()for the suggested join address.src/lib/format.ts—formatDuration,parseMemorySize.
src/hooks/use-server-insights.ts—useServerInsights(servers)/useServerInsight(server): self-chaining polls (4 s cheap, 60 s sizes; 2 s on the detail page) keyed on a server id/state/pid signature, holding a derived projection only.src/components/Table.tsx(+ barrel, +use-box-width.tsextracted fromForm.tsx) — the responsive table: purelayoutColumns(priority dropping → iterative flex distribution withmaxcaps → last-resort shedding),fitCell, selection, click-to-select/activate, an expanded row slot, andscrollRowswith a reserved scrollbar cell.- Dashboard rewritten — 4–7 responsive stat tiles (servers/running/players/cpu/memory/on
disk/unavailable-when-nonzero) and a full-width table of ID, STATE, PLAYERS, CPU, MEM, UPTIME,
KIND, MC, PORT, SIZE, RUNTIME, JAVA, MOTD, shedding columns as the terminal narrows. The
expanded row panel now has three groups (Server / Live / World) that stack when narrow. Route
added to
OWN_SCROLLinRouter.tsx. - Server page rewritten — six panels (Status, Resources with CPU/memory meters, Players with
the online sample and rosters, World & rules with the full
server.propertiesread, Storage & content, Configuration), two columns at ≥96 cells and one below. TPS/MSPT/network traffic/heap occupancy are named as unavailable rather than omitted. - Tests (182 total, 19 files, +48):
components/Table.test.ts(the never-overflow invariant at every width 1–200, drop order,maxcapping,fitCelltruncation incl. the multi-cell ASCII ellipsis),core/server/ping.test.ts(driven against a real TCP server speaking the protocol: decode, segmented response, no listener, immediate hang-up, garbage, timeout),core/server/properties.test.ts,lib/format.test.ts,lib/fs.test.ts(symlinks, truncation, exclusions). - Two real defects found by those tests and fixed: the ping never resolved when a peer hung up
without replying (needed an
endlistener — seememory.md), andlayoutColumnscould return a row wider than the terminal when only required columns were left. - Verified:
bunx tsc --noEmitclean;bun test182/182; and driven under tmux at 140×44, 140×32, 96×26, 90×30, 70×30 and 62×24 against a sandbox$HOMEholding three fabricated servers (one registered-but-missing) plus a stand-in "running" server — a live pid that answers a real list ping on 25565. Confirmed on screen: players3/40with names, CPU 5% of 8 cores, RSS against the 4G heap, uptime, 1 ms latency, advertised version, mods/plugins/datapacks counts, world vs total size, the full rules panel, columns dropping in priority order as the terminal narrowed, and header/row alignment holding once the scrollbar appeared.
- New core read path —
-
Server page became a tabbed multi-screen page (2026-08-07, user request). "Start with the scaffolding and implement the basics now."
src/app/Server/tabs.ts— the tab model (ServerTabId,SERVER_TABSwith label + description,DEFAULT_SERVER_TAB,serverTab,isServerTabId).src/app/Server/panels.tsx— the page's shared vocabulary:Panel,Detail,Meter,EmptyNote,Columns,LABEL_WIDTH,TWO_COLUMN_WIDTH,ServerTabProps,javaLabel.src/app/Server/tabs/— nine screens: Overview (status, live meters, connection, server facts), Console, Players (online sample + the four rosters), World (world, difficulty, rules, load), Content (mods/plugins/datapacks, resource pack, on-disk), Backups (honest Phase-4 note + the configured policy), Performance (now, a session sample window, runtime, and the not-measurable list), Network (join address, profile, listeners), Settings (identity, execution, location, and themctl editcommands — read-only for now).src/app/Server/index.tsxrewritten as the container: identity header + lifecycle action bar +Tabs+ tab body + hint + delete dialog, with a focus ring of[tabs, …actions, console?].src/app/Console/ConsoleView.tsx— the console pane extracted so theconsoleroute and the Console tab share one implementation; its input capture followsfocused.src/app/Router.tsx—serveradded toOWN_SCROLL.- One real defect found in the pty: the 1-row action bar had no
flexShrink={0}beside theflexGrowtab body, so at 74×24 yoga shrank it away and Start/Stop disappeared. Fixed on both pinned rows. - Verified:
bunx tsc --noEmitclean;bun test182/182 (no new tests — the tabs are presentation over already-tested read paths); driven under tmux at 120×40 and 74×24 against a sandbox$HOMEwith a fabricated Paper server — all nine tabs render, ←/→ switch them, the tab bar scrolls when narrow, panels stack to one column at 74, typing in the Console tab inserts characters instead of navigating, and pointing a runtime descriptor at a live pid showed real CPU (99% of 8 cores), RSS against the 4G heap, threads, a 3 h uptime and the session min/avg/peak summary, with the action bar flipping to Stop/Restart. No stderr in any run.
-
Global hint provider — one strip for the whole app (2026-08-07, user request). "The hints are showing in two places. Create a provider to update the global hints rendered from
Router.tsx."src/hooks/use-hints.tsx—HintProvider+useHints(items, {scope, active})+useHintItems()+ the pure, exportedcomposeHints. Scopescontext/page/global, merge by key signature (most specific wins the key), and awhen(always/idle/typing) that drops character shortcuts while an input capture is held. Two contexts so contributors don't re-render.src/app/Router.tsx— mountsHintProviderinsideRouterProvider, registers the shell's global hints, and renders the singleHintBar. Its own typing/idle branch is gone (the provider owns that rule now), and the global set no longer claimsEnter open(a page's key) or a typingTab(the page's keyboard, not the shell's).<Hint>removed from every page: Dashboard (and its bottom border row), Console, Server, Settings (its action bar keeps only the save-error text), ServerCreate (which also lost the duplicate key list in itsPageHeadersubtitle — that screen had three copies). The setup wizard keeps its own footer: it renders outside the router and has no strip to merge into.- Hints now follow the focus ring, not just the route — Settings shows
←→ grouponly on the tab bar andCtrl+S saveonly when a save is possible; the Server page swaps toEnter send commandwhile the Console tab's command line holds the ring. src/hooks/use-hints.test.ts— 7 tests (189 total, 20 files) over scope order, key-signature de-duplication, chord equivalence, the typing filter, and a suppressed hint freeing its key.- Verified:
bunx tsc --noEmitclean;bun test189/189;bunx biome check srcclean bar three pre-existing warnings. Driven under tmux at 120×36 against a sandbox$HOME— one strip on every screen, the Dashboard/Server/Settings/Create keys merging ahead of the shell's,Esc cancelreplacingEsc backon the create form, and the character shortcuts disappearing the moment a text field or the console command line takes the capture. No stderr in any run.
-
The
consoleroute removed (2026-08-08, user request). "We should only be able to see the console from inside the server page."app/routes.ts—consoledropped fromRouteId;RouteParams.serverIdnow servesserveralone.app/Router.tsx— theConsoleimport, thePagecase, theOWN_SCROLLentry and thetitleForline are gone.app/NavRail.tsx— the Dashboard tab lights forserver/create.app/Dashboard/index.tsx— theckey, its hint, and thec consoleline in the expanded row panel are gone;Enter(details) andn(new) are unchanged.app/Console/deleted:index.tsx(the page) removed andConsoleView.tsxmoved toapp/Server/ConsoleView.tsx— the Server page's Console tab is now its only host. Same directory depth, so onlyServer/tabs/Console.tsx's import specifier changed.- Verified:
bunx tsc --noEmitclean;bun test189/189;bun run formatclean.
-
The Players tab became a real screen (2026-08-08, user request). "Display all players together in list or grid format, online first then offline, banned below; add ban / kick / shadow ban / teleport / feed / kill; show every worthwhile stat; a random head per player, hidden on small screens; responsive and modern."
src/lib/nbt.ts— a read-only NBT decoder (no writer: MCTL never modifies world data). Gzip/zlib detected by magic number; 64-bit tags decode tobigint;nbtGet/nbtNumber/nbtStringfor the version-varying shapes.src/lib/fs.tsgainedreadBytesIfExistsandfileMtime.src/core/server/players.ts—readPlayers(server, {online, onlineCount, levelName})mergesusercache.json, the four roster files,<world>/stats/<uuid>.jsonand<world>/playerdata/<uuid>.datwith the ping sample intoPlayerProfile[](online first, then last seen). Detail reads capped at 64 files;onlineUnnamedreports connected players the sample did not name.src/core/server/player-admin.ts— the action catalogue (PLAYER_ACTIONS, 15 actions withapplies/needsRunning/ argument), the purecommandFor, andrunPlayerAction. Everything is a console command throughRuntimeManager.exec; MCTL never edits the server's roster files.- Shadow ban is an MCTL-side marker:
MctlJson.shadowBans(newShadowBanschema),EditServerOptions.shadowBans, andServerManager.shadowBans(id). It enforces nothing —TODO(phase-5)inplayer-admin.ts, and both the dialog and the toast say so. src/hooks/use-players.ts— 5 s self-chaining poll keyed on the online sample, plusact.- UI:
app/Server/tabs/Players.tsxrewritten (summary strip → Online / Offline / Banned / Banned addresses card grids, health + hunger meters, playtime/kills/deaths, badges on the card's bottom border) andapp/Server/PlayerActionsDialog.tsxadded (two-stage menu → argument).MinecraftHeadgainedskinFor(seed)— deterministic, so a head does not change face on every poll. The tab joins the container's focus ring asPLAYERS_ID. - Tests (217 total, 23 files, +28):
lib/nbt.test.ts(every tag type, gzip, the empty-list trap, truncation),core/server/player-admin.test.ts(every command's wording,gamemode's reversed argument order,applies),core/server/players.test.ts(the five-source merge against a real temp directory with real gzipped NBT, unit rescaling, a name-only ban folding in, a non-default level name, a malformed entry). - Verified:
bunx tsc --noEmitclean;bun test217/217;bunx biome check srcclean bar the three pre-existing warnings. Driven under tmux at 140×44, 74×40 and 52×30 against a fabricated$HOME(8 players, real gzipped player data, a real list-ping responder on a live pid): cards/badges/bars render, heads drop below 84 cells and the grid falls to one column at 52, the action menu filters byapplies, a shadow ban round-tripped throughmctl.jsonand came back as a badge, typing a reason containing5did not navigate (input capture), and a kick failed with the foreground runtime's realSessionNotOwnedError. Killing the fake server moved every player to Offline with the "not answering a status ping yet" note.
-
Player cards are fitted to the row, not fixed-width (2026-08-08, user request). "Instead of using fixed width, calculate to fit. Like if 2 column, make the width 50% and so on."
app/Server/tabs/Players.tsx—CARD_WIDTH_WITH_HEAD/CARD_WIDTH_PLAINreplaced byCARD_MIN_WIDTH_WITH_HEAD(36, nine less without a head) plus the purefitCards(available, minimum), which takes as many columns as fit at the minimum and then gives every card an equal share of the row.CARD_MAX_WIDTH(60) stops a lone card stretching across a wide terminal; leftover cells are left unused rather than making one card in a row wider than its neighbours.availableis now the measured interior of aSection— the section wraps its children in a box it measures withuseBoxWidthand reports through a newonWidthprop, because only the layout engine knows what the shell frame, tab padding, section border and scrollbar took. The oldwidth - 4terminal estimate survives asSECTION_CHROME = 9, used only until the first layout.- Verified:
bunx tsc --noEmitclean;bun test217/217;bun run formatclean. Driven under tmux at 140/100/84/83/70/60 columns against a fabricated$HOME(12 players, one op, one ban) — 3 columns of 43 filling all 131 available cells at 140, 3 at 100, 2 at 84 and 83 (where the heads drop), 2 at 70, 1 at 60, with no card overflowing or wrapping at any width.
-
Fix: the Players tab showed no player data on a Minecraft 26.x server (2026-08-08, user report). Every card read
seen —/— played/no player data.- Cause: Minecraft 26.1 regrouped the world's per-player directories under
players/—<world>/playerdata→<world>/players/data,<world>/stats→<world>/players/stats(advancementsmoved too).core/server/players.tsonly knew the pre-26.1 paths, so the stats and NBT reads found nothing. File formats are unchanged;lib/nbt.tsandreadStatsneeded no edit. - Fix: exported
resolvePlayerDirs(worldDir)incore/server/players.ts, which picks the layout by directory existence (<world>/players/data) rather than by version string — seememory.mdfor why the version is not a reliable discriminator.readPlayerscalls it. - Tests (220 total, 23 files, +3): a 26.1+-layout fixture reading state, stats and
lastSeen;.dat_oldsiblings not being mistaken for players; and the legacy/never-booted fallbacks ofresolvePlayerDirs. - Verified:
bunx tsc --noEmitclean;bun test220/220; a directreadPlayerscall against the user's real 26.2 Paper server returned playtime, deaths, health, hunger, game mode, position and distance for both players; and the app driven under tmux at 120×40 rendered the Offline cards with real values (seen 6m ago/6m played/1 deaths/lvl 0 · survival), no stderr.
- Cause: Minecraft 26.1 regrouped the world's per-player directories under
- Nothing mid-implementation. All the above compiles, tests, and runs.
- The dev shortcuts are gone —
app/Router.tsxboots to the Dashboard again andDEFAULT_SERVER_TABis"overview". - The working tree is clean as of 2026-08-13; the Dashboard tweak this file used to list as
uncommitted is committed (
c233a97, see the hand-made UI passes entry).
| Check | Result |
|---|---|
bunx tsc --noEmit |
clean |
bun test |
421 pass / 0 fail, 43 files |
bun run format |
clean |
bunx biome check src |
220 files, clean |
Nothing is failing and nothing is suppressed without a stated reason. The two suppressions in the
tree are hooks/use-toast.test.tsx (raise-once effect) and hooks/use-theme.tsx (the catalogue
invalidation counter); both name why. A Biome suppression has to be the last comment before the
node and sit above the hook call, not above its dependency array — prose after it silently voids it.
Networking (bullets 1 and 2) is done. What remains of Phase 4:
- Backup providers + scheduling.
BackupProviderjoinstypes/provider.tswith its first real implementation (filesystem), not before.config.backupalready carries provider / schedule / retention / compression and the setup wizard collects them; the Backups page and the Server page's Backups tab are honest scaffolding waiting for it.mctl backup/mctl restoreare still the only Phase-4 stubs left incli/router.ts. - Supervision behind the supervisor lock: auto-restart, health checks, resource monitoring — and tunnel keepalive, which networking deliberately does not have yet (see Known gaps).
- A profile editor. Network profiles can only be created by hand-editing
config.jsontoday; Settings picks a default among them and the Network page lists them read-only.
Carried over from Phase 3, deliberately not done:
- The Server page's Settings tab is still read-only (
TODO(phase-3)intabs/Settings.tsx). Making it a form overServerManager.editServerwas the one Phase-3-marked item outside the roadmap's four bullets;mctl editremains the way to change these values. mctl update <id>— changing a server'skindorminecraftVersionis a re-install, not an edit, andeditServerstill refuses it. The install machinery it needs now exists.
components/MinecraftHead.tsx— Renders a Minecraft head into an 8×4-cell FrameBuffer via half-block glyphs. A FrameBuffer showcase, not dashboard code; no longer mounted anywhere (App now routes wizard-or-Dashboardplaceholder) but still exported from the barrel. Technique inmemory.md.Gallery.tsxno longer exists — the component showcase was removed; verify the UI kit by running the wizard (it exercises Input/Select/Toggle/Checkbox/RadioGroup/Button/Hint/FormField in anger).
The self-contained ones were closed on 2026-08-13 (see the entry at the top of Done). What is left falls into three buckets, and the bucket is the reason it is still here:
Carried forward from the Properties tab (2026-08-21):
- No CLI peer. The domain logic is in core (
properties-catalogue.ts+properties-write.ts), so nothing is trapped in the TUI, but there is nomctl set <server> <key> <value>/mctl getprojecting it yet. That is the natural next command, and it is a thin one. - Two frame-level blind spots, both because the harness cannot press the tab's own screen bar:
only the default (General) screen is ever drawn, and the RCON-password masking is untested. The
per-screen ring logic is covered by calling
serverPropertiesRingIdsdirectly. - The catalogue is a snapshot of Minecraft 1.21. New keys will fall through to the Other screen as plain text fields — which is the designed fallback, not a break — but they get no label, no range check and no group until someone adds a row.
- Needs something MCTL cannot supply itself — a Cloudflare zone and token, a playit account, an RCON client. Listed below as unverified rather than unimplemented.
- Is a roadmap phase wearing a gap's clothes — backups, supervision/keepalive, the profile editor, Modrinth/CurseForge. These belong in Next up, not here; building them opportunistically under "close the gaps" would scaffold half a phase.
- A deliberate product decision recorded so nobody re-opens it: no version picker, the recorded
port on re-expose, NeoForge 1.20.1 steering to
--kind forge.
-
Phase 4a gaps (2026-08-13):
- No tunnel keepalive. An agent that dies takes the tunnel with it and nothing brings it back;
mctl network statusreportsdownandmctl network up <id>restores it by hand. Keepalive needs the supervisor lock, which is the operations half of Phase 4 — that is where it belongs, not bolted ontoNetworkManager. - Network profiles are hand-edited JSON. There is no UI or CLI to create one; Settings only picks the default among existing profiles.
- playit is wired but never confirmed against a real account. Its binary was present on the
test machine, but no tunnel was claimed, so the
options.addresspath and the scraper were exercised only by unit tests. cloudflared was confirmed end to end with a real quick tunnel; ngrok and tailscale were confirmed only as far as preflight (no account / logged out). - Cloudflare DNS was never run against the real API — only against a local stand-in speaking the v4 envelope. The shapes come from Cloudflare's docs; a live run needs a zone and a token.
- A named cloudflared tunnel is not created by MCTL, only run. Creating one is
cloudflared's own browser login flow. mctl network upon a server whose port changed since it booted uses the recorded port. That is deliberate (the running server did not re-readserver.propertieseither), but it means an edited port needs a restart, not just a re-expose.
- No tunnel keepalive. An agent that dies takes the tunnel with it and nothing brings it back;
-
Phase 3 gaps:
- Velocity is installable but not really managed. It is a proxy: no world, no
server.properties, no players of its own, and itsminecraftVersionholds a Velocity version. The inspection screens find nothing and say nothing about why. Its config isvelocity.toml— the one place a TOML file legitimately exists inside a server directory, written by Velocity, not by MCTL. - NeoForge for Minecraft 1.20.1 is not offered — those builds were published under the
net/neoforged/forgeartefact with a Forge-style version.--kind forgecovers it; the error message says so. - Fabric servers need network on their first boot (the launcher downloads the game then), and
Forge/NeoForge/Quilt creates need a JVM even with
--no-java, because the install is a program. Both are stated in the provider docs; neither is surfaced in the UI. launchSpec(dir)is now vestigial for the installer kinds. They record their spec inmctl.jsonat create time and theirlaunchSpec()returns therun.shfallback, which is only reached by a hand-writtenmctl.json. Widening the interface to take aServerwould let it go.
- Velocity is installable but not really managed. It is a proxy: no world, no
-
The Server page's Settings tab is read-only. Editing goes through
mctl edittoday; making it a form overServerManager.editServer(buffered draft + validation + Ctrl+S, mirroringapp/Settings/use-settings.ts) is markedTODO(phase-3)intabs/Settings.tsx— not done in Phase 3, carried into Phase 4. -
The Backups tab is honest scaffolding, not a feature: it shows the configured policy and says archives arrive with the backup subsystem, with a
TODO(phase-4)naming the provider call that fills it in. (The Network tab is now real — see Phase 4a above.) -
Shadow ban is recorded but not enforced.
mctl.json.shadowBansis an MCTL-side marker — Minecraft has no shadow ban, so nothing happens on the server. Real enforcement needs the RCON/plugin subsystem (TODO(phase-5)incore/server/player-admin.ts). -
Player actions require the server to be running, because they are console commands. Under the foreground runtime
execadditionally only works from the owning instance — a second MCTL getsSessionNotOwnedError, which the tab surfaces as a toast. Under tmux this is gone (verified): the console is addressed by session name, so any instance can send a command. Running a server on the tmux runtime is now the answer to that limitation. -
Per-player ping and current session length are unavailable and are named as such on the Players tab; both need RCON or a plugin.
-
Content counts jars; it does not list them. A real mod/plugin list needs the Modrinth/CurseForge integration (
TODO(phase-5)). -
TPS / MSPT, per-server network traffic, and JVM heap occupancy are still unavailable and are labelled as such in the Resources panel. TPS needs an RCON client (Phase 4/5) — that is the single highest-value addition to the Server page once RCON lands, and
server.propertiesalready tells us whether RCON is enabled and on which port. -
The disk walk has no cross-instance sharing or cache. Every open TUI re-walks every server directory once a minute. Fine for a handful of servers; if it ever bites, the answer is a cached measurement under
~/.cache/mctl/with an mtime check, not a longer interval. -
ServerProviderfixtures still absent (below) — the newping.tsis tested against a real socket, which is the pattern to copy for them. -
The Settings → Appearance icon picker still has not been driven to completion under a pty (carried from last session; the scripted run hung and was killed). The wiring type-checks and shares the theme picker's persist path, but picking a mode in the UI and seeing
config.iconswritten remains unconfirmed. -
mctl createhas no version picker in either front-end. Both take a free-text version and fall back to the kind's newest release. Listing versions is a network round-trip per kind and would make the form unusable offline; revisit if users ask. -
The TUI create form does not offer a Java pin. If nothing installed satisfies the requirement it downloads a JDK inside the create job, which can be a ~200 MB step with only a progress bar to show for it. The CLI has
--java <major>/--no-java; the form does not. -
A
{pinned}Java that is not installed is fetched silently during create/start. That is the right default, but there is no "ask first" prompt in the TUI (theautoInstall: falsepath exists inresolveJavaand is unused by the UI). -
ServerProviderimplementations are still not tested against recorded fixtures. AGENTS.md asks for this and it is now the largest untested surface: eight providers, verified live rather than against fixtures. Their pure parts are covered (decodeNeoVersion,compareMinecraftVersions, the install executor), but no test would catch an upstream schema change or a wrong URL. Recording one endpoint set per origin is the obvious next test, and Quilt's bad digest (seememory.md) is the case that shows why a fixture is not a substitute for the occasional live run.
- Do not scaffold empty phase-3+ folders (backups, network). Build per roadmap phase.
- Statelessness is non-negotiable: never cache an authoritative server set; recompute from disk +
runtime/<id>.jsonprobes. Cross-instance sync =fs.watch+events.jsonltail, no IPC/daemon. - JSON/JSONL only — no TOML anywhere.
mctl.json,config.json,secrets.json,events.jsonl. - Pages live in
src/app/, notsrc/pages/. CLI insrc/cli/. - Registry + statelessness invariants live in
architecture.md— read before touching discovery/session. - Verify with
bunx tsc --noEmit(orbun run typecheck),bun test, andbun run dev. Tests must live insidesrc/(a file outside it resolves a different copy of@opentui/core). The location registry itself still has no direct unit test, thoughcore/server/manager.test.tsnow exercises it end to end. - Isolating state in a test is just XDG env vars —
lib/pathsreads them on every call, so pointingXDG_STATE_HOME/XDG_CONFIG_HOME/XDG_CACHE_HOMEat a temp dir inbeforeEachisolates the whole tree (seecore/server/manager.test.ts,core/session/lock.test.ts). - Driving the TUI under a pty: prefix with
stty rows N cols M;scriptignoresCOLUMNS/LINESand inherits the parent's size, silently hiding anything below the fold. - Adding a provider is one file plus one line in
providers/index.ts. Nothing incore/changes.executeInstall's exhaustiveness guard will fail the build until the new strategy has a case. - Path discipline: never build an MCTL path by hand — call a
lib/paths.tshelper. Never read/write a shared JSON file directly — go throughlib/fs.ts(atomic) and validate with Zod. - Config service already exposes everything the wizard/
initneed:writeConfig,writeSecrets,ensureDirTree,resolveRootPaths. Don't re-implement writing in the front-end.