8000
Skip to content

Add TUI option to workbench - #396

Draft
jclark wants to merge 37 commits into
masterfrom
wb-tui
Draft

Add TUI option to workbench#396
jclark wants to merge 37 commits into
masterfrom
wb-tui

Conversation

@jclark
@jclark jclark commented Jul 31, 2026
Copy link
Copy Markdown
Owner

Adds satpulsewb --tui (shorthand -t): a Bubble Tea (v2) terminal UI over the same gps/app/session interface as the web workbench, run in process instead of the web server. The views follow the web workbench's panels (monitor, packets, corrections, configuration, message files), dropping the inherently graphical parts; the scatter panel's statistics are kept as a text readout.

The -t shorthand comes from master's removal of the --token shorthand, merged into this branch, and the TUI is adapted to master's typed session event payloads.

Later commits fold in the results of review passes: workbench-parity fixes across the views (gating, labels, filters, validation, clear controls, a 1 Hz age tick), and structural cleanups (one shared form-row machinery for all forms, side-effect-free rendering, bounded event queues in the sink).

For #395. See plan/wb-tui.md.

jclark added 30 commits July 31, 2026 10:19
Add the plan for a Bubble Tea terminal UI mode of satpulsewb: a third
shell over gps/app/session alongside the web workbench and the Wails
desktop app, usable over ssh with no browser.

The plan's prerequisite payload-aliasing check is done and its outcome
recorded: every session event payload is safe to hold by reference
after Sink.Emit returns, so the TUI sink does not need to copy on
receipt.
satpulsewb -t runs a Bubble Tea (v2) terminal UI over gps/app/session
instead of starting the HTTP server and opening a browser, so the
workbench's ground is covered over ssh on a headless box with a
receiver attached. The session is used in process: a coalescing Sink
feeds session events into the Bubble Tea message loop (keep-latest
per event name for the high-rate snapshot events, a bounded queue for
the packet stream), slog is routed through session.NewLogHandler with
a discard base handler so nothing writes to the terminal behind
Bubble Tea's back, and blocking session methods run as commands.

The views mirror the workbench tabs: Monitor (summary, clock, PVT
message tables, satellite signals as a text bar chart, survey, and
the position statistics the scatter panel computes, without the
plot), Packets (scrolling list with pause and a decode detail view,
with gps:packet gated on visibility through Wants), Corrections
(source form, status, and the per-message summary), Configuration
(read and apply, controls gated on the probed ConfigSupport flags as
in the workbench), and Messages (library catalog, tag list, send
progress, and receiver responses). A connection form overlay replaces
the workbench's fixed connection bar.

The -t shorthand moves from --token to the new --terminal; --token
keeps only its long form. See plan/tui.md.
Driving the TUI against the FIFO replay, the ubxsim simulator with
the fake NTRIP caster, and a live ZED-F9P turned up a batch of
usability defects:

- The corrections and connection text inputs had no width set, so
  they rendered a single column wide.
- The corrections NTRIP port default 2101 was only a placeholder, so
  Start stayed refused until the user typed the port the workbench
  fills in for them; it is now a real initial value, kept per mode.
- The correction message descriptions pushed the Count/Splits/Age
  columns off a normal-width terminal; they are capped at 44
  characters.
- The Messages send status and the decode of the selected response
  rendered after the form and were clipped off the bottom of the
  window; the status now follows the Send button and the decode sits
  inline under the selected response. The configuration status line
  moved above the form for the same reason.
- Scrolling a form down pinned the focused row to the bottom edge,
  hiding anything attached below it; the window now keeps a third of
  its height below the focus.
- The configuration form starts with the first focusable row focused
  instead of an invisible focus on the heading.
Cover the pieces of the TUI whose behaviour is not obvious from a
glance: the coalescing sink (keep-latest per event name and per
gps:msg kind, ordered delivery of stream events, the bounded packet
queue with its drop counter, and the Wants gating), the position
statistics accumulator (mean, CEP, RMS, and ARP distances on known
geometry, plus result caching and reset), the workbench's per-epoch
PVT row eviction, the correction message counting rules (fragments
count once per epoch with extras as splits), and the root model's
header line, Messages tab appearance, and a render smoke test across
all tabs. The sink's batch handoff is extracted as takeBatch so the
tests exercise exactly what the drain goroutine delivers.
The TUI shipped as satpulsewb -t, taking the -t shorthand from
--token, but -t as --token is established satpulsewb usage that must
not change (the e2e harness and existing habits rely on it). With the
mnemonic shorthand unavailable, a mode flag on satpulsewb loses its
appeal, so the terminal UI becomes a separate satpulsetui command
for now.

satpulsewb is restored exactly to its pre-TUI state. The tui package
moves to cmd/satpulsetui as package main, with the former tui.Run
becoming the command's run alongside satpulsewb-style flag parsing
(-d, -s, --vendor, --packet-log, -v). Not added to install or
deb/rpm packaging yet. The plan's Decided section records the
reversal.
Being functionally the same tool as the web workbench, the terminal
UI belongs in satpulsewb rather than a separate binary: satpulsewb
--tui runs the terminal UI instead of starting the HTTP server and
opening a browser, reversing the satpulsetui split. The long flag
name follows the gdb --tui precedent; the -t shorthand stays with
--token on this branch, to be freed on master (where --token, being
scripted-use only, does not need a shorthand) so --tui can take it
when that lands.

The tui package moves back to cmd/satpulsewb/tui with its former
Options/Run entry restored; the standalone flag parsing is deleted.
--tui cannot be combined with --listen or --token. The man page
documents the mode, and the NEWS, packages.md, and plan entries
follow the flag.
Match the branch, now named wb-tui, and the feature's final shape as
a satpulsewb mode rather than a standalone TUI.
The terminal UI now has issue #395; put its number in the plan
heading per the plan/issue conventions.
Brings in the removal of the satpulsewb -t token shorthand, freeing
-t to become the shorthand for --tui.

# Conflicts:
#	cmd/satpulsewb/satpulsewb.go
#	docs/man/satpulsewb.1.md
With master's removal of the --token shorthand merged in, -t is free
to take its natural meaning, following the gdb --tui precedent. The
man page, usage summary, NEWS entry, and plan record the shorthand.

For #395.
The first pass implemented the Packets view as a flat scrolling
packet stream with pause, following a summary sentence in the plan
rather than the workbench packet panel that the plan's own rule
names as the reference. Replace it with the workbench design: one
aggregated row per (protocol, message, direction) with a running
count, last timestamp and data, the current ~900 ms burst retained
per row and expandable, stale rows dimmed after 1.5 s,
freeze/unfreeze of a display copy while accumulation continues,
a snapshot listing of the currently active packets, clear, and a
decode pane for the selected entry below the table.

The ring buffer, pause semantics, and the dropped-packet marker go
away with the stream; aggregation makes a stalled UI cost at most an
undercount. The plan's Packets bullet now describes the panel it was
summarizing.
Keep-latest coalescing was applied to gps:msg (keyed by kind) and
gps:basearp, which are keyed streams, not snapshots: two messages of
the same kind but different native message IDs routinely land in one
batch, so the earlier one was silently replaced and the Monitor's
per-native-message rows intermittently lost entries; likewise base
ARPs with multiple station IDs.

Coalescing is now limited to the true singleton snapshots (state,
receiver, speed, corrections, time, epochPVT, nmeaPosition,
initialPos), and everything else is delivered in order through a
FIFO bounded like the packet queue, dropping the oldest non-singleton
entries on overflow. The bound only matters while the render loop is
stalled (a blocked terminal: Ctrl-S, dead ssh, suspension) -- Bubble
Tea's event loop wedges on the renderer mutex when the tty write
blocks -- and what gets dropped then either repopulates within an
epoch or merely undercounts, mirroring how the workbench SSE hub
treats a stalled client. The unused packet drop counter goes away.

A superseded singleton now also moves to its latest emission
position instead of keeping its first-emission slot, so a state
change can no longer be delivered ahead of stream events that were
emitted before it (a disconnect followed by messages from the dead
connection would have repopulated a just-cleared view).
Brings in the typed session event payloads (#396-adjacent; see
plan/wb-typed-event.md): session.Event is now a sealed union, and
the TUI is adapted to it in this merge's follow-up commit.
With session.Event now the sealed union from plan/wb-typed-event.md
(merged from master), every view's handleEvent becomes a plain type
switch on the payload value, dropping the guarded name-plus-assertion
pairs; the sink keys singletons and packets off EventName(); and the
test fixtures become typed values, so an invalid name/payload pairing
is no longer representable.
The session clears its cached probe on Disconnect but emits only
StateDisconnected, so the header kept showing the previous receiver
identification and baud rate beside "disconnected" until another
connection started. Drop the mirrored values when the state arrives;
corrections state stays, carrying its own lifecycle.
StartCorrections requires exactly StateConnected, but the form only
locked on disconnected: during connecting, reconnecting, configuring,
or sending it appeared editable and Enter attempted a start that must
fail. Gate editing and start on StateConnected.
Connecting through the overlay with the Configuration tab already
selected never triggered the tab's automatic readback: only a tab
switch sent viewShownMsg, and the connected transition alone updates
state. Closing the overlay now notifies the view it reveals, so the
readback (and the Messages catalog refresh) runs as it does when the
tab becomes visible.
After readback selects Mobile, Survey-in, or Fixed, cycling could
select the blank "--" state and mark the time mode touched, but
buildTarget has no case for it: a mode-only apply failed as a no-op,
while an apply bundled with other changes cleared the pending mode
without changing the receiver. The blank state now only reports that
nothing is selected; it cannot be selected as a request.
The tab was gated on the message-file catalog being non-empty, a
compromise reading of a plan sentence that misdescribed the
workbench. The built-in library is compiled in, so the catalog is
never empty: the gate's only observable effect was the tab popping in
a beat after startup once the asynchronous catalog listing arrived,
shifting the tab digits. Show it unconditionally, as the workbench
does for satpulsewb.
The workbench disables the Configuration tab until the probe
identifies a vendor; the TUI allowed entering it, enabled its
controls on StateConnected alone, and even fired the automatic
readback against an unidentified receiver. The tab now renders
dimmed and cannot be activated until the receiver is identified
(tab cycling skips it), and the view's master gate requires
identification as well as StateConnected, which also covers the
readback trigger and the case of sitting on the tab across a
reconnect to an unidentified receiver.
Stale-row dimming in the Packets table and the Age column of the
corrections table recomputed only when an event happened to trigger
a render, so a stopped stream quietly stopped aging. A 1 Hz
tea.Tick now runs while a view showing ages is active, matching the
workbench's per-second refresh of those tables.
The header showed raw state strings ("connected") and a bare
firmware field. Use the workbench labels ("Connected",
"Connecting..."), its receiver identity form "Vendor HW (FW fw)",
"Unknown (formats)" for an unidentified receiver, and
"Identifying..." while the probe runs.
The workbench has Clear buttons on the scatter panel and the
correction message table; the TUI only cleared on disconnect. Add x
as the clear key: position statistics in Monitor, the correction
message table (ignored while a text field has focus), and the
Packets table, whose advertised clear key c was dead -- the root
model intercepts c globally for the connection overlay, so the
binding never fired.
The row interval was tracked but unused; apply the workbench's
adaptive staleness threshold, max(10 s, 2.5 x interval), dimming
rows whose message has stopped arriving. A row with only one message
seen is never stale.
The workbench Time table shows the local time of day next to UTC;
the TUI omitted it. Local is derived from the row's UTC (or the
TAI-derived UTC) and never bold, per the bold-means-native
convention.
The summary's GS/C accuracy fields are shown only while moving; the
workbench requires ground speed and course both present before
treating the receiver as moving, the TUI only speed. A receiver
reporting speed without course showed accuracies the workbench
hides.
Two workbench apply behaviours were missing. A fixed-position ECEF
triple is now validated as on Earth (a direct geopos CheckOnEarth
call, where the workbench uses its geo endpoint) and refused with
the workbench's wording. And the survey fields (time, accuracy, do a
new survey, report progress) are cleared when an apply is issued,
as the workbench does, so a later apply does not silently re-request
a survey.
The workbench swaps the survey-accuracy and position-accuracy
placeholders to 'not supported' when the probed ConfigSupport lacks
surveyAcc/fixedPosAcc; the TUI only disabled the fields. Set the
placeholders when the receiver event arrives.
Default the speed field to 9600 and prefill a sole enumerated port
into an empty device field, as the workbench connection bar does.
While a connection exists the device and speed fields lock and the
two buttons collapse into the workbench's single toggle: enter
connects when disconnected and disconnects otherwise (previously
enter re-ran connect unless the Disconnect button was focused).
A changed vendor preselect on catalog refresh means a new receiver
was identified; it now overrides the current selection as in the
workbench, instead of only filling in when the selection is gone.
And binary responses with a label carry the workbench's truncated
hex preview beside it.
jclark added 7 commits July 31, 2026 15:26
The form validated only inside buildTarget when Apply was pressed;
the workbench validates continuously, marks invalid fields, and
disables Apply while any error exists. The rules now live in one
place: each numeric field is a cfgField carrying its relevance
(mode-scoped, like its control's gating), required-ness, and range
rule, with the cross-field rules (pulse width shorter than a
non-zero period, fixed ECEF on Earth) beside them. fieldErrors runs
per render: invalid fields render their label and message in red,
the status line shows the first error, and Apply is disabled while
any exist. buildTarget starts from the same fieldErrors, so its
value extraction cannot fail and its inline parse-and-validate
closure (including the separately added ECEF check) is gone.
The workbench signals panel has per-constellation toggle chips and a
Used-only checkbox; the TUI showed the full table unfiltered. Add
keyboard toggles (the SVID prefix letters, except b for BeiDou since
c belongs to the connection overlay) and u for used-only, gated on
the receiver reporting used flags as the workbench disables the
checkbox. A filter bar above the table shows the constellations
present, dimming excluded ones. The filters are view preferences and
survive disconnects, as the workbench panel's state does.
Editing and starting are different things: preparing the source
details makes sense in any receiver state, including disconnected,
while starting requires exactly StateConnected. The fields now lock
only while a correction session runs or a start/stop is pending;
the start gate keeps its StateConnected requirement. Neither prior
rule had this split: the workbench locks editing when disconnected,
and the TUI locked it in every non-connected state.
The decode pane re-decoded the selected row's newest entry every
render, tracking the stream; the workbench decodes the clicked entry
once and the pane stays put. The selection now captures the packet
it denotes (parent row: its newest entry at that moment), and the
pane shows that until the selection changes or is cleared.
The Corrections view and the connection overlay used hand-rolled
focus indexes, field rendering, and key dispatch written before the
shared cfgItem machinery existed, giving two implementations of
traversal, styling, and input capture. Both now build cfgItem rows:
same up/down traversal skipping disabled rows, same dimming, same
capture semantics, less code.

Two behaviour changes ride along. The overlay's speed becomes a
select over the standard speed list (with an off-list -s value
offered as an extra option), matching the workbench control, and its
enumerated ports are focusable rows that fill the device field on
enter, replacing the up/down cycling. The corrections start/stop
moves onto its button rather than enter-anywhere, consistent with
the other forms.
Two impurities bent Bubble Tea's update/render separation. Text
inputs called Focus()/Blur() during rendering; the focus sync moves
into moveItemFocus via a setFocus hook on the row, which also stops
discarding the cursor command Focus() returns. And the Messages view
ran the response decode on every render; it now decodes once when a
response is selected (or auto-selected), like the packet decode pane.
Derived display data (the config validation cache, the Messages item
list) stays computed at render deliberately: side-effect-free
memoization, one View call per Update.
The status line showed only the latest log message, so history was
lost and a debug line could overwrite a warning. It is now backed by
a 200-entry ring (the workbench's depth) with a minimum-severity
filter, cycled with v: the line shows the newest message at or above
the filter, and a right-aligned indicator cluster shows one letter
per severity present, the current filter bracketed, and a star on
any severity whose newest message is newer than the one displayed --
so filtered-out activity is visible without the line churning.

l opens a transient pane over the lower third of the view for
browsing the filtered history (arrows/page keys scroll, v refilters
live, esc closes), so screen space is spent only while reading and
there is no separate tab to forget. No severity colouring: the level
word carries it, avoiding light/dark background readability
problems.
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

0