Skip to content

feat(tui): the console shell — menu navigation, guarded profiles, the session banner (#388 C2) - #401

Merged
eaitbrahim merged 2 commits into
mainfrom
feat/388-console-shell
Aug 19, 2026
Merged

feat(tui): the console shell — menu navigation, guarded profiles, the session banner (#388 C2)#401
eaitbrahim merged 2 commits into
mainfrom
feat/388-console-shell

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

What

C2 of the TUI-operator-console PRD (issue #388): the console shell around the existing dashboard, profile switching across the four deployments, and the session banner on every screen. The dashboard stays the landing screen; nothing working was rewritten.

The shell's structure

  • keel/commands/console.py (new) holds the pure model: the menu tree, the profile convention, the switch/guard logic, and the banner builders — directly unit-testable without curses, mirroring tui.py's pure-model/thin-loop split.
  • run_live gains three modes — menu (the PRD §3 tree, cursor + 1-9 ordinals), profile (the deployment menu), placeholder (a future slice's notice) — and m in normal mode opens the menu. Every screen, all ten modes, is prepended the two-line session banner (fail-soft read).
  • run_live takes the shell as an optional console_binding: a caller that passes none gets the pre-C2 dashboard byte-for-byte — which is what keeps every pre-existing test unchanged. --once heads its frame with the same banner.
  • Only Dashboard / Profile / Help act in this slice; Trading, Rules, Compliance, Data, Research, Account render a "lands in C3/C4/C5 — navigation only" notice (owners per the PRD §5 phasing; Account rides with C5, commented as such).

The profile convention (O4)

The four deployments are discovered from the tracked config files present in the working directory — the same place the CLI resolves its relative --config/--db paths. The pairs are what the wrappers (keel-paper, keel-live, keel-paperhourly, keel-equities) and the runbook's deployment table pin:

profile config db guarded
paper-forward config.paperforward.yaml keel.db no
live config.live-sandbox.yaml keel-live.db yes
paper-hourly config.paper-hourly.yaml keel-paperhourly.db no
paper-equities config.paper-equities.yaml keel-equities.db no

The convention is stated as a comment in console.py; no new registry file. A switch rebinds config and db together through the same _load_cfg/_open_repo loaders every CLI command uses (ConsoleBinding.open_state), so one action rebinds every screen; a pair not in the registry — a config on another deployment's db — is refused outright. Selecting LIVE asks an explicit y/N at the terminal (a confirm step, deliberately not O3's typed contract: it changes what the operator is looking at, not what the engine does) and, once active, the header marks it unmistakably (LIVE (REAL MONEY), alert style). No secrets anywhere in the header or profile display — file names and venue declarations only.

The port schedule extension

market_clock() is untouched. Broker.market_schedule() is added, returning a frozen MarketSchedule(state, next_open_ts, next_close_ts):

  • the default implementation ships as default_market_schedule(broker) on the port (a protocol cannot carry a method body to structural implementors) — the broker's own clock answer with no timestamps claimed;
  • the 24/7 trio (coinbase, fake, robinhood) ship exactly that default: OPEN with nulls, no transport call;
  • Alpaca overrides it: the same /v2/clock, with next_open/next_close crossed as epoch ints — fail-soft on an unparseable timestamp (the state stands, the unusable field is dropped), fail-closed on an unreadable clock (CLOCK_UNAVAILABLE with nulls);
  • the conformance suite gains three checks: the port type, state agreement with market_clock(), and no-times-for-24/7.

The recorded banner (O9)

record_market_session now also records next_open/next_close under the same venue-namespaced keys and trust window — writing explicit nulls when the clock is unreadable so a previous cycle's next_open can never linger as fact, and deriving nulls for a pre-#388 third-party adapter that only implements market_clock(). latest_recorded_session(repo, config, now) is the one broker-free read: newest-stamped venue, with fresh (the same window recorded_market_closed uses) and defused flags. The banner composes from it:

  • 24/7 venue (from the adapter's offline session_bound declaration — capability display, no broker handle) → market: 24/7 (always open), muted;
  • session-bound, fresh record → OPEN/CLOSED with the recorded next open/close (local time), styled per B1's session-line conventions;
  • absent or stale record → CLOCK UNAVAILABLE, fail-loud, warn — same semantics as fetch --check, no TUI-side calendar.

TDD / gates

Red first for: the menu navigation contract, profile rebinding (switch → binding/header/config/repo all reflect it; live-guard confirm required and decline keeps the binding; wrong-pair refused), the port schedule read (default derivation, alpaca parse incl. malformed next_open, clock-failure → CLOCK_UNAVAILABLE with nulls, conformance), the recorded next_open/next_close round-trip (including the null-clearing and pre-port fallback), and banner rendering for all four states. Every pre-existing TUI/dashboard test passes unchanged where behavior is unchanged (the only edited legacy assertions are none — the help "which account" section text changed under its existing assertions, which still hold).

  • uv run pytest -q — 3329 passed, 3 skipped
  • uv run ruff check keel tests packages — clean
  • uv run mypy — no issues in 262 files

Fixes #388

… session banner (#388 C2)

The PRD's C2 slice: the TUI grows from a viewer into the operator console's
shell, around the existing dashboard (which stays the landing screen,
unchanged). Three pieces, test-first.

The shell (`keel/commands/console.py` + `run_live`'s three new modes): an `m`
console menu carrying the PRD §3 tree -- Dashboard, Profile, Trading, Rules,
Compliance, Data, Research, Account, Help -- selected by cursor or the 1-9
ordinals they render. Only Dashboard/Profile/Help do anything in this slice;
every other entry is a placeholder owned by a later slice (C3/C4/C5, per the
PRD's own phasing) and renders a "lands in Cx / navigation only" notice, so no
menu item is a dead click and no future slice restructures the tree.
`run_live` takes the shell as an OPTIONAL console_binding: a caller that
passes none gets the pre-C2 dashboard byte-for-byte, which is what keeps
every pre-existing test (and `--once`, and embedded consumers) unchanged.

Profile switching (O4): the four deployments are discovered from the tracked
config files in the working directory, by the convention the wrappers
(`keel-paper`/`keel-live`/`keel-paperhourly`/`keel-equities`) and the runbook
table pin -- paper-forward + keel.db, live + keel-live.db (guarded),
paper-hourly + keel-paperhourly.db, paper-equities + keel-equities.db -- with
the convention stated as a comment, no new registry file. Selecting a profile
rebinds config AND db together through the same `_load_cfg`/`_open_repo`
loaders every CLI command uses (`ConsoleBinding.open_state`), so one action
rebinds the whole console; a pair not in the registry (a config on another
deployment's db) is refused. LIVE demands an explicit y/N at the terminal
(`_confirm_live_profile`, the suspend-curses dance `_confirm_arm_autonomy`
keeps) and, once active, is marked unmistakably in the header. Deliberately a
confirm step, not O3's typed contract: it changes what the operator is looking
at, not what the engine does.

The session banner (O9), on every screen: active profile, then the venue's
market session + clock -- "24/7" for always-open venues, OPEN/CLOSED with the
next open/close for session-bound ones, CLOCK UNAVAILABLE fail-loud when the
record is absent or stale. The port's clock read is extended, not broken:
`Broker.market_schedule()` returns a small `MarketSchedule` value object
(state, next_open_ts, next_close_ts) with the default implementation derived
from `market_clock()` (`default_market_schedule` -- OPEN with nulls for the
24/7 trio: coinbase, fake, robinhood); Alpaca overrides it with `/v2/clock`'s
own next_open/next_close, failing soft on an unparseable timestamp and
closed on an unreadable clock. The conformance suite pins the type, the
state agreement, and the no-times-for-24/7 rule. `record_market_session`
records the schedule under the same venue-namespaced keys and trust window
(writing nulls when the clock is unreadable, so a stale next_open can never
render as fact), and `latest_recorded_session` is the one broker-free read
the banner composes from -- the TUI renders recorded state, never a clock
call or calendar of its own.

Fixes #388
…t discovery per binding

Seven adversarial-review findings on the console shell, test-first where
behavior changed.

Scroll clamp (all five scrollable overlays): `_scroll_offset` was clamped
against the banner-EXCLUDED length while `_visible_slice` slices the combined
[banner, overlay] list, so `End` stopped two lines short and the help tail was
permanently hidden whenever a console binding was supplied. Every call site
now passes the combined total; no binding means an empty banner and the
pre-C2 behavior unchanged.

Activity paging: `_activity_cursor` gained a `banner_lines` keyword and the
call site passes `len(banner)` (mirroring `_follow_cursor`), so PgUp/PgDn no
longer over-advance by the two banner rows.

LIVE-switch toast: `_message_style` classifies `profile -> LIVE` as alert --
the vocabulary's weight for real money, the same one the LIVE banner line and
arming autonomy ON get -- while paper switches stay ok.

Discovery caching: `venue_session_bound`'s adapter-registry walk
(`load_broker` -> `discover_brokers()`, an importlib.metadata scan) ran on
every banner render, i.e. every poll of every screen. It is now resolved once
per ConsoleBinding (`session_bound`), `rebind` is the invalidation point, and
the class docstring says so; the unresolvable-venue conservative fallback
(True) is untouched.

Profile mode's `m`: closes back to the menu like q/Esc/p, matching menu and
placeholder modes, and the footer line says q/Esc/p/m.

80-column budget: the LIVE guard note wraps to its own alert row under the
guarded pair (the appended form measured 107 columns -- the "asks for
confirmation" tail was exactly what clipped), and the banner's line two was
rewrapped worst-case-first -- CLOCK UNAVAILABLE leads, the stale record
carries a minute-precision stamp, the schedule stamps render through the new
`_short_dt` (local `YYYY-MM-DD HH:MM`) so an OPEN row can carry BOTH the
recorded next close and next open (alpaca's /v2/clock supplies both) inside
80 columns. New tests assert every banner/profile-menu line <= 80.

RecordedSession.defused: REMOVED (option b), because removal is the honest
reading of O9. Defusal is FR-9's staleness-ALERT concern -- owned by
`recorded_market_closed` and already surfaced to displays through
`MarketSessionStatus.defused` -- and the banner's every rendering decision
(state, fresh, times) never reads it; keeping a dead field whose docstring
claimed the banner renders it invited a rendering decision the banner has no
business making. The docstring now states the omission deliberately; the
computation (a `_slot_market_closed` call per read) and the field went with
it, and its two test assertions were simplified.
@eaitbrahim
eaitbrahim merged commit 346abdf into main Aug 19, 2026
5 checks passed
@eaitbrahim
eaitbrahim deleted the feat/388-console-shell branch August 19, 2026 12:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

C2: console shell — menu/sub-menu framework + profile switching (4 deployments)

1 participant