fix(code): show contextual auth footer actions (#5690)

The `/auth` manager footer now names the action for the highlighted row
instead of listing every action the screen supports.

Before, one line had to cover every row:

```
↑/↓ or Tab/Shift+Tab navigate • Enter add/replace/delete/install • Esc close
```

Now the action follows the cursor:

| Highlighted row | Footer |
| --- | --- |
| `anthropic  [env set: ANTHROPIC_API_KEY]` | `Enter replace` |
| `openai  [stored]` | `Enter replace/delete` |
| `tavily` | `Enter add` |
| `groq  [not installed]` | `Enter install` |
| `OpenAI Codex (ChatGPT login)  [chatgpt: plus]` | `Enter manage` |

---

**An expired ChatGPT access token counts as signed in.** Expiry is the
normal state for a returning user: the saved refresh token renews the
access token when a model is constructed, which is why
`_get_codex_auth_status` reports the row as `CONFIGURED` and badges it
`[chatgpt]`. The footer reads that same status, so the badge, the
footer, and Enter agree. Treating expiry as signed-out would have told
the user to re-authorize a session that never lapsed, and would have
hidden sign-out — which only the signed-in overlay offers — behind that
unnecessary OAuth round trip.

Reading the cached status also means highlighting a row never touches
the token file. Arrowing over the ChatGPT row used to read
`chatgpt-auth.json` on every keypress.

**A stored key with no known env var offers replace/delete.** The set of
providers with a credential in `auth.json` is snapshotted when the
option list is built. Without it, a provider whose env var is absent
from `PROVIDER_API_KEY_ENV` — a custom `class_path` provider, or a
well-known one renamed across an upgrade — resolved to `UNKNOWN` and the
footer said `add`, while the prompt behind Enter offered `Ctrl+D delete`
for the key it had just denied existed.

**The footer follows the row, not the cursor index.** `_refresh_options`
preserves the highlighted index while configured rows float to the top,
so saving or deleting a key changes which provider sits under an unmoved
cursor. The rebuild recomputes the footer from the row that ended up
there.

Both footer writers share one helper that tolerates a dismissed manager.
A queued `OptionHighlighted` can arrive after the screen is gone, where
an unguarded `query_one` raises `NoMatches` and Textual escalates it
into a callback error that tears down the REPL.

Made by [Open
SWE](https://openswe.vercel.app/agents/d9dd1ec2-f3c6-5ff0-aaa2-da82e97fcbc7)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-08-21 15:03:56 -04:00
committed by GitHub
parent 1f74959492
commit 074a5019e1
2 changed files with 354 additions and 23 deletions
+123 -15
View File
@@ -1610,6 +1610,13 @@ class AuthManagerScreen(ModalScreen[None]):
# populated each time the option list is built. Selecting one routes
# to the install confirmation instead of the key prompt.
self._install_extras: dict[str, str] = {}
# Resolved auth status per provider/service key, and the set of keys
# with a credential in `auth_store`. Both are side effects of
# building the option list, so both are empty until then — hence the
# `.get()` fallback in `_action_for_provider`, and the reason
# `compose` builds the options before the footer.
self._auth_statuses: dict[str, ProviderAuthStatus] = {}
self._stored_providers: set[str] = set()
# Set when the user confirms installing a provider's extra; the app
# reads these off the screen after dismissal to install then reopen
# the manager with the just-installed provider highlighted.
@@ -1623,7 +1630,6 @@ class AuthManagerScreen(ModalScreen[None]):
Yields:
Widgets for the manager listing.
"""
glyphs = get_glyphs()
options, store_warning = self._build_options_with_warning()
with Vertical():
yield Static("Manage API keys", classes="auth-manager-title")
@@ -1637,13 +1643,78 @@ class AuthManagerScreen(ModalScreen[None]):
classes="auth-manager-warning",
)
yield OptionList(*options, id="auth-manager-options")
# `OptionList` highlights its first row on construction, so the
# mount-time `OptionHighlighted` recomputes this footer before the
# first frame. Seeding it with the same provider keeps that frame
# from flashing a generic label.
first_provider = options[0].id if options else None
yield Static(
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab/Shift+Tab "
f"navigate {glyphs.bullet} Enter add/replace/delete/install "
f"{glyphs.bullet} Esc close",
self._build_manager_help(first_provider),
classes="auth-manager-help",
id="auth-manager-help",
)
def _build_manager_help(self, provider: str | None) -> str:
"""Build the manager footer for the highlighted provider.
Args:
provider: Highlighted provider or service config key, if any.
Returns:
Navigation help naming the highlighted row's Enter action.
"""
glyphs = get_glyphs()
action = self._action_for_provider(provider)
return (
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab/Shift+Tab navigate "
f"{glyphs.bullet} Enter {action} {glyphs.bullet} Esc close"
)
def _action_for_provider(self, provider: str | None) -> str:
"""Return the action exposed by selecting a provider row.
Args:
provider: Highlighted provider or service config key, if any.
Returns:
Short action label for the manager footer.
"""
if provider is None:
return "select"
if provider in self._install_extras:
return "install"
if provider == CODEX_PROVIDER:
return "manage" if self._codex_session_is_active() else "sign in"
status = self._auth_statuses.get(provider)
if provider in self._stored_providers or (
status is not None and status.source is ProviderAuthSource.STORED
):
# Enter opens the key prompt, which is where the delete action
# lives (`Ctrl+D`); the manager itself never deletes a key.
return "replace/delete"
if status is not None and status.state is ProviderAuthState.CONFIGURED:
return "replace"
return "add"
def _codex_session_is_active(self) -> bool:
"""Return whether a usable ChatGPT token is stored on disk.
An expired *access* token still counts. `_get_codex_auth_status`
reports it as `CONFIGURED` because the saved refresh token renews it
when a model is constructed, and the row's badge says so. Treating
expiry as signed-out would contradict that badge and hide sign-out
behind a re-authorization the user does not need. Reads the status
resolved when the option list was built, so highlighting a row never
touches the token file.
Returns:
`True` when a ChatGPT token is stored and refreshable.
"""
status = self._auth_statuses.get(CODEX_PROVIDER)
return status is not None and status.state is ProviderAuthState.CONFIGURED
def _build_description(self) -> Content:
"""Build the description line with an inline docs hyperlink.
@@ -1707,6 +1778,31 @@ class AuthManagerScreen(ModalScreen[None]):
"""Reset the pointer shape when the mouse leaves the manager."""
self.styles.pointer = "default"
def on_option_list_option_highlighted(
self, event: OptionList.OptionHighlighted
) -> None:
"""Update the footer for the highlighted provider row."""
self._update_manager_help(event.option.id)
def _update_manager_help(self, provider: str | None) -> None:
"""Rewrite the footer for `provider`, if the manager is still mounted.
Guarded against the user having dismissed the manager before a
queued `OptionHighlighted` was dispatched; without the guard,
`query_one` would raise `NoMatches` and Textual would surface it as
a callback error.
Args:
provider: Highlighted provider or service config key, if any.
"""
from textual.css.query import NoMatches
try:
help_widget = self.query_one("#auth-manager-help", Static)
except NoMatches:
return
help_widget.update(self._build_manager_help(provider))
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
"""Open the prompt for the selected provider.
@@ -1777,18 +1873,16 @@ class AuthManagerScreen(ModalScreen[None]):
def _open_codex_screen(self) -> None:
"""Push the ChatGPT OAuth flow modal and refresh on close.
When `openai_codex` is already signed in, give the user a chance to
sign out before launching a fresh sign-in flow. Otherwise just run
the sign-in worker.
When a ChatGPT token is already stored, give the user a chance to
sign out (or switch account) before launching a fresh sign-in flow.
Otherwise just run the sign-in worker.
"""
from deepagents_code.integrations import openai_codex
from deepagents_code.tui.widgets.codex_auth import (
CodexAuthScreen,
CodexSignedInScreen,
)
status = openai_codex.get_status()
if status.logged_in and not status.is_expired:
if self._codex_session_is_active():
self.app.push_screen(
CodexSignedInScreen(),
self._on_codex_signed_in_closed,
@@ -1851,7 +1945,7 @@ class AuthManagerScreen(ModalScreen[None]):
self.post_message(self.CredentialDeleted(provider))
def _refresh_options(self) -> None:
"""Rebuild option labels from current store state."""
"""Rebuild option labels, and the footer, from current store state."""
option_list = self.query_one("#auth-manager-options", OptionList)
highlighted = option_list.highlighted
option_list.clear_options()
@@ -1860,6 +1954,17 @@ class AuthManagerScreen(ModalScreen[None]):
option_list.add_option(option)
if highlighted is not None and option_list.option_count:
option_list.highlighted = min(highlighted, option_list.option_count - 1)
# Restoring the index re-posts `OptionHighlighted`, but only on a
# non-empty list, and only a frame later. Rewrite the footer here so
# an emptied list can't keep a stale action, and so a saved key can't
# leave the old label on screen for a frame — the rows resort, so the
# provider under an unmoved cursor may have changed.
restored = option_list.highlighted
self._update_manager_help(
option_list.get_option_at_index(restored).id
if restored is not None
else None
)
def _build_options_with_warning(self) -> tuple[list[Option], str | None]:
"""Render the option list, returning a corruption warning if any.
@@ -1880,6 +1985,7 @@ class AuthManagerScreen(ModalScreen[None]):
f"Credential file is unreadable ({exc}). "
"Saving a key here will overwrite it."
)
self._stored_providers = stored
config = ModelConfig.load()
config_providers = {
@@ -1917,12 +2023,14 @@ class AuthManagerScreen(ModalScreen[None]):
self._install_extras = self._uninstalled_known_providers(config, shown)
# Resolve each manageable entry's auth status once and reuse it for
# both ordering and badge rendering. `_auth_status_for` reads the
# credential file, so resolving it separately in the sort key and in
# `_format_label` would read `auth.json` twice per row (and, on a
# corrupt store, log the same warning twice). A single pass halves both.
# ordering, badge rendering, and the footer's Enter action.
# `_auth_status_for` reads the credential file, so resolving it
# separately in the sort key and in `_format_label` would read
# `auth.json` twice per row (and, on a corrupt store, log the same
# warning twice). A single pass halves both.
services = set(SERVICE_API_KEY_ENV) - shown - set(self._install_extras)
status_by_key = {key: _auth_status_for(key) for key in shown | services}
self._auth_statuses = status_by_key
# Float entries that already have a credential configured to the top so
# the keys a user is actively using are easiest to find; everything else
@@ -2176,6 +2176,55 @@ api_key_env = "MY_GATEWAY_API_KEY"
after = current_ids()
assert after.index("openai") < after.index("anthropic")
async def test_refresh_options_updates_footer_for_resorted_row(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A rebuild rewrites the footer for whatever row the cursor now sits on.
`_refresh_options` preserves the highlighted *index*, and saving a key
re-floats its provider, so the provider under an unmoved cursor
changes. The footer has to follow the row rather than the index, or it
promises an action Enter will not perform. Asserted before any
`pilot.pause()` so the rebuild's own footer write is what satisfies
it, not the re-posted `OptionHighlighted` a frame later.
"""
for var in (
"OPENAI_API_KEY",
"DEEPAGENTS_CODE_OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"DEEPAGENTS_CODE_ANTHROPIC_API_KEY",
"LANGSMITH_API_KEY",
"DEEPAGENTS_CODE_LANGSMITH_API_KEY",
"TAVILY_API_KEY",
"DEEPAGENTS_CODE_TAVILY_API_KEY",
):
monkeypatch.delenv(var, raising=False)
# Only these two count as installed, so the manageable rows are
# `anthropic`, `openai`, and the two services — a short enough list
# that the alphabetical head is predictable.
monkeypatch.setattr(
"deepagents_code.config_manifest.is_provider_package_installed",
lambda provider: provider in {"openai", "anthropic"},
)
app = _AuthHostApp()
async with app.run_test() as pilot:
app.show_manager()
await pilot.pause()
options = app.screen.query_one("#auth-manager-options", OptionList)
help_text = app.screen.query_one("#auth-manager-help", Static)
# Nothing is configured yet, so rows are alphabetical and
# `anthropic` leads.
assert options.highlighted == 0
assert options.get_option_at_index(0).id == "anthropic"
assert "Enter add " in str(help_text.content)
auth_store.set_stored_key("openai", "k")
screen = cast("AuthManagerScreen", app.screen)
screen._refresh_options()
# Index 0 is now `openai`, which has a stored key to replace.
assert options.get_option_at_index(0).id == "openai"
assert "Enter replace/delete " in str(help_text.content)
async def test_configured_group_preserves_alphabetical_order(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -2638,14 +2687,97 @@ enabled = false
# surfaces in the rendered span representation.
assert "providers" in repr(copy.content) or "providers" in content
async def test_footer_lists_full_action_set(self) -> None:
"""Footer mentions add/replace/delete (delete happens via the prompt)."""
async def test_footer_tracks_highlighted_action(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Footer names the action exposed by the highlighted provider row.
Pins all four non-codex branches of `_action_for_provider`: a stored
key offers replace/delete, an env-only key offers replace, a known
service with neither offers add, and an uninstalled provider offers
install. The `set_stored_key`/`setenv` split is what separates the
first two — scrubbing the env vars first keeps an ambient key from
turning `tavily` or `openai` into a false pass.
"""
for var in (
"OPENAI_API_KEY",
"DEEPAGENTS_CODE_OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"DEEPAGENTS_CODE_ANTHROPIC_API_KEY",
"TAVILY_API_KEY",
"DEEPAGENTS_CODE_TAVILY_API_KEY",
):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("ANTHROPIC_API_KEY", "from-env")
monkeypatch.setattr(
"deepagents_code.config_manifest.is_provider_package_installed",
lambda provider: provider in {"openai", "anthropic"},
)
auth_store.set_stored_key("openai", "stored-key")
app = _AuthHostApp()
async with app.run_test() as pilot:
app.show_manager()
await pilot.pause()
help_text = app.screen.query_one(".auth-manager-help", Static)
assert "add/replace/delete" in str(help_text.content)
options = app.screen.query_one("#auth-manager-options", OptionList)
help_text = app.screen.query_one("#auth-manager-help", Static)
# Assert the mount-time footer before navigating, so deleting the
# highlight handler can't be masked by dict ordering below.
initial = str(help_text.content)
assert "Enter replace " in initial, initial
assert "Esc close" in initial, initial
assert "navigate" in initial, initial
expected_actions = {
"openai": "replace/delete",
"anthropic": "replace",
"tavily": "add",
"groq": "install",
}
for provider, action in expected_actions.items():
index = next(
i
for i in range(options.option_count)
if options.get_option_at_index(i).id == provider
)
options.highlighted = index
assert options.highlighted == index
await pilot.pause()
assert f"Enter {action} " in str(help_text.content)
async def test_footer_uses_stored_snapshot_for_custom_providers(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Stored custom keys expose replace/delete regardless of auth status."""
config_path = tmp_path / "config.toml"
config_path.write_text("""
[models.providers.managed_provider]
class_path = "example.models:ManagedChat"
models = ["managed-model"]
""")
monkeypatch.setattr(model_config, "DEFAULT_CONFIG_PATH", config_path)
model_config.clear_caches()
monkeypatch.setattr(
"deepagents_code.config_manifest.is_provider_package_installed",
lambda provider: provider == "openai",
)
auth_store.set_stored_key("unknown_provider", "test-key")
auth_store.set_stored_key("managed_provider", "test-key")
app = _AuthHostApp()
async with app.run_test() as pilot:
app.show_manager()
await pilot.pause()
options = app.screen.query_one("#auth-manager-options", OptionList)
help_text = app.screen.query_one("#auth-manager-help", Static)
for provider in ("unknown_provider", "managed_provider"):
index = next(
i
for i in range(options.option_count)
if options.get_option_at_index(i).id == provider
)
options.highlighted = index
await pilot.pause()
assert "Enter replace/delete " in str(help_text.content)
async def test_corrupt_store_surfaces_warning_banner(
self, fake_state_dir: Path
@@ -2773,6 +2905,9 @@ enabled = false
break
assert target_index is not None
options.highlighted = target_index
await pilot.pause()
help_text = app.screen.query_one("#auth-manager-help", Static)
assert "Enter sign in " in str(help_text.content)
# We just need to observe that the screen is pushed *before* the
# fake worker finishes; capture the screen class via the
# `screen_stack` instead of asserting on `app.screen` (which the
@@ -2859,6 +2994,9 @@ enabled = false
break
assert target_index is not None
options.highlighted = target_index
await pilot.pause()
help_text = app.screen.query_one("#auth-manager-help", Static)
assert "Enter manage " in str(help_text.content)
pushed: list[type] = []
original = app.push_screen
@@ -2871,9 +3009,67 @@ enabled = false
await pilot.pause()
assert CodexSignedInScreen in pushed
async def test_codex_expired_token_shows_manage_and_pushes_signed_in(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An expired access token is still a session: manage, not sign in.
The row badges an expired token as `[chatgpt]` because the saved
refresh token renews it on use, so the footer has to agree — and
sign-out has to stay reachable, which only the signed-in overlay
offers.
"""
from deepagents_code.integrations import openai_codex as codex_integration
from deepagents_code.model_config import clear_caches
from deepagents_code.tui.widgets.codex_auth import (
CodexAuthScreen,
CodexSignedInScreen,
)
path = tmp_path / "auth.json"
self._write_token(path, expired=True)
monkeypatch.setattr(codex_integration, "default_store_path", lambda: path)
async def _fake_run( # noqa: RUF029 # async signature dictated by protocol
*_args: object, **_kwargs: object
) -> codex_integration.CodexAuthStatus:
return codex_integration.CodexAuthStatus(logged_in=False, store_path=path)
monkeypatch.setattr(codex_integration, "run_browser_login", _fake_run)
clear_caches()
app = _AuthHostApp()
async with app.run_test() as pilot:
app.show_manager()
await pilot.pause()
options = app.screen.query_one("#auth-manager-options", OptionList)
target_index = next(
i
for i in range(options.option_count)
if options.get_option_at_index(i).id == "openai_codex"
)
options.highlighted = target_index
await pilot.pause()
help_text = app.screen.query_one("#auth-manager-help", Static)
assert "Enter manage " in str(help_text.content)
pushed: list[type] = []
original = app.push_screen
def _capture(screen, *args, **kwargs): # noqa: ANN002, ANN003, ANN202
pushed.append(type(screen))
return original(screen, *args, **kwargs)
monkeypatch.setattr(app, "push_screen", _capture)
await pilot.press("enter")
await pilot.pause()
assert CodexSignedInScreen in pushed
assert CodexAuthScreen not in pushed
@staticmethod
def _write_token(path: Path) -> None:
"""Plant a valid (unexpired) token bundle at `path` with 0600 perms."""
def _write_token(path: Path, *, expired: bool = False) -> None:
"""Write a ChatGPT token bundle at `path` with 0600 permissions.
The token is unexpired unless `expired` is true.
"""
import json
from datetime import datetime, timedelta
@@ -2883,7 +3079,9 @@ enabled = false
{
"access_token": "fake",
"refresh_token": "fake",
"expires_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
"expires_at": (
datetime.now(UTC) + timedelta(hours=-1 if expired else 1)
).isoformat(),
"account_id": "acct",
"plan_type": "plus",
"user_id": "u",
@@ -2897,7 +3095,12 @@ enabled = false
async def test_signout_dispatch_deletes_token(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`SIGN_OUT` from the overlay deletes the stored token on disk."""
"""`SIGN_OUT` from the overlay deletes the token and re-labels the row.
The footer reads the status cached when the option list was built, so
signing out only re-labels the row if the dispatch invalidates that
cache — this fails if either `clear_caches` or the refresh is dropped.
"""
from deepagents_code.integrations import openai_codex as codex_integration
from deepagents_code.model_config import clear_caches
from deepagents_code.tui.widgets.codex_auth import CodexSignedInAction
@@ -2910,9 +3113,29 @@ enabled = false
async with app.run_test() as pilot:
app.show_manager()
await pilot.pause()
options = app.screen.query_one("#auth-manager-options", OptionList)
help_text = app.screen.query_one("#auth-manager-help", Static)
options.highlighted = next(
i
for i in range(options.option_count)
if options.get_option_at_index(i).id == "openai_codex"
)
await pilot.pause()
assert "Enter manage " in str(help_text.content)
manager = cast("AuthManagerScreen", app.screen)
manager._on_codex_signed_in_closed(CodexSignedInAction.SIGN_OUT)
await pilot.pause()
# Signing out drops the row out of the configured block, so the
# cursor's index now points at a different provider; re-find the
# ChatGPT row rather than assuming the cursor followed it.
options.highlighted = next(
i
for i in range(options.option_count)
if options.get_option_at_index(i).id == "openai_codex"
)
await pilot.pause()
assert "Enter sign in " in str(help_text.content)
assert not path.exists()
async def test_reauth_dispatch_pushes_oauth_screen(