Repository navigation
Refactor/scanner UI - #19
Merged
Merged
Conversation
Replace the bubbles list embedding with a custom renderer offering two modes: a sidebar with a detail pane (descriptions, open hint, shortcut) when the block is wide enough, and compact capsule rows otherwise. - add shared anim package (easing, color mixing, standard timings) - add item descriptions to main, target source, and scan type menus - staggered entrance fade; selection and activation stay instant - gap-spaced rows, left-bar selector, scroll markers for long lists - forward Init from wrapper menus so the entrance replays on mount - promote x/ansi to a direct dependency
Track header visibility in Layout and drive it with a HeaderVisibilityMsg emitted on every component stack change, so sub-screens get the extra body rows back.
Tables now grow with the row count (min 25 lines of box, capped by the body region) instead of always filling the body, and the help bar drops items that do not fit instead of wrapping past its border.
Any key now dismisses the splash, and the health check report delay drops from 100-200ms to 50-80ms so startup feels snappier.
Replace the string keys used in the validation error maps with a Field type, declared as constants in a new fields.go in both the dns and config/validate packages. The DNS probes, the config inspectors, the tunnel config forms and the formkit helpers move to the same type, so a misspelled field is a compile error rather than a silently missing error.
Add internal/ui/shared/keys holding every Bubble Tea binding together with its help text, plus matching and help-rendering helpers. Deps now carries the shared Keys, so components stop hardcoding key strings such as "up", "k" and "?" in their update loops.
Dialogs gain a title, a severity style and key hints drawn in the frame border, replacing the chrome each component used to render for itself. The dialog stack moves out of the workspace and into the root app model, which composites the frames over the current screen. The workspace goes back to a plain header/body/footer layout.
New basic/help component renders the compact hint row inline and the full binding list in a scrolling dialog, sized from the layout and clamped to the body.
Components hosted in a dialog now expose their title and key hints as data and let the frame draw them, so nothing is rendered twice. Add SetInDialog to the inputs, forms, log view, notice, picker and formkit base so they drop their own title and hint rows, and open the tunnel menus and configuration forms as framed dialogs.
The menu and the startup check screen match against the shared bindings instead of literal key strings, and the menu embeds the shared help panel. The preflight checks are split into named steps and the error formatting moves out of the reporter.
Each settings section now says what it holds in the menu's detail pane instead of leaving the reader to infer it from the title alone, matching how the entry menu lists its items.
Dialogs drop in from the top and collapse back up over 200ms instead of appearing and vanishing instantly, and back is held to the same short duration so it never feels like it is delaying the user. The frame renderer moves out of the app into the dialog package so it and the reveal share one home. The compositor that drives the reveal replaces bubbletea-overlay, which overwrites spaces and so would punch holes in the screen behind a half-open dialog; it is dropped from go.mod. A closing dialog now stays on the stack until its collapse finishes, otherwise there is nothing left on screen to animate away. That means the stack holds its transition state per layer instead of a parallel frames map, and OnClose is held until the layer is actually removed rather than fired by whoever asked for the close. A dialog on its way out keeps the keyboard without acting on it, so a second back press cannot start a second close and fire OnClose twice.
Default() hands back Keys by value instead of a pointer, so a component that was built from hand-assembled Deps no longer has to be told what to do about a nil keymap. OrDefault fills the gap for those callers. The bindings are grouped by the component that uses them: table, crud, filter and form each get their own struct, and the menu, dialog and form entries are derived from the shared nav bindings rather than repeating them. Half-page movement joins the table group. Global gains force-quit, goroutine dump and back-from-input, so the app stops matching literal key strings for them.
…lpers Deps.Keys becomes a value alongside the keys rework, and the env package loses IsBackKey and IsQuitKey. Those two maps described back and quit per mode in a place no component could see; the shared bindings now express the same thing and each screen matches against them directly.
The header, body and footer regions were each sized to the full content width, so a component drawing its own border pushed two cells past the right edge of the terminal. The regions now account for the frame the app draws around the whole screen. The footer in particular measured its text against a width that assumed it had no frame of its own, which made its last column fall outside the terminal.
The short help hints are packed greedily into the rows available, each hint truncated on its own first. Anything that does not fit becomes a "+n more" marker rather than being dropped in silence, which is what the old single-pass version did once a line ran out of room. Long words are now broken across lines by splitWord instead of a second copy of the same loop, and the wrapping no longer treats an over-wide word as a reason to leave gaps. The long help width comes from the widest label including the title, so the dialog stops resizing as its content changes.
The table drops its own KeyMap of ActionKey entries and matches against the shared keys instead, so a verb bound there is the same everywhere the table is used. The old filter was a plain string edited by hand; it is now a textinput, which gives it a real cursor, a character limit and the ability to clear itself. Rendering is rebuilt around a rounded frame: the body, the status line that continues the bottom border, the filter line and the help panel are all equal-width blocks, so joining them is exact and the frame no longer shifts a column between rows. The selected row's marker is drawn into the rendered string rather than copied, and the frame's own border size feeds the width arithmetic, so the two cannot drift apart. Sizing is explicit: WithWidth and WithHeight bound the table on purpose, the height accounts for the frame instead of a hardcoded 25 rows, and the width subtracts the real horizontal padding. Sorting is unchanged but compareCells now compares both cells in one pass instead of guessing from whichever side parsed first. The stale tests are dropped with the machinery they covered: the ActionKey keys, the string filter and the sorting they asserted are gone.
The menu reads its own menu keys from the shared set, which are the nav bindings plus select, instead of reaching into Nav for movement and Menu for select. The nil check that guarded the pointer is no longer needed now the keys arrive by value. Navigation folds into handleKey, and the long help lists every item's shortcut so the full help dialog describes the menu it was opened from.
Confirm, notice and form stop dereferencing Deps.Keys now that it is a value, and form.Hints takes the bindings so it can build its save hint from them instead of hardcoding the key. The save binding moves to a plain "s" so it is reachable without the modifier, matching the other single-letter verbs in the crud set.
The verb table becomes a slice of entries carrying the shared binding that triggers them, so a hook that is not set advertises no verb and responds to no key, and the hints the table shows are derived from the same table rather than reassembled by hand. Verbs are matched before the table's own keys so a provider cannot be shadowed by navigation or sorting, but the table still owns every key while the filter is open -- including enter, which has to confirm the filter rather than select the row. Failing to resolve the selected row now raises a notice, the same path the table itself takes.
The scan screen stops registering its tab, pause and log shortcuts as table keys, because the table does not act on them -- the scan screen does. They are passed in as hints instead, so the results table still tells the reader where to find them without pretending to own them. The viewer takes its own short and long hint sets through options and titles itself from its name, so the same component can be embedded with a different heading. dnstun threads the app state into openFormDialog to reach the bindings its form hints come from.
The app root matches force-quit and the goroutine dump against the shared bindings, and closing a dialog goes through the same per-mode switch the body stack already used. That switch is the fix: the old helper treated back as back in every mode, which let it fire while an input owned the keyboard, and it returned "handled" for keys it did not match, so an unrelated key press would still swallow a back. The body stack and the startup screen switch on the mode explicitly, and the startup screen raises the same exit confirmation the rest of the app does instead of quitting outright. Mode gains a String so the mode a component reports is readable in the logs.
Dialogs now open by splitting in from the centre rather than dropping in from the top, which matches the entrance animation the menus already use and reads as the dialog growing into place instead of sliding over the screen behind it.
The footer held back two cells of padding it no longer had once the layout regions started accounting for the screen frame. That left its right-hand column two cells short of the terminal edge, so the status line never reached the border.
Leaves were each holding their own layout/theme/log copies from Deps, so after a resize or theme switch some of them kept drawing with the old values. Now every leaf just keeps env *ui.Env and reads everything live from it. What changed: - basic components, header and footer take env *ui.Env instead of ui.Deps. No more separate layout/theme/log/keys fields. - layout is private now, read it with Terminal()/Body()/Header()/ Footer(). Layout.Update mutates in place so all holders see it. - theme lives as a value in Env, keys stay a pointer, log is a plain logger.Logger value sharing the same sink. Theme switch just replaces Env.Theme and sends an empty ThemeChanged. - screens and menus/tables/scanner/forms take session *ui.Session, build leaves with session.Env() and use session.CoreLog() for scanner/config/result/xray work. - dropped logger.Set and debug.log, AppState/Deps/HandleTheme and the old ThemeChangedMsg payload, and the old env.Mode. huh/bubbles leaves rebuild their cached styles on ThemeChanged, the rest just read env live.
Switch the module dependency from the old xray-core snapshot to v26.10.10 and pull in github.com/xtls/libxray for sharing-link conversion. The bundled third_party/xray-core submodule is advanced to the matching v26.10.10 scanner-fix commit, and indirect dependencies are updated accordingly.
Replace the old XrayConfig/XrayOutboundsFile types with Config, Outbound, OutboundFile, and related enums. Outbounds and stream/TLS settings now preserve fields bgscan does not model in Extra maps, and outbound templates are read as JSONC through cleanJSONC so Xray-style comments and BOMs are accepted without changing error line numbers. Outbound templates no longer use placeholders. SaveOutboundFromFile and ParseLink both extract the primary proxy outbound; addresses are rewritten at scan time by setOutboundAddress, which covers flat, vnext, servers, and WireGuard endpoints and supports an optional remote port override. ParseLink now delegates sharing-link conversion to github.com/xtls/libxray instead of maintaining a separate link parser. Inbound metadata loses its unneeded sniffing settings in favor of the simpler localhost SOCKS inbound. GenerateConfig gains a remotePort parameter and service/config tests use the new Config shape. The outbound table type checks and xrayprobe fakes are updated to OutboundFile/Config and the four-argument GenerateConfig signature. Example outbound templates use example.com addresses.
Update the outbound CRUD table and scan-type selector to the renamed xray OutboundFile type. The table now reports Security instead of a derived TLS yes/no column and renders protocol, network, and security as strings, matching the typed fields produced by the xray package.
Define shared bindings for the text, textarea, select, multi-select and toggle inputs, register their actions, and expose them on Keys. Also let Binding.LongItem append extra detail to the help label.
Build each input's huh/bubbles keymap from the shared key bindings and render frame hints from DialogHints instead of hard-coded strings. Drop the in-dialog title/hint duplication and align the Dialog interface around DialogHints and AppendOnSubmit.
Rename the crud messages to the exported RefreshMsg/TriggerActionMsg and the internal loaded/error messages. Add Source.Name for short labels, route the add verb through the injected hook, centralize item resolution, mark the table as loading while a refresh is in flight, and use the shared notice helper for selection errors.
Implement Source.Name on the dns tun, ip list and result list sources, wire their add verb through the OnAdd hook instead of intercepting MsgActionTrigger in Update, and update them to the renamed refresh message.
Return commands from the outbound menu actions instead of mutating state from inside a tea.Cmd: the add verb now opens the import menu via a message, the menu closes through Update, and saving runs in commands. Rewrite the outbound menu entries and hints from the shared menu keys, adapt the source name and table title, and update its callers.
Pad the dialog body uniformly and drop the unused confirm style helpers while tightening the confirm message padding.
Let effectiveSize prefer an explicit WithHeight value over the body height, and remove the stray width log line from View.
Bind TextArea.NewLine to a dedicated ActionTextAreaNewLine instead of the submit action, rename the constant to match its siblings and fix its help text.
Define InspectorKeys with the navigation, tab, edit and description bindings, register the inspector actions, and expose the set on Keys so the inspector can drive its help from shared bindings.
Swap the list's built-in help for the shared help component driven by the inspector bindings, and export DialogHints for dialogs that host it. Field is a list.Item now and the tabs/help children are kept by their concrete types, so a good chunk of the model and its sizing logic could go.
The form is only ever shown in a dialog now, so drop the in-dialog branching along with the local title, focus and hint rendering; the frame supplies the title and hints and the form renders just the inspector. Save and cancel now come from the shared form bindings via a new cancel action, which also retires the keys OrDefault fallback. Validation errors are sorted and multi-line messages get indented.
The merge was trusting a score-only cursor to handle replacements. A new record sharing a key with an existing one could slip past it, leaving both copies in the file - and since we re-read the file on every merge, the dupe stuck around for good. Changes: - Batch dedup now costs nothing: sort by score desc then key, so equal keys end up next to each other and CompactFunc drops the losers. - Replacement lookup uses an int array holding batch positions sorted by key, found with a binary search. No strings copied, so an IP key costs 8 bytes here instead of map overhead per row of the file. - A bool per batch record keeps track of what made it to disk, so a batch record can't be written twice (once by score, once as replacement). - Scanner: cancel() was skipped on the slipstream init error path, leaking the derived context on every failed construct. Tests cover same-key higher/lower score, dup within one batch, and a match sitting between two other records in score order - the last one was impossible to hit with the old positional check.
Notices, pickers and dialogs now get their chrome from the shared dialog frame, so the notice component drops its own title/footer handling. The picker goes the same way and shrinks a lot. The Tab component gets its animation split into an animate.go file: a 220 ms ease-in-out slide for its chevron. Table and inspector models receive small adjustments to match the new shared components.
Run/Close/AddStage guarded everything with one big mutex and silently ignored a stage added after start. started/closed are atomic now, close is wrapped in sync.Once with its error kept, and Run reports "scanner has already run" on a second call. AddStage returns an error instead of dropping a stage without a word, UpdateStageHooks refuses hook changes once a scan is running, and the stale allowed pre-scan type list is gone from the xray pre-scan error. Scantype menu adapted to the new AddStage signature.
ColumnDef carries a ColumnType (text, ip, duration, bandwidth) and every probe schema declares what its columns actually hold. Tables can now sort by the value behind the cell instead of the formatted string, and row actions can find the ip column by schema instead of guessing an index.
WithColumnTypes feeds the sort comparators from the schema: IPs compare as netip.Addr, durations as parsed values, bandwidth as bits per second, allANSI-stripped, with the old string compare as the fallback. WithFiltering switches the / filter off for tables that embed another one, and the filter hints follow the setting.
New sparkline for compact value charts. Progress drops the magic padding math: width is an option now and SetWidth clamps against the layout, so the caller can lay the bar out itself. Tabs lose the alignment option, gain a title, and slide in 110ms instead of 220.
Meter looks at a column type and maps the value to a quality tier using per-probe bands, so probe code never needs to know about colors. Duration and bandwidth parsing live here so the meter and the table sorting read values the same way. Zero bandwidth means the speed test was disabled, and that gets the fail tier instead of being scored as garbage.
Theme keeps optional tier fields and falls back to tokens when a theme doesn't tune them, so TierColor works for every theme out of the box. Retuned the good-tier tint per theme since the default blue read wrong against most palettes. The old theme test is replaced with a tier-focused one.
Latency and bandwidth cells take their color from the meter when the schema says the column carries one of those. Embedded resets used to kill the selected row's background halfway through, so trailing resets are swapped for SGR 39 now. Copy IP reads the schema's ip column instead of a hardcoded index, column types and the filtering switch pass through to the table, and resultlist hands the env the new constructor wants.
The layout warning used to live inside the scanner and never saw the dialog stack, so it could fight with notices. The app checks it once now: the effective key mode comes from the top dialog when one is open, otherwise workspace delegates Mode() down the chain (body -> topmost screen) and the warning opens only in normal mode with nothing already up. The non-latin detector moved to the notice package and the scanner's duplicate copy is gone.
Closing a dialog with q/esc/b ran only the component's OnClose, while the dialog closing itself ran only the WithOnClose command its opener passed. Openers' cleanup silently vanished depending on which key the user pressed - this is what stuck the keyboard layout flag with the notice closed by back. Both close paths build the same batch of the two callbacks now.
The scanner screen was one fat model. Format helpers, the chrome frame, the status panels and the stage log live in their own files now, styles moved out of the markup strings, and the model/view shed a large part of their lines. scannerUi.New takes just the session and the scanner. The non-latin check that used to sit in the key handling is gone - the app warning owns it.
The full help dialog animates with split instead of wipe, multiselect and select stop setting a huh title nothing shows, the inspector only offers tab-switch hints when there is more than one tab, the startup title row gets its height back, and the key alias maps fold to lowercase so hint labels match however a binding spells the key.
open now pushes the old screen out the left edge while the new one comes in from the right, both moving together. on close the same page runs back the other way. tried a plain slide, a full-cell dissolve and a top-down wipe before landing on the push - the first two just read as flicker on dense screens. the closing screen stays in the stack until the swap settles, so its OnClose and the footer status flip run at expiry, dialog-style. keys are swallowed for the swap window and reset still snaps instantly. settled body costs no timers - the tick stops when the swap does.
the banner was one static string shown on exactly one screen, but showing and hiding it cost a message round-trip through body, workspace and layout plus a fake WindowSizeMsg on every screen change. the banner now lives in the entry screen and goes up and down with everything else in that screen. header package, HeaderVisibilityMsg and the showHeader branch of the layout are gone. regions are now just body and footer, and the body gets the rows the header used to reserve.
back did nothing at the root, quitting needed its own key. now back opens the exit confirm too, so one key always takes you out from anywhere.
quic-go, purego, thefeed, libxray and friends. xray-core moved to the 1017 build and the third_party submodule goes with it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Scanner UI refactor PR notes
Base:
origin/main(merge-baseca1c17c)Branch:
refactor/scanner-uiCommits: 61, including a merge from
feat/libxray-link-parseDiff: 269 files changed, 12,445 additions and 7,809 deletions
This branch started as a UI refactor but ended up touching quite a bit more than the UI. It changes how key bindings are managed, how dialogs and screens are rendered, how scanner results are collected and displayed, and how Xray configs and result files are handled.
The changes are spread across 61 commits, so going through them one by one doesn't tell the full story. I've grouped them by area below, starting with the shared infrastructure and moving up to the screens and features that use it.
The main areas are:
At HEAD, the project builds successfully and
go test ./internal/...passes. There are still a few things worth checking before merging; they're listed at the end.Commit history
The commits are grouped by area rather than listed as a chronological changelog. This makes it easier to see which changes belong together and how the pieces fit.
[Keep the existing grouped commit index here.]
1. UI foundation
shared/ui— replacingAppStateandDepswithSessionandEnvPreviously, the app used
ui.AppStateandui.Deps, while key bindings lived separately inshared/env. That setup spread related state across multiple places.The new structure has two main parts:
ui.Sessionis created inapp.Newand holds the sharedConfig,Store,Program, and loggers.Programonly exposesSend, allowing components to send messages from worker goroutines without depending on the full Bubble Tea program.ui.Envprovides the values components need when rendering: layout, theme, key bindings, UI logger, and settings. Components access it throughsession.Env().Key modes (
Normal,Input, andManage) now live inuiasKeyModevalues. The oldManagedModeis nowKeyModeManage.This change affects most of the component tree. References to
*ui.AppStatehave been replaced with*ui.Session, and logging is split betweensession.CoreLog()andsession.Env().Log.The theme handling changed too. Components no longer receive theme updates through individual
SetThemecalls. Instead, the app broadcaststheme.ThemeChanged{}, and components read the current theme from the environment when they render.deps.go,state.go,theme.go, and the oldshared/envpackage have been removed.One detail worth keeping in mind:
Env.Themeis atheme.Themevalue, not a pointer. Theme changes are propagated through the update flow rather than by mutating a shared theme object in place.shared/keys— one place for key bindingsThe new
shared/keyspackage centralizes the app's key bindings and the text used to describe them.It consists of four files:
action.godefines action identifiers such as"global.back".binding.godefineskeys.Binding, including the key used for matching and the text shown in short and expanded help.defaults.gocontains the default bindings.keys.gogroups bindings by context, including navigation, tables, CRUD actions, dialogs, forms, scanning, inspectors, tabs, inputs, and the picker.Previously, components maintained their own Bubble Tea bindings and help strings. That made it easy for the actual shortcut and the displayed hint to get out of sync.
Now both come from the same binding definitions. The
Short,Long, andCombinehelpers build hint lists from those bindings, whileDisplayKeyformats key names for display (upbecomes↑,enterbecomes⏎).shared/dialog— shared dialog framing and animationsDialogs now use a common frame instead of each component drawing its own title, borders, and footer hints.
The new files handle different parts of that work:
frame.godraws the rounded border, puts the title into the top border, and places key hints along the bottom. The border is drawn manually so the title and hints can fit into the border lines.composite.gocombines the dialog with the content behind it during an animation. It handles partial rows using ANSI-aware text operations so the animation doesn't expose incorrectly aligned terminal content.animate.godefines the reveal animations and their timing.The available animations are split, curtain, drop-in, and rise-up, plus
AnimNone. The default animation duration is 120 ms.Dialog state is now managed at the app root rather than inside the workspace. The root keeps a stack of dialogs and tracks when each one opens or starts closing. A frame ticker runs only while an animation is active. Once a closing dialog finishes, it is removed and its deferred close commands are executed.
Keys are ignored while the top dialog is closing, preventing another action from being triggered against a component that's halfway through disappearing.
A later fix also combines the component's own
OnClose()command with the callback supplied by the code that opened it. Both callbacks now run regardless of whether the dialog closes itself or the user backs out.shared/anim— common animation helpersThe new
shared/animpackage contains the easing and interpolation helpers used throughout the UI:Clamp01EaseOutCubicEaseInOutCubicProgressMixIt also defines a 16 ms frame interval. Dialogs, menu entrance effects, tab transitions, and screen swaps use these helpers rather than implementing their own easing calculations.
shared/meter— interpreting scanner resultsThe new
shared/meterpackage maps result values to quality tiers.meter.Resolveuses the probe schema, column type, and cell value to select a tier. Latency and bandwidth have separate threshold tables, with thresholds keyed by schema name.The tiers are
Plain,Fail,Excellent,Good,Fair,Poor, andOver. Duration strings such asµs,ms, andsare parsed before being compared against latency thresholds.At the moment, latency and Xray bandwidth have configured thresholds. Other schemas can use the same mechanism when their thresholds are added.
2. App shell
Screen transitions
The body component now maintains a stack of full-screen components and can animate when one screen opens or closes.
When a screen is pushed, the body starts a transition if animations are enabled and another screen is already present. When a screen is popped, its removal and
OnClosecallback are deferred until the transition finishes.The transition is a horizontal page push: the old screen moves left as the new one enters from the right. Closing reverses the movement. The animation uses cubic easing and lasts 300 ms.
Earlier versions used simpler slides and other reveal effects. The push transition was kept because it works better with dense terminal content.
Input is suppressed while the transition is running, so pressing Back repeatedly doesn't accidentally close multiple screens. Closing a component in the middle of the stack remains immediate. Resetting the stack, such as when leaving the scanner, also remains immediate and runs pending close callbacks exactly once.
Back on the main menu now opens the exit confirmation instead of doing nothing.
Moving the banner into the entry menu
The old header system has been removed. Previously, opening or closing a screen could trigger a chain of messages to hide or show the header, recalculate the layout, and send a synthetic window-size update.
That was a lot of machinery for a banner that only appeared on the entry screen.
The banner now lives in
components/menus/entry/banner.goand is rendered directly by the entry menu. The header package and the layout's header visibility methods have been removed.The body always receives the available content height, and the footer sits below it. Workspace no longer needs to manage header state or react to header visibility messages.
One visible difference is that the banner now moves out with the entry screen when the scanner opens, rather than disappearing separately.
Workspace, footer, and startup
Workspace now mainly connects the body and footer and forwards theme and resize updates.
The footer retains its version, current-screen status, and runtime statistics. Screen status updates use
footer.UpdateStatus.The splash screen can be dismissed with any key. Startup health-check delays were also shortened from 100–200 ms to 50–80 ms.
3. Core changes
Typed validation fields
Validation errors now use typed string keys instead of plain strings.
internal/core/config/validate/fields.godefines field identifiers for general settings and probe configurations.internal/core/dns/fields.godoes the same for DNS tunnel settings.The validation rules and their bounds haven't changed. The main difference is that callers now use constants such as
validate.FieldWorkersinstead of repeating string literals. This makes it easier to keep validation results and inspector form fields in sync when a field is renamed.The DNS validators still use a shared return type, so the six tunnel implementations share one set of field keys. Existing string values were preserved, including the difference between
FieldDNSPortandFieldResolverPortin some implementations.ValidateDNSstill prefixes resolver and tunnel field names where needed. Tests now cover the resulting composite keys.The form error formatter was also updated to accept any string-based key type. It sorts field names before displaying errors, so validation messages appear in a consistent order.
Logger changes
The logger setup has been simplified to two loggers:
core.logandui.log.The old
logger.Set,NewSet, andDiscardSetAPIs have been removed. Each logger now has its own fallback if its file can't be opened, so a failure to initialize one no longer disables the other.debug.logand its startup check have also been removed.The logging methods now share a central level check, and nil-sink guards have been added to methods such as
With,SetLevel,Level,Slog,Subscribe,Unsubscribe, andClose.The app passes both loggers into
app.New, which applies the saved log level. The settings screen updates their levels directly.One small documentation detail: the old package comment describing printf-style logging and nil-logger behavior has been reduced to a single line. It may be worth restoring the useful parts.
4. Xray config handling and result files
Typed configs and preserving unknown fields
The Xray config types have been renamed from
XrayConfigandXrayOutboundsFiletoConfigandOutboundFile.Outbounds in
Configare now stored as[]json.RawMessage, since the scanner doesn't need to interpret every outbound before passing it to Xray.The outbound metadata used by the UI is now typed. Protocol, network, and security are represented explicitly, and the outbounds table displays a
Securitycolumn instead of the previous TLS yes/no field.Outbound,StreamSettings, andTLSSettingsnow preserve unknown JSON fields through anExtramap. This matters when importing provider configs: decoding and re-encoding a config should not silently discard fields the current structs don't know about.The new tests cover these round trips and
cleanJSONC, which removes JSON comments while preserving comment-like text inside strings.Link parsing now uses libxray
The hand-written share-link parser has been replaced with
libxray.ConvertShareLinksToXrayJson.This removes a substantial amount of parser code and its associated tests. The new path converts the links through libxray and extracts the main outbound.
The trade-off is that this repository no longer has direct
ParseLinktests. Scheme-specific behavior, including older Shadowsocks URL formats and base64 padding, now depends on the libxray implementation.Replacing outbound addresses
Outbound templates no longer need to contain a literal
$ADDRESSplaceholder.Previously, saving a template without that placeholder failed, and the scanner replaced the placeholder when generating a config. The new implementation stores the outbound as provided and replaces the server address structurally when preparing a scan.
It handles common address locations, including
settings.address,vnext,servers, and WireGuard peer endpoints.requireReplaceableAddresschecks the template when it is saved, so templates without a usable server address are rejected earlier.GenerateConfignow takes separate local and remote ports. The remote port is currently passed as0, leaving room for future port-range probing.Existing templates containing
$ADDRESScontinue to work because the replacement logic no longer depends on that exact placeholder.The generated inbound config also no longer enables sniffing. Sniffing can rewrite the destination based on SNI or Host, which conflicts with scanning a specific raw IP and port.
Result-file deduplication
The result merger has been rewritten to prevent duplicate rows when new scan results are merged into an existing CSV.
The previous implementation had several problems: it didn't remove duplicates within a batch, could append a key already present elsewhere in the file, and didn't guarantee stable ordering when scores were equal.
The new implementation sorts by descending score and then ascending key. It removes duplicates within each batch before writing and uses key-based matching when merging with the existing file.
A new test suite covers the merge behavior, including a case where a new row beats rows encountered earlier in the file but still scores below the old version of that same key.
Result columns also have a
ColumnType, allowing the UI to distinguish text, IP addresses, durations, and bandwidth values. All nine probes now declare their column types. The CSV format itself hasn't changed.5. Scanner lifecycle
Scanner lifecycle state now uses atomic booleans for
startedandclosed, andCloseis protected bysync.Once.Previously, some invalid lifecycle operations failed silently.
AddStageandUpdateStageHooksnow return errors when the scanner has already started or closed.Runalso returns distinct errors for a closed scanner, repeated runs, cancelled contexts, and missing stages.Stage initialization errors now include context, such as
load config,init port manager, andinit slipstream service.The shutdown timeout has been increased to 10 seconds, and the resulting timeout error is retained for subsequent calls to
Close.The separate
runSinglepath has been removed.runPipelinenow handles both single-stage and multi-stage scans, using sequential execution when there's only one stage.One area worth checking is the interaction between
RunandClose. There may be a narrow race betweenRunmarking the scanner as started and registering its wait-group work, during whichClosecould begin shutting down shared resources. The current UI flow may prevent this in practice, but the ordering deserves a closer look.6. Reusable components
Menus
The menu no longer uses the Bubble Tea list component. It has its own renderer and supports item descriptions, scrolling, shortcut selection, and an optional sidebar with additional information.
When the terminal is wide enough and menu items have descriptions, the menu uses a two-column layout. Narrower terminals fall back to a regular list.
Menu entrance animations fade rows in with a small delay between each row. They're disabled when the global animation setting is off, and the ticker stops when the animation finishes.
Shared help
The new
basic/helppackage builds short and expanded help views from the shared key bindings.Short help packs hints into the available width and height, showing a
+N moreindicator when everything won't fit. Expanded help uses a scrollable layout.Menus, tables, and inspector panels now use this shared component instead of maintaining their own help strings and layout logic.
Tables
Tables now support typed sorting, optional filtering, loading indicators, custom empty-state messages, and shared key hints.
IP addresses, durations, and bandwidth values are compared using their actual types instead of lexicographic string ordering. Text columns retain the string comparator.
Table height is calculated from the number of rows and the actual frame borders, rather than filling the available space regardless of content.
Filtering can be disabled per table. Filter mode also reports
KeyModeManage, allowing the app to route keyboard input correctly.One testing gap remains: the old sorting and filtering test files were deleted during the rewrite, and the replacement tests don't directly cover all of the previous behavior.
Inputs, dialogs, and picker
The text input, textarea, select, multiselect, and toggle input components now build their keymaps from the shared bindings. Each exposes dialog hints so the surrounding frame can display the correct shortcuts.
The textarea now handles newline insertion separately from submission, allowing multiline input without accidentally submitting the form.
Confirm dialogs and notices no longer draw all their own framing and footer hints. The shared dialog frame handles that consistently. The file picker also uses the shared keymap and has its own default title and entrance animation.
A new keyboard-layout warning detects non-ASCII text typed outside input mode and displays a single dialog suggesting that the user switch layouts.
Sparkline and tabs
A new sparkline component renders small charts using braille characters. It smooths incoming samples, handles flat data without exaggerating small changes, and keeps the gradient tied to screen position to avoid flickering as the data moves.
Tabs can optionally animate the underline when switching between tab labels. This effect uses the shared animation helpers and respects the global animation setting.
7. Inspector and scanner screens
CRUD actions
CRUD components now handle their own key bindings instead of relying on message interception in parent screens.
The shared hooks include
OnAdd, and the action bindings come from the central keymap. Help hints are generated in a fixed order rather than depending on map iteration.CRUD sources also expose a singular name for prompts and help text, so screens can use labels such as "add an outbound" instead of the generic "add an item".
Inspector forms
The inspector screens have been updated to use
SessionandEnv, and theme changes no longer need to be passed through a chain ofSetThemecalls.Form rendering has been simplified because the dialog frame now owns the title and footer hints. Form sizing is also calculated from the available body dimensions before applying component-specific options.
The UI settings screen has a new Animation toggle that controls menu entrance effects, dialogs, tabs, and screen transitions. Settings save errors are now checked rather than silently ignored.
Scanner screen rewrite
The scanner screen has been split into separate stage, frame, panel, and formatting components.
Previously, the screen maintained several parallel slices indexed by stage ID, all protected by a shared mutex. The new design gives each stage its own state and result table.
Workers append results to a stage-local pending batch. The UI drains that batch by swapping it under the mutex, avoiding a separate Bubble Tea message for every result and keeping the locked section short.
The stage update messages now carry stage IDs, so progress, errors, and completion updates go to the right stage.
Result merging has also been improved. Since the existing results and incoming batch are already sorted, they can be merged in linear time instead of sorting everything again. If the table is full and the best incoming result can't beat the worst displayed result, the merge can stop early.
The screen now has separate Scan and Errors panels, showing scan counts, remaining work, elapsed time, hit rate, and a breakdown of failures by type. Stage tables use the new typed result columns and quality-tier colors.
Closing the scanner now follows a consistent path: close the scanner, reset the screen stack when shutdown succeeds, or show an error notice if shutdown fails. Closing that notice also returns to the main menu.
Theme colors and result highlighting
Themes now support four configurable quality-tier colors: excellent, good, fair, and poor. Failed values use the muted color, values above the configured range use the error color, and plain values aren't styled.
The IP viewer uses the result schema and column type to choose a tier for each cell.
It also fixes a terminal styling issue where a cell's foreground reset could interfere with the selected-row background. A focused test covers that behavior.
Copy-IP now finds the IP column by its declared type rather than assuming it is the first column. Refreshes also avoid resetting the table rows when nothing has changed, preserving the current scroll position.
8. Dependencies and test coverage
The dependency updates include the Bubble Tea v2 stack, ANSI-aware text handling, libxray, display-width support, and updates to several networking-related dependencies.
The Xray and TheFeed submodule references have been updated to match the dependency versions. Two outbound example files also had trailing commas removed so they're valid JSON, and their example addresses now use
example.cominstead of$ADDRESS.New tests cover config field preservation, outbound handling, result merging, scanner lifecycle behavior, theme tier colors, and IP viewer row styling.
Some previous tests were removed alongside the implementations they covered:
These deletions are worth considering separately from the new implementation. In particular, the table behavior needs enough coverage to make sure the rewrite hasn't dropped existing functionality.
9. Changes visible to users
The main differences users will notice are:
core.logandui.log;debug.logis gone.10. Things to check before merging
Close()can run between theRun()state change and wait-group registration.ParseLinknow delegates to libxray, but the new path has no direct tests.OpenInputDialogcomment says the input title is used by default, but the implementation only attaches hints.chromeRowsandeffectiveSizecan leave the rendered menu one row taller than expected.Securitycolumn populated correctly.FieldDNSPortfor the corresponding inline validation error.crud.Updatestill assertsupdated.(*table.Model)directly, which can panic if the component type changes.ValidateWriterreportsResultDirectoryunderFieldResultBaseDir; confirm whether that mismatch should be fixed here.menu.Init()don't create duplicate animation tickers.These are review points, not all confirmed bugs. The goal is to verify the questionable paths before merging rather than assume each one needs a code change.