Guidance for any Claude agent working in this repository. Read this first, then read
docs/PLAN.md — the plan is the authoritative architecture + roadmap.
SharpMUTerm is a cross-platform TUI MU* (MUSH / MUCK / MUD) client in C# / .NET 10, targeting feature parity with BeipMU, running inside GPU-accelerated terminals (Kitty, WezTerm, Ghostty) on Windows and Linux.
"GPU acceleration" is a property of the terminal emulator, not this app. Our job is to emit rich truecolor/styled text and use the Kitty graphics protocol (with Sixel + half-block fallbacks) for inline images/maps.
- Target framework:
net10.0. - TUI base: SharpConsoleUI (
nickprotop/ConsoleEx, stable, net8/9/10) — a compositor-based framework with split layouts, tabs, resizable/mouse-draggable windows, Spectre-style markup, and a native Kitty graphics protocol (+ Sixel/half-block) for inline images. Replaced Terminal.Gui v2 (which was prerelease with an[Obsolete]mid-migration API); the switch is contained toSharpMUTerm.TuibecauseSharpMUTerm.Coreis UI-agnostic. - Scripting: Lua via MoonSharp (pure-managed, sandboxed).
- Inline graphics: in scope from day one (Kitty Unicode placeholders → Sixel → half-block).
- Protocols: aim for all common MU* protocols. GMCP/MSSP/CHARSET/NAWS/MTTS/EOR via TelnetNegotiationCore; MCCP, MSDP, MXP, and Pueblo are our own app layer.
- Config: fresh JSON schema of our own (worlds hold characters; automation lives in shared named trigger sets), versioned with automatic migration between schema revisions.
- The command line is ours (
InputBarControl+InputBuffer+InputLayout), and stays ours. The framework has two text controls and neither is the answer.PromptControlgenuinely cannot do it — single-line by construction (its setter turns\ninto a space), one row tall, scrolls sideways, and unfocuses on ⏎ with no way to switch that off.MultilineEditControlis a capable editor (wrap, undo, find/replace, mouse, paste, a pluggable gutter) and is worth knowing about, but a command line is not an editor: ⏎ has to send rather than insert, the bar grows to its content between a floor and a ceiling, it carries a prompt the text indents past, and two of them share one caret with per-window drafts and history recall behind it. Expanding ours is the call. Offering it upstream once it is genuinely finished and problem-free is a someday, not a plan — do not treat it as pending work. - License: MIT.
M1 delivered, plus substantial M2–M4 work. SharpMUTerm.slnx builds all ten projects on
net10.0, with the full test suite passing. In place:
-
Core —
AnsiParser(SGR 16/256/truecolor), styled-line +ScrollbackBuffermodel (a capped in-memory ring plus a file-backed spill,FileScrollbackSpill, so history deeper than memory is paged off an ephemeral per-session cache under$XDG_CACHE_HOME; absolute line indices, ranged reads capped atMaxRangeLines, and any disk failure degrades to memory-only. Emphatically not the session log — that staysPlainTextLogSink/HtmlLogSink, opt-in and kept),TcpTransport(TLS + IPv6),TelnetSession(wraps TelnetNegotiationCore — the version isDirectory.Packages.propsand is deliberately not restated here), trigger/alias/macro engines +IntervalScheduler, plain-text + HTML logging, versioned JSON config (worlds → characters + shared trigger sets, with migration),Theme/ThemeLibrary, andWorldSession/SessionManagerorchestration.- Automation is live, and that is a push rather than a read-through. Each engine holds its rules in
two lists: configured (what the active
TriggerSets contribute, swapped wholesale byReplaceConfigured) and runtime (what the Lua bridge'sTriggers.Addcontributed, which a reload must not delete).WorldSession.ReloadAutomation(sets)re-points all three engines, andSharpMUTermApp.SaveConfiguration— the single funnel every settings screen commits through (ScreenEdits) — calls it, so adding a rule on F2 or assigning a set on F5 reaches a connected session on its next line. It is not read-through to the configuration:Processruns on the telnet read loop and those lists are mutated on the UI thread, so enumerating them there would throw. Reading a rule's own fields per match (Trigger.Patterndrops its compiled regex on write) is safe and is a different thing from reading its membership. A timer's period still applies at the next connect — re-periodising a running one resets every other timer's phase, and this runs on every committed change.
- Automation is live, and that is a push rather than a read-through. Each engine holds its rules in
two lists: configured (what the active
-
Graphics — Kitty encoder + Unicode placeholders, Sixel + half-block fallbacks, capability probe, and
InlineImagePolicy— the Kitty → Sixel → half-block → text degradation chain (no UI dependency). Inside the TUI the pixels are drawn by SharpConsoleUI'sImageControl; ours supplies the policy, because only the framework's renderer can put an image into compositor cells. Sixel inside the compositor is blocked upstream: at SharpConsoleUI 2.5.14IImageRendererisinternalandImageControl.ResolveRenderer()is private, so no Sixel back-end can be injected — and the framework ships none. Reopening it needs an upstream PR makingIImageRendererpublic andResolveRendereroverridable; nothing on our side unblocks it.InlineImagePolicytherefore degrades a Sixel-only terminal to half-block explicitly rather than pretending. -
Scripting — sandboxed MoonSharp
ScriptHost(world/output/trigger/alias/timer/gmcp/log). -
Tui — SharpConsoleUI app: a
TabControlof output windows (main + trigger-routed spawn windows + web view, with unread badges), each aMarkupControlin aScrollablePanelControlviewport (PgUp/PgDn, Shift+↑/↓, ⌃Home/⌃End, wheel; unread badges count output arriving below a scrolled-back viewport), fed StyledLine → Spectre-style markup viaMarkupFormatter(clickable[link=…]MXP/Pueblo/web spans); anInputBarControlcommand line (wrapping, auto-growing, per-window drafts, plus an optional per-window second bar), status line,Ctrl+Qquit, per-pane NAWS (every connected session is told its own pane's output rectangle, on every resize and layout change, rate-limited to four writes a second with a trailing flush). The tab/pane set is driven by the testedCore.Workspacesmodel, with splits (thin single-line dividers) and the connection rail now rendered as well — and clickable: a world, character or window row switches to it, dispatched through the rail control's ownLinkClicked(never the output panes' handler, so a world cannot drive the client's UI from the wire). -
Panes come back after a restart, and the log that does it is keyed by window (
RestoreLog, Core;restore/besideconfig.json, one0600file per window, 500 lines each). This is the third thing in the repository that puts session text on disk and it is none of the other two: the spill is an ephemeral cache purged next launch, the transcripts are opt-in files a user keeps, and this is a small bounded tail nothing but startup reads. It cannot be built onWorldSession.Scrollback— a spawn window's lines never go there (ProcessOutputLineraisesSpawnLine, and a gagging capture rule keeps the line out of the transcript entirely), so a session-keyed restore refills the main windows and leaves every channel pane empty, which is the exact failure it exists to remove. It is therefore fed from the shell, atOnLine/OnSpawnLine, and not fromAppendWindowLine: that seam also carries the client's own chrome and the restore replay, so logging there would have each launch re-record its own history. Payload isStyledLineCodec, not markup, so the game's colours and a span's interaction survive and the current theme still renders them. Appends flush to the OS per line (a crash loses nothing; there is deliberately nofsyncper line), the bound is in lines and never bytes, and space is reclaimed by compaction — a byte-range copy through an atomic rename. Restored content is closed off by oneRestoreBarRendererrow and the lines themselves are left alone. Restoring 3,000 lines costs ~18 ms before the first frame.restore:is the third member of thesave:/logRoot:family — null by default, so no test and no snapshot owns one. -
⌃Fsearches the output the client is holding (OutputSearch, Core;SearchPrompt/SearchSurfaceSearchBarRenderer, Tui). A modal results surface — the⌃Ridiom, and the same pure-prompt/host split — over the pane buffers, with⌥Efor regex,⌥Ato widen from the focused window to every window,⏎to go, and⌥Gto walk to the next hit. Decisions worth not relitigating:
- What is searched is the pane buffer, not
WorldSession.Scrollbackand not the file-backed spill.RestoreLog's reasoning one layer over: a spawn window's lines reach neither, so a session-keyed search would find nothing in exactly the windows people search hardest. The bound is stated —12 found · 4,812 lines held— so a reader who cannot find an old line sees why rather than concluding the search is broken. - Only windows a pane holds are searched,
Workspace.WindowsFor's rule and for its reason:⏎takes the reader to the hit, andActivateWindowrefuses a window no pane holds._linesis wider than the workspace deliberately —RestorePreviousSessionbuffers a restore log the workspace cannot place under its own id, so its pane refills if that channel speaks again — and such a window has noWorkspaceWindowto be titled from, soWindowTitlehanded back the rawspawn:24:World|Character:Targetid. That was the reported "the results take up a small amount of room": a sixty-cell window column of blank against a twenty-cell result.GoToSearchHitnow honoursActivate's answer as well, rather than inserting its bar into a buffer nothing paints. - Both dimensions are the room there is. There is no unfiltered list to size to — an empty query
matches nothing — so the width used to be measured against the only content an empty surface has,
which is its own footer, and the surface opened at eighty-odd cells on any terminal while eliding
every result to fit a window sized by a key hint. The height always took the desktop; the width does
now too.
SearchPrompt.MaxLabelWidthbounds the window column on top of that, because a title is not this client's text to trust and every row is padded to the widest one. PaneLine.Plainis held, not derived. Matching runs over the visible text so a colour change mid-word cannot split a match and#ff0000cannot find every red line (UrlDetector's rule, one layer down) — and it is computed once at append, because the surface refilters over every line of every window on every keystroke. Chrome rows carry none, so a search cannot find its own bars.- Case is ignored in both modes, matching
HistorySearch;(?-i)is the way back, which is why there is no third toggle. An invalid pattern is a state (a regex is unparseable most of the time it is being typed), and there is a match timeout, because this runs on the UI thread per keystroke. ⏎goes throughActivate, the one activation path, so a hit in a background pane brings the pane, the tab and the session forward together rather than scrolling something nobody is looking at. One bar client-wide;⌥Gmoves it rather than leaving a trail.⌥Gremoves the bar before re-running the search. The bar is itself a row, so a search run around it returns indices in a buffer about to lose one and every hit below it lands a row early.- Escape does not clear the bar. A claimed Escape does not set
_escapeAt, andTryAltEnterpairs an unclaimed one with a following Enter to make⌥⏎— binding it here would break the newline chord for as long as a bar was on screen, which nobody would connect to search. - There is no backward chord, and that was measured.
⌥⇧Gwould decode (ProcessEscapereads Shift out ofchar.IsUpper), but kitty writes it asCSI 103;4u— a kitty-keyboard-protocol sequenceDispatchCsidrops.⌥⇧1's story one letter over; a decode test is not an arrival test. - Two kinds of client chrome now live in the line buffers, so
InsertChromeRow/RemoveChromeRoware the one place that fixes up everything indexing into one — the freeze point, the pending boundary, the activity bar and the search bar move together or none of them do. - The demo scene loads with
_watchingoff. It pours a spawn window's whole history in before the first frame, and every line would otherwise count as missed — so any frame that later made such a window visible carried an activity bar reporting the client's own setup as news.
-
A pane's output can be selected with the mouse and copied with
⌃C, and almost all of it is the framework's (MarkupControl.EnableSelection, shipped in the pinned 2.5.14 and off by default). Drag, double-click word, triple-click line, drag-autoscroll through theScrollablePanelControleach pane already sits in, one-selection-per-window arbitration and a wrap-aware copy all come from the package. This is the rare case where the answer was to switch something on rather than build it; what had to be ours is the colour, the clipboard, and what happens to a selection when the buffer moves.- The terminal's own selection cannot do this job, which is
UrlDetector's argument one layer over. Under?1003— which this client needs for the wheel, tab and rail clicks and pane drag-and-drop — a plain drag belongs to the application, and the emulator's escape hatch (kitty's ⇧-drag) selects a terminal row: on a vertical split that row crosses the divider into another pane's output, and since a pane is narrower than the row a logical line wraps and comes back with newlines injected at the wrap points. The framework's copy walks the painted cells and breaks a line only where a row is not a soft-wrap continuation. There is no partial retreat from?1003that keeps the wheel. - The selection band is one pair per theme, and for a different reason than
ReadingPlane's. There it is cost; here it is meaning. A pane's plane already says whose connection this is (hue) and where the keyboard is (luminance), and a selection is neither — it is the client answering a gesture being made now, in exactly one pane at a time, so a highlight that changed colour with whose pane it landed in would report a fact nobody asked about.WorkspacePalette.SelectionBandisReadingPlanepushed further in the direction of travel — brighter on a dark theme, darker on a light one — then leaned towardTheme.Prompt, the same anchorArmedBanduses. It is held to a fill floor against all fourteen planes (1.5:1; tightest measured 2.70:1) andSelectionInktoContrast.Flooron the band, which matters more here than anywhere: the highlight replaces the world's foreground too, so that one ink is what all selected output is read in. - The clipboard writer is caller-supplied and null by default — the
save:/logRoot:/openUrl:family, and here it also buys a single copy path. The framework's own ⌃C writes straight to the system clipboard through a static helper no caller can substitute, so a test run would replace whatever the developer had copied and the one path that could not be injected would be the one under test.CopyEnabled = falseon the pane controls and the chord is answered bySharpMUTermApp.CopyFocusedSelectioninstead;ProgramsuppliesClipboardHelper.SetText, which covers OSC 52 and the platform tool, so a copy lands locally and over ssh alike. - ⌃C is claimed in the main window's key chain, not in
MacroKeys.AppShortcuts. A global shortcut runs ahead of every window including the composer, whoseMultilineEditControlhas its own ⌃C and is a real editor. Being in the chain also puts it afterDispatchMacro, so a macro bound to ⌃C wins — the same relationship ⌃←/→ has with pane selection, and the reasonVerdictneeds no special case. - A selection is dropped whenever the rows under it move, and the clear belongs in
FeedRange— the one function that actually replaces a pane control's content.RepaintPaneis not the only caller:BuildFrozenContentfeeds both halves of a frozen pane andToggleFreeze's thaw branch pours the whole buffer back, so clearing at the repaint site alone left freeze and thaw re-feeding under a live selection. The thaw is the case with teeth — freezing leaves the live control empty, so a stale anchor merely yields nothing, while after a thaw the rows exist again and ⌃C hands over real text nobody dragged across (FreezingAndThawingDropsTheSelectionRatherThanReAnchoringItcopiedThe Grand Plaza…before this moved).MarkupControl.SetContentdoes not clear a selection — only its append path does (OnContentAppended) — so this cannot be left to the framework. - The copy asks the window's
SelectionManager, never the focused pane. That manager owns the one active selection and clears the previous owner when a new one starts. The focused pane is the wrong question: pane selection moves on ⌃arrows, ⌃O and a tab click, and a press in a pane's body moves none of them — so a drag in the pane beside the focused one left the copy looking elsewhere and reportingnothing selected, which reads as a feature that does not work. It also retires the special case for a frozen pane: one selection, one owner, whichever control that is. - A pane id and a window id are different namespaces that are both strings, and this is where that
bites:
PaneOutputRectsis keyed by pane, whileSimulatePaneDragandPaneSelectiontake a window.PaneWindows()maps between them, and exists because a test that passed a pane id to the drag seam selected nothing — indistinguishable from the feature being broken. NewPaneControlis the one place a pane's control is made, and it exists because enabling selection onPaneContentForalone left the main window — the pane most people are looking at — unable to select anything. That control is built in the constructor, before a workspace exists; every other one is built on demand. Two creation paths, and the trap announced itself immediately.SimulatePaneDragis the test seam, forSimulatePaneClick's reason: the framework registers its driver-mouse handler insideRun(), which no test calls. The drag flag rides with the button flag, because SGR encodes motion-while-held asButton1Pressed | Button1Draggedand a seam sending the bare form would exercise a path the terminal never produces. Theselectionview is the frame, and it is inFrameContrastTests' list — a colour nothing renders is a colour nobody checks.- Not fixed, and known: chrome rows live in the same buffer and are selectable (a terminal selection
would take them too); OSC 52 caps at ~74 KB and
Osc52.BuildSequencereturns null past it, so a very large copy lands locally and silently does not travel over ssh; GNU screen has OSC 52 disabled upstream; tmux needsallow-passthrough on. The Windows mouse path is a separate ad-hoc parser inNetConsoleDriverand nothing here can verify it — treat Windows drag-select as unproven.
- The terminal's own selection cannot do this job, which is
-
Coming back to a window you were not watching leaves a bar where you left off, and that covers two different absences. The window one is
NEWand is the common case: a line lands while the window is notWorkspace.IsCaughtUp— visible and at its live tail — and_missedFromrecords the index of that first line, exactly, at the moment it happens.MarkMissedLinesdraws it when the window is caught up again, from the only two places that can make it so (Activate, andSyncScrollbackState, where every scroll route lands). Three things not to relitigate.IsCaughtUpand notIsVisible, because that is already the unread badge's rule and one fact behind both means a badge showing a count always has a bar under it saying where the count begins — "the badge said 3 and nothing said which 3" was the report. The reveal is only for arriving: a pane bottom-anchors, so a deep absence's bar is drawn far above the fold and nothing on screen would change without the jump — but ⌃End is an explicit take me to the live tail, and scrolling somewhere else in answer to it is the "attention on one pane, keystrokes to another" defect wearing a different hat (CtrlEndGoesBackToFollowingTheLiveTailis the pin; the pane is re-pinned after the insert instead, because a whole-buffer re-feed leaves the offset a frame behind and the newest line would blink off screen). Nothing accrues before the constructor finishes (_watching): until the workspace is laid out, "not visible" means "no pane built yet", andRestorePreviousSessionpours a previous run through the same seam into windows that already sit under aRestoreBarRendererbar.- The bar retires on three conjuncts, and the third is a floor in time. At the live tail, one input
since it was drawn, and
TextSettings.ActivityBarSecondselapsed (F7 ▸ ACTIVITY, default 30,0is the old behaviour exactly). Two were not enough: a shallow absence leaves the pane at its tail, so the next keystroke took the bar a second or two after it appeared. Time and not keystrokes because that is the unit the complaint was in — a raised input count is an hour on a quiet character and three seconds on a busy one. It is a floor, not a timer: nothing fires on its own, so the bar goes on the first of these checks after it passes, and an untouched client keeps its bar. Measured off the app's existingTimeProvider, so tests move the clock rather than racing it, andAwayDividerTestssets the floor to zero so each suite asserts one rule.
- The bar retires on three conjuncts, and the third is a floor in time. At the live tail, one input
since it was drawn, and
-
Coming back to the terminal leaves a bar where you were (
AwayBarRenderer+TerminalFocusWatcher, Tui). The other absence, and the harder one, because a terminal reports focus-in and not focus-out — so this boundary is reconstructed from the input before the last, where the window one above is recorded forwards. Where a window has both, the older wins: a reader who typed after lines landed in a window they could not see has a terminal boundary at the end of that buffer, claiming they missed nothing, and the window's own boundary knows better. Still one bar per window. Third of the boundary bars, and it earns its row the same wayFreezeBarRendererandRestoreBarRendererdo: mark the boundary, never restyle the content. The signal is real terminal focus reporting (CSI ?1004h) and both halves of getting it are workarounds, which is why they are in one file. No released SharpConsoleUI asks for focus (verified against 2.5.18's string heap:?2004is there,?1004is in no version),IConsoleDriverhas no focus event, andUnixStdinReaderdispatches only key/paste/mouse. So we ask throughIConsoleDriver.WriteClipboardOsc52, which is named for its first customer and is really a verbatim raw write under the renderer's own_consoleLock— the only public write serialised against frame painting — funnelled through oneEmitTerminalModeso a version that starts validating that payload is one line to fix. And focus-in is recognised as the bare Tab keypress thatAnsiInputParser.DispatchCsimistranslates it into —:511reads a trailingIas Tab, which is right forESC [ 1;5 I= Ctrl+Tab and wrong for the bare form. Tab is claimed throughRegisterGlobalShortcut's declining overload, deliberately not throughMacroKeys.AppShortcuts: it declines nearly every Tab it sees, and listing it would tell F4's readers a key was gone that is not.- The watcher listens to the driver, so its
Inputruns on the driver's reader thread — and the app marshals. The framework's own key path does not run there (its driver handler only enqueues,ConsoleWindowSystem.cs:970-973, andInputCoordinator.ProcessInputdrains on the main loop), so every other key handler here is on the UI thread and this one cannot be: a queued key arrives too late to measure the gap in front of a focus-in Tab.NoteInputkeeps its timestamps on the reader thread; both subscriptions go throughOnUiThread, because everything past them — pane buffers, away marks, the controls they repaint — is the UI thread's. Marshalling reorders, so the input is numbered (InputCount, carried byInput;AwayMark.DrawnAfteris the stamp): a note raised before the return that drew a bar can be delivered after it, and would otherwise setInputSinceon a bar nobody has seen, retiring it to the keystroke that produced it. - Telling that Tab from a real one is a question about time, and the comparison must be against the
input before it. The disguised focus-in is itself a
KeyPressed, raised beforeInputCoordinatorreaches the global shortcuts, so measuring from the latest timestamp finds a gap of zero on every return and the feature never fires — a bug indistinguishable from the terminal not supporting?1004. The same trap bit the boundary, one field over: that keypress had already moved_awayPendingto the end of a buffer full of unseen lines, so_awayBoundarykeeps the value from the input before it.SimulateReturnFromAwaynotes an input first for that reason — a seam that skipped it would read a boundary the shipping path never reads. - Focus-out is not recoverable.
ESC [ Ohas no case and is dropped as anUnknownSequenceEvent, so a departure cannot be timestamped; the boundary is the last input event instead, which is seconds off. Unix only — the Windows branch is aConsole.ReadKeyloop with its own reassembly, so?1004must not be enabled there — and inert headless, because a harness pressing Tab must get a Tab. - A bar off the fold is scrolled to (
RevealAwayBar), and without that the feature is invisible in the case that matters most — the reported defect. Come back to more lines than the pane holds and the bar is drawn far above the viewport, so nothing on screen changes; nothing else covers for it either, because a window visible and at its live tail throughout an absence accrues no unread badge. A bar already in view is left alone: scrolling a shallow absence would take a pane off its tail to reveal what is already on it.ScrollVerticalByand notScrollToTop— it re-syncs metrics from the arranged bounds before clamping (so a scroll straight after mutating content is not clamped against a stale viewport) and detachesAutoScrollon the way up, which a jump that left it armed would have undone on the next repaint. - A buffer index is not a viewport row, and conflating them is a bug this has already had. The
panel's offset counts display rows and a buffer line wraps into as many as it needs, so in a narrow
pane scrolling to the index landed hundreds of rows adrift, in content from a previous session. The
height is measured, by the framework's own
MarkupControl.MeasureDOMthrough a throwaway control at the pane'sViewportWidth, so it wraps the way the real control will — and only the tail is measured, from the bar to the newest line, then subtracted from the panel's authoritativeTotalContentHeight. Never re-derive wrapping by counting characters; word breaks, zero-width markup tags and wide characters all change the answer. - Consumption is two conjuncts, and
Workspace.IsCaughtUpis not one of them. A pane bottom-anchors, so it is already "visible and not scrolled back" the instant you return with two hundred unread lines above the fold; clearing on it clears the bar before a word is read. It goes when the pane is at its live tail and one input has landed since it was drawn. What makes the first mean anything is the reveal: the pane was taken off its tail whenever the bar was not on screen, so arriving back at the bottom is having read down through what you missed rather than never having left. The second is what stops a shallow absence clearing in the frame it appears in. Insert and remove are mid-buffer, so each costs oneRepaintPane; affordable for the timestamp toggle's reason, bounded by a deliberate event rather than by lines or frames. - The bar is chrome: it never badges unread, never reaches the restore log (already free — that is fed from the session's line handlers, not the append seam), and a trim that takes it drops the mark with it. A window that gained nothing gets no bar.
SimulateKeyused to discard a global shortcut's result and swallow the key either way. Harmless while every claim returned true; wrong the moment one declined, and it now honours the decline.
- The watcher listens to the driver, so its
-
Every server's MSSP report is kept, and the INFO screen reads it (
MsspCache, Core;mssp.jsonbesideconfig.json, keyed byhost:port; F5 ▸i). Fourth of thesave:/logRoot:/restore:family with one deliberate difference: the constructor parameter is null by default like the others, but the field never is — aMsspCachewith no path is memory-only by construction, so the "a snapshot writes nothing" guarantee is a property of the object rather than a null check at each use site, and the screen needs no "is there a cache" branch. Three decisions worth not relitigating. Keyed by endpoint, not world: MSSP describes a server, a world name is a user-editable label two entries may share, and a rename must not lose a report. A second report replaces the first: MSSP is not a delta protocol — a server sends its whole table once per connection — so a merge would keep variables it has stopped publishing and would leave a report that is a snapshot of no moment that existed. Two timestamps, because there are three states and two would only separate two:ConnectedAtis written on theConnectedtransition andObservedAtonly when a report arrives, so "never dialled", "dialled and publishes nothing" and "here is the report, as of…" are three different screens. Report capture is bounded at the door (MaxVariables/MaxValuesPerVariable/MaxValueLength), not only at the renderer — a value only the screen trimmed would still be full size on disk and in memory on every later launch. -
This client asks no server to enable an option the server has not offered, and that rule was bought with a login (
UnsolicitedNegotiationTests, Core).TelnetSessionused to writeIAC DO MSSPstraight to the transport on connect — legal telnet (RFC 854 has either party initiating, and requires a response even to a refusal), and the way to reach the many servers that support MSSP but never volunteer it. Refusing an option means consuming its three bytes, and a server that implements neither leaves them in its line buffer, where they are prepended to the next line the client sends — which is always the auto-login. The server reads\xFF\xFD\x46connect Name password, redisplays its connect screen and logs nobody in; the transcript shows the welcome screen twice, with no reason for it, because the login line is deliberately not echoed or logged. Measured on a live game: with the request the login line was never evaluated, without it the same line reached the game, and only the first line after the request dies — which is why typing the login by hand always worked and the auto-login never did. Two things follow. We are not in a position to know which servers parse telnet properly, and the one that does not is exactly the one whose login we break. And negotiation is the library's to conduct: TelnetNegotiationCore would never have sent thatDO— its client-side MSSP answers a server'sWILLand initiates nothing (MSSPProtocol.OnWillMSSPAsync) — so a hand-written negotiation byte, written around it to avoidIACbeing escaped as data, is a negotiation nothing keeps state for. TheRequestOptionsmechanism is deleted rather than left empty, so there is no seam to reach for. -
A launch connects nothing unless it is told to (
StartupConnections.Resolve, Core). A host on the command line wins outright; otherwise it is every character withConnectAtStartup(F5'sat start), in configuration order; otherwise none, and the client says which of the two empty states it is in. There is no migration marking anybody — the old behaviour (first world, first character, unconditionally) is precisely what the setting removes.StartAsyncbuilds the windows sequentially in that order and then dials them concurrently: focus is taken by the first before any packet leaves, so several auto-connects always land you in the same place, and a black-holed host cannot hold the other worlds' windows hostage.ConnectAtStartupis notAutoLogin— one opens the socket, the other sends the connect line once one is open, and either is useful alone. -
The URLs a world prints as plain text are marked up by this client, because the terminal's own detection cannot survive a pane (
UrlDetector, Core; F7 ▸detect links in output, default on). Kitty, WezTerm and Ghostty all find URLs in the cells they paint — across the terminal row. A pane is narrower than the row, so a wrapped URL ishttps://exaon one row andmple.com/pageon the next with a divider and possibly another pane's output between them; neither half is a URL, neither is clickable, and nothing says so. Marking the span ourselves moves the decision to the layer that knows where the line really ends:MarkupParsersplits a[link=…]across every row it wraps onto (MarkupParser.cs:1272-1296) andMarkupControlhit-tests each row. OSC 8 is not an option — the compositor's cells carry no such attribute.- It runs over the whole line, never span by span. A server may change colour mid-URL (a highlight
rule, or a game that paints the scheme differently), and matching per span finds two half-URLs and
produces two links to two truncated targets — the same defect as the wrap, one layer down. Same
shape as
EmojiSubstitutor.ApplyToLine, and for the same reason. - It runs after the emoji substitution, and both feed the spawn dispatch. After, so a link's target
is exactly the text under it: a span whose visible text and destination differ is the shape of a
phishing link, and this client should not manufacture one.
ProcessOutputLinecomputes the shown line once and hands the same line toSpawnLineand toPrint— a capture used to getresult.Linewhile the main window got the substituted one, so one line read differently in the two panes it landed in. - A run overlapping an existing
SpanInteractionis skipped. What MXP marked up is MXP's; this may neither replace a<SEND>nor wrap one in a second link. - The setting is ingest-time, and that is stated rather than papered over. It joins
strip incoming colour, tab width andemoji substitution: unticking it stops the next line and leaves history alone. It is not the timestamp gutter's situation — the gutter is glued on when a control is fed, so it can repaint history, while a link is a property of the line's spans and a pane's history is markup by then. - Two schemes only,
http://andhttps://, spelt out. Nowww., no bare host, nomailto:. The output of this detector is eventually handed to the desktop, so what it can name is a security property. Trailing.,;:!?'"and unbalanced closers are given back to the sentence (a wiki URL keeps its parentheses); a scheme inside a URL does not start a second link; there is a length cap.
- It runs over the whole line, never span by span. A server may change colour mid-URL (a highlight
rule, or a game that paints the scheme differently), and matching per span finds two half-URLs and
produces two links to two truncated targets — the same defect as the wrap, one layer down. Same
shape as
-
Every http(s) link clicked in a pane opens in the desktop's browser; the built-in web view is reached by its own anchors and by
/web <url>(ExternalBrowser, Tui).LinkAction.Webis routed by the surface the click came from, not by the payload:windowId == WebWindowIdnavigates the view, anything else launches. Without that split the built-in browser would eject you to Firefox on its first in-page link. The window id is a trusted parameter set where the handler is subscribed — never server text — which is the same reasoning that makes that handler take one at all.- The scheme gate is at the moment of opening, and that is the security boundary.
UrlDetectoronly ever produces http(s), but this path also carries what a server marked up: an MXP<A HREF="file:///…">, ajavascript:, or one of the schemes a desktop registers to applications (ms-msdt:and relatives). Handing any of those toxdg-open/ShellExecuteis letting the world choose which program runs on the machine. The URL is launched asProcessStartInfo.FileName, never composed into a shell string, and what is launched isUri.AbsoluteUri— what .NET parsed, not a second reading of the same bytes. - The launcher is caller-supplied and null by default, the fourth member of the
save:/logRoot:/restore:family: a snapshot and a test start no browser, and an app with no opener refuses out loud (AutoLinkTests.AnAppWithNoOpenerLaunchesNothingAndSaysSo).
- The scheme gate is at the moment of opening, and that is the security boundary.
-
F1 is the composer: a full-screen editor for writing a post, sent as one command (
ComposeOverlay, Tui;ComposeMessage, Core; ⌃P ▸ Compose a post;--view compose/compose-literal). It is the one placeMultilineEditControlis right. CLAUDE.md rules that control out of the command line because ⏎ there has to send rather than insert; a composer is the opposite case, so undo, find, selection, mouse and a caret over wrapped rows all come free instead of being written again.- The buffer is the whole command, verb and all — nothing is prepended and nothing is guessed at —
and its line breaks are joined with
%r, because a MUSH stores a post as one string and renders the breaks itself. Blank rows at the ends are where the caret was left and are dropped; interior ones are paragraph breaks and become%r%r. - ⌥L switches escaping, and the escaping runs before the join. In
literalthe body's%,[,],{,},;and\are escaped so the post shows what was typed; inas typednothing is. Escaping after the join would produce%%r— the characters "%r" posted into the body instead of a line break, on every line of every literal post. The mode travels with the draft. - It is modal, and that is what makes it possible at all.
PinFocusToArmedBarwould fight an editor needing real focus for ever — except it stands down while the main window is inactive, which a modal guarantees. Same reason the settings screens are modal. - Paste is the framework's here, and must stay the only path.
SettingsOverlaytakes paste off the driver because its screens have no focusable target, and its own remarks warn that a focusableIPasteTargetwould make both fire.MultilineEditControlis one. That is why F1 refuses over an open settings screen (and why two modal windows with twoPreviewKeyPressedhandlers could not be driven headlessly anyway). - The editor is sized from the driver, not by
Fillalone.VerticalAlignment.Fillreads arranged bounds only once arranged, and the first frame is laid out against the control's ten-row default — a maximised window with a ten-row editor and the footer immediately under it.FitEditorsetsViewportHeightfromConsoleDriver.ScreenSize, and re-runs onScreenResized. Its colours must set both pairs: the control paints from the focused pair, and it always has focus here, so setting onlyBackgroundColorleaves the framework's grey on screen. - Drafts are per character and in memory only, for the life of the run. Not on disk deliberately: a
post is a few minutes' work, and a file would be a fourth thing this client writes, a purge entry, a
--helpline and somebody's unsent post in their home directory. Keyed by the window's owner, never_active; a window belonging to no connection keeps none.Close()raisesClosed, which is what stores the draft, so a send must close first and forget after — the other order posts the text and hands it straight back next time the window opens. - F1 is claimed in
MacroKeys.AppShortcutsbut is not a settings screen, soShortcutActionanswers it before the screen lookup — an arm reached only on a miss would make every future unclaimed F-key silently open the composer. Claiming it takes it offMacroKeys.Bindableautomatically, which is why a macro test that used F1 as "a free function key" had to move to F12. - The footer names ⌃S, ⌥L and Esc and nothing else. No
⌃F find: the control has a find API and no chord bound to it, and this screen is held to the same honesty rule as the settings screens.
- The buffer is the whole command, verb and all — nothing is prepended and nothing is guessed at —
and its line breaks are joined with
-
Every
[link=…]payload a pane carries is scheme-tagged byInteractionKind(LinkPayload:mux:send:/mux:prompt:/mux:web:), and the panes' handler takes the window id the click came from. Both are security properties, not tidiness. The tagging is disjoint because the parser decides the kind (<SEND>→SendCommand,<A HREF>→Hyperlink) and a world cannot choose that — while the hyperlink case passedhrefthrough bare,<A HREF="mux:send:@shutdown">was byte-identical to a real<SEND>and the click sent it. Never re-introduce a bare passthrough, and never add a "probably a URL" fallback for an untagged payload. The window id is what stops a link clicked in a background pane sending to whichever character is focused. -
No colour this client paints is left below a legibility floor, and the floor is applied where the colour meets the plane it lands on (
Contrast, Core;ChromeInk+WorkspacePalette.ReadingPlane, Tui; F7 ▸keep text legible, default on). The reported defect — "Freeze being purple against a blue background" — was one cell of a grid: the bar took its accent from the theme's index 5 and painted it on the pane, and#800080on a#36363dfocused pane is 1.27:1. Measured across the grid, six of the F2 picker's sixteen names fail 3:1 on the dark theme, nine fail on the light one, and onlygreyclears both — which is the finding the whole design turns on: a palette of fixed hexes cannot serve two themes, so the resolution has to happen at the moment of painting rather than the moment of picking.Contrast.Legible(fg, plane)moves a foreground the smallest distance that clears the floor and returns anything already legible byte-identical. Direction is the plane's, not the colour's — a dark plane lifts, a light plane darkens — which is what lets one function serve all three themes; the pivot is relative luminance against 0.18, because#808080looks like half way written down and is 0.216. Hue survives while there is headroom and then desaturates, and it has to: pure#0000ffhas a relative luminance of 0.0722 and tops out at 1.88:1 on a dark pane at full blue, so a rule holding hue absolutely would leave the commonest unreadable colour in MU* output unreadable.- The floor is 3.0:1 and deliberately not 4.5. A game's own de-emphasis is spoken in exactly the
colours a 4.5 floor would erase — bright black for asides,
dimfor a status line nobody is meant to read twice. Three is where a colour stops being invisible, which is the complaint; four and a half is where it stops being quiet, which is not. - The plane handed to
MarkupFormatteris per theme, not per pane. A pane's plane depends on its character's tint and on focus, and resolving against that would re-format a whole buffer on every focus move — the expensive path this file reserves for one deliberate keystroke.ReadingPlaneis the extreme of the fourteen a pane can wear, in the direction of travel, and that is the worst case rather than an approximation of one: once the foreground is past the background's luminance the ratio is monotone in the background, so clearing the extreme clears all fourteen. - A span carrying a background is measured against it. That plane is known exactly and a
highlight's pair must be judged as a pair; a span with the default background emits none and takes
the pane, so it is measured against the reading plane.
reverseswaps first. ScreenPaletteis deliberately not built this way. Those constants sit on the settings screens' own fixed backdrop, which no theme moves; measuring them against a theme plane would be measuring them against a plane they are never painted on.ChromeInkis the other four —Accent,Notice,Draft,Marker— and it carries the plane it was resolved against, so a renderer can hold a colour it was handed (a world's accent) to the same floor.- A fill is not text. A ribbon segment's accent, a pane tint and F2's swatch are identity or
sample, and lifting them would flatten the thing they exist to say; what has to clear the floor is
the ink on them, measured against the fill.
ChromeInk.Onis that, and the header ribbon is where both rules appear side by side. - The audit that found most of this is a test (
FrameContrastTests): every emitted SGR pair over 24 views × 3 themes. Reading the source found four of the offenders; the paint found nine. It exempts the powerline wedges and box-drawing rules (fill boundaries and dividers), the solid blocks (a swatch is a colour sample shown as the pane will paint it), and the framework's[dim]. The half blocks are not exempt —▌is the trigger left-rule and the focus marker, and one of them was a real defect this caught. - One thing is outside the floor's reach and is named rather than hidden. SharpConsoleUI resolves
[dim]to a fixed#808080through no option we hold: 4.01:1 on Dark, 2.52:1 on Solarized Dark's focused pane. Reaching it means giving up[dim]across every renderer for an explicit floor-checked grey — a sweep, for a near miss on one theme. The exemption is a named predicate with the number in it, so whoever does that sweep can delete it and watch the test pass.
-
A trigger's
routesays three things, and the third is "nothing" (TriggerActions.MainWindow, Core; F2'sroutelist).SpawnTarget = nullhas always meant this rule adds no destination — the line follows whatever the other matched rules decided, which is exactly what a highlight rule wants. F2 labelled itmain, so it read as a destination, and "highlight it and leave it where it was" looked like something the screen could not express. It is(none)now, and it is what a new rule defaults to.mainis a real destination — the matching session's own window. It earns a reserved word rather than being spelt as the window's title because one trigger set is shared by every character that lists it and a title can only name one of them; nothing collides, since a character's session window is titled after the character andmainis only the rail's label for it.- Gag suppresses the default delivery and nothing else. Every destination a rule asked for
survives it — always true of a spawn pane (
route: Chat+ gag has always meant "only in Chat"), and now true ofmain, soroute: main+ gag keeps the line where it used to delete it. - Destinations are deduplicated. They were not, and
WorldSessionraises oneSpawnLineper entry, so a highlight rule pointed at the same pane as its capture rule delivered every line twice. - A highlight rule needs no route to reach the pane a capture rule sent the line to. There is one line and one set of destinations, and every matched rule's highlight is on it. Do not "fix" that into a per-rule delivery.
-
A prompt reaches the pane again, and everywhere a line reaches (
WorldSession.OnOutputReceived, Core;fix(prompt),cd06e8b).22bd97f's header/status-bar rewrite deleted the one render that ever readCurrentPrompt, so a prompt was parsed and stored but shown nowhere. A prompt is now an ordinary line throughProcessOutputLine, the same function every other line of output runs through, so it reaches scrollback and the spill,PlainTextLogSink/HtmlLogSink,RestoreLog,OutputSearch, the trigger/alias/macro engines, and the unread/activity/away bars, exactly as a line the server terminated with its own newline would. On a MUD that prompts after every command, this is one extra line per command — the cost of the fix, not a defect in it; Mudlet does the same, and it is what "searchable and trigger-visible" requires. Two decisions not to relitigate. It lands exactly once.ProcessOutputLineis handed the pre-emojirawline, not the already-substituted one built forCurrentPrompt/PromptChanged— handing it the substituted line would run emoji substitution twice. A prompt boundary with no printable run prints no line. A burst that ends in bare SGR (ESC[0m) or an erase sequence and then goes quiet for the hold buffers nothing inAnsiParser, soFlush()returns null;CurrentPrompt/PromptChangedstill update, butProcessOutputLineis not called — that is a boundary, not a blank line the server sent, and is the opposite case fromfix(line)'s genuinely blank line, which does print. Getting this backwards means a blank line lands in every buffer once per prompt, forever.- The line carries
StyledLine.IsPrompt, set once at the end ofProcessOutputLineon the line actually delivered — not threaded throughStripColour/ExpandTabs/the trigger engine/emoji substitution. It travels throughLinePrintedandSpawnLineEventArgsfor free, since both already carry aStyledLine, and round-trips throughStyledLineCodec(a previously-unused flag bit, so noRestoreLogformat-version bump). Wired, not yet acted on: nothing today gags, logs, restores, searches or badges a prompt any differently from an ordinary line because of this bit.
- The line carries
- .NET 10 SDK: install via
apt-get install -y dotnet-sdk-10.0(the Microsoft CDN is often blocked; Ubuntu's repo works). NuGet (api.nuget.org) is reachable. - Tests are TUnit on Microsoft.Testing.Platform (
Exeprojects, not xUnit).dotnet testdoes not work — .NET 10 dropped VSTest. Run each suite directly, and keep the</dev/null: it detaches stdin so the test host doesn't hang waiting on it.There are five: Core, Graphics, Scripting, Web, Tui. Primary signal isdotnet run -c Release --project tests/SharpMUTerm.Core.Tests </dev/nulldotnet build SharpMUTerm.slnxplus all five green and warning-free. - Building against the local SharpConsoleUI clone surfaces 2 NuGet advisory warnings for AngleSharp. They are the framework's, not ours; a build against the package has none.
A headless environment can't run NetConsoleDriver or render Kitty graphics, but the TUI is not
therefore unverifiable: it renders real frames headlessly.
dotnet build -c Release SharpMUTerm.slnx # -c Release, or --no-build lies to you
dotnet run -c Release --project src/SharpMUTerm.Tui --no-build -- \
--snapshot --demo-config --view <name> --size 120x32 --out frame.ansi
python3 tools/ansi_frame_to_image.py frame.ansi frame.html # or .svg-
--theme <name>renders a frame in a built-in flavour (Dark/Light/Solarized Dark). It exists because the client's chrome is derived from the theme and held to a legibility floor against it, and every frame in the gallery renders Dark — which is exactly how the Light theme's accent (1.42:1), draft pen (1.26:1) and notice (1.73:1) stayed unreadable without anybody ever seeing them. It setsThemeNameandTheme:ResolveThemetreats an inline theme whose name disagrees withThemeNameas a customised one and prefers it, so setting the name alone would be overruled by the Dark theme still sitting inTheme. -
-c Releaseon the build, and it is not a formality. A baredotnet buildproduces Debug,--no-buildruns the Release output, and nothing warns you: the snapshot renders happily from a binary that predates your change, so a new view comes out byte-identical to the default frame and a changed one shows the old behaviour. That reads exactly like a feature that does not work, and it has cost real time. Either build Release first, or drop--no-build(--no-buildis only there to keep the render fast). Running the test suites also refreshes Release, which is why the trap hides whenever you happen to have just run them. -
--demo-configis not optional for verification work. Without it the snapshot renders whatever config is on the machine, and a saved~/.config/SharpMUTerm/quietly replaces the demo worlds — you end up checking your own data and calling it the demo. -
The demo has no live session, so anything a session writes has to be written into
DemoSceneby hand — and it has to match. Its saved main-window title isCorvidbecause that is whatBindSessionwrites (SessionTitle(session)); it used to saymain, and that one word of divergence hid the rail repeating the world's name under the character for as long as the rail has existed. Three separate bugs have now hidden in this gap.RailWindowRowTests.TheDemoScenesMainWindowIsTitledTheWayA…holds the two sides together; when you add state the demo fakes, pin it against the live writer the same way. -
Views:
worlds/settings,triggers,route,highlight,aliases,timers,keypad,set,textansi,input,logging,password,startup,freeze,spawn,split,move,drag,history,history-search,history-search-filter,draft,draft2,menu,menu-split,messages,quit,connections(two connections on one world — the one view where the header's fraction, the rail's dots and the quit prompt's count are all visible together and all have to agree; every other view has at most one character connected per world, which is what hid a header dividing connections by worlds and a quit prompt reducing them to distinct world names),characters(two characters genuinely open — the one state the rail's ⌥J/⌥K column can be seen in, and the one thingconnectionscannot fake: that view marks dots connected and opens no session, while the cycle walks the sessions this client holds),tint(two characters' panes in two different colours, one focused and one not — the one frame where the per-character pane tint can be seen doing its job, since a single tinted pane cannot answer "whose pane is this", and the one that shows the two cues are on separate channels: identity in hue, focus in luminance. It writes the colours onto the realCharacterDefinitions; the demo config carries none, becausePaneTint.Noneis the default and a tinted demo would make every other frame in the gallery show a state most clients are not in) andtint-input/tint-input-moved(the same scene with both command lines up, before and after a real ⌃→ — the only geometry that can show the bar wearing the focused character's colour beside a pane wearing it, an armed tinted band over an idle tinted one, and the colour travelling with the focus. The moved frame is also the one that shows a bar wearing a character's hue while its prompt readsno connection ›, which is the composition rule stated in paint: hue says whose, not whether),tint-tabs(the same two tinted characters with no split, so one pane holds both their windows as tabs — the only geometry where an idle chip can be seen wearing a colour the pane behind it is not, which is the whole of the per-tab chip),deletions,compose/compose-literal(the F1 composer in each of its two escaping modes — the pair exists because ⌥L changes what is sent and only the header says which way it is set; the demo has no session, so the target is handed in and pinned against the live writer byComposeWindowTests),mssp/mssp-none/mssp-never(the three states of the F5 ▸iserver-information report — a report, a server that answered and publishes none, and a world nothing has dialled; all three reached by driving the realiinto a real F5, and all three needed because the two empty ones are the pair it is easy to conflate),web,rail-long,scrollback,scrollback-up,freeze-scrollback,links(a URL too long for the pane it arrived in — a split, so the pane is narrower than the terminal, which is the whole defect; the frame shows one underlined span running to the pane's edge and continuing on the next row),away/away-scrollback(the bar marking where the reader was when they tabbed away from the terminal — the shallow absence, where the bar and everything below it are on screen at once and the pane is left on its live tail, and the deep one, where more arrived than the pane holds and the client has scrolled the pane to the bar itself; the second is the only frame that can show a bottom-anchored pane being "caught up" while nothing has been read, and the only one that would catch a scroll landing at the wrong row),search/search-regex/search-all/search-landed(the ⌃F surface: a plain query with its hits marked, the same query read as a pattern — the header is the only place either state is said, which is why they are a pair,compose/compose-literal's reasoning — the widened scope, where the window column appears and a background pane's hit is listed under it, and what ⏎ leaves behind. The last is over a split, so the pane is narrower than the terminal: that is the geometry that catches a landing scrolled to the wrong row, since a buffer index is not a viewport row),activity-bar(the other absence — a window the reader was not watching: three lines land in the main window while Chat is in front of it, and picking main back lands on theNEWbar with those three under it. Separate fromawaybecause the two are separate facts with separate wording, and this is the one that happens many times an hour),selection(a real ⌃-drag across the main window's output, throughSimulatePaneDragand the control's own hit test rather than a highlight posed by hand — the only frame carryingWorkspacePalette.SelectionBand, which is why it is inFrameContrastTests' list: the band is the one plane this client invents rather than derives from a pane, and its ink replaces the world's own on every selected cell),prefix-panel(the ⌃B which-key panel — the stateprefixbecomes a few hundred milliseconds later, if no key has arrived),focus/focus-moved(a split and a second command line — the one geometry showing a focused pane beside an unfocused one and an armed bar above an idle one, before and after a real ⌃→),tabs(two tabs in the pane that does not hold the focus — the one geometry that can show a strip saying which tab is in front, since every other view has at most one tab in an unfocused pane. It opens a third window for itself alone, and re-activates the main window before splitting, because opening brings a window to the front and a split carries the tabs that are not),tabs-many(more tabs than the strip can hold — five of one character's captures in a split pane, which is the state every other tab view is too small to reach. Without eliding, that pane draws two tabs and half of a third and says nothing about the rest), plus the default workspace (no--view). Any settings screen also takes a-editsuffix, which opens it and drives real keys in so the frame shows a field mid-edit. State toggles:collapsed,prefix,timestamps, andtimestamps-toggled— the same column reached the other way, by dispatching the real ⌃P entry after the scene is already on screen, over a split so one frame carries a session window beside a spawn window. The pair has to match cell for cell; when it did not, that was the reported bug. -
A snapshot never writes configuration.
SharpMUTermApptakes itssaveaction from the caller and the snapshot path passes none, so an app that isn't the live entry point owns no file. That matters because the settings screens persist each committed change as it is made: without the gate, a--demo-configframe that drove a key into a field (logging-edit,keypad-edit,deletions) would write the demo worlds straight over your ownconfig.json. It now also protects a second file: character passwords are saved insecrets.jsonbeside the config (SecretsStore,0600), withconfig.jsoncarrying only a meaninglesspasswordRefGUID, so a save writes a secret-bearing file too.CommandSurfaceSettingsTests.AnAppWithNoSaveActionPersistsNothingis the pin. -
A snapshot writes no session log either, and neither does a test. Same shape, same reason:
SharpMUTermApptakes itslogRootfrom the caller and onlyProgramsupplies one, so an app that isn't the live entry point owns no log directory. It used to resolve the directory ofConfigurationStore.DefaultPathunconditionally, and the demo scene'sAetherfall.CorvidisLogging.Format = Htmlwith no directory — so every headless run that opened a session for it created a real file under~/.config/SharpMUTerm/logs, beside genuine transcripts and the diagnostics log. 277 empty ones had piled up. Null means no logging at all, not "no default location": a character's explicitLogging.Directoryis refused too, because the root is the app's answer to may I write transcripts and the character's directory only ever chose where within that — read the other way, every fixture naming a path is free to write outside itself. Nothing may then claim otherwise: the header'sLOGcell readsLOG off(HeaderLogFormat, the same reasoning asWorldSession.CurrentEncoding— a configured value is a preference and a status cell may not report one as in force, which is why the--demo-configframes now readLOG off), and ⌃P ▸ Start logging refuses out loud. The pin is a file count, not a mock (LogRootTests+LiveLogDirectoryGuard, which lists the real log directory before the first test and after the last): the old code was internally consistent and still wrote those 277 files, so anything asserting onLogFolderor a fake sink would have passed all along. It also let a pile of per-testcharacter.Logging = new LoggingSettings()workarounds be deleted — with them gone the suite exercises the gate, and an unfixed build leaks seven files a run instead of three. -
Four views have more output than a pane holds —
scrollback,scrollback-up,freeze-scrollbackandaway-scrollback. Name them, rather than saying "thescroll*views": the prefix has now twice lagged behind the set it claimed to describe, and a reader looking for a long-output view goes by the list. (awayis the shallow one and fits.) Every other view fits too, which is exactly why no snapshot caught the panes being unable to scroll at all. Reach for one of these (orLoadLongScene) whenever a change touches the output area. -
Send the user the
.svg. For your own inspection render the.html— Chromium clips the bottom of a bare.svgthrough aspect-ratio scaling, which will make you chase a layout bug that isn't there. -
Decoding a frame precisely: the
.ansiis cursor-addressed SGR. To check exact column widths or which background band covers which row, walk it into a{row:{col:ch}}grid tracking48;2;r;g;b(background) — note48, not38, or you will read foreground and conclude wrongly.
Package SharpConsoleUI, repo nickprotop/ConsoleEx, pinned at 2.5.14. Always the package —
there is no source-reference path and no switch. A sibling clone at ../SharpConsoleUI used to be
detected and preferred by default, which meant merely having it on disk changed what this repo
compiled against, silently and differently from CI; that is gone. Clone it and read it, and never wire
it into this build.
App shape: ConsoleWindowSystem(new NetConsoleDriver(RenderMode.Buffer), new ConsoleWindowSystemOptions());
fluent WindowBuilder/Controls factories; AddControl is builder-time, so keep refs and mutate at
runtime. Marshal background work with system.EnqueueOnUIThread; global keys via
RegisterGlobalShortcut; system.Run() blocks, RequestExit(code) ends it. Text is Spectre-style
markup ([bold #rrggbb on #rrggbb]…[/], [[/]] escaping, [link=url]…[/] → LinkClicked).
- Controls default to
HorizontalAlignment.Left, which self-sizes to content instead of filling the slot. This is the single biggest cause of "why doesn't this fill the width?" Use.WithAlignment(HorizontalAlignment.Stretch). A.Flex(n)column only fills if the grid is arranged at full width and the child in it is Stretch. - Nothing focuses a control for you, and nothing keeps it focused. A click in the output pane, a
click on a tab strip, ⇥, and every overlay's
SetIsActiveall move focus. Typing is routed explicitly fromPreviewKeyPressedand so survives that; paste is not — it followsFocusManager, which is why paste broke after any click while typing appeared fine.FocusChanged → PinFocusToArmedBar()makes "which bar ⏎ sends from", "what the framework pastes into" and "where the caret is drawn" one fact. Keep the pin; don't re-sync three places. - A caret assertion must read the frame, not the function.
InputBarControl.GetLogicalCursorPositionis where a caret bug lives, so a test built on it (SharpMUTermApp.CaretReported) agrees with the code while the screen disagrees.CaretOnScreen()reads the cell the driver was handed — it goes throughConsoleWindowSystem.ProcessOncebecauseForceRenderpaints and stops, and the cursor pass (UpdateCursor) is a separate,internalstep of the real loop.FrameGrid.Decodeturns a frame back into cells so "where the text is" is read off the paint.PaintDOMandGetLogicalCursorPositionderive the text's origin and scroll from one place (InputBarControl.Geometry): the paint has always been clamped to the rows it was given and the cursor was not, so on any frame where the arranged height was short of the requested one they disagreed about the scroll. - The caret was placed from a rectangle only the mouse refreshed — an upstream bug, fixed in the
clone, and a whole class of defect no test here can see.
ControlBounds.ControlContentBoundsis written byWindowEventDispatcher.UpdateControlLayout()and by nothing else, and that has exactly one caller:ProcessMouseEvent. Both cursor readers preferred it and fell through to the layout node's (always fresh)AbsoluteBoundsonly while it was stillRectangle.Empty— so the caret was correct until the first mouse event the window ever saw, which populated the cache and froze the caret at wherever the pointer had last seen the control. Every later layout change (the input bar growing on F8, the second bar showing or hiding) then moved the text and left the caret behind, permanently: typing does not fix it, and leaving the terminal and coming back does, because that crosses it with the pointer. Fixed in../SharpConsoleUI(CursorBoundsStalenessTests) by preferring the node in both readers. Two things follow for this repo. That fix is not in the pinned package, and this repo builds only against the package, so we still have the bug until a release ships it. And nothing in our suite can catch this class of bug: the framework only registers its driver-mouse handler insideRun(), which no test calls, soControlContentBoundsis never populated headlessly and every caret test here silently exercises the fresh path. A caret report that our tests cannot reproduce is evidence for looking upstream, not evidence that it is not happening. - Ask the driver for the terminal size, never a literal.
_system.ConsoleDriver.ScreenSizeis correct from the moment the window system exists — before any window does. Chrome built in the app constructor against a literal wrapped the header on the first frame of any narrower terminal, and snapshots never saw it because every render path rebuilds the header on the way past. Same shape as gating chrome onheadless: right in a snapshot, wrong in a terminal. Test the class of bug by reading chrome width off a freshly constructed app. - Vertical space at the window root is sticky-first, Fill-last (
Layout/WindowContentLayout.cs). Sticky-top and sticky-bottom children are measured first and then trusted; Fill children divide what remains. A flow control therefore cannot starve a sticky one — so "the workspace is greedy and squeezed the input bars" is a diagnosis this layout cannot produce. But there is noMinHeightconcept and nothing checks the two sticky bands fit each other: at 80×6 they over-commit and the status line is arranged off-screen.SyncInputHeights' veto counts chrome rows to prevent it, andPaintStatusre-runs it because the status line's length changes at runtime. Assert layout with arranged bounds (ActualHeightafter a real frame), not arithmetic. - The desktop panels are off unconditionally (
ShowTopPanel: false, ShowBottomPanel: false). They restate our own header and trim window titles to fifteen cells — on this app, one row readingSharpMU...lient. They were once hidden in headless only, so no snapshot showed them and the truncated title survived to a real terminal. WindowBuilder.Centered()must come afterWithSize()— it reads_boundsand falls back to 80×25, so centring first positions the window as if it were that size.PromptControlis not the command line —InputBarControlis. The framework's prompt is single-line by construction (SetInputreplaces\nwith a space) and unfocuses itself on ⏎ (UnfocusOnEnter, default true, not settable through the builder).- Settings screens have no
IPasteTargetand cannot easily have one. They are markup rebuilt wholesale every key, with the edited field a buffer inSettingsSession.SettingsOverlaylistens atIConsoleDriver.Pasteinstead. That can't double-fire only because no control on those windows accepts paste — add a focusableIPasteTargetthere and both paths will. - The framework's own
ExitKeydefaults to Ctrl+Q and callsRequestExitwith nothing in between. Ours won only because application shortcuts are tried first. It is set tonull. - The framework claims
Alt+1–Alt+9too —InputCoordinator.HandleAltInputselects among top-level windows by index. It is the third framework default found outranking us, and the one with the least warning: it is reached fromProcessInput's fall-through for any unhandled Alt chord and, unlike the move and resize handlers on the two lines above it, is not gated onIsMovable/IsResizable— soMovable(false), which switched off the ⌃X-swallowing move handler, did nothing here.⌥1–⌥9are ours (JumpToWindow, go to the numbered window) and are claimed as application shortcuts, whichInputCoordinatortries before it offers a key to any window at all;PreviewKeyPressedwould also have won (WindowEventDispatcher.ProcessInputraises it first and returns immediately when handled), one step later. All nine are claimed, in range or not: an out-of-range ⌥7 reports and stops rather than falling through to a window selector that would do something else.⌥0is deliberately left free — the framework ignores it too, so it stays bindable. - Ctrl+digit is not a chord this terminal has, and that is why the numbered jump is Alt. Read off a
pty with
kitten @ send-keyat a raw-mode reader, the same wayAlt+Shift+arrowwas established: everyAlt+digitisESC+ the digit (one Alt chord out ofProcessEscape), while Ctrl+digit is1→0x31,2→NUL,3→**0x1B(Escape),4–7→0x1C–0x1F,8→0x7F(Backspace)**,9→0x39,0→0x30. So three of them are keys the client cannot afford to bind over and three are indistinguishable from typing — binding any breaks the plain key, exactly as with Ctrl+H/I/J/M. Recorded per digit inMacroKeys.DigitBytes, which is what F4 prints. - Where a chord could be Ctrl or Alt, it is Alt — and "safer" is measured, not preferred. Alt+key is
ESC+ the key and arrives intact; Ctrl+key collapses onto a byte the terminal may already spend (⌃HBackspace,⌃ITab,⌃M/⌃JEnter,⌃Taba bare Tab), and Ctrl+digit has no usable encoding at all. Every chord inMacroKeys.AppShortcutshas been driven at a raw-mode reader withkitten @ send-key; the bytes are inDigitBytes/ControlBytesand F4 reads them.- The connection pair is
⌥D/⌥R. Disconnect was⌃Don one justification ("the terminal's hang-up chord") and reconnect⌥Ron a different one ("one modifier over from⌃R") — each fine alone, and together making a reader learn two modifiers for one concept.⌃Dis released, not kept as an alias: a second key for one action is either a secret or a duplicate row on every surface that lists chords, and letting it go hands a clean Ctrl chord back for macros. Nothing takes it —HandleMoveInputis gated onIsMovable(false here) and acts only on the arrows andX. ⌃Tabis gone and was never real. It was claimed as a second spelling of "next window"; a terminal writes0x09for it, which is Tab, so the parser reportsConsoleKey.Tabwith no Control bit and the claim could never have matched — whileClaimedis consulted first, so F4 told users a chord was taken that cannot arrive.⌃Nis the chord; the byte is inControlBytes.- Deliberately left on Ctrl, because the convention is worth more than the pattern:
⌃R(readline's reverse history search),⌃P(command surface),⌃Q(quit — and safe here becauseTerminalRawModeclearsIXON, so it is not XON),⌃B(tmux's prefix),⌃O(pane cycle),⌃N/⌃W/⌃F(find), and the command line's⌃A/⌃E/⌃K/⌃U/⌃L. A sweep that moved everything would be as wrong as one that moved nothing. - Freeze is
⌥F, and⌃Fis find — the search surface, above. Freeze was on⌃Fand left it for exactly the reason the keys above stay where they are:⌃Fmeans find to everyone who has used a computer, and that convention outweighs freeze's claim on the chord. Freeze kept its letter and changed its modifier — the smallest move that frees it;⌥FisESC f, measured. Nothing is left behind on⌃Fas an alias (the⌃Drule). The label a frozen reader is looking at (FreezeBarRenderer) moves with the chord: a bar naming a key that no longer thaws the pane would be the worst possible place to leave a stale chord. - History recall has its own chord:
⌥↑/⌥↓. The bare arrows still recall where the caret has nowhere further to go, and that is unchanged — but it is a rule that stops being usable the moment the bar grows to a second row, which was the report.⌃↑/⌃↓, the alternative offered, was never available: the terminal writesESC [ 1;5 Aand this client already spends it on pane selection and on the ladder onto the second command line.ESC [ 1;3 Ais Alt, is free, and is whatTryRecallKeynow matches on — exactly, so⌥⇧↑(the pane resize) still reaches its own handler. A macro bound toAlt+Upwins over recall, becauseDispatchMacroruns first: the same relationshipCtrl+←/→has with pane selection. ⌃Nwalks the focused pane's tab strip, and it stays⌃Nbecause the familiar spellings do not exist here. Asked for "an easy key-combination to tab through the tabs of the active pane", the obvious candidates were measured at a raw reader withkitten @ send-keybefore anything was built:⌃Tabis09, byte-identical to a bare Tab (already inMacroKeys.ControlBytes);⌃⇧TabisCSI Z, byte-identical to plain⇧Tab; and⌥TabisESC+09, which isESC+ a control byte and so arrives as two key events rather than an Alt chord (AnsiInputParser.ProcessEscape). ATryAltEnter-style reassembly could pair them, but Tab is already spent asTerminalFocusWatcher's disguised focus-in — andsend-keywrites into the pty, so it says nothing about the compositor, which takes⌥Tabunconditionally on Windows, GNOME and KDE.⌃PgUp/⌃PgDnis the one familiar pair that does arrive (CSI 5;5~/CSI 6;5~, decoded byDispatchTilde, and free becauseTryScrollKeymatches PageUp/PageDown only atctrl: false) — kept in reserve rather than spent, since the reported problem was that⌃Ncould not be found, not that it was wrong.- So the fix was discoverability, and the chord had to earn it by answering.
⌃Nwas named on F4 and nowhere else. It is now a ⌃P entry (layout:next-tab, listed unconditionally like the directional pane entries, because this surface is where a reader learns a pane holds tabs at all) and a status-row segment (⌃N tab, shown exactly while the focused pane has a second tab, the same contextual rule its neighbours follow). Listing a key obliges it to answer:NextWindowreturned in silence on a single-tab pane, which is indistinguishable from a dead key, and now refuses out loud besidePrefixPanel.NoCycleRefusal's wording. Every surface says tab, not "window" — F4 and--helpsaid window while everything else said tab, and⌥Nalready owns the window noun. FocusHintsseparates reading order from drop order. Three independent conditions is eight cases, so the ladder is generated; but the row readspane · size · linewhile size is the first thing surrendered, so a generator that dropped from the end of the reading order would silently reorder a row nobody asked to reorder. The tab segment is given up second, and that judgement is written down: a pane's tabs are drawn as a strip you can see, so the hint names a shortcut to something already visible, while nothing on screen says how to reach another pane or the second bar.- Known and not fixed here:
⌃Nand⌃Ohave no reverse (the character cycle does —⌥J/⌥K), and⌃Wand⌃B xare two chords for one action. Both are shape complaints rather than defects, and both are behaviour changes rather than modifier moves.
- The connection pair is
- There are two numberings and one cycle, over three different sets, spelt differently everywhere.
Windows are
⌥NinWorkspace.WindowsFor(active)order; panes arepane NinLayout.Panesorder; characters have no number at all and are reached by the⌥J/⌥Kcycle. Both numberings are creation order, both take the number from the index rather than the sequence, and neither is ever written in the other's vocabulary. That separation is the whole safety property: the digits are the same ten characters, so if a surface could print a bare number that might be either, a user reading it cannot know which key to press.- ⌥1–⌥9 go to a window of the active character, numbered from 1 within that character
(
JumpToWindow). Windows rather than panes because a capture sharing a pane with its character's own window had no number of its own and was reachable only while it happened to be that pane's active tab — "switch not just characters, but captures, etc." Scoped rather than global, because global failed on a real client the first day: three characters sharing pane 1 as tabs, and the sidebar giving all three of them⌥1. Nine digits also do not stretch over everybody's windows. An unowned window (the web view) is in every character's list, so it wears a different digit under each — which is exactly the setBuildRailWindowsdraws, so the sidebar and the chord are one list read twice.WorkspaceWindow.Sequenceis a per-workspace counter assigned at creation, persisted inWorkspaceWindowState, never reused; a window restored without one is seeded from the saved order. - ⌥J / ⌥K cycle characters, forward and back. Letters and not a third digit row because there is
no third digit-bearing modifier this terminal delivers: kitty writes
⌥⇧1asCSI 49;4uand⌃⇧NasCSI 110;6u, kitty-keyboard-protocol sequences the parser does not decode and silently drops (measured, not assumed). It walks only the characters already open, becauseSwitchToCharactercreates a session and a window for one that is not, and a cycle key that did that per press would dial through a configuration by accident.Workspace.Windowsis a dictionary's values and is not the numbering — its order is unspecified after a removal, so opening a window into a closed one's slot renumbers everything after it (WindowNumberingTests.AWindowOpenedIntoAClosedOnesSlotStillTakesTheLastNumberis the pin, and it took a four-window fixture to expose: with fewer, both orders agree). Only placed windows are numbered — one no pane holds is drawnclosedand a digit for it would name nowhere to go. - ⌃B 1–⌃B 9 go to a pane. It was ⌥N until windows took that; it is on the prefix because that is
where the rest of the pane keymap lives, and it is kept rather than dropped because the pane
numbering does not go away with the chord — move mode badges each pane with it, the move and drag
prompts say
pane 2, the ⌃P entry saysGo to pane 2, and ⌃O counts in it. It also refuses on a single-pane workspace including ⌃B 1, because the which-key panel dims that row. Layout.Panesis creation order;LayoutNode.Panes()is tree order, and they are different things. Tree order (left-to-right, then top-to-bottom) is geometry and is whatLayoutSolver,PaneResizeand the renderer walk. It used to be the numbering too, and a number that is a function of where a pane is moves when a pane is inserted before it: dropping a window on the left edge of pane 2 made that pane into pane 3, so the digit that meant it stopped meaning it while the user was doing something else.PaneNode.Sequenceis the same mechanism as the window one, seeded from tree order for a config written before the field.- The number is the index, never the
Sequenceitself, in both numberings. Sequences have holes after a close; the numbering may not, or a digit is a silent no-op with the things still on the screen.PaneNumberingTests,WindowNumberingTests,WindowJumpTests.ClosingAWindowCompactsTheNumberingOnTheChordAndInTheSidebar. - ⌃O cycles panes in the pane order. It read tree order once, which agreed with the numbering back when the numbering was tree order. Its partner is ⌃B N, not ⌥N — the window movers (⌥N, ⌃N) are a separate ladder — and three presses of ⌃O from pane 1 must land where ⌃B 4 does.
- ⌥1–⌥9 go to a window of the active character, numbered from 1 within that character
(
- The rail is the only place you read either chord, and every row carries the one that reaches it.
Window rows carry their own
⌥N; the two character rows either side of you in the cycle carry⌥Jand⌥K, and every other character row carries nothing, because nothing else is one keystroke away. A character row used to carry the chord of its own window — which, once the numbering was scoped, printed⌥1against every character on the screen. The sidebar prints⌥…and neverpane N: it is about windows and characters, and a pane noun there would be a second reading of the same column.- The chord leads the row; the badges trail it. The reported complaint was the gap — five blank cells (the reserved pen and unread fields) between a window's name and its chord. Those fields cannot be removed (a cell that costs only when it has something to say resizes the sidebar on a keystroke or a line of output), so the chord moved to the front instead, against the name it names. The row's measured width is unchanged by the move: the demo rail is 22 columns before and after.
- The chord field is reserved per row kind. Reserved, so the width does not move as unread
arrives, as a draft is typed, or as the
⌥J/⌥Kpair travels between rows. Per kind, because with fewer than two characters open no character row can hold a cycle chord, and reserving across both spent three cells on every character row of the commonest client there is.RailChordColumnTestsis the pin, on the sidebar's column count and the pane rectangles.
- The ordinal movers carry a zoom; the directional ones cannot. A zoomed workspace realises
exactly one pane, so ⌃O, ⌃B N or ⌥N moving the selection and leaving
ZoomedPaneIdbehind puts the selection, the session the bar talks to and the caret on a pane that is not on the screen — the "attention on one pane, keystrokes to another" defect again.WorkspaceLayout.CarryZoomToFocusedre-points an existing zoom (it never starts or ends one) and both movers call it. ⌃←/→/↑/↓ do not and must not: with one pane realised there is no neighbour to ask for, which is why they refuse out loud. MarkupControldoes not scroll and does not bottom-anchor.PaintDOMpaints rows from index 0 until the box runs out, with no offset of its own — so a control holding 100 lines in a 10-row box renders lines 1–10 for ever and everything appended lands off-screen. Scrolling lives inScrollablePanelControl, and every output region in this app is now wrapped in one (SharpMUTermApp.ScrollViewFor). Two things about it:AutoScrollis not "on AddControl", whatever the property doc says: it re-pins to the bottom on any repaint while enabled, detaches when the user scrolls up and re-attaches at the bottom (ScrollablePanelControl.Rendering.cs:125-133,.Scrolling.cs:145-152). That is terminal behaviour; don't reimplement it. But it moves the offset during paint, after the children were arranged, so the frame that discovers new content is one frame stale — a headless snapshot or test must render a settling frame (SettleScroll/RenderWholeFrame).ScrollToTop/ScrollToBottomdo not touchAutoScroll. OnlyScrollVerticalBytreats itself as a user gesture. A jump-to-top that leaves auto-scroll armed is undone by the next repaint.- A disposed
ScrollablePanelControlclears its children, andRebuildPaneAreadisposes the whole old tree. The kept viewports therefore refill themselves; markup controls survive disposal (they override nothing), which is why the same one is re-parented for the life of the app.
- Only
MarkupControl.AppendLineandFeedRangehand pane content to a control — the seam a windowed feed replaces. Appending re-parses the whole control (the parse cache is keyed on a content version), so never "refresh" a pane by re-SetContent-ing the full buffer on a scroll or a frame.BindSession's scrollback replay used to be a third route, painting straight onto the control; the buffer and the control then disagreed and the next thing to re-feed that pane dropped those lines. - A buffered line is a
PaneLine— markup plus, held apart from it, when the line arrived — and the timestamp gutter is glued on inCompose, on the way to a control. That is what makes show timestamps a render-time decision: it repaints history in every window, including spawn windows, whose history is markup in this buffer and nothing else (there are noStyledLines left to re-render). Baked into the markup at append time it reached only lines yet to arrive, which is indistinguishable from a dead command on a quiet connection — the reported bug. Whatever else a setting does, do not decide it at append time if it describes lines that have already arrived. The re-feed it costs (RepaintPanes) is the expensive whole-buffer path, and it is affordable only because it is bounded by one deliberate keystroke; do not reach for it per line or per frame. - A ⌃P view toggle must not persist through
SaveConfiguration. That is the settings screens' funnel and it also runsReloadAutomation, which re-periodises every running timer and so resets every other timer's phase. The write-only half isPersistConfiguration. - The rail's width is derived from its widest row, so the rail must be re-measured whenever its rows
change — not only when the pane area is rebuilt.
RefreshRailrecomputes it and resizes the sidebar's own grid column (ApplyRailWidth); the width was once computed only inBuildWorkspaceRow, so the startup retitle poured longer rows into a column sized for shorter ones and the framework wrapped them. A wrapped rail row is what got reported as "the sidebar looks broken". Rows are also elided toRailMaxWidth - RailMarginbefore they are measured (RailRenderer.Render'smaxWidth), because the width is clamped: without that, any label past the clamp — a web page's title, most easily — wraps no matter how carefully the column is sized. The width feeds per-pane NAWS through the pane rectangles, and that report rides the frame (PostBufferPaint → ReportPaneSizes), so nothing needs to announce it here. - Nothing volatile on a rail row may cost a cell only when it has something to say. The width is the
widest row's, so a badge that appears out of nothing widens the sidebar, narrows every pane and
re-announces a new terminal size to every connected server. The unsent-draft ✎ did exactly that on the
first keystroke of every line (the reported "sidebar grows by one character"), and the unread count
does it unbidden from the wire — twice, once appearing and again at 9 → 10. Both now render into
reserved fields that are blank when empty (
RailRenderer.UnsentFieldWidth/UnreadFieldWidth, the count capped at99+so the field is finite). The sidebar is a few columns wider than it used to be at rest and it no longer moves. What is left variable, deliberately: the labels (they change when the thing they name does, and elision caps them), the hosting-pane column (only in a split, and appearing when the layout changes, which is already a relayout), and the set of rows itself (a spawn window opening is structural).RailRendererTests.Render_An*DoesNotChangeARowsWidthandFocusIndicationTests.TypingDoesNotMoveAnyPaneRectangleare the pins — the latter is the typing counterpart ofMovingFocusDoesNotMoveAnyPaneRectangle, and it has to read the rail's widest row, because in the demo scene the Chat row's unread badge coincidentally masked the pen's two cells. - A tab title is markup, and that is what makes the new-activity tint free.
TabControl.Renderingparses each label withMarkupParser.Parse, and every width it is measured by — the header paint, the strip's desired width, and the click hit test that picks the tab and its×— isMarkupParser.StripLength. So a[#rrggbb]tag on a tab costs no cells and moves no hit test.TabTitlesclaimed the opposite for as long as it existed, and the claim had two costs: the unread count went out untinted, and a window title was never escaped — a window called[Chat], or a web view titled from the page it loaded, had that eaten as a tag by the parser and by the hit test. Titles areMarkupText.Escaped now. The tint and the selection bold cover the name and the count only; the▌stays outside them, because focus, selection and activity are independent facts and a marker that changed colour on an incoming line would be reporting the wrong one. The cues are different channels on purpose — focus in background luminance, activity in a foreground no plane is painted in, selection in weight and in a within-strip step. A selected tab can be unread, so the two are one tag ([bold #rrggbb]) rather than two nested ones. - A tab chip says which tab you are viewing, relative to its own strip — and pane focus is not a term in
it. The selected chip is painted
PaneSurfaceToneexactly, the plane its own page is painted on, and its siblings areWorkspacePalette.Recessedone step below it (BackdropScale, the backdrop's own step). Focus arrives already folded into that plane — tinted for the character, lifted for focus — so one ratio inside every strip reads whether or not the pane holds the keyboard, and hue is untouched. Deriving the selected chip from focus is what made this unreadable:PaintTabChipscomputed an unfocused pane's selected chip and its siblings from the same expression, background and ink, so every chip in a background pane was byte-identical to the pane's surface and the▌was no help becauseTabTitlesonly emits it on the focused pane.TabSelectionTests, and thetabsview is the frame — no other view has two tabs in an unfocused pane, which is why nothing caught it. Not fixed here: the│between chips and the─filling the rest of the strip are hardcodedColor.GreyinTabControl.Rendering.cs, unthemeable in all three header styles. That is an upstream change. - An idle chip wears its own character's colour, and that has to be said in the title (
TabChip,SharpMUTermApp.ChipFor).TabControl's four chip properties belong to the control, so every unselected chip in a strip is one colour — and a pane can host several characters' windows as tabs while painting one rectangle, so that one colour is necessarily the front window's. The other characters' tabs were therefore drawn in a hue that named the wrong person, which is the one thing the tint exists to prevent. The per-tab channel is the title, because a tag there costs no cells (the entry above), so the chip goes out as[{ink} on {plane}]around the same span the bold and the activity tint already cover. Three things hold it together. It runs the same pipeline as the plane behind it —SurfaceToneIn(tint, then the focus lift if the pane holds focus) thenRecessed— so a strip whose tabs share an owner is byte-identical to what it was before this existed and only the mixed pane changes;PaneSurfaceToneis now one line of that same function, rather than a second arithmetic that would drift. The selected tab is never chipped: it is the strip's own colour, which is already its page's plane. And unread stays a foreground over the chip, so "whose" and "something new" remain the two channels they are everywhere else. The framework pads each title with one space either side (" {Title} ", outside our markup), so the colour hugs the text and the pad keeps the strip's tone.PaneTintTests.ABackgroundTabWearsItsOwnCharactersColouris the pin and thetint-tabsview is the frame — the old code was internally consistent and the screen was still wrong, so only a painted cell answers this. - A tab strip shrinks its labels to fit its pane, because the framework clips and does not scroll
(
TabStripFit;SharpMUTermApp.RetitlePane/SyncTabStripWidths; thetabs-manyview).TabControldraws its tabs left to right from index 0 and writes each clipped to the header rectangle — there is no first-visible-tab offset anywhere in the control — so a strip narrower than its tabs simply stops drawing. The tabs past the edge are gone, the selected one included, and with them the×, the separator and the framework's own← →hint, which is drawn only when there is slack left to draw it in and the strip has keyboard focus (which it never has here: focus is pinned to the armed command line). Measured: five captures in a split pane drew two tabs and half of a third.- The owner prefix goes first, and goes for the whole strip. A pane full of one character's
captures repeats
Corvid -on every tab; it is the cheapest thing there is to lose and the most there is of it, and what it said is still said twice over — by the chip the tab is painted on, and by the⌁a window belonging to another character wears. Dropping it alone is often the whole fix. - Then names share by water-filling, not in proportion. A cap is lowered until the strip fits and
every name longer than it is cut to it, so a strip holding
ChatbesideO-Gatecrasherstakes the cells from the long one and leaves the short one whole. - The selected tab is spared, with a floor of its own (
MinimumSelectedName) when it cannot be. It is the tab the strip exists to name. A strip whose every label including that one is three letters and an ellipsis has stopped answering the question it is for. - Only the name shrinks. The badge, the pen, the
⌁and the focus▌are facts about the window, each one or two cells against a name that is routinely twenty. - Past the floors the tabs really do overflow, and that is left alone. Eight tabs cannot be drawn in forty cells however they are labelled; the answer is the floors and the framework clips the remainder — the state that existed before this, reached later. Making the last tab visible is a scroll offset, which is upstream work.
- A tab's fixed cost is measured off a rendered title, never re-derived.
VisibleLength(title)less the name, plus the framework's own cells (the space either side, the×, the│). A second arithmetic for the decorations would drift fromTabTitles.Forand overflow the pane while every pure test still passed — which is whyTabStripElisionTestsasserts the sum against a realPaneOutputRectswidth and not against its own model. - The width is synced on
PostBufferPaintand there is no loop in it.TabControl.CalculateSizereturns the constraint's width, never its content's, so a title cannot widen or narrow the pane it is drawn in — the dependency runs one way. Re-titling is gated on the width having actually moved, and goes throughRetitlePanerather thanRefreshTabTitlesso it does not dragRefreshRailwith it: the rail resizes the sidebar's column, which changes the pane widths, which is the very thing that triggered the sync. - A width of zero means "not arranged yet", not "no room". Eliding against it would cut every title on the first frame and undo it on the second.
- The owner prefix goes first, and goes for the whole strip. A pane full of one character's
captures repeats
- One unread count, one spelling:
UnreadBadge. The sidebar and the tab strip are two views ofWorkspaceWindow.Unread, and they had two formatters — the rail capped at99+, the tab printed the raw integer, so a busy channel read99+in one place and(4127)in the other. Cap, field width and tint colour (the app accent, previously a#00f5b7literal inRailRenderer) now live in one type. The tab strip deliberately does not get the rail's reserved-width treatment, and the asymmetry is real rather than an oversight: a rail row's width sizes the sidebar's grid column and the pane area is what is left over, so there a badge that appears narrows every pane; a tab strip is aTabControlarrangedFill+Stretchinside the pane it already fills, and the framework pads the header row out to the pane's own edge, so a longer label only shifts the tabs beside it. Reserving three cells per tab would cost width on every strip for ever to prevent a reflow this layout cannot produce. The cap is kept for the other two reasons: the two surfaces must agree, and an unbounded count must not push a narrow pane's later tabs off the end.TabActivityIndicatorTests.ActivityMovesNoPaneRectangleis the pin. - The status row's scrollback distance was the same bug one field over, and it was live. That segment
counts lines below the viewport — a number that grows unbidden from the wire while a reader sits in
their scrollback — and it was written out raw. Its own comment already warned that a wordier phrasing
had once wrapped the row and that "a status line that grows a row takes one off the workspace"; the
number was never guarded. At 80 columns the 99 → 100 step took the row from 80 cells to 81, it wrapped,
every pane lost a row, and per-pane NAWS re-announced the new size to every connected server. It is now
in a reserved field capped by
UnreadBadge, the same as the rail's. The lesson generalises: any cell on the chrome whose width is a function of something arriving from the wire needs a reserved field, and "it is only one digit" is exactly the size of every instance of this bug so far. - A rail window row is
whatthenhow you get there, and neither column may wear the other's word. A character's own session window readsmain(RailWindowLabel); its title names the connection, which the row's own ancestors — its character, under its world — have already said. The second column is the⌥Nthat goes to that window and exists only once there are two windows (one window, one place to be). It used to be the hosting pane, from when ⌥N named panes — and before that it called the first panemaintoo, and▪ main mainis two meanings in one line. - Focus is indicated by recolouring what is already drawn — never by spending a cell. Per-pane NAWS
is derived from the pane rectangle (
PaneOutputRects), so a border, gutter or marker column that only the focused pane has would re-announce a different terminal size to every connected server on every focus change and reflow the game's own output. The cues are the pane's own plane (WorkspacePalette.Focus), the tab chips it carries that plane into, and a▌in the tab title — all zero-cost.FocusIndicationTests.MovingFocusDoesNotMoveAnyPaneRectangleis the test that stops this being "improved" into a border. Colours live inWorkspacePalette, whose constants are all derived from aScreenPalettepair so the workspace and the settings screens share one idea of what focus looks like; the focus step isCursorBg ÷ EditBg. The command line is the fourth cue and the one that follows you between panes: it wears the armed band when ⏎ sends from it, and — when the character behind the focused window has chosen a colour — that character's hue as well (see the tint entry below). - A plane says two things, and they are kept on separate channels: identity is hue, focus is
luminance. A character may be given a colour (
CharacterDefinition.Tint→PaneTint, Core;WorkspacePalette.Tint, Tui; F5'stintrow), that colour becomes the pane's base plane, and the same focus multiplication is applied on top of it —PaneSurfaceToneis two lines and the composition is the whole design. Load-bearing, in order. All six tints sit at exactly one luminance: the plane is re-lit to the target before the anchor is mixed into it (AtLuma), and luma is linear in the channels, so both ends of the blend share that luma and so does every point between them — by construction, on any theme, for anyTintStrength. No character's pane is brighter than another's, and the focus step — a multiplication — therefore lands the same ratio above each. That one luminance isTintDepthbelow the untinted surface, not level with it, and the change is deliberate: the first cut held the tinted plane at the surface's own luma to preserve every contrast ratio the theme was designed around, and MU* servers are written for black terminals, so the plane their bright ANSI is read on wants to be darker than the client's chrome rather than level with it. The depth is bounded and the bound is arithmetic: a client may hold tinted and untinted characters at once, so a depth reaching1 ÷ FocusScalewould leave a focused tinted pane no brighter than an unfocused untinted one — the focus cue reporting the wrong fact.TintDepthis the geometric mean of that floor and no darkening at all, so the untinted surface sits midway in ratio (√FocusScale ≈ 1.26 either way);EveryFocusedPaneOutshinesEveryUnfocusedOneAcrossTheWholePaletteis the pin, and it is why "make them darker still" is a change that has to moveFocusScaletoo. The anchors are saturated for the same reason: chroma is bounded by luminance, so at the darker target a muted anchor has nothing left — the first set's two closest colours were ΔE 8.2 apart and its nearest was ΔE 7.7 from the untinted plane; these are ΔE 14.4 and ΔE 12.1. It says nothing on a monochrome terminal, deliberately — the cue that must survive a lost hue is focus, and the rail and the tab title still name the character in words — and it is a truecolor cue, which is unacceptable for focus and is whyFocusSurvivesA256ColourTerminalexists and has no tint counterpart. And it costs no cells, for the same NAWS reason the focus cue does not:PaneTintTests.TintingACharacterMovesNoPaneRectangleis that pin (rectangles andLaidOutRows, since the command line is a sticky band and one that grew a row would take that row off every pane), and it commits throughSaveConfiguration, which is also what makes an F5 edit reach the panes now rather than at the user's next focus move. A pane wears the colour of the window in front of it — a pane can host several characters' windows as tabs and paints one rectangle — resolved through the workspace's ownership record and never through_active, because a background pane wearing the focused character's colour would say the opposite of what it means. The palette is a closed set of six names and not a hex: a free colour cannot be validated against a theme the user may change tomorrow, and a name survives that change where a hex picked against a dark theme becomes a hole.PaneTint.Noneis the default and no migration marks anybody — the same reasoning asConnectAtStartup, and the reason the schema version did not move for it.- The command line wears it too, and takes the hue without the depth (
PaintInputBands,WorkspacePalette.IdleBand(theme, tint)/ArmedBand(theme, tint)). On a pane, luminance carries focus as a ratio that a step applied equally to all six leaves intact; on the input row luminance is already spoken for — it is the whole armed-versus-idle cue — so a colour that moved it would put a second fact on a channel that carries one. Hue-only means the armed bar stays exactly the step above the idle one that it always was, in every colour and on every theme, and thatIdleInk(measured against the untinted band, and shared with the tab chips) keeps the contrast it was picked with. The armed band is derived from the tinted idle band rather than tinted itself, so the lean towardTheme.Promptsurvives on top of the character's hue and the pair still differ in brightness and colour. The one theme where the step narrows is Light, where the lift clamps against white before the prompt lean is applied — it predates tints, its untinted band is already the narrowest of the three, andATintedCommandLineIsStillObviouslyArmedOrIdleholds it toVisiblethere and toObviouseverywhere else. - Whose colour the bar wears is
SendTarget's answer, falling back to the focused window's recorded owner (InputTint) — never_active, which is the misdelivery bug in every shape it has had. The fallback is the second armWindowSessionalready walks and the same recordPaneTintOfreads, so the bar and the pane above it are one answer rather than two that agree most of the time. The colour says whose, never whether: a focused window whose owner has no session this run wears that owner's colour while the prompt readsno connection ›and ⏎ refuses out loud — identity and reachability are two facts, and the row already states the second twice. A window nobody owns (the web view) leaves both bands exactly as they were.
- The command line wears it too, and takes the hue without the depth (
- The Ctrl+arrows move pane selection, not keyboard focus — but selection carries the session. The
pin (
FocusChanged → PinFocusToArmedBar) is untouched: typing always lands in the armed command line wherever you have navigated to. That is a fact about which control gets a keystroke, and it says nothing about which character the bar talks to; the first cut of these keys reasoned "it never moves focus, so no third piece of state is needed" and left the command line pointed at the world you had navigated away from. Keep the two separate and keep both.TryFocusKeysits inHandleWindowKeyafterDispatchMacro(soMacroKeys.Verdictreporting a macro onCtrl+Leftas live stays true) and beforeTryScrollKey/TryRecallKeyand the command line. Word movement moved fromCtrl+←/→toAlt+←/→to make room. Vertically the panes and the bars are one ladder: ⌃↓ off the last pane arms the second command line, ⌃↑ leaves it (the second bar is per window, so the ladder is taken from a pane whose window has one). - A handler that does not handle a chord must decline it, not claim it — ordering is the second line
of defence, never the first. Three separate defects have been the same sentence:
TryRecallKeylooked at the key and never the modifiers and swallowedShift+↑; the framework's move-mode handler took every unclaimedCtrl+chord untilMovable(false); andInputBarControl.ProcessKeyreached an unguarded arrow switch below itsalt/ctrlblocks, so every modified arrow was a plain caret move — claimed withreturn true, becauseMove(Buffer.MoveLeft())is true whenever there is a character to the left of the caret. That last one had a signature worth remembering: it worked on an empty command line and died on the first thing you typed, andTryMoveRowreturning false on a one-row bar hid the vertical half of it entirely. Both now match on exact modifiers (key.Modifiers == 0for the bare navigation keys,== ConsoleModifiers.Altfor word movement). - The pane-resize chord is
Alt+Shift+arrow, and it isAltbecause of the terminal, not the parser. It wasCtrl+Shift+arrow, chosen on the strength of a test provingAnsiInputParserdecodesCSI 1;6 <final>— a true claim, and a smaller one than it was read as.kitty_modisctrl+shiftand kitty binds all four arrows by default:ctrl+shift+left/rightareprevious_tab/next_tab, whose handlers returnNone, which kitty'sdispatch_actiontreats as consumed — those bytes are never written to the pty and no app-side ordering can reach them.ctrl+shift+up/downarescroll_line_up/_down, which returnTrue(pass through) while the alternate screen is up, which is the whole reason the vertical half appeared to work and the horizontal half looked broken. Nothing in kitty's default map claimsalt+shift+arrow; its encoder writesCSI 1;4 <final>for it (observed withkitten @ send-key), the parser reads Alt+Shift out of that, and it is distinct from Shift alone (scrollback), Alt alone (word movement) and Ctrl alone (pane selection). A decode test is not an arrival test — check the emulator's keymap before spending a chord, and record the answer inMacroKeys.Verdict. - A resize arrow names what happens to the focused pane, never which way the divider travels:
⌥⇧↑ taller, ⌥⇧↓ shorter, ⌥⇧→ wider, ⌥⇧← narrower, from either end of the split. The vertical pair was
once read off the divider, so the bottom pane's ↑ made it shorter — and the test only exercised the
top pane, where the two rules agree.
PaneResize.StepCellsis 1, the same on both axes. SharpMUTermApp.Activateis the one activation path, and activating a window activates its session. Every gesture that brings a window forward goes through it: a tab click (OnTabChanged), a rail or ⌃P entry, a character switch, an MXPPROMPT, the web view, and both movers of pane selection (FocusPanefor ⌃arrows and the ⌃PFocus pane …entries,CyclePanefor ⌃O and ⌃B o). They were five paths and they disagreed — the pane movers and the tab click reloaded the drafts but left_activebehind, so typing after navigating went to the world you had left. It does four things: resolve and adopt the session (AdoptSessionOf), select the pane's tab,ChangeWindow()(drafts, second bar, history cursors), andSyncToFocusedPane()(indicator, scrollback segment, NAWS). Re-entrancy through the framework's ownTabChangedis guarded by_activating.- Whose window is this? One resolver,
WindowSession: the session printing into it, else the owner the workspace records, else refuse. There is no third arm falling back on_active— that fallback is the bug, in every shape it has had (a link clicked in a background pane sending to the focused character; a pane selection moving without the bar following; ⏎ itself, see below). Bounded throughSnippetwhere a message names a window, because a window title can be a world's text (the web view is titled from the page it loaded). Ownership is recorded on every path that binds or adopts a window (BindSessionandOpenSessionWindow), because the main window is built before any session exists and the rail and this resolver both read ownership. - ⏎, a macro key and the prompt all resolve through the focused window, not
_active(SendTarget).AdoptSessionOfdeliberately leaves_activeon the previous world when the window you navigated to has no session of its own, andOnCommandEnteredused to send to_active— each half defensible, and together a misdelivery: with a connected Ann in one pane and a session-less window in the other (a resumed workspace, which is every pane at startup), ⌃→ moved the focus, the indicator and the tab marker, and the next line went to Ann, whose pane was not the focused one. Navigation always succeeds — asking to go somewhere arrives, and the pane takes the focus and the caret like any other. It is sending that needs a target: with none, ⏎ refuses out loud at the moment of sending (NothingToSendTo, which names the pane's owner and what opens it) and the prompt readsno connection ›(PromptLabel) rather than naming a world it cannot reach. A client with nothing open anywhere keeps the resting prompt it always had — there is no other world for a keystroke to reach, and that is the arm the snapshot demo renders through. - The scrollback keys are routed from
PreviewKeyPressed(TryScrollKey), and the wheel from the driver (ScrollPaneUnderPointer), for the same reason everything else in this window is: focus is pinned to the armed bar, soScrollablePanelControl.ProcessKey— which returns false unless it has focus — would never see a key. - Control chords collapse onto their ASCII bytes, so some are unbindable.
AnsiInputParserdecodes no CSI-u and enables nomodifyOtherKeys:Ctrl+Harrives asBackspacewithcontrol: false(byte 0x08), and I/M/J are Tab/Enter/Enter — the app cannot even tell the modifier was held, so binding those breaks the plain key instead.Ctrl+⏎andShift+⏎are the same problem and cannot be bound at all: CR (0x0D) and LF (0x0A) both become a bareConsoleKey.Enterwith no modifier bits. They stay inInputBarControl's key table because the WindowsConsole.ReadKeypath does report them, but no surface may advertise them — that is test-enforced (AdvertisedKeyHonestyTests).MacroKeys.Verdictis the readable form of all this. - ESC + a control byte arrives as two keys, and that is a chord you can reassemble. Only
ESC+ a printable byte becomes a single Alt chord;ESC+ a control byte is emitted as two key events (AnsiInputParser.ProcessEscape) — which is whyAlt+Backspaceis not available.Alt+⏎is, though, and it is the newline chord:SharpMUTermApp.TryAltEnterpairs an Escape with an Enter arriving inside the framework's ownUnixStdinReader.EscTimeoutMs(50 ms) and hands the bar a synthetic Alt+Enter. It is safe because Escape in the command line is a genuine no-op and every other meaning of Escape is handled earlier inHandleWindowKey; and it is reliable because both halves land in one read, one parse and one dispatch batch (a terminal writesESC CRin a single write), so the observed gap is microseconds, not milliseconds. ⌃L is kept as the second spelling. GettingCtrl+⏎/Shift+⏎properly needs the Kitty keyboard protocol, and that cannot be done consumer-side — see below. - The input stack cannot be extended from here. Enabling the Kitty keyboard protocol is trivial
(
IConsoleDriver.WriteClipboardOsc52is a de-facto public raw-escape emitter, andStart/Stopalready pairCSI ?2004h/lfor bracketed paste). Decoding it is the wall:AnsiInputParser,UnixStdinReader,InputEventandTerminalRawModeare allinternal;NetConsoleDriverhas zero virtual members, a privateWriteOutput, field-like events a subclass cannot raise, and it constructs its parser and reader as locals insideStart(). So enabling reporting without a matching decoder makes the affected keys vanish silently (DispatchCsi'sdefault:emitsUnknownSequenceEvent, whichUnixStdinReaderdrops). Owning input means a from-scratchIConsoleDriver(~900–1400 lines re-authoring internal termios + parser logic). The cheap unblock is upstream: makeAnsiInputParser/InputEventpublic and add anUnknownSequenceHandlerhook, or add an input-reader factory toNetConsoleDriverOptions. ~15 lines there; do not try it from here. - A global shortcut runs before any window, so a chord in
MacroKeys.AppShortcutscan never reach a control's own key table. That is why the command line has no ⌃W (CloseActiveWindowclaims it) and whyInputBuffer.KillWordLeftcurrently has no chord that can reach it.
- TelnetNegotiationCore — the pinned version is
Directory.Packages.propsand is deliberately not restated in prose, which is how the Repository state section came to claim 2.7.0 while the pin said 2.8.1. The notes below say which release changed what, never what we are on. (Repo owner is its author — extend it by PR rather than working around it.) Fluent builder API; negotiates MCCP/MSDP/MXP itself; ships the keepalive interpreter (WithKeepAlive(TimeSpan?, …), default 30s, clamped to 1s–24h).TelnetSessionsets the init-onlyCallbackOnByteAsyncreflectively to see raw bytes including unterminated prompts — a first-classOnBytebuilder hook remains a good upstream PR. It handles the option handshake (TELOPT, GA, TTYPE/MTTS, EOR, NAWS, CHARSET, MSSP, GMCP) — Pueblo and all ANSI/MXP/Pueblo payload parsing stay our layer.- 2.8.1 changes that affected us:
TelnetInterpreter.SendNAWS(short, short)was removed — NAWS now reports the full RFC 1073 unsigned-16 range viaNAWSProtocol.SendWindowSizeAsync(int, int)on the plugin.TerminalTypeProtocolgained a publicWithTerminalTypes(string[])method — the private-field reflection hack (_terminalTypes) inApplyTerminalTypeswas replaced with it. Plaintext MSSP-REQUEST is now an opt-in plugin (MSSPPlaintextProtocol) rather than a hidden library fallback.fix(line): the library now submits genuinely blank lines instead of silently dropping them. - Releases published since, and what taking them buys: 2.9.0 adds
IsNegotiatedbesideIsEnabled, which never meant what its name suggests —InitializeAsyncsets it true the moment a plugin is registered, before a byte has crossed the wire, so anything reading it as "did the peer agree" was reading a constant. 2.9.1 givesIAC GAand a bareIACmid-negotiation permitted transitions fromStartNegotiation; before it each one hitOnUnhandledTriggerAsync, logging Critical and recovering throughTrigger.Error, which on some interleavings ate the sequence behind it. 2.10.0 carries the raw bytes of each MSSP value beside the decoded string. 2.11.0 is the prompt-marker fix below. 2.12.0 is the one that actually motivated a further jump: it shipsPacketPatchProtocol, included inAddDefaultMUDProtocolsautomatically (an appended, defaultedpacketPatchHoldTimeparameter this codebase doesn't set) and infers a prompt boundary from 500ms of silence for servers that mark none at all.IProtocolContextis unchanged in 2.12.0: the prompt-boundary members (TakePartialLineAsPrompt,HasPartialLine,HasSeenMarkedPrompt) are public onTelnetInterpreter, reachable through the interface's existingInterpreterproperty, so there is no public break to migrate for. TheCallbackOnByteAsyncproperty this file reaches by reflection is unchanged across the whole jump: stillpublic Func<byte, Encoding, ValueTask>? CallbackOnByteAsync { get; init; }onTelnetInterpreter. cd06e8b(the prompt render) and04e0e26(this 2.12.0 bump) must not be separated. Reverting the render alone while 2.12.0 stays pinned sends every packet-patch-inferred prompt fragment toCurrentPrompt, which nothing renders — the text is simply gone, silently. Before 2.12.0 that same fragment was merely glued to the head of the next line, which reads as a cosmetic wrap issue rather than lost text. Revert both together or neither.- Never pin a version that is not on nuget.org, and never work around it with a local feed. A
pin that only resolves from a local
nuget.configbuilds here and failsNU1101everywhere else; if a locally-built nupkg of that version is already in~/.nuget/packages/, the real package publishing later failsNU1403(content mismatch) until that cache directory is deleted. Delete the stale cache; do not commit anuget.configpointing at/tmp.
- 2.8.1 changes that affected us:
- A prompt ends with
IAC EORorIAC GA, and until 2.11.0 this client could only see the first of them.TelnetSession.OnPromptAsyncis the only thing that flushes_pending— the unterminated lineCallbackOnByteAsyncaccumulates — so a server whose prompt boundary the library does not report leaves that prompt in the buffer for ever. Both halves were wrong in the library and were fixed upstream rather than worked around here (PR #90):- GA is the boundary a default NVT uses, and most MU* servers are one. RFC 854 makes it
mandatory in the server-to-user direction — a process that "cannot proceed without input from the
other end" must send GA — so a server that negotiates neither EOR nor SUPPRESS-GO-AHEAD ends every
prompt with
IAC GAand nothing else. The library accepted the byte and discarded it. Measured ontdome.nukefire.org:4000: the character-creation prompt arrived, sat in_pending, and the session read as a server that had stopped answering. - RFC 858 is the only thing that takes GA's meaning away — once SUPPRESS-GO-AHEAD is in effect a GA is a NOP. EOR does not: RFC 885 is a different marker and says nothing about Go-Ahead.
- An
IAC EORthat arrives while the option was never negotiated is a NOP too (RFC 885), and was not: the library gated that prompt onIsEnabled, which is the always-true flag above. - Nothing on our side changes for any of it.
AddDefaultMUDProtocols(onPrompt: OnPromptAsync)already wires one callback to both plugins, so the signal arrives on the version bump. - What is still ours: a server that sends no boundary marker at all — no GA, no EOR, no newline
— still leaves a prompt in
_pending, because nothing here flushes on idle. That is a real gap and a deliberate non-decision, not something the library can fix.
- GA is the boundary a default NVT uses, and most MU* servers are one. RFC 854 makes it
mandatory in the server-to-user direction — a process that "cannot proceed without input from the
other end" must send GA — so a server that negotiates neither EOR nor SUPPRESS-GO-AHEAD ends every
prompt with
- MXP's line-security model is the parser's real job, and the allow-list is chosen deliberately over
a deny-list (
MxpLineMode,MxpTagCategory,MxpParser— the eightESC[#zline tags: OPEN, SECURE, LOCKED, TEMP SECURE, and LOCK OPEN/SECURE/LOCKED plus RESET). The spec draws its own line in one sentence — "Only the tags described in this section are OPEN tags. All other MXP tags are SECURE tags" — soMxpTagCategory.IsOpenenumerates the few tags a player-controlled open line may use and refuses everything else, including a tag the spec adds tomorrow: unknown is secure by omission, which is the safe direction for a deny-list to fail in and the wrong one for an allow-list, which is why it has to stay an allow-list rather than being "simplified" into its opposite. A refused tag is not swallowed — it is echoed as literal text, byte-for-byte from the raw tag body the parser was accumulating when it was refused — so a<SEND>a player typed into asaycomes back as exactly the bytes they typed, rather than vanishing (which would teach an attacker that nothing survives a round trip) or being reinterpreted (which would teach them what does). Two exploits surfaced and were closed during review, both the shape "a refusal that doesn't fully refuse": TEMP SECURE (ESC[4z) staying armed across intervening text instead of being spent on the very next tag only, and a refused closing tag leaving its frame open on the stack, so a deferred<SEND>kept absorbing the player's own following text into its own command. Get either wrong again and the failure is silent — nothing throws, the line just renders as something other than what arrived.Flush()is the line boundary this parser actually sees, not'\n'(MxpParser.EndLine), and that is the non-obvious fact behind the worst bug this file has had. The telnet layer strips the terminator before the parser is fed, soWorldSession.OnOutputReceivedcallsFeed(e.Text)— with no newline anywhere in it — and thenFlush(). Everything the end of a line does therefore has to hang offFlush(): the mode reverting to the default, the spec's auto-close, finalising an open interaction, abandoning an unterminated escape. It lived inCompleteLine()(the'\n'arm ofProcessText) instead, so on a real connection it never ran — oneESC[1zfrom the server and the session stayed SECURE for the rest of the connection, which is a player's<SEND>becoming a clickable command. Every cross-line test inMxpLineModeTestshad put"\n"inside a singleFeedcall, an input shape the product never produces, and so agreed with the code while the client disagreed; the pins are now writtenFeed+Flush(FeedLines), and a new test must be too.Flush()was already the boundary for the tag stack (CloseInteractionsAtBoundary), so this is one call being a boundary for everything rather than for half of it. Deliberately not anEndLine()onILineParser: a consumer that forgets to call one silently reproduces exactly this bug, where a consumer that does not flush gets no output at all.- The spec auto-closes unclosed open tags, and that is the bound on how far a player's markup
reaches (
Frame.OpenedInOpenMode,CloseOpenModeTags). Two rules, both verbatim: "when the mode is changed from OPEN mode to any other mode, any unclosed OPEN tags (tags that were used while in open mode) are automatically closed", and "when in OPEN mode, any unclosed OPEN tags are automatically closed when a newline is received from the MUD". Neither was implemented, and the comment where the second belonged said the opposite — that formatting tags survive a boundary because "MXP tags may span lines". The spec inverts that: "secure tags are never automatically closed", open ones always are. Which is why it is a flag on the frame (was the line in OPEN mode when this element opened?) rather than a property of the tag name. Without it a player typing<COLOR FORE=black BACK=black>into a public channel paints every later line of the session black on black and the tag stack grows without bound — and it is why the spec is willing to callCOLORan open tag at all. - The threat model is not "a player emits
ESC[1z". They cannot; only the server writes line tags, and anyone who could emitESC[1zhas already bypassed the whole model (that residual is recorded and accepted). What a player needs is for the server to have emitted one earlier and never cleared it — which servers routinely do, because they rely on the newline revert the spec promises rather than closing withESC[0z. So every rule that ends a stretch of elevated trust is load-bearing in a way the rules that begin one are not: the revert at the boundary, TEMP SECURE being spent by the very next tag, the auto-close, and a reconnect starting clean. Three of the four have already shipped broken. - Parser state is per connection.
WorldSession.ConnectAsyncrebuilds the parser fromWorld.ContentFormatrather than callingILineParser.Reset(), because the negotiated MXP upgrade is parser state too: MXP has to be negotiated again on the new socket to take the session off ANSI. Left alone, a server'sESC[6zmade the next connection start in secure default, and the black-on-black above survived the reconnect that is the obvious way a user tries to get rid of it. AnsiParserandMxpParserhold near-duplicate escape state machines, deliberately unconsolidated — so a fix to one has to be applied to the other, with its own test in each suite. (Same states, same CSI ranges, same OSC/DCS/ST handling;MxpParser's differs only by thezline-tag arm and by disarming TEMP SECURE on the way out.) This was weighed and kept: extracting a shared scanner is a structural refactor, and it was not going into a fix wave that already carried a Critical. The duplication has already cost once — the OSC gapMxpParsershipped with wasAnsiParser's behaviour never copied across — and again with the newline-aborts-an-unterminated-string fix, which is three lines in each. Whoever consolidates them should delete this paragraph.
- ANSI is decoded inside
MxpParseritself, through the newly-sharedSgrCodes.Apply, because nothing chains it withAnsiParser.WorldSession.CreateParserhands a session one parser, never a chain of two — it may be swapped for another when MXP negotiates (the entry two below), but at any moment exactly one parser sees the stream — so anMxpParserthat understood only MXP markup would have to either swallow a server's ESC sequences or print them as literal text — and it used to do the latter: a capture against a real MXP server lost every colour, because the class's own doc comment claimed ANSI was "handled upstream" when there is no upstream to hand it to.SgrCodes.Apply(TextStyle, string)is the SGR tableAnsiParseralready had, extracted so both parsers decode from the same place instead of two that can quietly drift apart;MxpParsernow decodes CSI, OSC, DCS and the two-byte intermediate escape forms at parity withAnsiParser, and treats anything else in that space as either az-suffixed line tag or discards it. - Which parser a session uses is decided by what the connection negotiates, not by
WorldDefinition.ContentFormat(TelnetSession.MxpEnabled,WorldSession.OnMxpEnabled). The session used to answer a server'sIAC WILL MXPwithDOand then keep parsing the stream with whateverContentFormatsaid —Ansi, by default, since nothing sets it — so a live capture against a real MXP server showed its<SEND>tags rendering as literal text under a parser that had never heard of them;onMXPEnabledwas a callback that did nothing but return a completed task, discarding the fact that MXP had negotiated at all.TelnetSessionnow raisesMxpEnabledonce the option negotiates, andWorldSessionswaps in a freshMxpParseron it — but only when the session started fromContentFormat.Ansi, which is the default and therefore means "nobody chose" rather than "somebody chose ANSI": a world explicitly configured for Pueblo is a user's decision about what that server speaks, and a stray negotiation must never overrule it. The old parser is flushed first, so whatever partial line it still held is delivered under the rules it arrived under rather than silently dropped; style does not carry across the swap, because MXP's own RESET is how a server re-establishes it and inventing a carry-over would make the first line after negotiation depend on parser internals nobody else needs to know. What this does not cover:TelnetSession._pendingcan still batch pre- and post-negotiation bytes into oneOutputReceivedchunk, so text sent before MXP was advertised can be re-read as MXP by whichever parser is current when that chunk is finally parsed. Bounded by the line-security model above, not fixed by it — the replayed text lands on an OPEN line, where a secure tag is refused and echoed literally, so the worst case is a stripped formatting tag rather than an injected command. That bound depends on the boundary revert: it was written while the mode never reverted in production, where "lands on an OPEN line" was not true of a session a server had ever secured. Closing the buffering hole itself needs its own change at the telnet layer and is out of this scope. PuebloParserdecodes no ANSI, and nothing else decodes it for it. Its header used to say escapes were "handled upstream" — the same false premiseMxpParsercarried, in the same words, and there is no upstream either way: a session gets one parser. So a Pueblo world's colour arrives on screen as a literal<ESC>[0;33m. The behaviour clause was accurate; only the justification was invented. The fix is the oneMxpParseralready had — its own escape state machine overSgrCodes— and until someone does it this is a known gap rather than a design.<VERSION>/<SUPPORT>are answered, and theSUPPORTSlist is held to the same rule as the MTTS bit vector: an honest claim, not an aspirational one. The spec requires both replies to go out as a secure-tagged line (ESC[1zprefix,MxpParser.SecureLinePrefix) specifically so a server can tell a client's own answer apart from anything a player typed; a server running the same line-security model this client implements would refuse an unsecured reply outright, so that prefix is not decoration — it is what makes the reply legible to the peer this client is trying hardest to be correct for. WhatSUPPORTSlists (SupportedTags) names only tagsHandleOpeneractually has a case for:+highwas left off even thoughH/HIGHsits onMxpTagCategory's open allow-list, because being allow-listed only means the tag is let through —HandleOpener's default arm still drops it on the floor, unhandled. Claiming+highwould tell a server to send markup this client silently discards, exactly the failure mode the MTTS bit vector is already held to; add a tag to this list only once the handler for it exists, never once the tag is merely accepted.- MSSP is read by the library, not by us — since 2.6.5, and that is the standing example of the
rule above. 2.6.0's reader destroyed the protocol's own array notation inside the library:
PORT "80" "23" "4201"arrived as the integer80234201,REFERRAL(array-only) arrived null, booleans failed to bind from1/0,CHARSETand every invented name were dropped,CRAWL_DELAY/MINIMUM_AGEbound to nothing, and a variable with no value wedged MSSP for the rest of the connection. We carried a byte-levelMsspSubnegotiationParserfor exactly as long as that was true; the fix went upstream (PR #56) and the parser is deleted. Do not re-add one.MSSPConfig.Variablesis now an ordered name → value-list map,MsspData.Fromprojects it, andMsspDatais a projection with no parsing in it — what it adds is ours: ports validated as ports, and-1read as the specification's "data not available" rather than as minus one. Two further upstream defects — MSSP fields decoded as ASCII rather than the negotiated charset, and an escapedIAC IACinside a value losing the literal byte — were fixed in 2.7.0, andMsspParsingTestsnow pins the fixed behaviour by name rather than the bugs. MSSP still has no payload size cap upstream —SubnegotiationBufferguards GMCP, MSDP and CHARSET's TTABLE, but not this — so a hostile server can make a session buffer as much as it likes. - MSSP is waited for, never asked for, and the client surfaces what arrives (
MsspCache; the F5 ▸iINFO screen). The MSSP specification writes one handshake and only one: the server "should send IAC WILL MSSP", the client answersIAC DO MSSPorIAC DONT MSSP. It says nothing about a client opening withDO— crawlers do that on RFC 854's authority, not MSSP's — and this client used to, which cost it the auto-login on any server whose telnet parser leaks an unknown option into its command buffer (see the entry above; the mechanism is gone). The library's opening negotiation isIAC WILL NAWSand nothing else, so MSSP is reached only when the server volunteers it, and the servers that never do are exactly what the INFO screen's dialled, publishes none state is for. Do not re-add the ask — not as an option, not per world: the cost lands on the login, silently, on the users least able to diagnose it. - Text encoding is CHARSET's answer, not a setting (
SessionEncoding,TelnetSession.CurrentEncoding). A world'sencodingisautoby default — state the app'sCharsetOrder, decode with whatever RFC 2066 settles on — and naming one is an override: still offered at the head of the order so a cooperative server agrees, but used regardless of what it says. Four things about this library will bite you, and all four already have:TelnetInterpreter.CurrentEncodingdefaults toEncoding.UTF8(TelnetStandardInterpreter.cs:46), and that default is not something to rely on unseeded: it is handed toCallbackOnByteAsync/CallbackOnSubmitAsyncfor every byte and used for GMCP, MSDP and everything we send, before this session's own encoding decision (an override, or the head ofCharsetOrder) has had any say.TelnetSessionseeds that property (reflectively,internal set, the same wayCharsetProtocolitself writes it) with the head of the stated order — otherwise a world pinned to a non-UTF-8 override, or a server that never negotiates CHARSET at all, would decode against whichever encoding the library happens to default to rather than what this session decided. MSSP fields were decoded asEncoding.ASCIIthrough 2.6.x (fixed in 2.7.0, pinned byMsspParsingTests); treat non-ASCII in an MSSP field from an older library as unrecoverable. Two consequences worth knowing from the pre-fix era: the plaintextMSSP-REQUESTfallback went through a different code path (also fixed in 2.7.0), and 2.6.0 additionallyToUpper()ed variable names with the current culture, a Turkish-I hazard (also gone).- The encodings we state must be the platform provider's own instances (
Encoding.UTF8, notnew UTF8Encoding(false)).CharsetProtocolranks a server's offer byIndexOfover our list against encodings fromEncoding.GetEncodings(), andUTF8Encoding.Equalscompares the BOM flag — so a BOM-less instance matched nothing, scored −1, and sorted below every charset that did match. AGetBytesnever emits a preamble, so the BOM that instance was avoiding was never at risk. - The interpreter's
CurrentEncodingis updated after the read batch returns, so polling it is a batch late;CharsetProtocol.OnCharsetChange's own argument is the prompt, authoritative signal. But that callback is only raised when the server offers a list and we choose — the direction where we offer and the server accepts updates the interpreter silently. Both arms are needed. (AnOnCharsetChangeon the accepted path is a good upstream PR.) - The seed is a
Clone()for a reason: it doubles as the "nothing has negotiated" marker by reference, and seeding a provider instance would make a successful negotiation of that same charset look like the seed.
- MoonSharp — package id
MoonSharp, pure-managed, no native deps. - Serilog behind
Microsoft.Extensions.Logging(ClientDiagnostics) feeds a capped in-memoryClientMessageLog(⌃P ▸ Show client messages) and a rolling file kept separate from session transcripts. Never add a console sink — it would paint over the TUI.
SharpMUTerm.Core stays UI-agnostic and fully unit-testable. All transport, telnet, parsing
(ANSI/MXP/Pueblo), GMCP/MSDP routing, scrollback, and trigger/alias/macro engines live there.
SharpConsoleUI is referenced only from SharpMUTerm.Tui.
Planned solution layout:
| Project | Responsibility |
|---|---|
SharpMUTerm.Core |
Transport, telnet, ANSI/MXP/Pueblo parsers, GMCP/MSDP routing, scrollback, engines, logging (no UI deps) |
SharpMUTerm.Graphics |
Kitty/Sixel encoders, capability probe, half-block fallback, InlineImagePolicy (no UI deps) |
SharpMUTerm.Scripting |
MoonSharp host + scripting API |
SharpMUTerm.Tui |
SharpConsoleUI application |
*.Tests (Core, Graphics, Scripting, Web, Tui) |
TUnit |
Kept for context; M1 is done (see Repository state above). As originally scoped:
- Create
SharpMUTerm.slnxwith the projects above targetingnet10.0, plus the TUnit test projects. - Add NuGet references (see Other dependency notes).
- Runnable stub: connect over TCP (+ optional TLS via
SslStream, IPv6-capable), pipe received bytes through a first-passAnsiParser(SGR: 16 / 256 / 24-bit color), render colored output in a SharpConsoleUI window with an input line + history. - Unit-test
AnsiParserand the telnet-session wrapper inSharpMUTerm.Core.Tests.
- Primary signal:
dotnet build SharpMUTerm.slnxplus all five suites (see Building and testing). Keep coverage inSharpMUTerm.Core.Tests— ANSI/SGR parser, telnet round-trips, engines. - The TUI is verifiable headlessly via the snapshot pipeline above; a claim about layout or chrome should be backed by a rendered frame you actually looked at, not by reading the markup.
- Kitty graphics cannot be rendered here. Treat that layer as build-verified and
capability-probed, never visually confirmed, and make sure it degrades cleanly when no protocol is
available — this environment is exactly that case.
SHARPMUTERM_GRAPHICS=halfblockmakes thewebview draw a real decoded picture as half-block cells, which is the closest available look.
- Branch from
main; open a PR. Do not commit directly tomain. - Follow
.editorconfig: file-scoped namespaces, 4-space C#, LF line endings. - Keep commits focused with clear messages.