Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 73 additions & 8 deletions libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ class _ConfigWriteResult:
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
Expand Down Expand Up @@ -13782,24 +13783,78 @@ async def on_notification_suppress_requested(
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,
)
Expand Down Expand Up @@ -14034,7 +14089,17 @@ async def _enter_service_api_key(
# 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
Expand Down
64 changes: 59 additions & 5 deletions libs/code/deepagents_code/tui/widgets/notification_center.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
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
Expand Down Expand Up @@ -88,6 +92,50 @@ def __init__(self, key: str) -> None:
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."""

Expand Down Expand Up @@ -332,6 +380,12 @@ def handle_detail(action_id: ActionId | None) -> 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:
Expand Down
Loading
Loading