GopherTrunk TUI

August 11, 2026 · View on GitHub

A full-screen operator console over the daemon's REST + SSE API. The TUI ships in the same binary as the daemon — no separate install — and points at a running daemon over HTTP. Eleven panels cover every read surface; the Scanner cockpit, talkgroup table, and a curated set of mutations are interactive when the daemon permits mutations under api.auth (wide open by default on a loopback bind) and the TUI is started with --write.

Quick start

# In one terminal: run the daemon as usual.
gophertrunk run -config config.yaml

# In another: open the TUI.
gophertrunk tui

By default the TUI connects to http://127.0.0.1:8080. Override with -server:

gophertrunk tui -server http://10.0.0.5:8080
gophertrunk tui -server https://radio.example.com -insecure

Flags

FlagDefaultPurpose
-server URLhttp://127.0.0.1:8080daemon base URL
-insecurefalseskip TLS certificate verification (for self-signed test daemons)
-timeout DURATION5sper-request timeout; SSE streams are unaffected
-no-colorfalsestrip ANSI colour, useful when piping or on a monochrome terminal
-writefalsesurface mutation keybindings; the daemon must also permit mutations via api.auth (default disabled allows them on a loopback bind)

Panels

#PanelWhat it shows
1DashboardAt-a-glance health, active call count, recent events, recent tone alerts. Default landing panel.
2SystemsConfigured trunking systems: name, protocol, control channels, IDs (WACN / SystemID / RFSS-Site).
3TalkgroupsFull talkgroup table from the daemon. Substring filter (/) and a sort cycle (s: ID → Alpha → Priority).
4Active callsCalls currently being followed. Driven by a 1 s poll plus live call.start / call.end events for instant updates. The Flags column shows encryption (E), emergency (!), and — for DMR — the TDMA timeslot (TS1 / TS2), so a carrier's two concurrent calls are distinguishable.
5Call historyEnded calls from /api/v1/calls/history. On-demand reload (r); the panel does not poll continuously. Row formatting runs off the Update goroutine so the reducer never blocks on history rebuilds.
6EventsThe full SSE feed in chronological order. Substring filter (/), pause auto-scroll (p), clear filter (c). 500-entry ring buffer.
7Tone alertsJust tone.alert events with profile / device / matched frequencies. 100-entry ring buffer.
8MetricsCurated subset of /metrics: calls active / total, grant total, CC locked, SSE clients, devices attached, tone alerts.
9DevicesThe SDR pool snapshot: serial, driver, tuner, role, configured gain / PPM / bias-tee, attach state. Refreshed on sdr.attached / sdr.detached events for instant updates.
0ScannerPolice-scanner cockpit: per-trunked-system CC hunter state, conventional FM scan list (current dwell highlighted), talkgroup scan-list summary, audio cockpit.
SettingsTabbed read-only inspector of the live daemon configuration (Daemon · Storage · Audio · Recording · Tones · API · Vocoders · SDR · FEC). Reach via Tab / Shift+Tab or the command palette — there's no number shortcut.

Keybindings

Global

KeyAction
Tabnext panel
Shift+Tabprevious panel
19, 0jump directly to a panel (0 = Scanner; Settings has no number)
Ctrl+Popen the fuzzy command palette (see below)
Ctrl+Ttoggle theme (dark ↔ monochrome)
?toggle help overlay
q / Ctrl+Cquit

Inside tables

KeyAction
j / next row
k / previous row
gtop
Gbottom
Page Down / Page Upscroll a page

Panel-local

PanelKeys
SystemsEnter open detail card
Talkgroups/ filter, s cycle sort, l toggle lockout, S toggle scan flag, + / - priority up/down, Enter open detail card, Esc exit filter input
Active callse end highlighted call (write)
Call historyr reload
Events/ filter, p pause auto-scroll, c clear filter
Tone alertsR reset detector for highlighted device (write)
MetricsS run retention sweep now (write)
Devices(table navigation only)
Scannerj / k move row, h hold/resume highlighted row, r force re-hunt (Systems section, confirms), Enter dwell on highlighted conv channel, L lockout / unlockout highlighted conv channel (skipped from scan rotation, runtime-only), m cycle scan_mode, + / - volume up/down (5% step), M mute toggle, R recording toggle, f manual tune (type frequency in MHz, Enter to listen, Esc to cancel)
Settings[ / ] / h / l / / cycle through inspector tabs

Mouse

The tab strip is clickable, and every table-backed panel (Systems · Talkgroups · Active · History · Devices · Tones · Metrics) reacts to body clicks and scroll-wheel ticks:

  • Left-click on a tab switches to that panel.
  • Left-click on a data row moves the cursor onto that row. Follow up with Enter to open the detail card, or with a mutation key (e to end the highlighted call, R to reset the highlighted tone detector, L to toggle conventional channel lockout, etc.).
  • Scroll-wheel up/down advances the cursor one row at a time in the same panels. Bubbles v1.0.0's table.Update is KeyMsg-only, so wheel forwarding is plumbed by the TUI itself via a shared MouseAware interface.

Chrome clicks (border / title / column header) are ignored; out-of-range clicks clamp to the last row.

Command palette

Ctrl+P opens a centered fuzzy-matched action list. Type to filter; / or Ctrl+J / Ctrl+K to move; Enter to run; Esc or another Ctrl+P to dismiss. The list is rebuilt every time you open the palette so newly-arrived systems / talkgroups / devices are reachable without a restart.

Discovered actions:

  • Panel jumps for every panel (including Settings, which has no number shortcut).
  • System / talkgroup / device drill-ins — pre-positions the destination panel's cursor on the matching row before opening the detail modal, so keyboard and mouse paths converge on the same selection. Talkgroup actions cap at the first 200 to keep the list legible on large rosters.
  • Audio mutations — volume ±5%, mute toggle, recording toggle.
  • Retention sweep (confirms).
  • Scanner — toggle scan_mode list ↔ all, per-system hold/resume.
  • Theme toggle, help overlay, quit.

Theme

Ctrl+T cycles between the dark and monochrome palettes at runtime. -no-color starts the TUI on monochrome and pins the lipgloss renderer to a nil writer so ANSI is stripped even when piping output — Ctrl+T flips the palette in that mode but the renderer won't re-emit colour.

Panels that own a cached bubbles/table style bundle handle a ThemeChangedMsg broadcast by re-applying tableStyles() so the swap takes effect on the next render without a restart.

Settings panel

Read-only inspector of the live daemon configuration, fetched once at startup and refreshed every 30 s from GET /api/v1/runtime. Cycle through tabs with [ / ] (or h / l / / ):

TabCoverage
Daemonversion, log level / format, metrics enabled
Storagecall-log DB path, CC cache file, retention windows (days) + sweeper interval
Audioenabled, device, sample rate, buffer ms, available backends, auto-fallback disable flag (Linux)
Recordingoutput dir, sample rate, raw vocoder frames, CMA equalizer (taps + step size)
Tonesper-profile name, cooldown, frequencies
APIHTTP / gRPC / WebSocket / metrics listener addresses, mutations allowed
Vocodersper-protocol vocoder mapping
SDRregistered backend names, per-device serial / role / gain
FECper-system FEC state — every protocol's chain is on by default, this view surfaces explicit <key>: off opt-outs and the current channel-coding / colour-code parameters

Every config knob the daemon reads has a touch-point here. Mutating the settings still requires editing config.yaml and restarting the daemon.

Polling cadences

The TUI keeps SharedState fresh with a fan of polling Cmds:

EndpointPeriod
/api/v1/calls/active1 s
/api/v1/health2 s
/api/v1/scanner2 s
/api/v1/audio3 s
/metrics5 s
/api/v1/systems10 s
/api/v1/devices10 s (also refreshed on sdr.attached / sdr.detached)
/api/v1/talkgroups30 s
/api/v1/runtime30 s
/api/v1/calls/historyon-demand (r in History panel)
/api/v1/systems/{name}on-demand (Systems panel Enter)
/api/v1/talkgroups/{id}on-demand (Talkgroups panel Enter)

A long-lived /api/v1/events SSE stream complements the polls, so call.start / call.end / cc.locked / tone.alert / audio.state arrive instantly. If the SSE stream drops, the TUI shows a transient toast and reconnects with exponential backoff (1 s → 30 s cap).

Mutations

The TUI drives a curated set of operator actions when both sides opt in:

# Daemon side (config.yaml). On a loopback bind the default
# api.auth.mode ("" → disabled) already permits mutations; on a LAN
# bind, use "auto" or "required" with a token instead.
api:
  http_addr: 127.0.0.1:8080   # bind to loopback if you can
  auth:
    mode: disabled            # default; or "auto" / "required" (+ token_file) on a LAN bind

# TUI side: pass --write to surface the keybindings.
gophertrunk tui --write

Both ends are gated separately on purpose. Mutation endpoints are gated by api.auth.mode (see hardening.md): the default disabled leaves them wide open — fine on a loopback bind — while auto requires a bearer token on non-loopback binds and required requires one everywhere. Bind to 127.0.0.1 and trust the operator, or configure a token before exposing the listener. The legacy api.allow_mutations: true still works as a deprecated alias for auth.mode: disabled.

PanelKeyActionConfirm?
Active callseEnd the highlighted call (POST /api/v1/calls/{serial}/end, reason=manual)yes
TalkgroupslToggle lockout on the highlighted talkgroup (PATCH /api/v1/talkgroups/{id})no — reversible
TalkgroupsSToggle scan flag (relevant when scan_mode: list)no
Talkgroups+ / -Bump priority up / down (clamped 0–99)no
Tone alertsRReset tone-out match progress on the highlighted device (POST /api/v1/devices/{serial}/tone-reset)yes
MetricsSRun a retention sweep now (POST /api/v1/retention/sweep)yes
ScannerhHold/resume highlighted system or conv channel (POST /api/v1/scanner/hunt/{system}/{hold,resume} or /conventional/{hold,resume})no
ScannerrForce re-hunt the highlighted system (POST /api/v1/scanner/hunt/{system}/retune)yes
ScannerEnterDwell on the highlighted conv channel (POST /api/v1/scanner/conventional/{index}/dwell)no
ScannerLLockout / unlockout the highlighted conv channel (POST /api/v1/scanner/conventional/{index}/{lockout,unlockout})no — reversible
ScannermCycle global scan_mode list ↔ all (PATCH /api/v1/scanner)no
ScannerfManual tune: type a frequency in MHz and Enter to listen (POST /api/v1/scanner/manual_tune)no
Scanner+ / - / M / RVolume ± 5%, mute toggle, recording toggle (PATCH /api/v1/audio)no

When a key requires confirmation, a centered modal opens. The modal captures keyboard focus until you press:

  • y or Enter — fire the action and close the modal
  • n or Esc — cancel and close the modal

On success the status bar shows <label> ok and the dependent read panels refresh immediately rather than waiting for the next poll tick. Audio mutations additionally publish an audio.state SSE event so a second TUI / curl PATCH converges in one round-trip instead of waiting for the next 3 s poll. On failure the status bar shows the HTTP error.

The TUI fetches GET /api/v1/mutations once at startup to learn which subsystems the daemon has wired (engine / retention / tone detector / scanner / audio). If the daemon doesn't recognise the route (older build), the TUI assumes mutations are off and --write is a no-op.

Endpoints reference

MethodPathBodyNotes
GET/api/v1/mutationsCapability probe. Always 200.
GET/api/v1/runtimeSanitized snapshot of config.Config. Powers the Settings panel.
GET/api/v1/audioCurrent backend / volume / mute / recording state.
PATCH/api/v1/audio{"volume":0.7} / {"muted":true} / {"recording_enabled":true}Any combination of the three knobs.
GET/api/v1/scannerUnified scanner snapshot — systems, conventional, scan_mode, scan-list counts.
PATCH/api/v1/scanner{"scan_mode":"list"}Cycle scan_mode at runtime.
POST/api/v1/scanner/hunt/{system}/{hold,resume,retune}Per-system CC-hunt control.
POST/api/v1/scanner/conventional/{hold,resume}Conventional scanner control.
POST/api/v1/scanner/conventional/{index}/{dwell,lockout,unlockout}Per-channel control.
POST/api/v1/scanner/manual_tune{"frequency_hz":155895000,"label":"...","mode":"fm"}Adds a runtime VFO channel and dwells.
POST/api/v1/calls/{deviceSerial}/end{"reason":"manual"}404 if no active call on that device.
PATCH/api/v1/talkgroups/{id}{"priority":3,"lockout":true,"scan":false}All fields optional; supply at least one. Returns the updated TalkgroupDTO.
POST/api/v1/retention/sweepSynchronous; 503 if no call-log persistence configured.
POST/api/v1/devices/{serial}/tone-reset503 if the tone-out detector isn't wired.

Under the default auth.mode: disabled, mutations are not gated. With auth.mode: auto or required, a request without a valid token is rejected: 401 Unauthorized ({"error":"auth: missing Authorization: Bearer header"} or auth: invalid token), or 403 Forbidden ({"error":"auth: no token configured (set api.auth.token or api.auth.token_file)"}) when the daemon itself has no token configured.

Troubleshooting

  • "daemon unreachable": check the daemon is running and the -server URL is reachable. If the daemon binds to a non-default address or port, pass -server explicitly.
  • "event stream disconnected": the TUI auto-reconnects. If the toast keeps reappearing, check the daemon logs and the connection in front of it (corporate proxies sometimes truncate SSE).
  • Garbled colours: pass -no-color, or set TERM to a value your terminal supports (e.g. xterm-256color). Ctrl+T also cycles to the monochrome palette at runtime.

Implementation notes

  • Library: charmbracelet/bubbletea (Elm-style model/update/view) + bubbles (table, textinput, viewport, help) + lipgloss (styling).
  • DTOs are mirrored from internal/api/types.go into internal/tui/client/types.go rather than imported, keeping the TUI a pure HTTP client of the wire protocol.
  • The SSE pump uses bubbletea's canonical channel pattern: a goroutine reads the stream, writes typed Events into a buffered channel, and a listenSSE Cmd blocks on <-ch and re-arms itself in the model's Update.
  • All colour decisions flow through internal/tui/theme. Panels read theme.Theme() at render time; the cached lipgloss styles on *Model and inside each bubbles/table re-pick up the palette via panels.ThemeChangedMsg.
  • Optional panel interfaces:
    • panels.Revealer — pre-position a panel's cursor on a key (system name, talkgroup ID, device serial, scanner row). Driven from the command palette.
    • panels.MouseAware — left-press + scroll-wheel handling on body rows. Driven from the root model's mouse hit-test.
    • Both are opt-in; panels that don't implement them are skipped silently by the dispatcher.