mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
fix(code): keep notification center open for API-key entry (#4568)
Pressing `Esc` in the API-key entry prompt reached from a notification now returns to the notification center instead of closing all open modals. --- When a user opens the notification center with `ctrl+n`, selects a service notification, and chooses **Enter API key**, they are still working through that notification. If they decide not to enter a key yet, `Esc` should take them back to the notification details so they can review, suppress, or choose another notification — not drop them back into the main chat screen. Before this change, selecting **Enter API key** dismissed the notification center before the API-key prompt opened. Because the center was already gone, pressing `Esc` in the prompt fell through to the base chat screen instead of returning to the notifications viewer. For example: - Before: 1. Press `ctrl+n` to open notifications. 2. Open a Tavily API-key notification. 3. Select **Enter API key**. 4. Press `Esc` in the API-key prompt. 5. The prompt closes and the user lands in the main chat screen, losing the notification context. - After: 1. Press `ctrl+n` to open notifications. 2. Open a Tavily API-key notification. 3. Select **Enter API key**. 4. Press `Esc` in the API-key prompt. 5. The prompt closes and the user returns to the notification center, with the notification details still available. The fix treats `ENTER_API_KEY` as an in-place action, mirroring the existing `SUPPRESS` handling: instead of dismissing the center, the detail modal posts a `NotificationActionRequested` message. The app handles it in a worker so the message pump is not blocked while the prompt awaits input, pushes the `AuthPromptScreen` on top of the still-mounted center, then reloads the center once the action resolves. Saving the key drops the notification entry, and the center dismisses only when the list becomes empty. Made by [Open SWE](https://openswe.vercel.app/agents/7f8edaca-58bc-3065-4243-6edc6d5d3449) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -367,6 +367,7 @@ if TYPE_CHECKING:
|
||||
from deepagents_code.tui.widgets.goal_review import GoalReviewMenu, GoalReviewResult
|
||||
from deepagents_code.tui.widgets.model_selector import ModelSelectorScreen
|
||||
from deepagents_code.tui.widgets.notification_center import (
|
||||
NotificationActionRequested,
|
||||
NotificationSuppressRequested,
|
||||
)
|
||||
from deepagents_code.tui.widgets.restart_prompt import RestartChoice
|
||||
@@ -14180,24 +14181,78 @@ class DeepAgentsApp(App):
|
||||
message: NotificationSuppressRequested,
|
||||
) -> None:
|
||||
"""Suppress the notice in place and refresh the open center."""
|
||||
message.stop()
|
||||
await self._dispatch_notification_action(message.key, ActionId.SUPPRESS)
|
||||
await self._refresh_open_center()
|
||||
|
||||
def on_notification_action_requested(
|
||||
self,
|
||||
message: NotificationActionRequested,
|
||||
) -> None:
|
||||
"""Dispatch an in-place notification action, keeping the center open.
|
||||
|
||||
The action (e.g. `ENTER_API_KEY`) pushes a follow-up modal on top
|
||||
of the still-mounted center, so it must run in a worker rather than
|
||||
block the message pump while that modal awaits input.
|
||||
"""
|
||||
message.stop()
|
||||
# `group` is for observability only; with `exclusive=False` it does
|
||||
# not single-flight. `exclusive=True` would be wrong here — a
|
||||
# re-trigger for the same key would cancel an in-progress API-key
|
||||
# prompt mid-entry.
|
||||
self.run_worker(
|
||||
self._dispatch_in_place_notification_action(
|
||||
message.key,
|
||||
message.action_id,
|
||||
),
|
||||
exclusive=False,
|
||||
group=f"notification-action-{message.key}",
|
||||
)
|
||||
|
||||
async def _dispatch_in_place_notification_action(
|
||||
self,
|
||||
key: str,
|
||||
action_id: ActionId,
|
||||
) -> None:
|
||||
"""Run an in-place action, then refresh the still-open center.
|
||||
|
||||
The action's follow-up modal (e.g. the API-key prompt) stacks on
|
||||
top of the center so Esc returns to it. Once the action resolves,
|
||||
the center is reloaded so any handled entry drops out; reloading an
|
||||
empty list dismisses the center.
|
||||
"""
|
||||
await self._dispatch_notification_action(key, action_id)
|
||||
await self._refresh_open_center()
|
||||
|
||||
async def _refresh_open_center(self) -> None:
|
||||
"""Reload the notification center if it is still the active screen.
|
||||
|
||||
Shared tail of the in-place action handlers (SUPPRESS and the
|
||||
`IN_PLACE_ACTIONS` worker). No-ops when the center is no longer on
|
||||
top — e.g. concurrently dismissed — because the registry is already
|
||||
authoritative and the next open re-renders from it.
|
||||
|
||||
A `NoMatches` from a dismiss/mount race — a concurrent dismissal can
|
||||
detach the `VerticalScroll` before `reload` queries it — is
|
||||
downgraded to a warning toast; the worst case is a stale or
|
||||
partially-rebuilt row list, which the next open heals. Any other
|
||||
exception is a genuine `reload` bug and propagates so it surfaces
|
||||
instead of being mischaracterized as a transient race.
|
||||
"""
|
||||
from textual.css.query import NoMatches
|
||||
|
||||
from deepagents_code.tui.widgets.notification_center import (
|
||||
NotificationCenterScreen,
|
||||
)
|
||||
|
||||
message.stop()
|
||||
await self._dispatch_notification_action(message.key, ActionId.SUPPRESS)
|
||||
screen = self.screen
|
||||
if not isinstance(screen, NotificationCenterScreen):
|
||||
return
|
||||
try:
|
||||
await screen.reload(self._notice_registry.list_all())
|
||||
except Exception as exc: # defend against dismiss/mount races
|
||||
# A concurrent dismissal can detach the VerticalScroll before
|
||||
# `reload` queries it. The worst case is a stale row list,
|
||||
# which the next open of the center will heal. Log + toast
|
||||
# so the failure surfaces instead of vanishing into a worker.
|
||||
except NoMatches as exc: # dismiss/mount race detached the scroll
|
||||
logger.warning(
|
||||
"Failed to refresh notification center after suppress: %s",
|
||||
"Failed to refresh notification center after in-place action: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -14432,7 +14487,17 @@ class DeepAgentsApp(App):
|
||||
# exactly membership in `SERVICE_API_KEY_ENV`.
|
||||
env_var = SERVICE_API_KEY_ENV.get(service)
|
||||
if env_var is None:
|
||||
# Misconfiguration: an ENTER_API_KEY action on a tool with no
|
||||
# known env var. Log for devs, and tell the user why nothing
|
||||
# opened — via the in-place path the center would otherwise just
|
||||
# reload unchanged with no explanation.
|
||||
self._log_unknown_action(entry, ActionId.ENTER_API_KEY)
|
||||
self.notify(
|
||||
f"No API-key entry is available for {service}.",
|
||||
severity="warning",
|
||||
timeout=6,
|
||||
markup=False,
|
||||
)
|
||||
return
|
||||
|
||||
from deepagents_code.tui.widgets.auth import AuthPromptScreen, AuthResult
|
||||
|
||||
@@ -4,11 +4,15 @@ Surfaces a list of `PendingNotification` entries as single-line rows.
|
||||
Selecting a row drills into a dedicated detail modal
|
||||
(`UpdateAvailableScreen` for update entries, `NotificationDetailScreen`
|
||||
otherwise) stacked on top of the center. When the detail modal
|
||||
dismisses with any non-SUPPRESS action the center dismisses with a
|
||||
`NotificationActionResult` so the app layer can dispatch; SUPPRESS is
|
||||
handled in place via `NotificationSuppressRequested` so the remaining
|
||||
notifications stay reachable. When the detail cancels, the center
|
||||
stays open on the list.
|
||||
dismisses with a terminal action (one that closes the center) the
|
||||
center dismisses with a `NotificationActionResult` so the app layer can
|
||||
dispatch. Actions that must keep the center open are handled in place:
|
||||
SUPPRESS via
|
||||
`NotificationSuppressRequested` (so the remaining notifications stay
|
||||
reachable) and actions in `IN_PLACE_ACTIONS` via
|
||||
`NotificationActionRequested` (so a follow-up modal, e.g. the API-key
|
||||
prompt, stacks on top and Esc returns to the center). When the detail
|
||||
cancels, the center stays open on the list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -88,6 +92,50 @@ class NotificationSuppressRequested(Message):
|
||||
self.key = key
|
||||
|
||||
|
||||
IN_PLACE_ACTIONS: frozenset[ActionId] = frozenset({ActionId.ENTER_API_KEY})
|
||||
"""Actions handled in place without dismissing the center.
|
||||
|
||||
Each opens a follow-up modal on top of the center, so the center stays
|
||||
mounted and Esc in that modal returns here (rationale in
|
||||
`NotificationActionRequested`). SUPPRESS is also handled in place but
|
||||
routes through its own `NotificationSuppressRequested` message, so it is
|
||||
deliberately excluded from this set.
|
||||
"""
|
||||
|
||||
|
||||
class NotificationActionRequested(Message):
|
||||
"""Posted for an action that opens a follow-up modal in place.
|
||||
|
||||
Some actions (those in `IN_PLACE_ACTIONS`, currently `ENTER_API_KEY`)
|
||||
push another modal, such as the API-key prompt, on top of the
|
||||
still-open center. Dismissing the center first would drop that stack,
|
||||
so Esc in the follow-up modal would fall through to the base screen
|
||||
instead of returning here. The app handles this message by dispatching
|
||||
the action while the center stays mounted, then reloading it with the
|
||||
refreshed registry snapshot.
|
||||
"""
|
||||
|
||||
def __init__(self, key: str, action_id: ActionId) -> None:
|
||||
"""Initialize the message.
|
||||
|
||||
Args:
|
||||
key: Registry key of the notification the action targets.
|
||||
action_id: The in-place action the user selected. Must be a
|
||||
member of `IN_PLACE_ACTIONS`.
|
||||
|
||||
Raises:
|
||||
ValueError: If `action_id` is not an in-place action, which
|
||||
would be a programmer error (the message is only meant to
|
||||
carry actions that keep the center open).
|
||||
"""
|
||||
super().__init__()
|
||||
if action_id not in IN_PLACE_ACTIONS:
|
||||
msg = f"{action_id} is not an in-place action"
|
||||
raise ValueError(msg)
|
||||
self.key = key
|
||||
self.action_id = action_id
|
||||
|
||||
|
||||
class _NotificationRow(Static):
|
||||
"""Clickable single-line row displaying a notification's title."""
|
||||
|
||||
@@ -332,6 +380,12 @@ class NotificationCenterScreen(ModalScreen[NotificationActionResult | None]):
|
||||
# in `NotificationSuppressRequested`'s class docstring.
|
||||
self.post_message(NotificationSuppressRequested(entry.key))
|
||||
return
|
||||
if action_id in IN_PLACE_ACTIONS:
|
||||
# Keep the center open so the follow-up modal (e.g. the
|
||||
# API-key prompt) stacks on top and Esc returns here.
|
||||
# Rationale is in `NotificationActionRequested`'s docstring.
|
||||
self.post_message(NotificationActionRequested(entry.key, action_id))
|
||||
return
|
||||
self.dismiss(NotificationActionResult(entry.key, action_id))
|
||||
|
||||
try:
|
||||
|
||||
@@ -15205,6 +15205,28 @@ def _missing_dep_entry(
|
||||
)
|
||||
|
||||
|
||||
def _service_dep_entry(tool: str = "tavily") -> PendingNotification:
|
||||
"""A missing-dep entry whose primary action opens the API-key prompt."""
|
||||
from deepagents_code.notifications import (
|
||||
ActionId,
|
||||
MissingDepPayload,
|
||||
NotificationAction,
|
||||
PendingNotification,
|
||||
)
|
||||
|
||||
return PendingNotification(
|
||||
key=f"dep:{tool}",
|
||||
title="Web search disabled",
|
||||
body=f"No {tool} API key is set.",
|
||||
actions=(
|
||||
NotificationAction(ActionId.ENTER_API_KEY, "Enter API key", primary=True),
|
||||
NotificationAction(ActionId.OPEN_WEBSITE, "Open website"),
|
||||
NotificationAction(ActionId.SUPPRESS, "Don't show"),
|
||||
),
|
||||
payload=MissingDepPayload(tool=tool, url=f"https://{tool}.com"),
|
||||
)
|
||||
|
||||
|
||||
def _update_entry(latest: str = "2.0.0") -> PendingNotification:
|
||||
from deepagents_code.notifications import (
|
||||
ActionId,
|
||||
@@ -15504,6 +15526,8 @@ class TestNotificationCenterIntegration:
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._push_screen_wait = AsyncMock() # ty: ignore
|
||||
messages: list[str] = []
|
||||
app.notify = lambda message, **_: messages.append(message) # ty: ignore
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await app._dispatch_notification_action(
|
||||
entry.key, ActionId.ENTER_API_KEY
|
||||
@@ -15511,9 +15535,181 @@ class TestNotificationCenterIntegration:
|
||||
await pilot.pause()
|
||||
|
||||
app._push_screen_wait.assert_not_awaited() # ty: ignore
|
||||
# Non-service tool: nothing opened, entry untouched, dev-facing log only.
|
||||
# Non-service tool: nothing opened and the entry is untouched, but the
|
||||
# user is told why (dev-facing log) plus a warning toast.
|
||||
assert app._notice_registry.get("dep:ripgrep") is entry
|
||||
assert "Unknown action_id" in caplog.text
|
||||
assert any("No API-key entry is available" in m for m in messages)
|
||||
|
||||
async def test_enter_api_key_esc_returns_to_notification_center(self) -> None:
|
||||
"""Esc in the API-key prompt returns to the center, not the base screen."""
|
||||
from deepagents_code.tui.widgets.auth import AuthPromptScreen
|
||||
from deepagents_code.tui.widgets.notification_center import (
|
||||
NotificationCenterScreen,
|
||||
)
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
app._notice_registry.add(_service_dep_entry("tavily"))
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._open_notification_center()
|
||||
await pilot.pause()
|
||||
assert isinstance(app.screen, NotificationCenterScreen)
|
||||
|
||||
await pilot.press("enter") # drill into the tavily entry
|
||||
await pilot.pause()
|
||||
# The primary action (ENTER_API_KEY) is selected; open it.
|
||||
await pilot.press("enter")
|
||||
for _ in range(6):
|
||||
await pilot.pause()
|
||||
|
||||
# The API-key prompt stacked on top of the still-open center.
|
||||
assert isinstance(app.screen, AuthPromptScreen)
|
||||
|
||||
await pilot.press("escape")
|
||||
for _ in range(6):
|
||||
await pilot.pause()
|
||||
|
||||
# Back at the notification center, not the base chat screen.
|
||||
assert isinstance(app.screen, NotificationCenterScreen)
|
||||
assert app._notice_registry.get("dep:tavily") is not None
|
||||
|
||||
async def test_enter_api_key_save_reloads_open_center(self) -> None:
|
||||
"""Saving the key removes the entry and reloads the still-open center."""
|
||||
from deepagents_code.notifications import ActionId
|
||||
from deepagents_code.tui.widgets.auth import AuthResult
|
||||
from deepagents_code.tui.widgets.notification_center import (
|
||||
NotificationActionRequested,
|
||||
NotificationCenterScreen,
|
||||
_NotificationRow,
|
||||
)
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
app._notice_registry.add(_service_dep_entry("tavily"))
|
||||
app._notice_registry.add(_missing_dep_entry("ripgrep"))
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._open_notification_center()
|
||||
await pilot.pause()
|
||||
app._push_screen_wait = AsyncMock(return_value=AuthResult.SAVED) # ty: ignore
|
||||
app.notify = lambda *_a, **_k: None # ty: ignore
|
||||
app.screen.post_message(
|
||||
NotificationActionRequested("dep:tavily", ActionId.ENTER_API_KEY)
|
||||
)
|
||||
for _ in range(6):
|
||||
await pilot.pause()
|
||||
|
||||
assert app._notice_registry.get("dep:tavily") is None
|
||||
assert isinstance(app.screen, NotificationCenterScreen)
|
||||
keys = [r.notification.key for r in app.screen.query(_NotificationRow)]
|
||||
assert keys == ["dep:ripgrep"]
|
||||
|
||||
async def test_enter_api_key_save_last_entry_dismisses_center(self) -> None:
|
||||
"""Saving the only key removes it and dismisses the emptied center."""
|
||||
from deepagents_code.notifications import ActionId
|
||||
from deepagents_code.tui.widgets.auth import AuthResult
|
||||
from deepagents_code.tui.widgets.notification_center import (
|
||||
NotificationActionRequested,
|
||||
NotificationCenterScreen,
|
||||
)
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
app._notice_registry.add(_service_dep_entry("tavily"))
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._open_notification_center()
|
||||
await pilot.pause()
|
||||
app._push_screen_wait = AsyncMock(return_value=AuthResult.SAVED) # ty: ignore
|
||||
app.notify = lambda *_a, **_k: None # ty: ignore
|
||||
app.screen.post_message(
|
||||
NotificationActionRequested("dep:tavily", ActionId.ENTER_API_KEY)
|
||||
)
|
||||
for _ in range(6):
|
||||
await pilot.pause()
|
||||
|
||||
assert app._notice_registry.get("dep:tavily") is None
|
||||
assert not isinstance(app.screen, NotificationCenterScreen)
|
||||
|
||||
async def test_enter_api_key_reload_failure_surfaces_toast(self) -> None:
|
||||
"""A reload race after saving logs and toasts instead of vanishing."""
|
||||
from textual.css.query import NoMatches
|
||||
|
||||
from deepagents_code.notifications import ActionId
|
||||
from deepagents_code.tui.widgets.auth import AuthResult
|
||||
from deepagents_code.tui.widgets.notification_center import (
|
||||
NotificationActionRequested,
|
||||
NotificationCenterScreen,
|
||||
)
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
app._notice_registry.add(_service_dep_entry("tavily"))
|
||||
app._notice_registry.add(_missing_dep_entry("ripgrep"))
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._open_notification_center()
|
||||
await pilot.pause()
|
||||
app._push_screen_wait = AsyncMock(return_value=AuthResult.SAVED) # ty: ignore
|
||||
toasts: list[tuple[str, object]] = []
|
||||
app.notify = lambda message, **kw: toasts.append( # ty: ignore
|
||||
(message, kw.get("severity"))
|
||||
)
|
||||
|
||||
assert isinstance(app.screen, NotificationCenterScreen)
|
||||
race = NoMatches("scroll detached")
|
||||
app.screen.reload = AsyncMock(side_effect=race) # ty: ignore
|
||||
app.screen.post_message(
|
||||
NotificationActionRequested("dep:tavily", ActionId.ENTER_API_KEY)
|
||||
)
|
||||
for _ in range(6):
|
||||
await pilot.pause()
|
||||
|
||||
# The save side effect still applied, and the failed refresh
|
||||
# surfaced as a warning toast rather than vanishing in the worker.
|
||||
assert app._notice_registry.get("dep:tavily") is None
|
||||
assert any(
|
||||
sev == "warning" and "Could not refresh notifications" in msg
|
||||
for msg, sev in toasts
|
||||
)
|
||||
|
||||
async def test_refresh_open_center_noops_when_center_not_top(self) -> None:
|
||||
"""The guard skips reload when the center is no longer the active screen."""
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Static
|
||||
|
||||
from deepagents_code.tui.widgets.notification_center import (
|
||||
NotificationCenterScreen,
|
||||
)
|
||||
|
||||
class _Blocker(ModalScreen[None]):
|
||||
def compose(self): # noqa: ANN202
|
||||
yield Static("blocker")
|
||||
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
app._notice_registry.add(_service_dep_entry("tavily"))
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._open_notification_center()
|
||||
await pilot.pause()
|
||||
center = app.screen
|
||||
assert isinstance(center, NotificationCenterScreen)
|
||||
|
||||
center.reload = AsyncMock() # ty: ignore
|
||||
|
||||
# Push another modal so the center is no longer on top, then run
|
||||
# the shared refresh directly: it must see a non-center screen
|
||||
# and skip the reload entirely.
|
||||
await app.push_screen(_Blocker())
|
||||
await pilot.pause()
|
||||
assert not isinstance(app.screen, NotificationCenterScreen)
|
||||
|
||||
await app._refresh_open_center()
|
||||
|
||||
center.reload.assert_not_awaited() # ty: ignore
|
||||
|
||||
async def test_suppress_message_reloads_center_in_place(self) -> None:
|
||||
"""Posting NotificationSuppressRequested refreshes the open center."""
|
||||
|
||||
@@ -13,6 +13,7 @@ from deepagents_code.notifications import (
|
||||
UpdateAvailablePayload,
|
||||
)
|
||||
from deepagents_code.tui.widgets.notification_center import (
|
||||
NotificationActionRequested,
|
||||
NotificationActionResult,
|
||||
NotificationCenterScreen,
|
||||
NotificationSuppressRequested,
|
||||
@@ -39,6 +40,20 @@ def _dep_entry(key: str = "dep:ripgrep") -> PendingNotification:
|
||||
)
|
||||
|
||||
|
||||
def _service_entry(key: str = "dep:tavily") -> PendingNotification:
|
||||
return PendingNotification(
|
||||
key=key,
|
||||
title="Web search disabled",
|
||||
body="No Tavily API key is set.",
|
||||
actions=(
|
||||
NotificationAction(ActionId.ENTER_API_KEY, "Enter API key", primary=True),
|
||||
NotificationAction(ActionId.OPEN_WEBSITE, "Open tavily.com"),
|
||||
NotificationAction(ActionId.SUPPRESS, "Don't show notification again"),
|
||||
),
|
||||
payload=MissingDepPayload(tool="tavily", url="https://tavily.com"),
|
||||
)
|
||||
|
||||
|
||||
def _update_entry() -> PendingNotification:
|
||||
return PendingNotification(
|
||||
key="update:available",
|
||||
@@ -157,6 +172,40 @@ class TestNotificationCenterScreen:
|
||||
|
||||
assert [m.key for m in messages] == ["dep:ripgrep"]
|
||||
|
||||
async def test_in_place_action_keeps_center_open_and_posts_message(self) -> None:
|
||||
"""ENTER_API_KEY posts a request message and leaves the center up."""
|
||||
messages: list[NotificationActionRequested] = []
|
||||
|
||||
class _App(App):
|
||||
def on_notification_action_requested(
|
||||
self, message: NotificationActionRequested
|
||||
) -> None:
|
||||
messages.append(message)
|
||||
|
||||
app = _App()
|
||||
screen = NotificationCenterScreen([_service_entry()])
|
||||
async with app.run_test() as pilot:
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
await pilot.press("enter") # drill into the tavily entry
|
||||
await pilot.pause()
|
||||
assert isinstance(app.screen, NotificationDetailScreen)
|
||||
# ENTER_API_KEY is the primary (first) action, selected on mount.
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
# Center is still the active screen; no dismissal fired.
|
||||
assert isinstance(app.screen, NotificationCenterScreen)
|
||||
|
||||
assert [(m.key, m.action_id) for m in messages] == [
|
||||
("dep:tavily", ActionId.ENTER_API_KEY)
|
||||
]
|
||||
|
||||
def test_action_requested_rejects_non_in_place_action(self) -> None:
|
||||
"""Constructing the message with a terminal action fails fast."""
|
||||
with pytest.raises(ValueError, match="not an in-place action"):
|
||||
NotificationActionRequested("dep:ripgrep", ActionId.INSTALL)
|
||||
|
||||
async def test_reload_rebuilds_rows_and_preserves_selection_by_key(self) -> None:
|
||||
"""`reload` re-renders the list and keeps the cursor on the same entry."""
|
||||
dep = _dep_entry()
|
||||
|
||||
Reference in New Issue
Block a user