tui

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 14, 2026 License: Apache-2.0 Imports: 38 Imported by: 0

README

internal/tui — the interactive trading terminal

The full-screen terminal behind korbit-cli tui: live market data (ticker, orderbook, trades) plus the account's open orders, fills, and balances, with order entry. Built on Bubble Tea v2 (charm.land/bubbletea/v2), with the textinput bubble and lipgloss for layout. The lists (markets, open orders, balances) are hand-rendered with one shared windowing model rather than the bubbles table, so a mouse click maps exactly to a row and scrolling is uniform.

Layering (who owns what)

internal/stream         delivers events + reliability notices (reconnect, backfill)
internal/stream/state   materializes them: "what is true right now"
internal/tui            renders state, routes input, dispatches order actions
internal/cli/tuicmd  command wiring: key/URL resolution, session, Trader impl

Panes are components

Each pane is a small component in its own package under internal/tui/components/ (header, footer, sidebar, orderbook, trades, orders, fills, balances, notices, the trade ladder's ladder, plus the funding screen's curlist and transfers). A pane is a pure view component: View(Key, Data) string, a function of explicit inputs that owns no shared state. The model keeps the interactive/coupled state (active symbol, focus, cursors, scroll, search) and builds each component's Key and Data (the viewSidebar/viewOrderbook/… helpers in view.go). The lists (markets, open orders, balances) are still hand-rendered with one shared windowing model rather than the bubbles table, so a mouse click maps exactly to a row.

Shared presentation primitives — styles, the palette, the panel/box helpers, the decimal/clock formatters — live in internal/tui/uikit, the single source for them (the tui package keeps none of its own).

Render caching. A component caches its last rendered string via uikit.Memo[Key], keyed on a comparable Key = the geometry, the palette identity (uikit.StyleID, the comparable stand-in for the non-comparable lipgloss palette), the pane's UI scalars, and the relevant store revision(s). state.Store carries a per-section revision counter (TickerRev/BookRev/ TradeRev/OrderRev/FillRev/BalanceRev/NoticeRev/HealthRev) bumped at its only two mutation points (Apply, MarkMarketStale); a revision stands in for "did this section's data change", so the Key alone decides a cache hit and the Data is read only on a miss. A redraw that doesn't touch a pane reuses its cached string. Components are pointer fields on the model so the value-receiver View writes through one shared cache (single goroutine — no lock). The orders pane keys on a model-owned ordersRev bumped in refreshOrders (its rows are a model-built projection, with a parallel per-row canceling flag rendered as a grayed-out row rather than a status column). The candle chart is the exception: an interactive component that owns its own state (zoom/scroll/selection) plus Update, rather than a pure-view one — but its View is frame-cached too (keyed on a content revision + a rendered style probe + the geometry/selection/toggle scalars), so it re-renders only when something actually changes rather than every frame it is on screen.

Facts to preserve:

  • The TUI never signs or sends anything itself. Order placement and cancellation go through the Trader interface, implemented in cli/tuicmd over the exact same machinery as order place / order cancel: spec-driven param validation, the cross-field sizing matrix, minted clientOrderIds, the action journal's hard guarantee (the pre-call order row must be written or the order is NOT sent), and the idempotency-gated retry layer (place is single-shot; cancel may auto-retry). One deliberate deviation: a post-acceptance journal-write failure cannot "print the result, then exit non-zero" inside a full-screen program, so it surfaces as a Warning on the result and the UI shows it as an error toast.
  • Order entry is available whenever a Trader is wired (private mode); the only gate is public mode. The b/s order panel, the : command bar, and the t trade ladder open whenever Config.Trader != nil — there is no experimental opt-in. In public mode (Trader == nil) the keys only toast and the b:buy/s:sell footer caps / the order-panel help row are dropped. Cancel (x) and cancel-all (X) stay live regardless.
  • The %-of-balance size presets are config-driven and pinned. The order panel's size chips (%, or a click) and the ladder's number keys (or its ←/→/click chip stepper) arm a preset from Config.OrderLevels (default [10,25,50,100], 100 → "max"). The cli layer writes the built-in defaults to config.json's tui.orderLevels on the first tui launch and reads them thereafter, so a program update never silently changes what a preset key does — muscle memory holds. All chip labels and the key/value hints derive from the levels (sizeKeysLabel/sizeValuesLabel), so a customized list never disagrees with what a key actually does. The levels count is dynamic (keys 1..N, capped at 9); editing config.json is the only way to change them.
  • The state store is single-goroutine by design. One pump goroutine forwards session events into the program (Program.Send is safe from other goroutines and a no-op after exit); state.Store is only touched inside Update. Don't introduce other writers. The pump also selects on a progDone channel so it can't block forever on an Events channel that never closes (independent of whether StopSession is wired).
  • At most one money action is in flight at a time — TUI-wide. A single model-level orderInFlight gate blocks opening the order panel, arming a new order, or starting a cancel while a place or cancel is pending — so backing out of an in-flight order can't dispatch a second live one. The funding screen's requests share the rule: moneyActionInFlight() (orderInFlight || funding.busy()) is what the order keys check, and the funding dispatch checks orderInFlight back — an order action and a funding request can never be on the wire together. The Trader and Funding contracts depend on this; don't weaken it.
  • Reconciliation lives in internal/stream/state, not here. Per-key newest-ServerTime-wins ordering, the terminal-status latch, openOrders snapshot reconciliation (StatusClosedUnknown), WS/REST field normalization (unfilledopen, fee/feeQty, filledAt/tradedAt), and tradeId dedupe are documented in that package. The TUI renders what the store says.
  • Reliability stays visible. Connection dots (pub/prv), backfill and gap counters sit in the status line, the latest warn/error notice is shown inline, and n opens the full notices log. The stream layer's "up to date or TOLD" property must never be hidden by the UI.
  • No pane shows stale data — it shows "loading…" instead. Every live pane is gated on a freshness latch in state.Store so a value is displayed only while it is current; otherwise the pane reads "loading…". The latches mirror OpenOrdersReady: a public one (OrderbookReady/TradesReady/TickerReady, cleared on a public DISCONNECTED) and a private one (OpenOrdersReady, BalancesReady, cleared on a private DISCONNECTED), each re-set when the reconnect re-snapshots (a REST snapshot landing while the feed is still down applies its data but does not re-set the latch) — so a drop-and-recover cycle flips the affected panes to loading and back, not just a mode switch. Orderbook/trades are additionally gated on marketSettled() (the active-symbol re-subscribe has landed) and reset for the incoming pair on a switch (MarkMarketStale), so a previously-viewed pair's retained book/trades never flash as current before the new snapshot. The data is kept, only its display is gated (the trade ring's high-water mark still drives gap-patching). The sidebar is the exception to hiding: as the top-level selector it always lists every symbol (the list is known up front) and gates only the price — a stale ticker shows the symbol with a compact "…" marker in place of the change%. Fills are the other exception: append-only with no re-snapshot (snapshot-only backfill), so they gate on the private endpoint simply being up (Health().Private.Up).
  • The symbol set is broad; the heavy channels follow one active symbol. The watched set is --symbols (or every launched pair when omitted; resolved in cli/tuicmd). tuiSubscriptions subscribes ticker for every symbol (it powers the sidebar, which lists them all and never shrinks) but orderbook + trades for only the active symbol. Switching the active symbol re-points those via the stream session's dynamic Subscribe/Unsubscribe (the Config.SetActiveMarket seam, wired to tuiActiveMarket in the cli) — subscribe-next-then-unsubscribe-prev. The account channels cover every symbol (live order/fill/balance updates are account-wide), and the session runs in BackfillSnapshotOnly mode so that breadth re-backfills open orders + balances on reconnect without fanning per-symbol order/fill history out over every pair. myTrade therefore has no historical backfill — live fills only.
  • The orderbook can be grouped. +/- cycle the active symbol's orderbook grouping level between the raw book and the levels tickSizePolicy publishes as orderbookLevels (they ride the same per-symbol metadata fetch as the tick bands; the choice is sticky per symbol for the session, model.bookGrp). A change re-points the orderbook subscription alone through Config.SetOrderbookLevel — unsubscribe-then-subscribe, because an unsubscribe matches channel+symbol regardless of level — on the same settle debounce as a symbol switch, and hides only the book (store.MarkBookStale; the untouched trade feed stays current) until the regrouped snapshot lands. A symbol switch carries the incoming pair's level (SetActiveMarket's nextLevel). The orderbook pane and the ladder disclose the active level as a grp title chip — the disclosure matters because every figure derived from the book (the order preview, the slippage sim) reads the displayed granularity; grouped bids bucket down and asks up, so a marketable sim over a grouped book overestimates cost, never underestimates it. On the ladder, resting orders render — and x-cancel — at their bucket row (ladder.Bucket, the one mapping both use). The keys work in normal mode, order mode (form view), and the ladder (browsing) — never while a review is armed, so the numbers under a confirm can't be regrouped mid-decision.
  • Open-order snapshots are lazy: scoped to what you're looking at. The live myOrder channel stays subscribed to every pair (account-wide), but the authoritative GET /v2/openOrders snapshot — the part that is a per-symbol, per-account REST call — is taken only for the tracked {account, symbol} scope set, not all of them. The session runs LazyOpenOrders; the TUI drives the tracked set through the Config.SetTrackedOrders seam (wired to Session.SetTrackedOrderScopes, scopes built under the active account via model.trackScopes): the active pair in the default per-pair view, or every pair when the user toggles "all pairs" (a, on the orders pane). Switching the active pair re-points the tracked set (debounced, alongside SetActiveMarket), and the session snapshots newly-tracked scopes on demand and re-snapshots the tracked set on every reconnect — but a scope re-tracked within the same private connection is not re-fetched at all (the session remembers its baseline generation; the lossless account-wide feed kept it current), so flipping focus back and forth costs no REST calls. This is what keeps launching against hundreds of pairs from firing hundreds of sequential /v2/openOrders calls at startup; the all-pairs view backfills them concurrently (bounded). The open-orders panel filters its rows to the same scope and shows the scope + a "loading…" hint (derived from Store.OpenOrdersReady) until the displayed pair(s) have an authoritative snapshot.
  • The active symbol is the single switch point, and switching is immediate. model.setActive is the one place active moves: the arrows (and a row click) change the selection at once — the detail panels switch immediately. The expensive part (re-pointing the orderbook/trade subscriptions via SetActiveMarket) is debounced (resubscribeMsg, generation-gated): only the latest selection that has settled for resubscribeDelayMs fires a Subscribe/Unsubscribe, so scrubbing the list with the arrow key doesn't churn the stream. subscribed tracks what is actually subscribed so the debounced call passes the correct prev.
  • The mouse wheel scrolls; it never selects. m.scroll is the sidebar window top, moved only by the wheel (scrollSidebar) — wheeling pans the list without changing the selected market. A keyboard move instead calls ensureActiveVisible to keep the selection on screen. (Wheel over the orders pane moves the table cursor; wheel over the balances pane moves its window.)
  • The balances pane is focusable and scrolls, with no selection. Like the sidebar wheel, m.balScroll is just the balances window top. The balances list is in the focus ring (focusBalances, private mode only); with it focused the arrows/paging/home/end scroll the window (scrollBalancesKey) — there is no selected balance row, so nothing is highlighted and nothing is committed. The column header stays pinned while the entries below it scroll (the balances component windows from the scroll offset). balancesVisible/balScrollMax derive the bounds from the panel height; the stored offset is clamped on read so a resize or a shrunk balance set can't strand the window past the end.
  • Each navigable pane shows a position indicator. The sidebar, the orders panel, and the balances list render their position spliced into the bottom-right of their border (uikit.PanelWithFooter/uikit.BottomBorderLabel): the sidebar/orders show selected/total (e.g. 12/352); balances, having no selection, show the visible row range (e.g. 3-6/20). The live feeds (orderbook/trades/fills) have no scrollback, so they carry no indicator.
  • The trades panel is seeded with depth, per active symbol. The active symbol's trade subscription requests TradeHistory recent trades (tuiTradeHistory, in cli/tuicmd) so the panel starts full from REST rather than waiting on the server-defined live snapshot (which can be a single trade). tradeHistoryFor reads that depth from the live subscription registry, so the seed fires on a symbol's first activation; a later switch-back reuses the retained per-symbol tradeId high-water mark and gap-patches the hole instead of re-seeding. Those rows arrive as origin=backfill; the state layer orders trades by tradeId, so seed/patch rows interleave correctly with live ones.
  • The candle chart has two views over one feed. A full-screen overlay (g) is the interactive view — select (←/→, or click a candle), scroll (pgup/pgdn, wheel), zoom (+/-), interval ([ ]), jump-to-live (g), volume toggle (v), indicator cycle (i) — with scroll-back history backfill. The overlay is dismissed by esc or a click outside its box (inChartOverlay over chartOverlayBounds, the frame counting as inside), so a mis-click is reversible with the mouse alone; its hint line leads with the close key. The inline pane (G, on by default, hidden on a short terminal) sits over the orderbook/trades center region as a display-only glance: it always shows the live edge (no in-pane scroll or selection) and follows the active market on the same debounce as the orderbook re-subscribe. Its one mouse gesture is a click to open the full overlay (inlineChartAt). Both render the one candle feed (a candles.Series from internal/candles) for the active symbol+interval, so the inline pane inherits every overlay setting (zoom, interval, volume, indicator, color scheme) — it is a per-frame value-copy of the overlay's candlechart.Model, pinned to live. The copy reads the stored chart's candles and overlays read-only (no per-frame re-feed): the stored chart is held current with the feed by the Update path (foldChartTrades/applyCandles call SetCandles on every change), so the inline render skips the candle copy and any indicator recompute. The feed stays fresh (re-sync loop + live trade folding) whenever either view is active (chartActive). The chart is the one float island: a price/qty becomes a float64 only at the chart edge (chartCandles in chart.go, feeding candlechart — a chart is a visual artifact); every order/balance value elsewhere stays a decimal string.
  • Chart indicators are pluggable. candlechart.Indicator is the wiring seam (candle series → overlay line(s)); the chart owns the wiring and drawing, the math lives outside it in internal/tui/chartind (over internal/indicators / go-talib). candlechart.Model.SetIndicators registers them and the chart recomputes their overlays from the closed candles (the live in-progress bar is excluded) whenever that closed series changes, caching the result so a live-price tick (the per-event re-feed of the stored chart) reuses the cached line instead of recomputing. The TUI sets them once. EMA 20 is on by default (model.chartInd, wired onto the chart in newModel); the i key cycles chartIndicatorOptions (off → EMA 20 → …). The full overlay and the inline pane name the active indicator via the shared chartTitle. Add an entry there and a chartind type to offer another indicator — no change to candlechart or the candle feed.
  • Decimal strings stay strings. Display formatting (uikit.GroupThousands, which groups thousands and, on a large-magnitude value, caps the fraction to one digit — a currency-agnostic display rule; uikit.GroupExact where the rendered figure must equal the wire value, e.g. an order review) renders a copy; order values pass through verbatim. The one place a value becomes a float is the orderbook depth bar (depthBarW/parseDepthQty, in the orderbook component), where a level's qty sizes an inline background bar relative to the largest visible level — display-only, and the wire string is still rendered untouched. No order/balance value is ever parsed into a float.
  • Symbols/currencies render human-friendly, identity stays wire-form. Every visible currency pair is rendered through uikit.FmtSymbol (btc_krwBTC/KRW) and every currency through uikit.FmtCurrency (btcBTC); the raw wire string is never mutated — it remains the identity for map keys, cache Keys, subscriptions, active-symbol matching, and the order path. Format only at the leaf render point, never the data model. The transform assumes nothing about length, so panes sizing a column must measure the formatted string. Quick search matches case- and separator-insensitively (uikit.SymbolSearchKey), so typing either BTC/KRW or btc_krw finds the pair.
  • Up/down coloring is color-scheme- and profile-aware (uikit/colors.go). The semantic up/down colors (rising/falling, bid/ask, buy/sell) are NOT fixed styles — they resolve from a uikit.ColorScheme (green-red, the default, or red-blue as in the Korbit app; C toggles, and the scheme in effect at exit is written to config.json's tui block via the Config.SaveColorScheme seam — once, on quit, so it survives the next launch without a per-keypress write) and the terminal's detected color profile (arrived via tea.ColorProfileMsg, held as model.profile). uikit.PaletteFor produces the model.pal up/down uikit.SideStyles, which the chart reads and each pane component resolves from its uikit.StyleID. On truecolor and 256-color the foreground is an EXPLICIT color (not a basic ANSI name) so a remapped 16-color palette can't repaint "green"/"red" arbitrarily.
  • Orderbook depth bars (depthRow/depthBarW, in the orderbook component). Each level draws an inline background bar sized by its qty relative to the largest visible level on either side (≥1 cell for any non-zero level). The bar background is a very dark tint of the side color — near-black hex on truecolor, the darkest colored 256-cube cell on 256-color — so the foreground text stays legible over it. On a 16-color or colorless terminal the palette's barBG is nil and the bar is dropped (a dark 256 tint would collapse to black, i.e. an invisible bar), leaving the plain row.
  • Deliberate stops are success. q/Ctrl-C exit 0. q works only from the normal screen (it closes no overlay — esc does that) and is a soft quit: with a money action still on the wire it opens modeQuitConfirm (enter quits, esc stays; the dialog auto-dismisses if the action lands first — closeQuitConfirmIfIdle). Ctrl-C quits unconditionally from any mode. When the session dies fatally its events channel closes, the TUI quits, and the cli layer reports the session error after the screen is restored (same exit-code classification as monitor).
  • The layout is resizable, and it's the single source of geometry. The four body columns (sidebar / orderbook / trades / account) and the account column's three stacked panels (orders / fills / balances) are sized from seam fractions on the model (sideDiv/colDivA/colDivB, rowDivA/rowDivB), not fixed widths. colWidths/rightHeights derive cells from those fractions with per-panel minimums (minSideW/minColW/minRightW/ minPanelH) so a drag can never collapse a panel, and hitDivider/applyDrag (in view.go) translate a mouse position to a seam and move it. Public mode (rightPaneVisible false) drops the account column — three columns, notices in the n popup not a pane. Defaults (resetLayout): sidebar 16%, orderbook/trades 26% each, account the rest (~32%), the account column split 40/30/30. = resets; the geometry is session-only (not persisted). Any new size-dependent component must read these helpers, never hardcode a width.
  • Terminal size has a hard floor and a recommended size, per mode via sizeFloors (in view.go): 100×28 / 120×32 private, 60×18 / 80×24 public (fewer columns, so comfortable on a classic 80×24). Below the hard floor render() replaces the whole screen with a resize notice (the model keeps running — resizing back restores everything). Between the two, the TUI runs normally with a ⚠ WxH — best ≥WxH chip in the header (crampedNoteheader.Key.Cramped): panes work, but long figures and fact lines may truncate. At and above the recommended size everything renders whole. SMALL-TERMINALS.md is the full constrained-space policy — the size floors, the truncation/compaction toolkit (uikit.Truncate/FactsLine/Compact), and where each rule applies. Read it before composing any line that could outgrow its pane.
  • Focus decides where navigation keys go. A focusPane (market selector / orders table / balances list) gates up/down and paging; tab/shift+tab cycle the focus ring (focusable: the orders table and balances list are in the ring only in private mode, so public mode is single-pane and tab is a no-op). The focused pane's border is highlighted (uikit.StyBorderFocused) and the footer names it.
  • Quick search (/) is an inline, incremental FILTER on the list panes. Pressing / with the market selector or the balances list focused starts a search: subsequent keys build a query (shown on the bottom-left of that pane's border via uikit.BottomBorderLabel, opposite the position indicator) and the pane shows only the rows whose symbol/currency contains it, recomputed live (filteredSymbols/filteredBalances). The arrows move within the filtered results — the markets candidate (searchCursor, reverse-highlighted) or the balances window (searchScroll). enter commits the pick and the full list returns: markets setActive the highlighted symbol, balances scrollBalancesTo the first match. esc just closes — as does backspace on an already-empty query (deleting past the start backs out of the search). Crucially the underlying active symbol and balances scroll are not touched until enter, so esc has nothing to restore and typing causes no subscription churn. It is not an overlay — it lives inside modeNormal (the searching flag), so handleKey routes to handleSearchKey while active and search owns the keyboard and the mouse (clicks/wheel are suppressed while searching) until enter/esc. A command letter typed into the search is query text, not a command. Match is a case-insensitive substring.
  • The orders table is self-rendered (not the bubbles table). It has a selected row (orderCursor, the cancel target) and its own window (orderScroll), the same model as the sidebar/balances. The orders component's tableLines draws the dim column header plus the windowed rows with the selected row reverse-highlighted; the model's refreshOrders rebuilds the row projection (passed as the component's Data) and preserves the selection by order id, re-clamping the window. Hand-rendering replaces the bubbles table specifically so a click maps exactly to a row — the bubbles table's internal viewport offset isn't observable, so a click couldn't be translated to an absolute row reliably.
  • The orders pane has a read-only closed tab (o, or the title's tab switcher): the terminal orders the session's store has observed (Store.ClosedOrdersFor), most recently closed first, with the status column the open tab deliberately omits — it is where a rejected (expired) or canceled order becomes visible — and a filled/qty column that makes a partial fill's returned remainder legible. The pane keeps ONE row projection (ordersClosed selects which set refreshOrders reads), so the cursor/scroll/wheel/click plumbing is tab-agnostic; the cursor resets on toggle, x refuses with a hint, and the a scope toggle governs both tabs. The tab selection is sticky — it survives focus moves and mode changes until switched back — and the switch works from anywhere it is safe: o in the normal view (any pane, no focus round-trip), in ladder browse, and in the docked order panel (form view only — never mid-confirm or in a text input), a click on the title's tab switcher (orders: [open] │ closed; both tabs carry the clickable key-cap style and the active one is bracketed and reverse- highlighted; per-segment hit-testing in orders.TitleTab measures the localized label widths past the orders: prefix), and the footer's o cap. Every pane-focus move goes through the setFocus seam, so a future focus side effect has one home. The set is session-scoped (account backfill is snapshot-only, so closes that happened while disconnected show as closed? — real status unknown — and orders that opened and closed entirely within a gap are absent), and the view is capped (closedOrdersCap).
  • Mouse is capture-on (MouseModeCellMotion) for resize, focus, switching, selecting, and scrolling. A left-press on a seam starts a resize drag; a click on a sidebar row switches the active symbol (sidebarRowAt); a click on an open-orders row focuses that pane and selects the order (ordersRowAt, so x then cancels it); a click on the inline candle strip opens the full chart overlay (inlineChartAt); a click on a focusable pane focuses it (paneAt, which reports ok=false for the header/footer, the orderbook/trades center, the fills panel, and gaps — those clicks are no-ops and never steal focus); the wheel scrolls the pane under it (handleWheel — sidebar window, orders cursor, or balances window). Detected in modeNormal and not while searching — overlays and quick search own the body. The help and notices overlays (modeHelp/modeNotices) are dismissed by a click outside their box (inActiveOverlaycenteredOverlayBounds, the frame counting as inside), the same reversible gesture as the chart overlay. Capturing the mouse takes over the terminal's native text selection while the TUI runs — an accepted trade.
  • The modal dialogs (confirm/cancel-all/quit-confirm) take body clicks too. Beyond their hint caps (below), a cancel-all scope chip selects its scope (setCancelAllScope, shared with the ←/→/tab keys); regions are hit-tested by overlayBodyAt. A click outside the box dismisses the dialog (dismissModal reuses each dialog's esc) — except a running cancel-all batch, which only its esc/stop hint aborts.
  • The key-hint strips are clickable buttons. The footer hint line, the chart overlay hint, and the modal-overlay (confirm/cancel-all) hints are all built from one reusable component, components/keystrip: an ordered list of tokens (a key cap plus its dim label) rendered with the cap styled (underlined + cyan) so it reads as pressable, plus a relative hitmap. stripKeyAt (view.go) maps a left click to the cap under it and dispatches the cap's synthetic tea.KeyPressMsg through handleKey — so clicking a hint is identical to pressing it, single source of truth in the key handlers. A paired command ([/], /, +/-, /) renders as two separately-clickable caps, each showing and pressing its own real key. The footer's clickable regions come from footer.Hits (computed from the same footer.Key as its render, so they can't drift from the line on screen); the centered overlays recompute their hint and body geometry in overlayLineOrigin from the same lines they render.
  • An overlay owns the keyboard; the footer steps back. Key dispatch is per-mode: handleKey routes to the open overlay's handler (confirm/cancel-all/quit-confirm/help/notices/chart) and the main-screen keys never reach handleNormalKey. Two keys work in every overlay: esc backs out of it and ctrl+c quits (ctrl+c is handleKey's unconditional first check; esc closes/aborts in every overlay handler — TestCtrlCQuitsInEveryMode and TestEscClosesEveryOverlay pin both). So while an overlay is open (footer.Key.OverlayOpen, set from any mode except normal, order mode, and ladder mode — the order panel and the trade ladder dock into the layout, so their footers keep live, clickable hints via footer.Key.OrderView / footer.Key.LadderView) the footer stops advertising the now-dead main keys and shows a single non-clickable reminder, esc to close overlay, ctrl+c to quit. It is deliberately prose, not clickable caps: a footer-row click while a chart/help/notices overlay is open is the "click outside the box to close it" gesture, so a clickable quit there would be an accidental-exit footgun. The all-dim line (the cyan caps gone) is itself the signal that the main keys are off. The overlay's own in-box hint remains the source of its contextual keys.
  • Bubble Tea v2 idioms: View() tea.View (alt screen AND MouseMode are set per-frame on the returned View, not program options), tea.KeyPressMsg + msg.String() for keys, the tea.Mouse{Click,Motion,Release,Wheel}Msg set with zero-based X/Y and a Button for mouse, lipgloss colors are color.Color values. Color downsampling for the terminal is handled by the runtime; styles here use the 16 basic ANSI colors on purpose so any theme renders sanely.

Keybinding conventions

Conventions a new key should follow. They are guidelines, not hard rules — a pane may deviate where its UX genuinely calls for it — but holding to them keeps the surface predictable as it grows.

  • Arrows select in lists; j/k walk prices. / (with pgup/pgdn/home/end) move a selection or scroll a window — the market selector, orders table, balances, the funding lists, and the account switcher (handleNavKey, moveOrderCursor, scrollBalancesKey, funding's cursors, handleAccountSwitchKey). j/k drive the orderbook price cursor — the order form's book ladder (moveLadder) and the trade ladder (handleBrowseKey) — so away from a price ladder they are not list navigation.
  • tab/shift+tab change focus; enter commits; esc backs out; ctrl+c quits. These mean the same thing everywhere — don't repurpose them per pane.
  • One key per action; aliases only when the UX earns it. Prefer a single key per action over an arrow+letter pair for the same move. Multiple keys are fine where they genuinely help: the trade ladder is a pure price cursor, so it takes both / and j/k (and both pgup/pgdn and J/K); a two-option scope selector takes both and . Before adding a key, check it isn't already an alias of another.
  • Letters are commands, not motion. Command letters (b/s, t, x, f, /, :, …) act; they don't move a cursor. A letter typed into a numeric input or quick search is text, never a command.
  • A key you surface in a hint is clickable. Footer and overlay hint caps dispatch the real tea.KeyPressMsg through handleKey (see the key-hint strips above), so a documented key works by click through the same handler — no parallel path.

Scope decisions (v1)

  • The order panel offers limit and market orders only; best (BBO) orders need bestNth + a mandatory TIF and stay on order place. Market orders carry price protection (pp) by default (a panel toggle turns it off).
  • Multiple sub-accounts per session, one active at a time. --account-seq selects the subscribed set: a single value (--account-seq 2), a comma-separated list (--account-seq 1,2,3 — subscribe several, active on the first listed), or omitted (subscribe every account the key may access — its allowedAccountSeqs — active on the key's configured default, else 1/main). The account channels (myOrder/myTrade/myAsset) cover every subscribed sub-account; the panels show ONE active account at a time, switched in-session with the @ switcher (see "Account switcher" below). The single-active model: the active account is model.accountSeq() (the one normalization point, returning model.activeSeq); the order/ladder/funding sub-models capture it at construction and switchAccount re-stamps their accountSeq fields on a switch — the funding screen included: it always operates on the currently active account and never defaults to main. Deposits/withdrawals are main-account-only server-side (the API accepts only accountSeq 1), so on a non-main account it shows a warning banner (fundingModel.onMainAccount) and the requests are rejected — it never silently retargets. Every store read goes through the per-account accessors (OpenOrdersFor/BalancesFor/FillsFor), so panels flip to "loading…" via the per-account readiness latches until the new account's backfill lands; order tracking is {account, symbol}-scoped and re-pointed on a switch; and the Trader takes the sub-account explicitly per call (a cancel targets the order row's own account). The cli layer (cli/tuicmd) resolves the subscribe set and the active account from the flag + the key's allowedAccountSeqs (resolveTUIAccounts); a configured default that is not itself allowed is a ConfigError before the alt-screen opens. --account-seq is private-only — passing it with --public is a usage error (public channels carry no sub-account), and the @ switcher is a no-op in public mode. When --account-seq is omitted, expanding to allowedAccountSeqs depends on the startup /v2/currentKeyInfo read; if that read fails, the session refuses to start (preserving the failure's exit code) rather than silently narrow to the single active account — an explicit --account-seq needs no read and stays resilient to a transient preflight failure.
  • Account backfill is snapshot-only (open orders + balances): the account channels cover every watched symbol, and full per-symbol history recovery on reconnect would be a REST storm over all launched pairs. Live fills still flow; historical gap fills are not recovered.
  • The live feeds (orderbook/trades/fills) show the most recent rows with no scrollback — only the sidebar, orders, and balances are wheel-scrollable / indicator-bearing.
  • The dragged layout and the chosen active symbol are session-only — not persisted to config.

Localization (--lang, internal/i18n)

The TUI's core UI chrome — keystrip hints, dialogs, pane titles, form labels, status words, toast prefixes — localizes through internal/i18n. Everything a rendering path shows a user goes through i18n.T("english text", args…); the English text is the lookup key AND the fallback, so an untranslated (or orphaned) entry renders English rather than stale text. Translations live in flat per-surface files under i18n/locales/{code}/ — the TUI's are tui.json (key = the call-site literal, sorted; see the i18n package doc); the guard tests in i18n/guards_test.go extract every i18n.T literal from the tree and hold the files and call sites to each other — a new string fails until its ko entry lands (the failure names the file it belongs in), an English copy edit fails until the dead entry is updated, and an entry filed under the wrong surface fails placement. T renders via fmt.Sprintf, so numeric verbs are never locale-grouped; money still interpolates as a pre-formatted decimal string (%s).

Rules for TUI text:

  • What localizes: UI chrome only. Trading vocabulary (buy/sell values, gtc/ioc/fok/po, bid/ask/mid/last anchors, interval labels), symbols, currency codes, numbers, wire field names (orderId, clientOrderId, seq), the command-bar grammar and its parse prompts, and API error passthrough all stay English.
  • Whole sentences, not fragments: when a message varies by case, pick between complete keys ("filter markets: …" / "filter balances: …") instead of splicing a noun into one English sentence — target languages order words differently. When one English word carries two meanings that translate differently, give the sites distinct keys and map the extra key back to the shared English display with a locales/en/tui.json override (the canonical case: key "market order" displays "market" in English, 시장가 주문 in Korean).
  • Language is fixed at startup. The display language is resolved once at startup by i18n.Resolve from the global --lang flag (else the host locale — $LANG/$LC_* on Unix, the preferred UI language on Windows — else English); cli/tuicmd then activates it via i18n.Activate(i18n.Detected()) before the program starts, so the memoized components never see it change and there is no in-session toggle. The tui is a surface that localizes; a surface that does not activate stays English.
  • Widths derive from the active labels: the form label columns (orderLabelW/fundingLabelW) and header-derived floors measure the localized strings with lipgloss.Width at render time — never hardcode a column to an English label's length. The uikit pad/clip helpers measure display cells (Hangul is two cells per glyph), with a byte-length fast path for the ASCII numeric content.

Order mode (b/s, modeOrder)

b/s swap the right column for a docked order panel: the orderbook stays live beside it as a price picker and the open-orders slice stays beneath it, so an accepted order lands — and flashes (flashOrderID, orders.Key.FlashID) — right below where it was placed. There is no modal anywhere in the flow: the review (confirm) and busy states swap the panel's content in place, and a rejection returns to the form with the error inline and the inputs preserved. Facts to preserve:

  • It is a sub-model in the funding screen's mold (ordermode.go state + keys, orderview.go render + click spans): the parent owns the mode switch, key/mouse routing, geometry, and the money single-flight; handleKey returns an orderAction (close/place) rather than dispatching itself, so orderInFlight and the Trader call stay parent-owned (dispatchPlace).
  • The engine is pure and lives in orderentry.go: orderDraft (the sizing matrix as predicates — usesQty/usesAmt are the WIRE field, inputAmt is which size the user enters), buildPreviewops.AnalyzePlace over the LIVE store book (the same simulation/warnings as order place --dry-run, re-evaluated per frame against BookRev-fresh data), applyPreset (%-of-balance sizing with quote-fee headroom), anchorPrice, and placeGateReason. No I/O — the fetched inputs (tick bands via Config.TickSizePolicy, fee rates via Config.Fees) are cached per symbol on the sub-model, best-effort: missing metadata degrades features (no grid, no fee estimate), never blocks entry.
  • A limit order can be entered by quantity OR amount (sizeInAmt, toggled with u). Amount is a limit-only input: the wire always carries a quantity, derived at the price by wireQty/qtyFromAmt (the API has no limit-amount field). Market buys stay amount-native, market sells quantity-only. orderDraft.validate is the sizing-matrix guard both arm paths call before a review — it rejects any unsupported (type, side) shape so a bad combination is caught in tests, never sent.
  • Rounding (one policy, all pairs). Quantity and amount are floored to the request precision (8 dp) by the single-source roundQty/roundAmt (and divFloorQty/divFloorAmt where a balance-sizing division is floored — same precision, exact via QuoRem). Price rounds to the symbol's tick grid, not a decimal count: a programmatic price (a mid anchor) snaps in the side's conservative direction — buy floors, sell ceils (snapPriceForSideops.SnapToTick/SnapUpToTick) — while an explicitly typed price is checked, never modified (ops.OnTick): an off-grid price is rejected with its tick, so the number the user typed is the number placed. When the tick policy has not loaded, the check defers to the server. The : command bar applies the identical rules.
  • The funding pane's one key rule, plus trading verbs. / move the field cursor; / change the value under it on the selector rows (side/type/tif/pp cycle) but move the text caret on the numeric rows (price/qty/amt); [/] and {/} nudge the price ±1/±10 grid ticks on the ops.StepTicks grid and % cycles the size presets — all from any row (they never depend on where the field cursor sits), typing digits edits the input under it, enter advances (arm → place), esc backs out one step. On top: j/k walk the orderbook's visible levels (orderbook.RowPrices, the single row-layout source shared with the render) writing the level's price into the draft; b/s declare the side and re-seed the price to the new side's own best level (applySide — a price picked for one side is usually marketable on the other, so it never survives a flip; the side row's / toggle applies the same rule), with the current side's key doubling as the join-the-touch re-anchor; a anchors to the opposite touch (the would-cross price); m/l anchor to grid-snapped mid (buy floors / sell ceils) / last; u toggles a limit order's size unit (quantity ↔ amount), carrying the size across at the price. The price controls are limit-only — on a market order the price row, the j/k/a/m/l/[ ]/{ } price keys, and the orderbook cursor arrow all disappear (a market order has no price, so offering to pick one only confuses). The deliberate deviation from the funding pane: the price/qty/amt inputs accept digits and a dot only (editInput/numericText), so command letters stay commands even while a numeric field holds the cursor — speed is the point of this panel, and unlike a free-form amount a stray letter is never valid here. A numeric input NOT under the field cursor renders its value grouped (inputView — a nine-digit price is unreadable raw); the draft floors the size to the request precision while the input keeps the raw text, so the preview and the placed order match.
  • Placement is freshness-gated, unlike display. Arming and placing refuse unless marketSettled() && OrderbookReady && !moneyActionInFlight() (orderGate/placeGateReason); the gate reason lands inline and on the panel's book-state line. A pane can say "loading…" — an order cannot be sent against a book the store no longer stands behind.
  • Arming checks and freezes the review. arm validates locally (size parses positive; a limit has an on-grid price — off-grid is rejected, never silently snapped), then swaps to the in-place review; [/] and {/} still nudge the armed price ±1/±10 ticks (/ is inert while armed — it is the form's adjust key). The review gives the notional a = line of its own — it is the number a mistake hides in, so it never shares (or loses) its line to another figure, on any panel width. The busy view is escapable (the result still lands — as an inline form error, or a toast + flash on accept).
  • Drafts are sticky per symbol, per session (drafts): reopening a pair restores its size/type/tif, but the price is re-seeded from the side's own best level (best bid for a buy, best ask for a sell) so a stale price never carries over silently.
  • Render and hit-test share one line builder (panelLinesorderFormLine/orderSpan, consumed by renderOrderPanel and panelClick), so a row's on-screen position can never drift from its click behavior — the funding pane's pattern. The panel renders directly (not memoized): it is on screen only in order mode and its preview reads the live book anyway. Mouse: a click on an orderbook level writes that price into the draft (bookPriceAt), the wheel over the book walks the ladder cursor, a panel click moves the cursor / flips the value / presses the button, and clicks anywhere else are inert — leaving order mode is a deliberate esc, never a mis-click.

Command bar (:)

: opens a one-line command bar above the footer (the body yields two rows; cmdBarVisible folds them out of bodyHeight, so every geometry consumer stays consistent). Like quick search it lives inside the normal view and owns the keyboard while active (handleCmdBarKey); the footer steps back to prose (footer.Key.CmdBarOpen). Facts to preserve:

  • The grammar parses to the same engine. parseOrderCmd (pure; table test) → resolveOrderCmd materializes anchors (b/a/m/l ±N grid steps via anchorPrice+ops.StepTicks), checks an explicit price is on the tick grid (rejected off-grid, never snapped), and sizes percents through applyPreset — the result is an orderDraft, and the echo line is buildPreview of it, re-resolved every frame until armed. Size is a base quantity, a quote-currency amount (the quote-ccy unit, e.g. 500k krw), or N%; k/m multiply any number (size or price) and carry no currency meaning. An amount sizes a market buy natively and any limit order by deriving the quantity at the resolved price (qtyFromAmt) — the API has no limit-amount field.
  • The echo fits its width by dropping facts, never by clipping the tail (cmdEchouikit.FactsLine). What the order IS (side/size/type/price), the warning code, and the book state are rank 0 and always survive; the derived figures drop first (fee/note/tif/pp, then the notional), each drop disclosed by a dim +n tail. The key hints re-attach only when they fit whole — the footer carries them regardless.
  • Arming freezes; anchors carry drift protection. The armed draft's price is what goes on the wire. If an ANCHORED price's anchor re-resolves more than cmdDriftTicks grid steps away, placing refuses and disarms with the reason; an explicitly typed price is never second-guessed.
  • One dispatch point. dispatchPlaceForm(form, origin) is the only place an order leaves the TUI; placeDoneMsg.origin routes the result (bar rejections toast; panel and ladder rejections land inline on their surface). The bar never touches the panel's sticky draft.
  • Same gates as the panel: private mode (Trader wired) to open; orderGate() (freshness + money single-flight) to arm and to place. The bar kicks fetchMeta on open so the tick/fee caches warm for its resolution. The normal-mode footer advertises it (a clickable ::cmd cap, gated with the other order-entry caps), and the empty bar's echo line shows the grammar.
  • History is per session (recall): ↑/↓ move through placed lines, which re-parse live (anchors stay market-relative until armed again).

Trade ladder (t, modeLadder)

t rebuilds the orderbook+trades center region as a full-height, DOM-style trade ladder — MINE | bid qty | price | ask qty | MINE rows over the live book, asks above a last/spread rule, bids below — while the sidebar and the account column stay. It is the fastest order loop the TUI offers: the size is decided calmly in advance (a preset), the price is picked spatially (the cursor), and a money mover still always costs exactly two deliberate keys (arm + confirm). Facts to preserve:

  • It is a sub-model in the funding/order-panel mold (laddermode.go state
    • keys, ladderview.go render + geometry, components/ladder the pure body): the parent owns the mode switch, key/mouse routing, geometry, and the money single-flight; handleKey returns a ladderAction (close/place/cancel) and only the parent dispatches (dispatchPlaceForm/dispatchLadderCancel). Opening shares the order panel's gate (Trader wired) and warms the same per-symbol tick/fee caches (m.order.fetchMeta) — one fetch feeds the panel, the command bar, and the ladder.
  • ladder.Rows is the single row-layout source. Rows are the live book's levels (not per-tick — a 1,000-KRW tick on a 9-digit price would show a sliver of the market); tick-fine placement happens on the armed strip via [/] (±1) and {/} (±10) grid nudges. The render, the j/k cursor walking, and the click mapping all read the same Rows, so they can never drift. The component memoizes on BookRev+OrderRev+TickerRev+geometry+ cursor/armed/flash scalars; the foot strip and title are model-rendered per frame (tiny), and the body-row count shrinks by exactly the strip's height.
  • Resting orders live on the ladder (store.OpenOrdersFor(accountSeq, symbol)): each order's remaining size shows in the MINE column at its price row (buys left, sells right); an order on no visible level gets a synthetic row — a distant one pulls in as an edge row displacing the level farthest from the spread, an inside-spread one inserts at its sorted position — so nothing the account owns is ever off-screen. A just-accepted order flashes its MINE cell (the shared flashOrderID), and x at a MINE row arms a cancel of the resting order there (the newest when several rest at one price), capturing its identity at arm time.
  • The size chip is armed state, per symbol, per session (sizePcts): number keys arm a %-of-balance preset from the configured levels (default 1-4 → 10/25/50/max; see the size-presets note above); the preset resolves to a quantity through the engine's applyPreset at arm time, at the cursor price (so a limit buy's 100% preset is exact and fee headroom applies). There is no typed custom size on the ladder — the panel and the command bar cover that. While browsing, the foot strip resolves the armed preset live at the cursor price, per side (presetEstimates, the same applyPresetbuildPreview path the arm and its confirm strip run), flagging a notional the server would reject (⚠ >max / ⚠ <min) — a %-preset on a large balance can breach the order-notional cap, and that must be visible before the arm, not on the confirm strip. The foot line reads as three groups: the buy/sell estimates carry the book's bid/ask colors (buy = pal.Up, sell = pal.Down, the same colors the ladder rows use — so the live block stands out and buy vs sell reads at a glance), a marks the boundary, then the dim action hints; stays warn-yellow throughout. Each fragment is styled on its own — the line is never wrapped in one outer style, since an inner color/warn segment's ANSI reset would otherwise leak past it.
  • The default-tif chip is armed state too, but session-wide (defaultTifIdx, set with t while browsing, shown as a title chip beside the size). Unlike the per-symbol size preset it is one preference across symbols, and it is not persisted (resets to gtc on restart). Every limit arm inherits it (armOrder stamps draft.tifIdx); a market arm ignores it (ioc-only). The per-order t in the confirm strip edits the armed copy, never this default.
  • The size and tif title chips are real steppers, not decoration (titleLayout, titleFocus). Both render guilleted (‹ … ›), and exactly one is focused (reverse-video; browse only). The direct keys set the focus (a number → size, t → tif) as well as acting on that chip; / then steps the focused chip (stepChipstepSize/stepTif; the tif wraps, the size clamps at both ends — matching the order panel), and a click on / steps it directly while a click on the value just focuses it (ladderTitleChipAt, wired in model.go's modeLadder click block). Render and hit-test share titleLayout, so the click targets can't drift from what's drawn; the columns are derived by lipgloss.Width accumulation over the plain fragments, never by scanning the styled string (the same technique as the order panel's selector/presetLine). Vertical arrows stay the cursor (j/k//); only horizontal arrows touch chips.
  • Arming freezes; the same gates hold. b/s arm a limit at the cursor row, B/S a market order (pp on by default); the confirm strip at the ladder's foot echoes the fully resolved order via buildPreview (notional, fee, book freshness — plus the preplace warnings, each on its own strip line like the panel: the facts line truncates at narrow widths, and a clipped warning is a safety disclosure silently lost) live each frame, t cycles the tif of this limit arm only (the draft is a copy — it never moves the ladder's default tif; a market order is ioc-only), and enter places under orderGate() — arm and place both refuse against a stale book. A cancel arms/places under the money single-flight only (like x elsewhere, it must not require a fresh book). A rejection returns to the armed strip with the error inline (nudge and re-place, or esc out); a cancel result returns to browsing and reports via the standard toast.
  • Mouse: a click on a price row moves the cursor, the wheel walks it one row per notch (browse only); a click on a title chip's / steps it (its value focuses it) — the only in-panel click spans (ladderTitleChipAt). The armed strip is confirmed by keyboard or the footer's clickable caps and carries no click spans. c recenters the cursor on the row nearest the last trade. The footer follows the ladder's views (footer.Key.LadderView), staying live and clickable like order mode (the ladder docks into the layout; it is not an overlay).

Cancel all (X)

X opens the cancel-all dialog (modeCancelAll, state in cancelAllState). Unlike x (cancel one), it acts on a snapshot of the open-orders set rather than the table selection, so it needs no pane focus — it is live from any pane and advertised in every pane's footer. Facts to preserve:

  • The set to cancel is SNAPSHOTTED, not live. Open orders come and go constantly, so the dialog freezes the target set (snapshot, captured by tryCancelAllSnapshot) the moment it opens or the scope changes — and only once that scope's orders are loaded. The confirmed count can't drift under the user. Once snapped, new order events do not re-snapshot (maybeSnapshotCancelAll is a no-op when snapped/running).
  • Two scopes: the active pair or all pairs (allPairs, toggled with ←/→/tab). The default follows the current open-orders view. Selecting all pairs calls prepareCancelAllScopeSetTrackedOrders(allSymbols) to turn on track-all, and while any watched pair lacks an authoritative snapshot (cancelAllScopeReady) the dialog shows a loading state with an N/M pairs ready count (cancelAllLoaded) and refuses to confirm — the user waits until the count settles. (The count also makes a stall visible: readiness only advances as each pair's /v2/openOrders backfill succeeds, so a failed per-pair fetch would otherwise look like a silent spinner.)
  • No bulk endpoint exists, so the batch issues one cancel at a time. Confirm runs the snapshot sequentially through the same Trader.Cancel as x (dispatchNextCancelAllcancelAllStepMsg → next), so the single-action orderInFlight gate holds throughout and the per-order canceling row style shows progress. esc mid-run sets aborted (stop after the in-flight cancel returns); the step handler tallies ok/failed and closes with a summary toast.
  • State resets on close (closeCancelAll zeroes cancelAllState and restores the tracked-order set to the visible scope via applyOrderTracking), so a reopen always starts clean — no stale snapshot, scope, or progress.

Account switcher (@, modeAccountSwitch)

@ opens a small centered popup listing the session's subscribed sub-accounts as a seq · name table (state in model.accountSeqs; seq 1 is named mainaccountName in view.go — other sub-accounts have no name), for switching which account the panels, tracking, and order entry follow. It is a private, multi-account feature — inert (a toast) with a single account. Its footer/? hints and the chip's underlined switch affordance appear only then (the header's accountSeq: chip itself shows in any private session, just dimmed). Facts to preserve:

  • The active account is the single value model.accountSeq() returns (from model.activeSeq), and switchAccount is the one place it moves: it re-stamps the order/ladder/funding sub-models' captured accountSeq, re-points the session's {account, symbol} order tracking, and rebuilds the open-orders projection. The balances/fills/orders panes read through the per-account store accessors, so they follow on the next render — showing "loading…" via the per-account readiness latches until the new account's snapshot/backfill lands. Funding also drops its per-account caches (setAccountSeq) so a reopen never shows the previous account's transfer history or withdrawable amounts.
  • Layout (accountSwitchParts): a shared overlayParts, so rows and the clickable hint caps (↑/↓, enter, esc) hit-test through the same machinery as every other modal (modalOverlayParts/stripKeyAt/overlayBodyAt) — render and click map can't drift. The active account is marked by a chevron (no "active" label); the pending selection is a separate reverse-highlighted row. The box shows at most accountSwitchMaxRows rows and scrolls (acctScroll, clamped by ensureAcctVisible, with an n-m/total indicator), so it stays a stable size as the account set grows.
  • Selecting is not committing. ↑/↓/pgup/pgdn/home/end/wheel move the highlight; enter commits (switch + close); esc/outside-click/close-cap closes without switching. A click selects a row (moves the highlight, no switch); a click on the already-selected row confirms, like enter (clickAccountRow). So a stray click never switches, and the popup is fully mouse-drivable.
  • Opening the switcher is gated (openAccountSwitch, the single entry point for both @ and the chip click). It opens only from the normal view, private and multi-account, with no money action in flight; otherwise it toasts why. The money-in-flight guard (moneyActionInFlight(), which folds in funding.busy()) is load-bearing: switching the displayed account out from under a place/cancel/withdrawal still on the wire breaks "what you see is what is happening", so the in-flight action finishes first and the switch waits.
  • What moves with the active account. Store reads (orders/fills/balances), order tracking, new-order placement, and the order/ladder balance-sizing all read accountSeq(). Fee estimates are per sub-account too: the Fees seam takes the account explicitly, switchAccount clears the order panel's fee cache (clearFees), and a fee reply for a since-switched-away account is dropped (orderFeesMsg.accountSeq) — so a preview never mixes tiers. A cancel always targets the order's OWN account (captured), which need not be the active one.
  • The header accountSeq: chip is the always-visible indicator and a second entry point. It shows the active account left of key:<name>, underlined (the key-cap affordance) when the session is multi-account to mark it a clickable switch target, else dim. A click routes through openAccountSwitch (acctChipAt over header.AcctChipSpan, the header's single render/hit-test source).

Funding screen (f, modeFunding)

f (or enter on the focused balances pane) opens the deposit/withdrawal screen — a full-screen master–detail view, crypto and KRW: the currency list on the left, and on the right a persistent request pane over the transfer history. It is a self-contained sub-model (fundingModel in funding.go, rendering and hit-testing in fundingview.go, the inline form helpers in fundingform.go); the parent model owns only the mode switch, message/key routing, and the shared money gate. All data and actions go through the Funding seam (implemented in cli/tuicmd/funding.go over the same ops operations as the deposit/withdraw/krw commands — validation, journaling, and the single-shot policy for money movers included). Facts to preserve:

  • The left list merges three live sources: the public currency catalog (/v2/currencies, fetched once), the streamed balances, and the streamed tickers. Order: KRW pinned, held assets by estimated KRW value descending (balance × the pair's last price — shown only when that pair's ticker is subscribed and ready; unpriced holdings sort after, alphabetical), a divider, then the unheld rest. The estimate is display-only arithmetic; wire values never derive from it.
  • The request pane IS the form — there are no funding overlays. The pane always shows the selection's fields (direction chips, ‹ network ›, ‹ destination ›, the amount input, the action button) plus the facts the request is checked against (withdrawable amount, fee/min/precision, the KRW app-push note) — visible before anything is committed, not inside a modal. The confirm and busy states swap the pane's content in place (fundingView); the history below stays visible throughout. The pane is content-sized (formHeight); only the list | detail seam is draggable.
  • One key rule, everywhere in the pane (handleBrowseKey): / move the field cursor, / change the value of the selector under it (direction: ← deposit / → withdraw; network and destination: step), typing edits the amount input under it, enter activates — the button acts, the deposit address/memo copy, anything else just advances, so enter alone walks the form. While the amount input holds the cursor every unclaimed key is text (a command letter typed there must never fire a command; the strip drops the //r caps then). There are no per-action letter keys (no w/d/g or [/]); the active keys are x (cancel a history row), r, /, =. tab cycles the focus ring: list → request pane → history. The / currency filter follows the main quick search's contract: typing moves a highlight only (searchSel — the real selection and detail panes don't move), enter commits it, esc/backspace-on-empty cancel with no effect, and the mouse (row clicks, wheel) is suppressed while filtering.
  • Render and hit-test share one line builder (paneLinesfundingFormLine): each pane row carries its field identity and clickable sub-spans (chips, / arrows, copy targets, the button, the in-pane confirm's enter/esc), so a click's meaning can never drift from what is drawn.
  • The three request actions (crypto withdrawal, KRW deposit push, KRW withdrawal push) require a key provisioned with setup --with-transfers (the registration link omits the transfer permission bits otherwise). Deposit-address generation (idempotent server-side, returns the existing address) and withdrawal cancel need no such key — stopping money movement must not be gated.
  • The withdraw flow reads top to bottom: network → address → amount. The ‹ destination › selector offers only registered addresses (withdraw addresses) filtered to the selected network, and it is never auto-selected: it starts on ‹ select — n registered › — even with a single registration — and the button refuses until the user picks one explicitly ( from unchosen is a no-op; there is no way back to "none"). The pick is dropped whenever the ground moves under it: a network cycle, a currency switch, or a refetched address list (fundingWdAddrsMsg) — a kept index could silently point at a different address. The amount is the form's only free-form input — validated against the network's min/precision and the withdrawable amount (validateWithdraw), then re-validated by the ops layer. With no addresses registered on the network, the address row itself explains that (with the developers-portal pointer) in place.
  • The deposit address and its memo/tag are copy targets. enter (or a click on the value) copies via OSC 52 (tea.SetClipboard) — the TUI captures the mouse, so native terminal text selection is unavailable and this is the copy path. The ack is a parent footer toast (fundingCopiedMsg, deliberately not the sticky banner); OSC 52 support depends on the terminal.
  • Every request passes an explicit in-pane confirm step, and its outcome handling is asymmetric on purpose (applyActionResult): a definite rejection (a Korbit error envelope or a validation error — provably not executed) returns to the form with the error inline and inputs preserved; an ambiguous failure (timeout/transport) is NEVER retried — the view lands on the freshly refetched history under a standing "outcome UNKNOWN — verify below" banner, because resending an unacknowledged money mover can double-spend. Success lands on the same refetched history with the receipt and clears the form (a stale amount or pick must not ride into the next request). The banner renders FIRST in the pane (it tail-truncates) and persists until a refresh or the next action — deliberately, for the unknown case.
  • Detail fetches are seq-stamped and debounced. Scrubbing the currency list debounces the fetch (fundingSettleMs), and every history/withdrawable reply carries the seq of the request that asked for it — a stale reply for a currency the user moved past is dropped, never rendered.

Tests

tui_test.go drives the model directly (messages in, rendered frames out, ANSI stripped) — no real terminal. The Trader is faked; the cli-side implementation is covered end-to-end in cli/tuicmd_test.go through the Deps.TUIRun seam with a stub HTTP doer and fake WebSocket conns. funding_test.go covers the funding screen the same way (a fake Funding seam; the send-exactly-once, rejection-vs-ambiguous, and gating properties are pinned there), and cli/tuicmd/funding_test.go pins the seam's endpoint routing and response mapping over a scripted wire.

Documentation

Overview

Package tui is the interactive trading terminal behind the `tui` command: a full-screen view of live market data (ticker, orderbook, trades) and the account's orders, fills, and balances, materialized from a stream.Session's events by internal/stream/state and kept current through reconnects and REST backfill by the stream layer.

Division of labor: the stream package delivers events and reliability notices, the state package answers "what is true right now", and this package only renders and routes input. Order placement and cancellation go through the Trader interface, implemented by the cli package over the same validated, journaled request path as `order place` / `order cancel` — the TUI never signs or sends anything itself.

Concurrency model: one pump goroutine forwards session events into the Bubble Tea program (Program.Send is safe from other goroutines and a no-op after exit); the state.Store is mutated only inside Update, so it needs no locking. Trader calls run inside tea commands (the runtime's goroutines) with an in-flight guard so only one order action runs at a time.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DefaultOrderLevels

func DefaultOrderLevels() []int

DefaultOrderLevels returns a copy of the built-in size-preset defaults, for the cli layer to pin into config.json on first launch.

func Run

func Run(cfg Config) error

Run starts the TUI and blocks until it exits. A deliberate quit returns nil; the caller owns reporting any session error after the screen is restored.

Types

type Config

type Config struct {
	// Symbols are the trading pairs to watch (normalized, at least one).
	Symbols []string
	// Events is the stream session's output; the TUI consumes it until closed.
	Events <-chan stream.Event
	// StopSession cancels the stream session; called when the TUI exits (and
	// must be idempotent).
	StopSession func()
	// Private says the account channels (myOrder/myTrade/myAsset) are
	// subscribed, which selects the account-state panels.
	Private bool
	// AccountSeq is the sub-account the session starts active on — displayed,
	// tracked, and placed under until the user switches (the '@' account
	// switcher). The store's readiness and per-account reads are keyed by the
	// active account; the model reads it through model.accountSeq() (which also
	// normalizes zero to 1/main), and the order/ladder/funding sub-models capture
	// the active value, re-stamped on a switch. Defaults to 1 (main) when zero.
	AccountSeq int
	// AccountSeqs are all the sub-accounts this session subscribed (the account
	// channels cover every one), and the set the '@' switcher offers. AccountSeq
	// is one of them (the initial active). Empty/one-element means a
	// single-account session — the switcher is inert and its footer hint hidden.
	// The cli layer fills this from --account-seq (an explicit list) or, when
	// omitted, the key's allowedAccountSeqs.
	AccountSeqs []int
	// Trader enables the order panel and order cancellation; nil (public
	// mode) disables order entry.
	Trader Trader
	// SetActiveMarket re-subscribes the active symbol's market-data channels
	// (orderbook + trades) when the user switches symbols in the sidebar. It is
	// called with the previous and next symbol whenever the active symbol
	// actually changes; nextLevel is the next symbol's chosen orderbook
	// grouping level ("" = the raw book), so a switch-back restores the pair's
	// grouping. nil disables dynamic switching (the initial symbol's
	// market data is whatever was subscribed up front). Must be safe to call
	// from the UI goroutine and must not block (the cli wires it to the stream
	// session's non-blocking Subscribe/Unsubscribe).
	SetActiveMarket func(prev, next, nextLevel string)
	// SetOrderbookLevel re-points the active symbol's orderbook subscription
	// at a grouping level ("" = the raw book) without touching the trade
	// channel. The wire order is unsubscribe-then-subscribe: the stream
	// registry (and the server) match an unsubscribe by channel+symbol
	// regardless of level, so subscribing the new level first would be undone
	// by the old level's unsubscribe. nil disables grouping (the +/- keys
	// toast). Must be safe to call from the UI goroutine and must not block.
	SetOrderbookLevel func(symbol, level string)
	// SetTrackedOrders sets which {account, symbol} scopes' open-order
	// snapshots the session keeps authoritative (the stream session's
	// LazyOpenOrders mode): the active pair under the active account in the
	// default per-pair view, or every watched pair in the "all pairs" view.
	// Called on an active-symbol switch and on the view toggle. Must be safe to
	// call from the UI goroutine and must not block. nil disables the lazy
	// behavior (every subscribed myOrder symbol stays snapshotted).
	SetTrackedOrders func(scopes []stream.OrderScope)
	// Funding enables the deposit/withdrawal screen (the 'f' key): list
	// currencies with balances, show deposit addresses and transfer histories,
	// and request transfers. nil (public mode) disables the screen.
	Funding Funding
	// TickSizePolicy fetches a symbol's tick metadata (a public read): the
	// tick-size bands and the valid orderbook grouping levels. The bands back
	// the order panel's price stepper, snapping, and off-grid warnings; the
	// levels back the +/- orderbook grouping keys. nil (or a failed fetch)
	// leaves the panel without a grid — typed prices only, the server still
	// validates — and grouping unavailable. Must be safe to call from a tea
	// command goroutine.
	TickSizePolicy func(symbol string) (TickPolicy, error)
	// Fees fetches a sub-account's trading-fee policy for a symbol (a signed
	// read). It takes the account explicitly (the fee tier can differ per
	// sub-account) so it follows the active account across a switch; results
	// are cached per {account, symbol} for the session. It backs the order
	// panel's fee estimate, the buy presets' quote-fee headroom, and the
	// placed order's local balance hold — so when the seam is wired, arming
	// an order is gated until the active pair's policy has loaded (fetched
	// async and retried; the gate says why). nil disables all of it, gate
	// included. Must be safe to call from a tea command goroutine.
	Fees func(symbol string, accountSeq int) (FeeRates, error)
	// Candles fetches up to limit OHLC bars for symbol at interval (the candles
	// endpoint's interval enum: "1","5","15","30","60","240","1D","1W"), oldest
	// first. endMs bounds the newest bar (unix ms, exclusive of later buckets);
	// 0 means "up to now", so the current still-open bucket comes last. A non-zero
	// endMs pages back into history for scroll-back backfill. It backs the candle
	// chart (the 'g' overlay); nil disables the chart. Wired to the candles
	// operation in the cli layer; must be safe to call from a tea command
	// goroutine.
	Candles func(symbol, interval string, limit int, endMs int64) ([]candles.Bar, error)
	// KeyName is the signing key's name, for the header ("" in public mode).
	KeyName string
	// BaseURL is shown in the header so a sandbox session is unmistakable.
	BaseURL string
	// ColorScheme is the initial up/down color convention ("green-red" or
	// "red-blue"); "" or an unrecognized value starts at the default. The cli
	// layer passes the value persisted in config.json.
	ColorScheme string
	// OrderLevels are the %-of-balance size presets the order UIs bind to number
	// keys (e.g. [10,25,50,100], 100 → "max"). The cli layer passes the levels
	// pinned in config.json; empty falls back to the built-in defaults. Pinning
	// keeps a preset key stable across program updates so muscle memory holds.
	OrderLevels []int
	// SaveColorScheme persists the color scheme so it survives the next launch.
	// It is called once, when the TUI exits (Run), with the final scheme's name
	// ("green-red"/"red-blue") and only when it differs from ColorScheme — never
	// on the UI goroutine and never per keypress, so a file write can't stall
	// rendering and rapid toggles can't race. A returned error is best-effort
	// (the caller logs it); it does not change the exit outcome. nil disables
	// persistence (tests, and any caller that doesn't want it).
	SaveColorScheme func(scheme string) error
	// Out/In are the terminal streams the program runs on.
	Out io.Writer
	In  io.Reader
	// Now is the local clock (unix ms); nil = wall clock. Display only.
	Now func() int64
	// StoreLog is the optional logger for the materialized state Store (reconcile
	// mechanics, Debug; tagged component=stream/state). NoticeLog is the optional
	// logger for mirrored reliability notices (tagged component=stream kind=stream_notice).
	// Both nil = silent; the cli sets them only when --log-file diverts logs off
	// the alt-screen.
	StoreLog  *slog.Logger
	NoticeLog *slog.Logger
	// contains filtered or unexported fields
}

Config describes one TUI session.

type FeeRates

type FeeRates struct {
	MakerRate       string
	TakerRate       string
	MaxRate         string
	BuyFeeCurrency  string
	SellFeeCurrency string
}

FeeRates is one symbol's trading-fee policy, as fetched from the fees endpoint (decimal-string rate fractions). It crosses the Config seam so the preview can estimate the fee and the buy presets can reserve headroom for a quote-currency fee.

type Funding

type Funding interface {
	Currencies() ([]FundingCurrency, error)
	DepositAddresses(accountSeq int) ([]FundingDepositAddress, error)
	GenerateDepositAddress(accountSeq int, currency, network string) (FundingDepositAddress, error)
	WithdrawAddresses(accountSeq int) ([]FundingWithdrawAddress, error)
	Withdrawable(accountSeq int, currency string) (FundingWithdrawable, error)
	DepositHistory(accountSeq int, currency string, limit int) ([]FundingTransfer, error)
	WithdrawHistory(accountSeq int, currency string, limit int) ([]FundingTransfer, error)
	RequestWithdrawal(accountSeq int, o FundingWithdrawOrder) (FundingReceipt, error)
	CancelWithdrawal(accountSeq int, id int64) error
	RequestKRWDeposit(accountSeq int, amount string) error
	RequestKRWWithdraw(accountSeq int, amount string) error
}

Funding is the deposit/withdrawal data and action seam. The cli implementation runs every call through the same validated, journaled ops path as the endpoint commands. The three request calls and the cancel are serialized by the UI (one money action in flight); the reads may be issued concurrently, so implementations must be safe for concurrent use. History reads route "krw" to the KRW endpoints, so the UI never selects an endpoint itself. Funding is the funding seam. Every account-scoped call takes the accountSeq to operate on: the funding screen always passes the CURRENTLY ACTIVE account (no defaulting to main). Deposits/withdrawals are main-account-only server-side, so a non-main seq is rejected with ACCOUNT_SEQ_NOT_ALLOWED — the screen warns but never silently retargets. (Currencies is the account-agnostic public catalog.)

type FundingCurrency

type FundingCurrency struct {
	Currency       string // wire code, lower-case ("btc")
	FullName       string
	DefaultNetwork string
	Networks       []FundingNetwork
}

FundingCurrency is one supported currency from the public catalog. Networks is empty for fiat (KRW).

type FundingDepositAddress

type FundingDepositAddress struct {
	Currency         string
	Network          string
	Address          string
	SecondaryAddress string
}

FundingDepositAddress is one crypto deposit address.

type FundingNetwork

type FundingNetwork struct {
	Name                string
	DepositLaunched     bool
	WithdrawalLaunched  bool
	WithdrawalFee       string // fee charged in addition to the sent amount
	WithdrawalMin       string // minimum amount per withdrawal
	WithdrawalPrecision int    // max decimal places of the amount; -1 unknown
	HasSecondaryAddress bool   // the network uses a destination tag / memo
}

FundingNetwork is one blockchain network a currency moves on, with the per-network withdrawal constraints the form validates against. Fee, min, and precision are "" / -1 when the catalog does not carry them.

type FundingReceipt

type FundingReceipt struct {
	ID     int64
	Status string
}

FundingReceipt is the accepted crypto-withdrawal's identity.

type FundingTransfer

type FundingTransfer struct {
	ID        int64
	Currency  string
	Amount    string
	Fee       string
	Status    string
	Network   string
	Address   string
	TxHash    string
	CreatedAt int64
}

FundingTransfer is one deposit or withdrawal record, crypto or KRW (KRW rows carry no network/address/hash; deposits carry no fee).

type FundingWithdrawAddress

type FundingWithdrawAddress struct {
	Currency         string
	Network          string
	Address          string
	SecondaryAddress string
}

FundingWithdrawAddress is one address registered for API withdrawals. Currency is "" when the registration is valid for any asset on the network.

type FundingWithdrawOrder

type FundingWithdrawOrder struct {
	Currency         string
	Network          string
	Amount           string
	Address          string
	SecondaryAddress string
}

FundingWithdrawOrder is one crypto-withdrawal request. Amount is the user's decimal string, passed through untouched; Address/Network/SecondaryAddress come verbatim from a registered withdrawal address.

type FundingWithdrawable

type FundingWithdrawable struct {
	Currency string
	Amount   string
	InUse    string
}

FundingWithdrawable is one asset's withdrawable amount: what can be sent now, and what is locked in pending withdrawals.

type OrderForm

type OrderForm struct {
	Symbol string
	Side   string // buy | sell
	Type   string // limit | market
	Price  string // limit only
	Qty    string // limit, and market sell
	Amt    string // market buy (KRW to spend)
	TIF    string // time-in-force, always set (gtc/ioc/fok/po; a market order is ioc)
	PP     bool   // price protection (market orders): cap taker fills near the mid
	// AccountSeq is the sub-account the order is placed under (>= 1). Stamped
	// by the model's single dispatch point (dispatchPlaceForm) with the active
	// account — never defaulted below the TUI, so the Trader always receives it
	// explicitly.
	AccountSeq int
	// ClientOrderID is the placement's idempotency key, minted by
	// dispatchPlaceForm before the order leaves the TUI so the model can key
	// the order's local balance hold (state.AddLocalHold) on it. The Trader
	// must send it as the order's clientOrderId (minting its own only when
	// empty, for callers other than the model).
	ClientOrderID string
}

OrderForm is one order entry from the order panel. Values are the user's decimal strings, passed through untouched; empty fields are omitted from the request. Type "best" is not offered by the panel (it needs the bestNth level and a mandatory time-in-force — use `order place`).

type PlaceResult

type PlaceResult struct {
	OrderID       string
	ClientOrderID string
	Warning       string
}

PlaceResult is the accepted order's identifiers. Warning carries a non-fatal post-acceptance problem (the order IS live): a full-screen program can't surface it as a printed result line plus a non-zero exit, so the TUI shows the warning loudly in-UI instead.

type TickPolicy

type TickPolicy struct {
	Bands  []ops.TickBand
	Levels []string
}

TickPolicy is one symbol's tick metadata, as fetched from the tick-size policy endpoint. It crosses the Config seam: the bands feed the price grid (stepping/snapping/off-grid checks), the levels are the valid orderbook grouping levels the +/- keys cycle (decimal strings, in no promised order — the consumer sorts; the raw, ungrouped book is not among them).

type Trader

type Trader interface {
	Place(t OrderForm) (PlaceResult, error)
	Cancel(symbol string, orderID int64, accountSeq int) (warning string, err error)
}

Trader places and cancels orders. The cli implementation validates with the same cross-field rules as `order place`, journals the action, and sends the signed request; a validation failure comes back as a *output.UsageError so the form can show it inline. Cancel's warning has the same meaning as PlaceResult.Warning (the cancel was accepted). The sub-account is explicit on every call (OrderForm.AccountSeq / Cancel's accountSeq): a cancel targets the ORDER'S OWN account (from the store row), which need not be the account new orders are placed under. Implementations must be safe to call from a non-UI goroutine (calls are serialized by the UI).

Directories

Path Synopsis
Package candlechart renders OHLC candlestick charts in a terminal cell grid.
Package candlechart renders OHLC candlestick charts in a terminal cell grid.
demo command
Command demo shows the candlechart component in a real terminal with a simulated live feed (no API, synthetic data).
Command demo shows the candlechart component in a real terminal with a simulated live feed (no API, synthetic data).
Package chartind holds the technical-indicator implementations for the candle chart.
Package chartind holds the technical-indicator implementations for the candle chart.
components
balances
Package balances renders the balances pane: a pinned column header (currency / available / total) above a scrolling list of per-currency balances.
Package balances renders the balances pane: a pinned column header (currency / available / total) above a scrolling list of per-currency balances.
curlist
Package curlist renders the funding screen's currency list: a pinned column header (currency / available / est.
Package curlist renders the funding screen's currency list: a pinned column header (currency / available / est.
fills
Package fills renders the recent-fills pane: one row per fill, newest first, each showing the local time, symbol, side (buy/sell, side-colored), price, and quantity.
Package fills renders the recent-fills pane: one row per fill, newest first, each showing the local time, symbol, side (buy/sell, side-colored), price, and quantity.
footer
Package footer renders the two-line status footer: line one is the connection dots, stream-health counters, and the latest non-info notice; line two is the focus-specific key-hint, replaced by a transient toast while it is unexpired.
Package footer renders the two-line status footer: line one is the connection dots, stream-health counters, and the latest non-info notice; line two is the focus-specific key-hint, replaced by a transient toast while it is unexpired.
header
Package header renders the two-line page header: line one names the app, the active-symbol count, and a right-aligned key/base-URL (with a terminal-size warning chip beside it when the parent flags a cramped terminal); line two is the active symbol's ticker summary (last/bid/ask/24h range/volume) or a loading line.
Package header renders the two-line page header: line one names the app, the active-symbol count, and a right-aligned key/base-URL (with a terminal-size warning chip beside it when the parent flags a cramped terminal); line two is the active symbol's ticker summary (last/bid/ask/24h range/volume) or a loading line.
keystrip
Package keystrip renders the TUI's key-hint strips — the global footer hint line and the chart/overlay hint lines — from a single ordered list of [Item]s, and returns BOTH the styled line and a click hitmap so the same tokens that document a key are also clickable buttons that dispatch that key.
Package keystrip renders the TUI's key-hint strips — the global footer hint line and the chart/overlay hint lines — from a single ordered list of [Item]s, and returns BOTH the styled line and a click hitmap so the same tokens that document a key are also clickable buttons that dispatch that key.
ladder
Package ladder renders the trade-ladder body: a DOM-style vertical price ladder over the live book — MINE | bid qty | price | ask qty | MINE — asks above a last/spread line, bids below, the account's resting orders shown in the MINE columns at their price row.
Package ladder renders the trade-ladder body: a DOM-style vertical price ladder over the live book — MINE | bid qty | price | ask qty | MINE — asks above a last/spread line, bids below, the account's resting orders shown in the MINE columns at their price row.
notices
Package notices renders the stream-notices list shown in the TUI's 'n' popup: recent notices, newest first, each line "HH:MM:SS CODE message" with the code colored by its level (warn yellow, error red, otherwise dim).
Package notices renders the stream-notices list shown in the TUI's 'n' popup: recent notices, newest first, each line "HH:MM:SS CODE message" with the code colored by its level (warn yellow, error red, otherwise dim).
orderbook
Package orderbook renders the depth (orderbook) pane: asks above, a mid line, bids below, each level drawn over a side-colored depth bar.
Package orderbook renders the depth (orderbook) pane: asks above, a mid line, bids below, each level drawn over a side-colored depth bar.
orders
Package orders renders the orders pane, a fixed five-column table with a dim header, the data rows windowed by the scroll offset, and the selected row reverse-highlighted.
Package orders renders the orders pane, a fixed five-column table with a dim header, the data rows windowed by the scroll offset, and the selected row reverse-highlighted.
sidebar
Package sidebar renders the markets pane: one row per watched symbol (symbol + 24h %change), windowed by a scroll offset.
Package sidebar renders the markets pane: one row per watched symbol (symbol + 24h %change), windowed by a scroll offset.
trades
Package trades renders the recent-trades (time & sales) pane: newest trade on top, each row showing local time, price, and quantity, with the price colored by taker side (buyer-taker up, seller-taker down).
Package trades renders the recent-trades (time & sales) pane: newest trade on top, each row showing local time, price, and quantity, with the price colored by taker side (buyer-taker up, seller-taker down).
transfers
Package transfers renders the funding screen's history pane: a windowed table of deposit or withdrawal records with a dim column header and the selected row reverse-highlighted.
Package transfers renders the funding screen's history pane: a windowed table of deposit or withdrawal records with a dim column header and the selected row reverse-highlighted.
Package uikit holds the shared presentation primitives and the component contract for the TUI: the per-component render cache (Memo), the comparable style identity (StyleID), and the shared styles, palette, box/panel helpers, and formatters that the individual pane components draw with.
Package uikit holds the shared presentation primitives and the component contract for the TUI: the per-component render cache (Memo), the comparable style identity (StyleID), and the shared styles, palette, box/panel helpers, and formatters that the individual pane components draw with.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL