feat(tui): the console shell — menu navigation, guarded profiles, the session banner (#388 C2) - #401
Merged
Merged
Conversation
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, mirroringtui.py's pure-model/thin-loop split.run_livegains three modes —menu(the PRD §3 tree, cursor + 1-9 ordinals),profile(the deployment menu),placeholder(a future slice's notice) — andmin normal mode opens the menu. Every screen, all ten modes, is prepended the two-line session banner (fail-soft read).run_livetakes the shell as an optionalconsole_binding: a caller that passes none gets the pre-C2 dashboard byte-for-byte — which is what keeps every pre-existing test unchanged.--onceheads its frame with the same banner.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/--dbpaths. The pairs are what the wrappers (keel-paper,keel-live,keel-paperhourly,keel-equities) and the runbook's deployment table pin:config.paperforward.yamlkeel.dbconfig.live-sandbox.yamlkeel-live.dbconfig.paper-hourly.yamlkeel-paperhourly.dbconfig.paper-equities.yamlkeel-equities.dbThe 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_repoloaders 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 frozenMarketSchedule(state, next_open_ts, next_close_ts):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;/v2/clock, withnext_open/next_closecrossed 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);market_clock(), and no-times-for-24/7.The recorded banner (O9)
record_market_sessionnow also recordsnext_open/next_closeunder the same venue-namespaced keys and trust window — writing explicit nulls when the clock is unreadable so a previous cycle'snext_opencan never linger as fact, and deriving nulls for a pre-#388 third-party adapter that only implementsmarket_clock().latest_recorded_session(repo, config, now)is the one broker-free read: newest-stamped venue, withfresh(the same windowrecorded_market_closeduses) anddefusedflags. The banner composes from it:session_bounddeclaration — capability display, no broker handle) →market: 24/7 (always open), muted;OPEN/CLOSEDwith the recorded next open/close (local time), styled per B1's session-line conventions;CLOCK UNAVAILABLE, fail-loud, warn — same semantics asfetch --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 skippeduv run ruff check keel tests packages— cleanuv run mypy— no issues in 262 filesFixes #388