Skip to content

feat(core-settings): expert-mode window with Moonbot's Settings dialog, live over the wire - #424

Merged
guyverino merged 11 commits into
mainfrom
feat/core-expert-settings-window
Sep 5, 2026
Merged

feat(core-settings): expert-mode window with Moonbot's Settings dialog, live over the wire#424
guyverino merged 11 commits into
mainfrom
feat/core-expert-settings-window

Conversation

@guyverino

@guyverino guyverino commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Reproduces Moonbot's Settings dialog as a second face of the core-settings gear — eight tabs in Moonbot's own strip order — and makes it live wherever the safe-share wire reaches.

What is live

Page Controls Live
Автостарт 47 47
АвтоПокупка 37 34
Специальные 73 34
Интерфейс 60 33
Hotkeys 19 17
Основные 21 16
Телеграм 11 8
Логин 25 0

Every page draws EVERY row Moonbot has, including the ones this terminal cannot fill. A row it cannot fill is drawn in full and disabled, never hidden: a trader reads this window beside Moonbot's own dialog, and a missing row reads as a bug where a dead one reads as a limit. Each dead row states its reason in the source, and the reasons are distinct — the field is outside safe-share (Login's API keys, Remote's bot token and UDP password), or the wire carries it but no field means what the caption says, or it belongs to another surface's projection area.

Areas, not sections

An area is a PAGE, not a wire section. One wire field belongs to exactly one area, and a surface may write only what it drew — that is what stops an OK pressed on one page from reverting what a Moonbot user changed on another while the window stood open. ExpertTab::add_sections folds the mask over the tabs actually edited, so a window where only dead rows were touched sends nothing at all.

The General page is the one place a Moonbot page splits in two: the compact gear popup draws its exits and blacklist and not the seven rows beside them, so those seven are their own area. Carrying them in the popup's mask would let its OK stamp them back from a frozen draft.

Refusals

Several rows are drawn dead on purpose, and the reason is recorded where the row is:

  • sign_orders and join_sell_kind are marked on the wire as mirrors of the compact ClientSettingsCommand; writing them here would set two routes fighting over one field.
  • trading.free_position_check is documented as CLOSING orphaned positions, not as the position-limit check the neighbouring caption names.
  • pending_buy_price, h_pos_control, the order-book «Границы» slider and a few others have no wire field that means what their caption says. A mirrored control bound to the wrong field is worse than one that plainly does nothing.

The echo path

The last commit fixes a defect the window's own traffic exposed. SharedConfigSequence confirmed a write by comparing the WHOLE projection, so a concurrent core-side change to a field the write never named left a landed edit unconfirmed, re-sent the snapshot, and eventually dropped it as GaveUp. Confirmation, rejection and "the core already holds this" are now one mask-scoped comparison, and both it and apply_core_config destructure FieldMask so a bit added later cannot compile without an arm in each.

Verification

cargo build --all-targets and cargo fmt --check clean; cargo test --workspace green — 1581 in moon-core, 1423 in the UI, 273 contract tests. New tests pin the wire ordinals of the mouse-gesture and move-kind lists (their docs call them a display order, and a reorder would silently rewrite every core's stored gestures), the round trip of each new area against its NAMED wire slots, the popup's mask against the rows it does not draw, and the two echo regressions — each written before its fix and confirmed to fail without it.

Not verified live: the window's own pixels and a real core's echo.

guyverino and others added 11 commits September 4, 2026 11:55
The header gear gets a second face. An application-wide "expert mode" switch,
off by default, makes it open a singleton tool window reproducing Moonbot's own
Settings dialog — ten tabs in Moonbot's order — instead of the compact popover.
This is the frame only: the pages are not drawn yet.

Both faces send through one function. `Shell::commit_core_draft`'s send moves to
`core_settings::draft::send_core_config`, which keeps the leverage clamp, the
seeded-target guard, the section mask and the client-side blacklist-delta half in
one place; the contract test that pinned that guard to `commit_core_draft` now
pins it there and forbids the new window from calling `edit_core_config` itself.

The window answers by explaining where the popup answers by closing, because a
window that vanishes on its own reads as a save: `PageState` names why OK is
dark — no core, Auto Overview, the active core moved, the page not arrived, a
different MoonBot process on the connection, or the store's own `Stale`. A
refused send keeps the window up with a banner, and the popup no longer closes on
one either.

Two fixes to the shared send fall out of that: it reports failure instead of
returning `true` after `edit_core_config` errored, and it no longer rewrites an
untouched `fix_lev` of 0 — the wire's "no fixed multiplier chosen" — to 1 on
every OK.

Tabs record how far each page's values reach: Основные and Автостарт are both
projected and writable today, five more cross the wire but need `CoreConfig` and
`FieldMask::RENDERED_SECTIONS` widened before they can be drawn, and Логин,
Помощь с настройкой and PRO have nothing in safe-share at all. Every page opens
regardless — hiding one would renumber the rest for a trader who reaches for a
tab by position.
…ontrol

Every tab of the expert window now reproduces Moonbot's own dialog in full,
with each control the trader sees there drawn here — disabled where the
terminal's projection carries no value for it. A missing row reads as a bug;
a dead one reads as a limit.

New pages: Interface, Hotkeys (read-only, decoding the core's real bindings
across six inner tabs) and Special (four collapsible sections). Help and PRO
are gone from the strip: both are Moonbot actions rather than values.

Layout follows that dialog rather than approximating it. The window's root
font is the UI face, not the monospaced one, so it stops disagreeing with the
text inside it line by line; a caption is sized to its own text, so a sentence
with boxes in it keeps them inside the sentence instead of at the row's edge;
and the watchdog tracks are short and start halfway across, as they do there.

Behaviour fixed while porting:

- OK writes only the sections this window DRAWS. With the popup's wider mask
  it wrote a draft seeded when the window opened back over Leverage and the
  Signals alerts, silently undoing whatever had changed in them meanwhile.
  send_core_config takes the mask from its caller for that reason.
- sync_from_core no longer binds the core before the state resolves. The
  binding was undone again by every state without a page, so the pair of
  writes reported a change on every backend notification — a repaint per tick,
  plus a scan of the configured servers, in the states that last longest.
- The AutoStart report counters gate the repaint too, so a Reset shows its
  new number instead of waiting for an unrelated frame.
- parse_num refuses a non-finite value. Rust parses "nan" and "inf", and
  these fields are thresholds: a NaN one compares false to everything, which
  turns a watchdog off while its checkbox still reads as on.
- Closing a Special section blurs, as switching a tab already did: a disabled
  field takes focus on a click, and focus left on a control nothing draws
  takes the window's dispatch path with it.
- The gear pressed twice on its own group keeps the staged page.
- A refused Reset says so in the log, as a refused OK already did.
The expert window's Interface tab was drawn in full and disabled. Half of
what Moonbot puts on that page does cross the safe-share wire, so it is now
seeded from the core and sent on OK: 22 switches, its two alert-sound pickers
with their levels, the order-book zone transparencies, the two line widths and
the pending-order spread.

A new `InterfaceSettings` block carries them — 28 fields spread across the
wire's `trading`, `visual`, `signals` and `ui` sections, because a projected
area is a PAGE rather than a wire section: what a surface may write is what it
draws, and Moonbot's Interface page draws across all four.

That principle also changed how OK builds its mask. It used to be a constant
naming everything the window could reach, so an OK pressed on General wrote
this window's copy of the Interface block, frozen when it opened, back over
whatever the Moonbot user had changed there meanwhile. The mask is now folded
from the tabs whose controls were actually touched (`ExpertTab::add_sections`),
and an OK that touched nothing sendable closes without a write.

Three of Moonbot's rows stay disabled on purpose, and the module docs say why:
`trading.pending_buy_price` is not the drawing flag its caption suggests — the
wire documents it as using the pending-buy price for SELL calculations, so a
cosmetic-looking checkbox would have changed sell maths on a live bot;
`trading.use_lev_for_take` already belongs to the manual block, and one wire
field projected into two areas leaves the second stale after a write;
`visual.manual_charts_full_screen` sits behind its section's tail gate, so an
older core reads it back as false however it was written. Four more rows are
disabled because which wire field backs them does not follow from the
protocol's own documentation, and a mirrored control wired to the wrong field
is worse than one that plainly does nothing.

Also:

- `InterfaceSettings` compares its two `f64` spreads with `total_cmp`, as
  `ManualSettings` already did: a core holding a non-finite one must still
  compare equal to itself, or every edit on it burns its attempts and gives up.
- The spinners declare no range. The component clamps what it DISPLAYS, so a
  range there showed one number while OK sent another, and an arrow press then
  stepped from the displayed one and discarded the core's value.
- The spread boxes print a value that reads back as itself, and refuse what
  falls outside a sanity range instead of quietly rewriting it.
- `set_exclude_blacklisted_delta`, the client-side half of a core-side filter,
  now travels only with the section that owns its checkbox.

MoonUI's disabled slider still emits on a thumb drag — its bar guards its
handlers, its thumb does not. Logged in docs-internal/FORK_BUGS.md.
The densest page of the expert window was drawn in full and disabled. Almost
all of it crosses the safe-share wire, so it is now seeded from the core and
sent on OK: 18 switches, the five keyword lists, the word-distance, dip and
message-price offsets, the auto-cancel timer and the message word cap.

A new `AutoBuySettings` block carries them — 31 fields out of the `signals`
section, its `signal_config` sub-record and the one `trading` field Moonbot
files under this page. An area is a PAGE, not a wire section, so it does not
overlap `SignalsSettings`: those six alert-sound fields live on the Interface
page, where Moonbot draws them, and one wire field belongs to one area or a
write from either surface would put the other's frozen copy back.

Rows that stay disabled, and why, are named in the page's module doc. The
TradingView webhook and "не покупать пересланное" have no wire field at all.
The three "search mode" buttons turned out not to be a mode: `look_full_link_*`
is an additive parse option and `advanced_filter*` a separate feature, and the
wire's own default sets both, so the page shows each flag as itself, draws the
third button dead because nothing says what it means, and accepts no click —
an exclusive write would clear a flag the trader never touched. The two group
titles (`monitor_clipboard`, `do_monitoring`) are unclaimed for the same
reason: this window draws them as frame captions rather than switches.

Also: `widgets::radio` is read-only again and `widgets::stepper` is gone, both
following what the pages actually need.
… dialog

The protocol reads as if the three "search mode" buttons were two independent
flags: `SignalsSection::default()` sets `look_full_link_*` and `advanced_filter*`
together, which no one-of-three control can express, so the group was made
read-only. Moonbot's own dialog settles it the other way — it draws radio
buttons with exactly one selected, so that default is a factory value its OK
normalises rather than a state its user can reach.

So the mode stages again, and picking one writes the whole choice. A click on
the option ALREADY selected now stages nothing, which is what makes that safe:
without it, opening this window on a factory-default core and clicking the mode
it already shows would clear the other flag.

The same dialog restores `signals.monitor_clipboard` to "захватывать буфер".
It had been unbound because the wire calls it "enable clipboard monitoring",
which reads like a title for the group — but that dialog puts no switch in
either frame caption, and this is the only clipboard control left once
auto-buy, lowercase and the mode pair have their fields.

`signals.do_monitoring` stays unclaimed: it is the master toggle for the whole
signal pipeline and has no control on this page.
The channel box shows the core's real channels and the four rules over them
are staged and sent on OK. A new `TelegramSettings` block carries six fields
out of `signals` plus the one `trading` flag Moonbot files under this tab; it
does not overlap `AutoBuySettings`, which owns how a message is parsed, or
`SignalsSettings`, which owns the alert sounds.

This is the window's first list a user can change, which brought two things
with it.

A pick, held by the WINDOW because a page is rebuilt every render, and cleared
everywhere the page it points into is dropped: on a re-seed, on a tab switch,
on a blocked state, on a rebind. Removal then matches by the picked row AND by
name — the core can publish a new list between the frame that drew the pick
and the click that acts on it, and an index alone would still be in range while
naming a different channel.

And a text box that stages nothing. `editors::scratch_input_state` builds a
control with no subscription, for a value on its way somewhere else rather than
into the draft: routed through the normal path, one keystroke in the "add a
channel" box marked the page edited, froze it against the core, and put the
whole Telegram section on the wire for a name still being typed.

Adding a channel strips the "@" Moonbot draws as a caption beside the box,
refuses a duplicate, fills an empty primary channel rather than appending to a
list the core only reads in multi-channel mode, and empties the box once the
name has landed. A removal or an add that changes nothing does not mark the
page edited.

Moonbot's built-in Telegram client — its switch, proxy, status and log-out —
stays dead, as does "отправлять статистику на сервер": the safe-share subset
carries no part of either.
…reaches

A new `SpecialSettings` block carries 28 fields out of `trading` and its
`send_shots_config` sub-record, and the page's engine switches, its logging
and its screenshot rules are seeded from the core and sent on OK: 14 switches,
8 boxes and 5 sliders.

What stays disabled, and why, is stated on the page. The Remote block's own
identity — the bot token, its PIN, the UDP password, the control VDS address —
is outside the safe-share subset, so what is live inside that section is only
the screenshot rules and the multi-command switch. The iceberg pair belongs to
`GeneralSettings`, which the compact popup edits. A few rows have no wire field
that means what their caption says: `trading.free_position_check`, the nearest
neighbour of "Не проверять лимиты позиции", is documented as CLOSING orphaned
positions and travels on the compact ClientSettings route besides. And
`trading.orders_control`, behind several more rows, is on the wire but not
projected yet.

Three captions word their field differently from the protocol's own doc, and
the projection records each: "Мультистроковые команды" against a batch-order
flag, "или профит за час" against a session profit, "Ось времени, %" against
seconds of history. Each binding rests on there being no other candidate in
its sub-record.

The iceberg slider is bounded 0..1 because the wire calls that field a
fraction of the order, and its staged value is rounded to the track's own step:
0.01 has no exact f32, so the raw widening put 0.07000000029802322 on the wire
for a thumb dropped on 0.07. The numeric boxes refuse a negative count or one
outside i32 rather than saturating through `as`, which turned a typo into
i32::MAX — and refuse nothing else, since the wire's own defaults include a
price scale of zero.

"No trades on markets" is shown but not editable: the wire holds that list one
ticker per line and this page draws a single-line box, which neither renders
the second line nor could keep it.
Seven more rows of Moonbot's "Специальные" page are live: Liquidation Control
and Ignore Replacing Bug in the engine block, Turn Off Protection in System,
and in the hang watchdog the worker-bot switch, Report to Telegram, AutoSell
and the list of balances the watcher leaves alone.

Four fields of `trading.orders_control` stay out, and each has its own reason.
`sign_orders` is marked on the wire as a mirror of
`ClientSettingsCommand::sign_orders`, so it travels on the compact channel too
and writing it here would set two routes fighting over one field.  `min_price`
and `max_time` are this core's own watchdog thresholds, which no row of the
worker-bot block sets — the snapshot carries no follower list, so the columns
Moonbot shows in its table are a watcher's view of other bots.  And
`h_pos_control` has no caption of its own: the single switch above says
"следить за ОРДЕРАМИ", which is `orders_control.active`, and binding one
checkbox to two flags would turn a feature on and off that nobody named.  What
that costs is stated on the page rather than hidden: Report to Telegram and
AutoSell act only while hanging-position detection is on in the core. The test
asserts all four survive a write untouched.

`ignore_protection` is a bypass LEVEL under a checkbox. Ticking it supplies a
level only when the core holds none, so a level already set survives; a
negative reads as protection on, the wire stating a meaning for zero and for a
level but not for less than zero.

The balances box edits a one-line comma-separated list, which is what Moonbot's
own dialog and documentation show — the wire names no separator for this field.
It strips a stray carriage return on the way in, because moonui's single-line
paste drops a newline but keeps a lone CR, and it refuses to edit at all if the
core somehow sends a value this control cannot render whole.
…e reasons

The label was a link in Moonbot and is nothing here, so the four copies of it
go, along with the locale key they were the only readers of.

Two comments in this window stated a reason that does not hold, and a comment
that misnames the wire is worse than none — the next reader takes it as
established.

The General page claimed the stop-loss pair is held back by the wire's
"Mirrors `ClientSettingsCommand::…`" note. Five fields that page already
writes carry the same note: `trailing_stop`, `g_take_profit`, `vol_drop_level`,
`coins_black_list_text`, `use_coins_black_list`. The note says the compact
channel carries the same field, not that this one may not. The real reason is
the plain one that holds for every other dead row: the field is outside
`GeneralSettings`, so there is nothing to seed it from.

The same page called Copy/Paste of the settings something that "does not cross
the wire". moonproto exports the very format those buttons use —
`shared_config::to_mbsc_string` / `from_mbsc_string` and the `.mbshare` byte
form — and this window holds the whole snapshot. They are unimplemented, not
impossible, and now say so.

Also from the audit: the Interface page's doc lumped three rows in with "could
not be established", when each has a concrete reason recorded in `moon_core`;
the Autostart page's layout comment still described a help link that is gone;
and the ten manual-strategy checkboxes shared one GPUI `ElementId`, which makes
ten controls into one. Harmless while all ten are disabled and stage nothing,
which is why it went unseen — and a real collision the day that block becomes
writable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e tab

Seven rows of Moonbot's "Основные" page and the whole "Orders Controls" tab
of its Hotkeys page are live.

On General: the trailing addition, the partial-fill sell threshold, the buy
auto-cancel, the cancel-after-Sell switch, the fresh-coin hold, the
trades-into-deltas switch, and the startup analysis above the two columns.

`auto_cancel_buy_order` is a COMPOSITE scale and the one number on that page
that is not what it reads as: the wire documents values below 30 as seconds and
30 and above as `value - 29` minutes. The page prints the unit and sends the
value untouched. Zero is drawn as zero seconds, not as "off" — the wire states
no meaning for it, and naming one is how a row comes to tell a trader the
opposite of what the core will do.

`deltas_by_trades` has a client-side half, like the blacklist-delta filter
beside it: moonproto applies its own copy to the analytics this terminal
retains, so the row drives both through a new `CoreCmd::SetDeltasByTrades`.

The seven are their own projection area, `OrderRulesSettings`, although they
share a Moonbot page with the exits. The compact gear popup draws the exits and
the blacklist and NOT these, and a mask names what a surface DREW: carrying
them in the popup's mask would let its OK stamp all seven back from a frozen
draft over whatever Moonbot changed meanwhile. A test asserts it does not.

On Hotkeys, sixteen selectors and one checkbox become `GestureSettings`.
Sixteen fields of `trading.multi_orders` plus `trading.pending_order_set_click`
beside it — the wire carries no pending-LONG inside that sub-record, so
Moonbot's own pair of rows straddles two records. Of the eight left out,
`join_sell_kind` mirrors the compact channel and would set two routes fighting
over one field; six belong to Moonbot's chart rather than to its gestures; and
`ver` is the wire's version byte.

The ordinals are not guessed. moonproto's own defaults annotate
`buy_set_click: 1` as Dbl_Click and `sell_move_click: 2` as CTRL_Click, which
is `MouseGestureBinding::ALL` at 1 and 2; `TReplaceMultiKind` (Vars.pas:37) runs
None=0 through LastMoved=7, exactly as `MoveKind::ALL` is ordered. A new test
pins every position of both lists: their docs call them a display order, and a
reorder would silently rewrite the stored gestures of every core.

"Одни хоткеи для Long и Short" mirrors the long gestures onto the short ones,
as Moonbot's dialog and this terminal's own Hotkeys tab both do. The two
sources for reading that flag disagree — Moonbot's caption and the shipped port
say Long/Short, moonproto's one-line gloss says primary/secondary — and the
disagreement is recorded in the code rather than resolved silently. The rule
lives on `GestureSettings` with the reader that resolves the short column to
what actually fires, beside where `config::hotkeys` keeps the same pair for the
terminal's own copy.

The Hotkeys page is `Projected` now, which retires `TabSource::Wire`: no page
is on the wire but unprojected any more. Its five read-only sub-tabs and the
Controls grid say so themselves, since the window's banner is per page and this
page is only partly live.

Ancillary: `MouseGestureBinding::menu_label` and `MoveKind::locale_key` are the
one place each label is spelled, so the two surfaces drawing these lists cannot
drift; the gesture list is built once per process and borrowed, and a disabled
selector builds no menu items at all, where the first revision rebuilt ~224 of
them per frame; the hand-written `PartialEq` impls destructure `Self`, so a
field added later fails to compile rather than silently dropping out of
equality; and the trailing addition rounds through `util::fmt::round_to`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e whole snapshot

`SharedConfigSequence` asked the wrong question of the core's echo. The
confirmation compared the WHOLE projection, so anything a trader moved in
Moonbot's own dialogs while a packet was in flight made the echo differ. That
matched neither the "confirmed" branch nor the "rejected" one, so the edit
resolved as nothing, the whole snapshot was sent again, and enough of those in
a row dropped a write that had LANDED as `GaveUp`.

`rejection_within_mask` already knew the difference — it restricts itself to
the areas the edit named. The confirmation now goes through it, and so does
`edit_satisfied`, which had the same defect from the other side: an OK that
changed nothing still sent the whole snapshot whenever any unrelated area had
drifted since the surface seeded. Three questions, one comparison.

Both regressions are covered by tests written before the fix and confirmed to
fail without it.

Narrowing `edit_satisfied` creates a new way to fail silently: a mask bit with
no comparison arm would no longer merely lose a rejection, it would make every
edit naming that bit drop from the queue without ever being sent. So
`rejection_within_mask` and `apply_core_config` both destructure `FieldMask` —
a bit added later cannot compile without being named in each. That is the half
worth having; leaving a named bit unused is only a warning.

It also made "this entry is already satisfied" common where it had been nearly
unreachable, which exposed a second bug. There is ONE edit row per core and
`Confirmed` clears it outright, so a satisfied entry drained in the same pass
as a give-up erased the give-up — a trader would read a write that never
applied as saved. The suppression that already existed for a rejection now
covers both terminal verdicts. The cost is stated where it is paid: that
entry's own success goes unannounced.

`AutoStartSettings` and `BtcBlinkSettings` were the last two projection areas
still deriving `PartialEq` over wire `f64`s, which matters more now that one
comparison answers all three questions: a non-finite value makes an area never
equal itself, so every OK naming it burns the budget. Both are hand-written
against `total_cmp` and destructure `Self`, like the five areas that already
did. `parse_num` canonicalises `-0.0` in the same spirit, at the one place
every typed number enters rather than in each comparison downstream.

Ancillary, from the same review: the three alert-sound pickers in the expert
window were gated on the alert's own switch while the level beside them was
not, and the compact popup gates neither — the switch turns the alert on and
off, not the ability to choose which sound it will use. With the last one
ungated the `enabled` parameter had no caller left and is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@guyverino
guyverino merged commit 5e02e36 into main Sep 5, 2026
6 checks passed
@guyverino
guyverino deleted the feat/core-expert-settings-window branch September 5, 2026 09:02
guyverino added a commit that referenced this pull request Sep 5, 2026
…g, live over the wire (#424)

* feat(core-settings): expert-mode window with Moonbot's settings tabs

The header gear gets a second face. An application-wide "expert mode" switch,
off by default, makes it open a singleton tool window reproducing Moonbot's own
Settings dialog — ten tabs in Moonbot's order — instead of the compact popover.
This is the frame only: the pages are not drawn yet.

Both faces send through one function. `Shell::commit_core_draft`'s send moves to
`core_settings::draft::send_core_config`, which keeps the leverage clamp, the
seeded-target guard, the section mask and the client-side blacklist-delta half in
one place; the contract test that pinned that guard to `commit_core_draft` now
pins it there and forbids the new window from calling `edit_core_config` itself.

The window answers by explaining where the popup answers by closing, because a
window that vanishes on its own reads as a save: `PageState` names why OK is
dark — no core, Auto Overview, the active core moved, the page not arrived, a
different MoonBot process on the connection, or the store's own `Stale`. A
refused send keeps the window up with a banner, and the popup no longer closes on
one either.

Two fixes to the shared send fall out of that: it reports failure instead of
returning `true` after `edit_core_config` errored, and it no longer rewrites an
untouched `fix_lev` of 0 — the wire's "no fixed multiplier chosen" — to 1 on
every OK.

Tabs record how far each page's values reach: Основные and Автостарт are both
projected and writable today, five more cross the wire but need `CoreConfig` and
`FieldMask::RENDERED_SECTIONS` widened before they can be drawn, and Логин,
Помощь с настройкой and PRO have nothing in safe-share at all. Every page opens
regardless — hiding one would renumber the rest for a trader who reaches for a
tab by position.

* feat(core-settings): draw Moonbot's eight expert pages, control for control

Every tab of the expert window now reproduces Moonbot's own dialog in full,
with each control the trader sees there drawn here — disabled where the
terminal's projection carries no value for it. A missing row reads as a bug;
a dead one reads as a limit.

New pages: Interface, Hotkeys (read-only, decoding the core's real bindings
across six inner tabs) and Special (four collapsible sections). Help and PRO
are gone from the strip: both are Moonbot actions rather than values.

Layout follows that dialog rather than approximating it. The window's root
font is the UI face, not the monospaced one, so it stops disagreeing with the
text inside it line by line; a caption is sized to its own text, so a sentence
with boxes in it keeps them inside the sentence instead of at the row's edge;
and the watchdog tracks are short and start halfway across, as they do there.

Behaviour fixed while porting:

- OK writes only the sections this window DRAWS. With the popup's wider mask
  it wrote a draft seeded when the window opened back over Leverage and the
  Signals alerts, silently undoing whatever had changed in them meanwhile.
  send_core_config takes the mask from its caller for that reason.
- sync_from_core no longer binds the core before the state resolves. The
  binding was undone again by every state without a page, so the pair of
  writes reported a change on every backend notification — a repaint per tick,
  plus a scan of the configured servers, in the states that last longest.
- The AutoStart report counters gate the repaint too, so a Reset shows its
  new number instead of waiting for an unrelated frame.
- parse_num refuses a non-finite value. Rust parses "nan" and "inf", and
  these fields are thresholds: a NaN one compares false to everything, which
  turns a watchdog off while its checkbox still reads as on.
- Closing a Special section blurs, as switching a tab already did: a disabled
  field takes focus on a click, and focus left on a control nothing draws
  takes the window's dispatch path with it.
- The gear pressed twice on its own group keeps the staged page.
- A refused Reset says so in the log, as a refused OK already did.

* feat(core-settings): make Moonbot's Interface page live over the wire

The expert window's Interface tab was drawn in full and disabled. Half of
what Moonbot puts on that page does cross the safe-share wire, so it is now
seeded from the core and sent on OK: 22 switches, its two alert-sound pickers
with their levels, the order-book zone transparencies, the two line widths and
the pending-order spread.

A new `InterfaceSettings` block carries them — 28 fields spread across the
wire's `trading`, `visual`, `signals` and `ui` sections, because a projected
area is a PAGE rather than a wire section: what a surface may write is what it
draws, and Moonbot's Interface page draws across all four.

That principle also changed how OK builds its mask. It used to be a constant
naming everything the window could reach, so an OK pressed on General wrote
this window's copy of the Interface block, frozen when it opened, back over
whatever the Moonbot user had changed there meanwhile. The mask is now folded
from the tabs whose controls were actually touched (`ExpertTab::add_sections`),
and an OK that touched nothing sendable closes without a write.

Three of Moonbot's rows stay disabled on purpose, and the module docs say why:
`trading.pending_buy_price` is not the drawing flag its caption suggests — the
wire documents it as using the pending-buy price for SELL calculations, so a
cosmetic-looking checkbox would have changed sell maths on a live bot;
`trading.use_lev_for_take` already belongs to the manual block, and one wire
field projected into two areas leaves the second stale after a write;
`visual.manual_charts_full_screen` sits behind its section's tail gate, so an
older core reads it back as false however it was written. Four more rows are
disabled because which wire field backs them does not follow from the
protocol's own documentation, and a mirrored control wired to the wrong field
is worse than one that plainly does nothing.

Also:

- `InterfaceSettings` compares its two `f64` spreads with `total_cmp`, as
  `ManualSettings` already did: a core holding a non-finite one must still
  compare equal to itself, or every edit on it burns its attempts and gives up.
- The spinners declare no range. The component clamps what it DISPLAYS, so a
  range there showed one number while OK sent another, and an arrow press then
  stepped from the displayed one and discarded the core's value.
- The spread boxes print a value that reads back as itself, and refuse what
  falls outside a sanity range instead of quietly rewriting it.
- `set_exclude_blacklisted_delta`, the client-side half of a core-side filter,
  now travels only with the section that owns its checkbox.

MoonUI's disabled slider still emits on a thumb drag — its bar guards its
handlers, its thumb does not. Logged in docs-internal/FORK_BUGS.md.

* feat(core-settings): make Moonbot's AutoBuy page live over the wire

The densest page of the expert window was drawn in full and disabled. Almost
all of it crosses the safe-share wire, so it is now seeded from the core and
sent on OK: 18 switches, the five keyword lists, the word-distance, dip and
message-price offsets, the auto-cancel timer and the message word cap.

A new `AutoBuySettings` block carries them — 31 fields out of the `signals`
section, its `signal_config` sub-record and the one `trading` field Moonbot
files under this page. An area is a PAGE, not a wire section, so it does not
overlap `SignalsSettings`: those six alert-sound fields live on the Interface
page, where Moonbot draws them, and one wire field belongs to one area or a
write from either surface would put the other's frozen copy back.

Rows that stay disabled, and why, are named in the page's module doc. The
TradingView webhook and "не покупать пересланное" have no wire field at all.
The three "search mode" buttons turned out not to be a mode: `look_full_link_*`
is an additive parse option and `advanced_filter*` a separate feature, and the
wire's own default sets both, so the page shows each flag as itself, draws the
third button dead because nothing says what it means, and accepts no click —
an exclusive write would clear a flag the trader never touched. The two group
titles (`monitor_clipboard`, `do_monitoring`) are unclaimed for the same
reason: this window draws them as frame captions rather than switches.

Also: `widgets::radio` is read-only again and `widgets::stepper` is gone, both
following what the pages actually need.

* fix(core-settings): restore the AutoBuy search mode against Moonbot's dialog

The protocol reads as if the three "search mode" buttons were two independent
flags: `SignalsSection::default()` sets `look_full_link_*` and `advanced_filter*`
together, which no one-of-three control can express, so the group was made
read-only. Moonbot's own dialog settles it the other way — it draws radio
buttons with exactly one selected, so that default is a factory value its OK
normalises rather than a state its user can reach.

So the mode stages again, and picking one writes the whole choice. A click on
the option ALREADY selected now stages nothing, which is what makes that safe:
without it, opening this window on a factory-default core and clicking the mode
it already shows would clear the other flag.

The same dialog restores `signals.monitor_clipboard` to "захватывать буфер".
It had been unbound because the wire calls it "enable clipboard monitoring",
which reads like a title for the group — but that dialog puts no switch in
either frame caption, and this is the only clipboard control left once
auto-buy, lowercase and the mode pair have their fields.

`signals.do_monitoring` stays unclaimed: it is the master toggle for the whole
signal pipeline and has no control on this page.

* feat(core-settings): make Moonbot's Telegram page live over the wire

The channel box shows the core's real channels and the four rules over them
are staged and sent on OK. A new `TelegramSettings` block carries six fields
out of `signals` plus the one `trading` flag Moonbot files under this tab; it
does not overlap `AutoBuySettings`, which owns how a message is parsed, or
`SignalsSettings`, which owns the alert sounds.

This is the window's first list a user can change, which brought two things
with it.

A pick, held by the WINDOW because a page is rebuilt every render, and cleared
everywhere the page it points into is dropped: on a re-seed, on a tab switch,
on a blocked state, on a rebind. Removal then matches by the picked row AND by
name — the core can publish a new list between the frame that drew the pick
and the click that acts on it, and an index alone would still be in range while
naming a different channel.

And a text box that stages nothing. `editors::scratch_input_state` builds a
control with no subscription, for a value on its way somewhere else rather than
into the draft: routed through the normal path, one keystroke in the "add a
channel" box marked the page edited, froze it against the core, and put the
whole Telegram section on the wire for a name still being typed.

Adding a channel strips the "@" Moonbot draws as a caption beside the box,
refuses a duplicate, fills an empty primary channel rather than appending to a
list the core only reads in multi-channel mode, and empties the box once the
name has landed. A removal or an add that changes nothing does not mark the
page edited.

Moonbot's built-in Telegram client — its switch, proxy, status and log-out —
stays dead, as does "отправлять статистику на сервер": the safe-share subset
carries no part of either.

* feat(core-settings): make Moonbot's Special page live where the wire reaches

A new `SpecialSettings` block carries 28 fields out of `trading` and its
`send_shots_config` sub-record, and the page's engine switches, its logging
and its screenshot rules are seeded from the core and sent on OK: 14 switches,
8 boxes and 5 sliders.

What stays disabled, and why, is stated on the page. The Remote block's own
identity — the bot token, its PIN, the UDP password, the control VDS address —
is outside the safe-share subset, so what is live inside that section is only
the screenshot rules and the multi-command switch. The iceberg pair belongs to
`GeneralSettings`, which the compact popup edits. A few rows have no wire field
that means what their caption says: `trading.free_position_check`, the nearest
neighbour of "Не проверять лимиты позиции", is documented as CLOSING orphaned
positions and travels on the compact ClientSettings route besides. And
`trading.orders_control`, behind several more rows, is on the wire but not
projected yet.

Three captions word their field differently from the protocol's own doc, and
the projection records each: "Мультистроковые команды" against a batch-order
flag, "или профит за час" against a session profit, "Ось времени, %" against
seconds of history. Each binding rests on there being no other candidate in
its sub-record.

The iceberg slider is bounded 0..1 because the wire calls that field a
fraction of the order, and its staged value is rounded to the track's own step:
0.01 has no exact f32, so the raw widening put 0.07000000029802322 on the wire
for a thumb dropped on 0.07. The numeric boxes refuse a negative count or one
outside i32 rather than saturating through `as`, which turned a typo into
i32::MAX — and refuse nothing else, since the wire's own defaults include a
price scale of zero.

"No trades on markets" is shown but not editable: the wire holds that list one
ticker per line and this page draws a single-line box, which neither renders
the second line nor could keep it.

* feat(core-settings): wire the orders_control block on the Special page

Seven more rows of Moonbot's "Специальные" page are live: Liquidation Control
and Ignore Replacing Bug in the engine block, Turn Off Protection in System,
and in the hang watchdog the worker-bot switch, Report to Telegram, AutoSell
and the list of balances the watcher leaves alone.

Four fields of `trading.orders_control` stay out, and each has its own reason.
`sign_orders` is marked on the wire as a mirror of
`ClientSettingsCommand::sign_orders`, so it travels on the compact channel too
and writing it here would set two routes fighting over one field.  `min_price`
and `max_time` are this core's own watchdog thresholds, which no row of the
worker-bot block sets — the snapshot carries no follower list, so the columns
Moonbot shows in its table are a watcher's view of other bots.  And
`h_pos_control` has no caption of its own: the single switch above says
"следить за ОРДЕРАМИ", which is `orders_control.active`, and binding one
checkbox to two flags would turn a feature on and off that nobody named.  What
that costs is stated on the page rather than hidden: Report to Telegram and
AutoSell act only while hanging-position detection is on in the core. The test
asserts all four survive a write untouched.

`ignore_protection` is a bypass LEVEL under a checkbox. Ticking it supplies a
level only when the core holds none, so a level already set survives; a
negative reads as protection on, the wire stating a meaning for zero and for a
level but not for less than zero.

The balances box edits a one-line comma-separated list, which is what Moonbot's
own dialog and documentation show — the wire names no separator for this field.
It strips a stray carriage return on the way in, because moonui's single-line
paste drops a newline but keeps a lone CR, and it refuses to edit at all if the
core somehow sends a value this control cannot render whole.

* fix(core-settings): drop the "Need help!" labels and correct two false reasons

The label was a link in Moonbot and is nothing here, so the four copies of it
go, along with the locale key they were the only readers of.

Two comments in this window stated a reason that does not hold, and a comment
that misnames the wire is worse than none — the next reader takes it as
established.

The General page claimed the stop-loss pair is held back by the wire's
"Mirrors `ClientSettingsCommand::…`" note. Five fields that page already
writes carry the same note: `trailing_stop`, `g_take_profit`, `vol_drop_level`,
`coins_black_list_text`, `use_coins_black_list`. The note says the compact
channel carries the same field, not that this one may not. The real reason is
the plain one that holds for every other dead row: the field is outside
`GeneralSettings`, so there is nothing to seed it from.

The same page called Copy/Paste of the settings something that "does not cross
the wire". moonproto exports the very format those buttons use —
`shared_config::to_mbsc_string` / `from_mbsc_string` and the `.mbshare` byte
form — and this window holds the whole snapshot. They are unimplemented, not
impossible, and now say so.

Also from the audit: the Interface page's doc lumped three rows in with "could
not be established", when each has a concrete reason recorded in `moon_core`;
the Autostart page's layout comment still described a help link that is gone;
and the ten manual-strategy checkboxes shared one GPUI `ElementId`, which makes
ten controls into one. Harmless while all ten are disabled and stage nothing,
which is why it went unseen — and a real collision the day that block becomes
writable.

* feat(core-settings): live order rules on General and the whole gesture tab

Seven rows of Moonbot's "Основные" page and the whole "Orders Controls" tab
of its Hotkeys page are live.

On General: the trailing addition, the partial-fill sell threshold, the buy
auto-cancel, the cancel-after-Sell switch, the fresh-coin hold, the
trades-into-deltas switch, and the startup analysis above the two columns.

`auto_cancel_buy_order` is a COMPOSITE scale and the one number on that page
that is not what it reads as: the wire documents values below 30 as seconds and
30 and above as `value - 29` minutes. The page prints the unit and sends the
value untouched. Zero is drawn as zero seconds, not as "off" — the wire states
no meaning for it, and naming one is how a row comes to tell a trader the
opposite of what the core will do.

`deltas_by_trades` has a client-side half, like the blacklist-delta filter
beside it: moonproto applies its own copy to the analytics this terminal
retains, so the row drives both through a new `CoreCmd::SetDeltasByTrades`.

The seven are their own projection area, `OrderRulesSettings`, although they
share a Moonbot page with the exits. The compact gear popup draws the exits and
the blacklist and NOT these, and a mask names what a surface DREW: carrying
them in the popup's mask would let its OK stamp all seven back from a frozen
draft over whatever Moonbot changed meanwhile. A test asserts it does not.

On Hotkeys, sixteen selectors and one checkbox become `GestureSettings`.
Sixteen fields of `trading.multi_orders` plus `trading.pending_order_set_click`
beside it — the wire carries no pending-LONG inside that sub-record, so
Moonbot's own pair of rows straddles two records. Of the eight left out,
`join_sell_kind` mirrors the compact channel and would set two routes fighting
over one field; six belong to Moonbot's chart rather than to its gestures; and
`ver` is the wire's version byte.

The ordinals are not guessed. moonproto's own defaults annotate
`buy_set_click: 1` as Dbl_Click and `sell_move_click: 2` as CTRL_Click, which
is `MouseGestureBinding::ALL` at 1 and 2; `TReplaceMultiKind` (Vars.pas:37) runs
None=0 through LastMoved=7, exactly as `MoveKind::ALL` is ordered. A new test
pins every position of both lists: their docs call them a display order, and a
reorder would silently rewrite the stored gestures of every core.

"Одни хоткеи для Long и Short" mirrors the long gestures onto the short ones,
as Moonbot's dialog and this terminal's own Hotkeys tab both do. The two
sources for reading that flag disagree — Moonbot's caption and the shipped port
say Long/Short, moonproto's one-line gloss says primary/secondary — and the
disagreement is recorded in the code rather than resolved silently. The rule
lives on `GestureSettings` with the reader that resolves the short column to
what actually fires, beside where `config::hotkeys` keeps the same pair for the
terminal's own copy.

The Hotkeys page is `Projected` now, which retires `TabSource::Wire`: no page
is on the wire but unprojected any more. Its five read-only sub-tabs and the
Controls grid say so themselves, since the window's banner is per page and this
page is only partly live.

Ancillary: `MouseGestureBinding::menu_label` and `MoveKind::locale_key` are the
one place each label is spelled, so the two surfaces drawing these lists cannot
drift; the gesture list is built once per process and borrowed, and a disabled
selector builds no menu items at all, where the first revision rebuilt ~224 of
them per frame; the hand-written `PartialEq` impls destructure `Self`, so a
field added later fails to compile rather than silently dropping out of
equality; and the trailing addition rounds through `util::fmt::round_to`.

* fix(core-settings): confirm a shared-config write by its mask, not the whole snapshot

`SharedConfigSequence` asked the wrong question of the core's echo. The
confirmation compared the WHOLE projection, so anything a trader moved in
Moonbot's own dialogs while a packet was in flight made the echo differ. That
matched neither the "confirmed" branch nor the "rejected" one, so the edit
resolved as nothing, the whole snapshot was sent again, and enough of those in
a row dropped a write that had LANDED as `GaveUp`.

`rejection_within_mask` already knew the difference — it restricts itself to
the areas the edit named. The confirmation now goes through it, and so does
`edit_satisfied`, which had the same defect from the other side: an OK that
changed nothing still sent the whole snapshot whenever any unrelated area had
drifted since the surface seeded. Three questions, one comparison.

Both regressions are covered by tests written before the fix and confirmed to
fail without it.

Narrowing `edit_satisfied` creates a new way to fail silently: a mask bit with
no comparison arm would no longer merely lose a rejection, it would make every
edit naming that bit drop from the queue without ever being sent. So
`rejection_within_mask` and `apply_core_config` both destructure `FieldMask` —
a bit added later cannot compile without being named in each. That is the half
worth having; leaving a named bit unused is only a warning.

It also made "this entry is already satisfied" common where it had been nearly
unreachable, which exposed a second bug. There is ONE edit row per core and
`Confirmed` clears it outright, so a satisfied entry drained in the same pass
as a give-up erased the give-up — a trader would read a write that never
applied as saved. The suppression that already existed for a rejection now
covers both terminal verdicts. The cost is stated where it is paid: that
entry's own success goes unannounced.

`AutoStartSettings` and `BtcBlinkSettings` were the last two projection areas
still deriving `PartialEq` over wire `f64`s, which matters more now that one
comparison answers all three questions: a non-finite value makes an area never
equal itself, so every OK naming it burns the budget. Both are hand-written
against `total_cmp` and destructure `Self`, like the five areas that already
did. `parse_num` canonicalises `-0.0` in the same spirit, at the one place
every typed number enters rather than in each comparison downstream.

Ancillary, from the same review: the three alert-sound pickers in the expert
window were gated on the alert's own switch while the level beside them was
not, and the compact popup gates neither — the switch turns the alert on and
off, not the ability to choose which sound it will use. With the last one
ungated the `enabled` parameter had no caller left and is gone.

---------
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant