feat(code): unify notification center and settings (#5698)

`Ctrl+N` and `/notifications` now open one notification hub where
warning settings expand inline under a "Notification settings"
disclosure row, alongside pending notices — including when the inbox is
empty.

---

This removes the separate warning-preferences modal. Toggling the
settings row mounts the warning checkboxes directly inside the hub, so
preferences never leave the notices list and closing them just collapses
the section. Existing notification detail, suppression, and in-place
action flows are unchanged.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-08-21 15:22:44 -04:00
committed by GitHub
parent 074a5019e1
commit 9767f420c6
8 changed files with 911 additions and 325 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ aliases, descriptions, visibility, or hidden-command metadata.
| `/manual` | | Switch to Manual approval mode |
| `/mcp` | | Manage MCP servers and authentication |
| `/model` | | Switch models or edit model settings |
| `/notifications` | | Configure warning notifications |
| `/notifications` | | Review notifications and configure warning settings |
| `/offload` | `/compact` | Summarize and offload older messages to free context |
| `/plugins` | | Manage plugins |
| `/quit` | `/q` | Exit app |
+24 -51
View File
@@ -9453,9 +9453,9 @@ class DeepAgentsApp(App):
Deliberately synchronous. Callers warn *before* awaiting anything, so
that a cancellation or a raise downstream can never land between "YOLO
is live" and "the user was told". Offloading the small config read to
a thread (as `_show_notification_settings` does) would reopen exactly
that window. An unreadable or malformed config fails open: when
suppression cannot be determined, the warning still fires.
a thread (as the notification hub's settings load does) would reopen
exactly that window. An unreadable or malformed config fails open:
when suppression cannot be determined, the warning still fires.
Args:
timeout: Seconds the toast stays on screen.
@@ -15377,7 +15377,7 @@ class DeepAgentsApp(App):
elif cmd == "/theme":
await self._show_theme_selector()
elif cmd == "/notifications":
await self._show_notification_settings()
self._open_notification_center()
elif cmd == "/effort" or cmd.startswith("/effort "):
await self._handle_effort_command(command)
elif cmd == "/model" or cmd.startswith("/model "):
@@ -20753,9 +20753,6 @@ class DeepAgentsApp(App):
from deepagents_code.tui.widgets.agent_selector import AgentSelectorScreen
from deepagents_code.tui.widgets.auth import AuthManagerScreen, AuthPromptScreen
from deepagents_code.tui.widgets.mcp_viewer import MCPViewerScreen
from deepagents_code.tui.widgets.notification_settings import (
NotificationSettingsScreen,
)
from deepagents_code.tui.widgets.theme_selector import ThemeSelectorScreen
from deepagents_code.tui.widgets.thread_selector import ThreadSelectorScreen
@@ -20768,8 +20765,8 @@ class DeepAgentsApp(App):
):
self.screen.action_cursor_up()
return
if isinstance(self.screen, (AuthPromptScreen, NotificationSettingsScreen)):
# These modals hold multiple focusable inputs; reuse shift+tab to
if isinstance(self.screen, AuthPromptScreen):
# This modal holds multiple focusable inputs; reuse shift+tab to
# step focus backward (the Screen's own app.focus_previous binding
# never fires because this priority binding consumes the key first).
self.screen.focus_previous()
@@ -20780,6 +20777,21 @@ class DeepAgentsApp(App):
if isinstance(self.screen, PluginManagerScreen):
self.screen.action_previous_tab()
return
from deepagents_code.tui.widgets.notification_center import (
NotificationCenterScreen,
)
if (
isinstance(self.screen, NotificationCenterScreen)
and self.screen.settings_expanded
and self.screen.settings_checkbox_focused
):
# Expanded settings hand key focus to real checkboxes, so
# shift+tab must step focus backward between them rather than
# move the row cursor via `_SupportsReverseNav` below (which
# would leave focus stranded on the first checkbox).
self.screen.action_focus_previous()
return
if isinstance(self.screen, _SupportsReverseNav):
# Membership is by `action_move_up` presence, not an enumerated
# list: this catches the cursor-style modals (update-available,
@@ -23033,36 +23045,6 @@ class DeepAgentsApp(App):
except Exception:
logger.exception("Failed to restore pending goal review")
async def _show_notification_settings(self) -> None:
"""Show notification settings modal."""
from deepagents_code.model_config import is_warning_suppressed
from deepagents_code.tui.widgets.notification_settings import (
WARNING_TOGGLES,
NotificationSettingsScreen,
)
suppressed: set[str] = set()
try:
for key, _ in WARNING_TOGGLES:
if await asyncio.to_thread(is_warning_suppressed, key):
suppressed.add(key)
except Exception:
logger.warning("Failed to read notification settings", exc_info=True)
suppressed = set()
self.notify(
"Could not read notification preferences. Showing defaults.",
severity="warning",
timeout=6,
markup=False,
)
def handle_result(_result: None) -> None:
if self._chat_input:
self._chat_input.focus_input()
screen = NotificationSettingsScreen(suppressed=suppressed)
self.push_screen(screen, handle_result)
def _notify_actionable(
self,
notification: PendingNotification,
@@ -23416,7 +23398,7 @@ class DeepAgentsApp(App):
]
def _open_notification_center(self) -> None:
"""Push the notification center modal, or toast when empty."""
"""Push the shared notification center and settings hub."""
from deepagents_code.tui.widgets.notification_center import (
NotificationActionResult,
NotificationCenterScreen,
@@ -23435,15 +23417,6 @@ class DeepAgentsApp(App):
return
pending = self._notice_registry.list_all()
if not pending:
self.notify(
"No pending notifications.",
severity="information",
timeout=2,
markup=False,
)
return
self._dismiss_registered_toasts()
def handle_result(result: NotificationActionResult | None) -> None:
@@ -23521,8 +23494,8 @@ class DeepAgentsApp(App):
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.
the center is reloaded so any handled entry drops out while settings
remain reachable.
"""
await self._dispatch_notification_action(key, action_id)
await self._refresh_open_center()
@@ -187,7 +187,7 @@ COMMANDS: tuple[SlashCommand, ...] = (
),
SlashCommand(
name="/notifications",
description="Configure warning notifications",
description="Review notifications and configure warning settings",
bypass_tier=BypassTier.IMMEDIATE_UI,
hidden_keywords="warnings alerts suppress startup yolo",
),
@@ -1,7 +1,8 @@
"""Notification center modal for pending actionable notices.
"""Notification hub for pending notices and warning preferences.
Surfaces a list of `PendingNotification` entries as single-line rows.
Selecting a row drills into a dedicated detail modal
Surfaces `PendingNotification` entries as single-line rows plus an expandable
settings section, including when no notices are pending. Selecting a notice
drills into a dedicated detail modal
(`UpdateAvailableScreen` for update entries, `NotificationDetailScreen`
otherwise) stacked on top of the center. When the detail modal
dismisses with a terminal action (one that closes the center) the
@@ -12,11 +13,13 @@ SUPPRESS via
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.
cancels, the center stays open on the list. Warning preferences expand
inline under "Notification settings" so toggles never leave the hub.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from importlib import import_module
@@ -27,7 +30,7 @@ from textual.containers import Vertical, VerticalScroll
from textual.content import Content
from textual.message import Message
from textual.screen import ModalScreen
from textual.widgets import Static
from textual.widgets import Checkbox, Static
if TYPE_CHECKING:
from textual.app import ComposeResult
@@ -38,6 +41,7 @@ if TYPE_CHECKING:
from deepagents_code import theme
from deepagents_code.config import get_glyphs, is_ascii_mode
from deepagents_code.notifications import ActionId, UpdateAvailablePayload
from deepagents_code.tui.widgets.notification_settings import WARNING_TOGGLES
logger = logging.getLogger(__name__)
@@ -93,6 +97,15 @@ class NotificationSuppressRequested(Message):
self.key = key
class NotificationSettingsRequested(Message):
"""Posted when the user toggles the settings disclosure row.
The app responds by refreshing the open center's settings state from
config; the center also handles the message itself so toggles stay
responsive in isolation (e.g. widget tests on a bare host app).
"""
IN_PLACE_ACTIONS: frozenset[ActionId] = frozenset({ActionId.ENTER_API_KEY})
"""Actions handled in place without dismissing the center.
@@ -189,18 +202,135 @@ class _NotificationRow(Static):
self.post_message(NotificationRowClicked(self._notification.key))
class NotificationCenterScreen(ModalScreen[NotificationActionResult | None]):
"""Modal listing pending notifications with drill-in details.
class _NotificationSettingsRow(Static):
"""Selectable disclosure row for the inline warning-preferences section."""
Each `PendingNotification` is a single row. Up/Down (or j/k)
moves the cursor; Enter or click pushes a detail modal for the
highlighted entry. The detail modal carries the action list and
dismisses with an `ActionId` or `None`. Esc on the center returns
def __init__(self, index: int, *, expanded: bool = False) -> None:
"""Initialize the settings row.
Args:
index: Position in the center's selectable rows.
expanded: Whether the settings section is currently expanded.
"""
super().__init__(id="nc-settings", classes="nc-row")
self._index = index
self._expanded = expanded
self._is_selected = False
self.update(self._render())
@property
def index(self) -> int:
"""Row index in the center's selectable rows."""
return self._index
def set_expanded(self, expanded: bool) -> None:
"""Point the disclosure arrow at the section's new state.
Args:
expanded: Whether the settings section is now expanded.
"""
if self._expanded == expanded:
return
self._expanded = expanded
self.update(self._render())
def set_selected(self, selected: bool) -> None:
"""Toggle selection styling.
Args:
selected: Whether this row is currently under the cursor.
"""
if self._is_selected == selected:
return
self._is_selected = selected
self.set_class(selected, "-selected")
self.update(self._render())
def _render(self) -> Content:
glyphs = get_glyphs()
cursor = glyphs.cursor if self._is_selected else " "
disclosure = (
glyphs.disclosure_expanded
if self._expanded
else glyphs.disclosure_collapsed
)
return Content.assemble(
f"{cursor} ",
("Notification settings", "bold"),
f" {disclosure}",
)
def on_click(self, event: Click) -> None:
"""Request notification settings when clicked."""
event.stop()
self.post_message(NotificationSettingsRequested())
class _NotificationSettingsGroup(Vertical):
"""Container for the expanded warning checkboxes.
Owns up/down/tab navigation so the keys cycle between this section's
checkboxes instead of falling through to the center's row cursor or
dropping focus. The center's priority cursor bindings are disabled while
a checkbox is focused (see `NotificationCenterScreen.check_action`), so
these run unshadowed.
"""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "focus_previous", "Previous", show=False),
Binding("k", "focus_previous", "Previous", show=False),
Binding("down", "focus_next", "Next", show=False),
Binding("j", "focus_next", "Next", show=False),
Binding("tab", "focus_next", "Next", show=False),
Binding("shift+tab", "focus_previous", "Previous", show=False),
]
def _checkboxes(self) -> list[Checkbox]:
"""The mounted warning checkboxes in order.
Returns:
The `Checkbox` widgets in this group, in display order.
"""
return list(self.query(Checkbox))
def _cycle(self, step: int) -> None:
"""Move focus *step* checkboxes forward/back, wrapping at the ends.
Args:
step: `1` to advance, `-1` to go back.
"""
checkboxes = self._checkboxes()
if not checkboxes:
return
focused = self.app.focused
if isinstance(focused, Checkbox) and focused in checkboxes:
index = (checkboxes.index(focused) + step) % len(checkboxes)
else:
index = 0 if step > 0 else len(checkboxes) - 1
checkboxes[index].focus()
def action_focus_next(self) -> None:
"""Move focus to the next checkbox (wraps)."""
self._cycle(1)
def action_focus_previous(self) -> None:
"""Move focus to the previous checkbox (wraps)."""
self._cycle(-1)
class NotificationCenterScreen(ModalScreen[NotificationActionResult | None]):
"""Shared hub for pending notifications and warning preferences.
Each `PendingNotification` is a single row followed by a settings
disclosure row. Up/Down (or j/k) moves the cursor; Enter or click drills
into a notification or toggles the inline settings section. Expanded
settings hand key focus to their checkboxes (Space/Enter toggle); Esc
there collapses back to the row cursor. Esc on the row cursor returns
`None`.
"""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "cancel", "Close", show=False),
Binding("escape", "cancel", "Close", show=False, priority=True),
Binding("up", "move_up", "Up", show=False, priority=True),
Binding("k", "move_up", "Up", show=False, priority=True),
Binding("down", "move_down", "Down", show=False, priority=True),
@@ -236,6 +366,24 @@ class NotificationCenterScreen(ModalScreen[NotificationActionResult | None]):
max-height: 24;
}
NotificationCenterScreen .nc-section {
height: 1;
color: $text-muted;
text-style: bold;
margin-top: 1;
}
NotificationCenterScreen .nc-section-first {
margin-top: 0;
}
NotificationCenterScreen .nc-empty {
height: 1;
padding: 0 1;
color: $text-muted;
text-style: italic;
}
NotificationCenterScreen .nc-row {
height: 1;
padding: 0 1;
@@ -250,6 +398,18 @@ class NotificationCenterScreen(ModalScreen[NotificationActionResult | None]):
background: $surface-lighten-1;
}
NotificationCenterScreen #nc-settings-group {
height: auto;
}
NotificationCenterScreen #nc-settings-group Checkbox {
margin: 0;
border: none;
&:focus {
border: none;
}
}
NotificationCenterScreen .nc-help {
height: 1;
color: $text-muted;
@@ -259,39 +419,83 @@ class NotificationCenterScreen(ModalScreen[NotificationActionResult | None]):
}
"""
def __init__(self, notifications: list[PendingNotification]) -> None:
def __init__(
self,
notifications: list[PendingNotification],
suppressed: set[str] | None = None,
) -> None:
"""Initialize the screen with a snapshot of pending notifications.
Args:
notifications: Entries to render. Order is preserved.
suppressed: Currently suppressed warning keys, used to render
checkbox state when the settings section expands. `None`
means the app has not supplied settings state; the screen
loads it from config the first time the section expands.
"""
super().__init__()
self._notifications = notifications
self._suppressed = suppressed
self._selected: int = 0
self._rows: list[_NotificationRow] = []
self._rows: list[_NotificationRow | _NotificationSettingsRow] = []
self._drilling = False
self._settings_expanded = False
self._settings_loading = False
self._settings_transitioning = False
@property
def settings_expanded(self) -> bool:
"""Whether the inline warning preferences are currently expanded."""
return self._settings_expanded
@property
def settings_checkbox_focused(self) -> bool:
"""Whether key focus is on one of the settings checkboxes.
Used by `DeepAgentsApp.action_toggle_auto_approve` to route
shift+tab into reverse checkbox traversal instead of the row cursor.
"""
return self._settings_has_focus()
def _build_list(self) -> list[Static]:
"""Build list widgets and refresh the selectable row index.
Returns:
Static widgets for the notification hub's scrollable list.
"""
widgets: list[Static] = [
Static("Pending", classes="nc-section nc-section-first")
]
rows: list[_NotificationRow | _NotificationSettingsRow] = []
if self._notifications:
for idx, notification in enumerate(self._notifications):
row = _NotificationRow(notification, idx)
rows.append(row)
widgets.append(row)
else:
widgets.append(Static("No pending notifications.", classes="nc-empty"))
settings = _NotificationSettingsRow(len(rows), expanded=self._settings_expanded)
rows.append(settings)
widgets.extend(
[
Static("Preferences", classes="nc-section"),
settings,
]
)
self._rows = rows
return widgets
def compose(self) -> ComposeResult:
"""Compose the modal layout.
Yields:
The title widget, one row per pending notification, and a
help footer.
Pending notifications, the settings row, and navigation help.
"""
glyphs = get_glyphs()
with Vertical():
yield Static("Notifications", classes="nc-title")
with VerticalScroll():
for idx, notif in enumerate(self._notifications):
row = _NotificationRow(notif, idx)
self._rows.append(row)
yield row
help_text = (
f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate "
f"{glyphs.bullet} Enter open "
f"{glyphs.bullet} Esc close"
)
yield Static(help_text, classes="nc-help")
yield from self._build_list()
yield Static(self._help_text(), classes="nc-help")
def on_mount(self) -> None:
"""Apply ASCII borders and highlight the first row."""
@@ -303,6 +507,87 @@ class NotificationCenterScreen(ModalScreen[NotificationActionResult | None]):
self._rows[0].set_selected(selected=True)
self._rows[0].scroll_visible()
def _settings_has_focus(self) -> bool:
"""Whether key focus is inside the expanded settings checkboxes.
Returns:
`True` when the focused widget is one of the settings checkboxes.
"""
return isinstance(self.app.focused, Checkbox)
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
"""Stand the screen's key bindings down while a checkbox has focus.
The screen-level `enter`/`up`/`down`/`tab` bindings are priority, so
without this gate they consume the key before the focused `Checkbox`
can use it: Enter/Space must toggle (Textual binds them on
`ToggleButton`), and up/down/tab must step between checkboxes.
Returning `False` disables the screen binding for that dispatch so
the key reaches the checkbox / focus system.
Args:
action: The action name being dispatched.
parameters: The action's parameters.
Returns:
`False` to disable the binding, otherwise the superclass verdict.
"""
if not (self._settings_expanded and self._settings_has_focus()):
return super().check_action(action, parameters)
# While a settings checkbox is focused it owns the keys: Enter/Space
# toggle (Textual's `ToggleButton` binding), and up/down/tab step
# between checkboxes via normal focus movement. The screen's priority
# cursor bindings would otherwise swallow all of these.
if action in {"activate", "move_up", "move_down"}:
return False
return super().check_action(action, parameters)
def action_activate(self) -> None:
"""Drill into the highlighted notification or toggle settings."""
if self._settings_expanded and self._settings_has_focus():
# Focused checkboxes own Enter/Space for toggling.
return
if not self._rows:
return
row = self._rows[self._selected]
if isinstance(row, _NotificationSettingsRow):
self.post_message(NotificationSettingsRequested())
return
self._drill_into(row.notification)
def action_cancel(self) -> None:
"""Collapse expanded settings, else close without firing an action."""
if self._settings_expanded:
self.run_worker(self._toggle_settings(), group="nc-settings")
return
self.dismiss(None)
def _help_text(self) -> str:
glyphs = get_glyphs()
if self._settings_expanded:
return (
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab navigate "
f"{glyphs.bullet} Space/Enter toggle "
f"{glyphs.bullet} Esc collapse"
)
return (
f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate "
f"{glyphs.bullet} Enter open "
f"{glyphs.bullet} Esc close"
)
def _refresh_help(self) -> None:
self.query_one(".nc-help", Static).update(self._help_text())
def _first_checkbox(self) -> Checkbox | None:
"""The first settings checkbox, if the section is mounted.
Returns:
The first `Checkbox` in the settings group, or `None` when the
section is collapsed.
"""
return next(iter(self.query(Checkbox)), None)
def _set_selected(self, new_index: int) -> None:
"""Move the selection cursor to *new_index*.
@@ -331,16 +616,202 @@ class NotificationCenterScreen(ModalScreen[NotificationActionResult | None]):
return
self._set_selected((self._selected + 1) % len(self._rows))
def action_activate(self) -> None:
"""Drill into the highlighted notification."""
if not self._rows:
self.dismiss(None)
return
self._drill_into(self._notifications[self._selected])
def action_focus_previous(self) -> None:
"""Step checkbox focus backward for the app's shift+tab router.
def action_cancel(self) -> None:
"""Close without firing any action."""
self.dismiss(None)
The app-level priority `shift+tab -> toggle_auto_approve` binding
wins dispatch, and `action_toggle_auto_approve` routes cursor-style
modals to `action_move_up` via `_SupportsReverseNav`. With settings
expanded that would only move the row cursor and strand focus on the
first checkbox, so the app branches here instead (see
`DeepAgentsApp.action_toggle_auto_approve`).
"""
group = next(iter(self.query(_NotificationSettingsGroup)), None)
if group is not None:
group.action_focus_previous()
def on_notification_settings_requested(
self, message: NotificationSettingsRequested
) -> None:
"""Toggle the settings section from the disclosure row.
Handles the message locally so the section stays responsive when
the app has no handler of its own (e.g. widget tests on a bare
host app).
"""
message.stop()
if self._settings_loading:
return
self.run_worker(self._toggle_settings(), group="nc-settings")
async def _toggle_settings(self) -> None:
"""Expand or collapse the inline warning-preferences section.
Serialized against re-entry: the mount/unmount awaits yield, so a
rapid second toggle (e.g. keyboard-repeat Esc) would otherwise start
a second `nc-settings` worker — which is not exclusive by default —
and the expand branch would mount a duplicate `#nc-settings-group`
while the first worker's removal is still in flight, raising
`DuplicateIds` and killing the app via `WorkerFailed`.
"""
if self._settings_transitioning:
return
self._settings_transitioning = True
try:
if self._settings_expanded:
await self._collapse_settings()
else:
await self._expand_settings()
finally:
self._settings_transitioning = False
def _settings_row(self) -> _NotificationSettingsRow:
"""The settings disclosure row (always the last selectable row).
Returns:
The `_NotificationSettingsRow` at the bottom of the row list.
"""
row = self._rows[-1]
assert isinstance(row, _NotificationSettingsRow) # noqa: S101
return row
async def _expand_settings(self) -> None:
"""Mount the warning checkboxes under the settings row.
Loads suppressed keys from config on first expand when the app did
not supply them at construction. A `reload()` that rebuilt the row
list while the section was open calls this to remount the checkboxes,
so an already-`_settings_expanded` state only skips a *mounted*
section.
"""
if self._settings_expanded and self.query("#nc-settings-group"):
return
if self._settings_loading:
return
# Drop a stale group that a `reload()` rebuild or an interrupted
# collapse left mounted, so the mount below cannot raise
# `DuplicateIds` on `#nc-settings-group`.
for stale in self.query("#nc-settings-group"):
await stale.remove()
if self._suppressed is None:
self._settings_loading = True
try:
self._suppressed = await self._load_suppressed()
finally:
self._settings_loading = False
# A rapid Esc while the config read was in flight already
# dismissed the screen; expanding a dead screen would raise.
if not self.is_mounted:
return
self._settings_expanded = True
self._settings_row().set_expanded(True)
scroll = self.query_one(VerticalScroll)
group = _NotificationSettingsGroup(id="nc-settings-group")
await scroll.mount(group)
checkboxes = [
Checkbox(
label,
value=key not in self._suppressed,
id=f"ns-{key}",
)
for key, label in WARNING_TOGGLES
]
await group.mount(*checkboxes)
self._set_selected(len(self._rows) - 1)
if checkboxes:
checkboxes[0].focus()
self._settings_row().scroll_visible()
self._refresh_help()
async def _collapse_settings(self) -> None:
"""Unmount the warning checkboxes and return focus to the row."""
if not self._settings_expanded:
return
self._settings_expanded = False
for group in self.query("#nc-settings-group"):
await group.remove()
self._settings_row().set_expanded(False)
self.focus()
self._rows[self._selected].scroll_visible()
self._refresh_help()
async def _load_suppressed(self) -> set[str]:
"""Read suppressed warning keys from config off the event loop.
An unreadable or malformed config falls back to empty (all warnings
shown) with a warning toast, matching the old settings modal.
Returns:
The set of suppressed warning keys from `config.toml`.
"""
from deepagents_code.model_config import is_warning_suppressed
suppressed: set[str] = set()
try:
for key, _ in WARNING_TOGGLES:
if await asyncio.to_thread(is_warning_suppressed, key):
suppressed.add(key)
except Exception:
logger.warning("Failed to read notification settings", exc_info=True)
self.app.notify(
"Could not read notification preferences. Showing defaults.",
severity="warning",
timeout=6,
markup=False,
)
return set()
return suppressed
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
"""Persist warning suppression toggle to config.toml on change."""
event.stop()
checkbox_id = event.checkbox.id
if not checkbox_id or not checkbox_id.startswith("ns-"):
return
key = checkbox_id.removeprefix("ns-")
enabled = event.value
async def _persist() -> None:
from deepagents_code.model_config import (
suppress_warning,
unsuppress_warning,
)
try:
if enabled:
ok = await asyncio.to_thread(unsuppress_warning, key)
else:
ok = await asyncio.to_thread(suppress_warning, key)
except Exception:
logger.warning(
"Failed to persist notification setting for %r",
key,
exc_info=True,
)
ok = False
if not ok:
# Roll the box back to what is actually on disk. Leaving it
# showing the requested state would claim a warning is armed
# when it is still suppressed — the unsafe direction to lie in.
# `prevent` keeps the rollback from re-entering this handler.
with event.checkbox.prevent(Checkbox.Changed):
event.checkbox.value = not enabled
self.app.notify(
"Could not save notification preference. "
"Check file permissions for ~/.deepagents/config.toml.",
severity="warning",
timeout=6,
markup=False,
)
if self._suppressed is not None:
if event.checkbox.value:
self._suppressed.discard(key)
else:
self._suppressed.add(key)
self.call_later(_persist)
def on_notification_row_clicked(self, message: NotificationRowClicked) -> None:
"""Handle a mouse click on a notification row."""
@@ -405,37 +876,55 @@ class NotificationCenterScreen(ModalScreen[NotificationActionResult | None]):
raise
async def reload(self, notifications: list[PendingNotification]) -> None:
"""Rebuild the row list from a refreshed snapshot.
"""Rebuild the hub from a refreshed notification snapshot.
Preserves cursor position by key when possible; falls back to
clamping the previous index into the new bounds. Dismisses the
screen with `None` when the list is empty.
Preserves a notification selection by key and keeps the settings row
selected across refreshes. When a selected notification disappears,
the cursor clamps to the remaining notification rows before falling
back to settings. An expanded settings section is remounted so the
refresh never silently collapses it.
Args:
notifications: Current pending entries to display.
"""
if not notifications:
self.dismiss(None)
return
prev_key: str | None = None
if self._notifications and 0 <= self._selected < len(self._notifications):
prev_key = self._notifications[self._selected].key
new_selected = next(
(i for i, n in enumerate(notifications) if n.key == prev_key),
min(self._selected, len(notifications) - 1) if prev_key else 0,
)
settings_selected = False
if self._rows and 0 <= self._selected < len(self._rows):
selected_row = self._rows[self._selected]
if isinstance(selected_row, _NotificationSettingsRow):
settings_selected = True
else:
prev_key = selected_row.notification.key
was_expanded = self._settings_expanded
self._notifications = notifications
scroll = self.query_one(VerticalScroll)
await scroll.remove_children()
new_rows = [
_NotificationRow(notif, idx) for idx, notif in enumerate(notifications)
]
await scroll.mount(*new_rows)
self._rows = new_rows
self._notifications = notifications
await scroll.mount(*self._build_list())
if settings_selected:
new_selected = len(self._rows) - 1
elif prev_key is not None:
new_selected = next(
(
i
for i, notification in enumerate(notifications)
if notification.key == prev_key
),
min(self._selected, len(notifications) - 1)
if notifications
else len(self._rows) - 1,
)
else:
new_selected = 0
self._selected = new_selected
new_rows[new_selected].set_selected(selected=True)
new_rows[new_selected].scroll_visible()
self._rows[new_selected].set_selected(selected=True)
self._rows[new_selected].scroll_visible()
if was_expanded:
# The rebuild above dropped the settings group with the old rows;
# remount the checkboxes so the section stays open.
await self._expand_settings()
@staticmethod
def _detail_screen_for(
@@ -1,25 +1,9 @@
"""Notification settings screen for `/notifications` command."""
"""Warning-toggle definitions for the notification hub's settings section."""
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING, ClassVar
from textual.binding import Binding, BindingType
from textual.containers import VerticalGroup
from textual.screen import ModalScreen
from textual.widgets import Checkbox, Static
if TYPE_CHECKING:
from textual.app import ComposeResult
from deepagents_code import theme
from deepagents_code.approval_mode import YOLO_WARNING_KEY
from deepagents_code.cold_cache import COLD_CACHE_WARNING_KEY
from deepagents_code.config import get_glyphs, is_ascii_mode
logger = logging.getLogger(__name__)
# Warning keys and their user-facing labels.
# Checked = warning is shown (not suppressed). Unchecked = suppressed.
@@ -29,151 +13,3 @@ WARNING_TOGGLES: list[tuple[str, str]] = [
("tavily", "Warn when TAVILY_API_KEY is not set (web search)"),
(YOLO_WARNING_KEY, "Warn when YOLO mode is active (no approval review)"),
]
class NotificationSettingsScreen(ModalScreen[None]):
"""Modal dialog for managing warning preferences.
Each checkbox maps to a key in `[warnings].suppress` in
`~/.deepagents/config.toml`. Toggling a checkbox immediately
persists the change.
"""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "cancel", "Close", show=False),
Binding("up", "app.focus_previous", "Previous", show=False, priority=True),
Binding("down", "app.focus_next", "Next", show=False, priority=True),
Binding("tab", "app.focus_next", "Next", show=False, priority=True),
Binding(
"shift+tab",
"app.focus_previous",
"Previous",
show=False,
priority=True,
),
]
CSS = """
NotificationSettingsScreen {
align: center middle;
}
NotificationSettingsScreen > VerticalGroup {
width: 65;
max-width: 90%;
height: auto;
max-height: 80%;
background: $surface;
border: solid $primary;
padding: 1 2;
}
NotificationSettingsScreen .ns-title {
text-style: bold;
color: $primary;
text-align: center;
margin-bottom: 1;
}
NotificationSettingsScreen .ns-help {
height: 1;
color: $text-muted;
text-style: italic;
margin-top: 1;
text-align: center;
}
NotificationSettingsScreen Checkbox {
margin: 0;
border: none;
&:focus {
border: none;
}
}
"""
def __init__(self, suppressed: set[str]) -> None:
"""Initialize the notification settings screen.
Args:
suppressed: Set of currently suppressed warning keys.
"""
super().__init__()
self._suppressed = suppressed
def compose(self) -> ComposeResult:
"""Compose the screen layout.
Yields:
Widgets for the notification settings UI.
"""
glyphs = get_glyphs()
with VerticalGroup():
yield Static("Notification Settings", classes="ns-title")
for key, label in WARNING_TOGGLES:
yield Checkbox(
label,
value=key not in self._suppressed,
id=f"ns-{key}",
)
help_text = (
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab navigate"
f" {glyphs.bullet} Space/Enter toggle"
f" {glyphs.bullet} Esc close"
)
yield Static(help_text, classes="ns-help")
def on_mount(self) -> None:
"""Apply ASCII border if needed."""
if is_ascii_mode():
container = self.query_one(VerticalGroup)
colors = theme.get_theme_colors(self)
container.styles.border = ("ascii", colors.success)
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
"""Persist warning suppression toggle to config.toml on change."""
event.stop()
checkbox_id = event.checkbox.id
if not checkbox_id or not checkbox_id.startswith("ns-"):
return
key = checkbox_id.removeprefix("ns-")
enabled = event.value
async def _persist() -> None:
from deepagents_code.model_config import (
suppress_warning,
unsuppress_warning,
)
try:
if enabled:
ok = await asyncio.to_thread(unsuppress_warning, key)
else:
ok = await asyncio.to_thread(suppress_warning, key)
except Exception:
logger.warning(
"Failed to persist notification setting for %r",
key,
exc_info=True,
)
ok = False
if not ok:
# Roll the box back to what is actually on disk. Leaving it
# showing the requested state would claim a warning is armed
# when it is still suppressed — the unsafe direction to lie in.
# `prevent` keeps the rollback from re-entering this handler.
with event.checkbox.prevent(Checkbox.Changed):
event.checkbox.value = not enabled
self.app.notify(
"Could not save notification preference. "
"Check file permissions for ~/.deepagents/config.toml.",
severity="warning",
timeout=6,
markup=False,
)
self.call_later(_persist)
def action_cancel(self) -> None:
"""Close the screen."""
self.dismiss(None)
+102 -21
View File
@@ -25913,29 +25913,68 @@ class TestNotificationCenterIntegration:
):
yield
async def test_ctrl_n_with_empty_registry_emits_toast(self) -> None:
"""ctrl+n with nothing pending notifies and doesn't push a modal."""
async def test_ctrl_n_with_empty_registry_opens_notifications_hub(self) -> None:
"""ctrl+n keeps settings reachable when no notifications are pending."""
from deepagents_code.notifications import NotificationRegistry
from deepagents_code.tui.widgets.notification_center import (
NotificationCenterScreen,
_NotificationSettingsRow,
)
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
app._notice_registry = NotificationRegistry()
notified: list[str] = []
original_notify = app.notify
def capture_notify(message: str, **kwargs: Any) -> None:
notified.append(message)
original_notify(message, **kwargs)
app.notify = capture_notify # ty: ignore
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("ctrl+n")
await pilot.pause()
assert not isinstance(app.screen, ModalScreen)
assert any("No pending notifications" in m for m in notified)
assert isinstance(app.screen, NotificationCenterScreen)
assert len(app.screen.query(_NotificationSettingsRow)) == 1
async def test_notifications_command_opens_same_hub(self) -> None:
"""The slash command and keybinding share the notification center."""
from deepagents_code.tui.widgets.notification_center import (
NotificationCenterScreen,
)
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
async with app.run_test() as pilot:
await pilot.pause()
await app._handle_command("/notifications")
await pilot.pause()
assert isinstance(app.screen, NotificationCenterScreen)
async def test_notification_hub_expands_settings_inline(self) -> None:
"""Preferences expand inside the hub; Esc collapses back to the list."""
from textual.widgets import Checkbox
from deepagents_code.tui.widgets.notification_center import (
NotificationCenterScreen,
)
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("ctrl+n")
await pilot.pause()
center = app.screen
assert isinstance(center, NotificationCenterScreen)
await pilot.press("enter")
await pilot.pause()
await pilot.pause()
assert app.screen is center
assert center.settings_expanded
assert len(center.query(Checkbox)) > 0
await pilot.press("escape")
await pilot.pause()
assert app.screen is center
assert not center.settings_expanded
async def test_ctrl_n_over_modal_toasts_close_hint(self) -> None:
"""ctrl+n while a modal is open surfaces a hint instead of stacking."""
@@ -26248,13 +26287,15 @@ class TestNotificationCenterIntegration:
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."""
async def test_enter_api_key_save_last_entry_keeps_settings_hub(self) -> None:
"""Saving the only key leaves warning preferences reachable."""
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,
_NotificationSettingsRow,
)
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
@@ -26273,7 +26314,9 @@ class TestNotificationCenterIntegration:
await pilot.pause()
assert app._notice_registry.get("dep:tavily") is None
assert not isinstance(app.screen, NotificationCenterScreen)
assert isinstance(app.screen, NotificationCenterScreen)
assert not list(app.screen.query(_NotificationRow))
assert len(app.screen.query(_NotificationSettingsRow)) == 1
async def test_enter_api_key_reload_failure_surfaces_toast(self) -> None:
"""A reload race after saving logs and toasts instead of vanishing."""
@@ -26422,11 +26465,13 @@ class TestNotificationCenterIntegration:
assert keys == ["dep:ripgrep", "dep:tavily"]
assert app._notice_registry.get("dep:ripgrep") is dep
async def test_suppress_last_entry_closes_center(self) -> None:
"""Suppressing the only remaining entry dismisses the center."""
async def test_suppress_last_entry_keeps_settings_hub(self) -> None:
"""Suppressing the final entry leaves warning preferences reachable."""
from deepagents_code.tui.widgets.notification_center import (
NotificationCenterScreen,
NotificationSuppressRequested,
_NotificationRow,
_NotificationSettingsRow,
)
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
@@ -26446,7 +26491,9 @@ class TestNotificationCenterIntegration:
app.screen.post_message(NotificationSuppressRequested("dep:ripgrep"))
await pilot.pause()
assert not isinstance(app.screen, NotificationCenterScreen)
assert isinstance(app.screen, NotificationCenterScreen)
assert not list(app.screen.query(_NotificationRow))
assert len(app.screen.query(_NotificationSettingsRow)) == 1
async def test_suppress_action_failure_keeps_entry_and_warns(self) -> None:
"""When suppress_warning returns False, the entry stays and a warning toasts."""
@@ -27778,8 +27825,42 @@ class TestNotificationCenterIntegration:
assert screen._selected == 0
await pilot.press("shift+tab")
await pilot.pause()
# Wraps from row 0 to the last row; auto_approve stays off.
assert screen._selected == len(entries) - 1
# Wraps from row 0 to settings; auto_approve stays off.
assert screen._selected == len(entries)
assert app._auto_approve is False
async def test_notification_center_shift_tab_cycles_checkboxes_when_expanded(
self,
) -> None:
"""App-level shift+tab wraps checkbox focus while settings are expanded.
The app's priority `shift+tab -> toggle_auto_approve` binding wins
dispatch and routes cursor-style modals to `move_up` via
`_SupportsReverseNav`; with expanded settings that would strand focus
on the first checkbox instead of wrapping to the last.
"""
from textual.widgets import Checkbox
from deepagents_code.tui.widgets.notification_center import (
NotificationCenterScreen,
)
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
async with app.run_test() as pilot:
await pilot.pause()
screen = NotificationCenterScreen([], suppressed=set())
app.push_screen(screen)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert screen.settings_expanded
checkboxes = list(screen.query(Checkbox))
assert app.focused is checkboxes[0]
await pilot.press("shift+tab")
await pilot.pause()
assert app.focused is checkboxes[-1]
assert app._auto_approve is False
async def test_notification_detail_shift_tab_moves_cursor_up(self) -> None:
@@ -4,6 +4,7 @@ from __future__ import annotations
import pytest
from textual.app import App
from textual.widgets import Checkbox, Static
from deepagents_code.notifications import (
ActionId,
@@ -19,8 +20,10 @@ from deepagents_code.tui.widgets.notification_center import (
NotificationCenterScreen,
NotificationSuppressRequested,
_NotificationRow,
_NotificationSettingsRow,
)
from deepagents_code.tui.widgets.notification_detail import NotificationDetailScreen
from deepagents_code.tui.widgets.notification_settings import WARNING_TOGGLES
from deepagents_code.tui.widgets.update_available import UpdateAvailableScreen
@@ -90,6 +93,22 @@ class TestNotificationCenterScreen:
"dep:ripgrep",
"update:available",
]
assert len(screen.query(_NotificationSettingsRow)) == 1
async def test_empty_center_shows_settings_destination(self) -> None:
"""An empty hub stays useful by selecting warning preferences."""
app = App()
screen = NotificationCenterScreen([])
async with app.run_test() as pilot:
app.push_screen(screen)
await pilot.pause()
assert not list(screen.query(_NotificationRow))
assert len(screen.query(_NotificationSettingsRow)) == 1
assert "No pending notifications" in str(
screen.query_one(".nc-empty", Static).content
)
assert screen._selected == 0
async def test_widget_ids_are_collision_free_across_duplicate_keys(self) -> None:
"""Enumerated widget ids survive keys that would sanitize identically."""
@@ -123,6 +142,177 @@ class TestNotificationCenterScreen:
await pilot.pause()
assert isinstance(app.screen, UpdateAvailableScreen)
async def test_enter_on_settings_expands_inline_without_dismissing(self) -> None:
"""Enter on the settings row expands the checkboxes inside the hub."""
app = App()
screen = NotificationCenterScreen([], suppressed=set())
async with app.run_test() as pilot:
app.push_screen(screen)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert app.screen is screen
assert screen.settings_expanded
assert len(screen.query(Checkbox)) == len(WARNING_TOGGLES)
assert screen._selected == len(screen._rows) - 1
async def test_esc_while_expanded_collapses_before_closing(self) -> None:
"""Esc collapses expanded settings first; a second Esc dismisses."""
results: list[NotificationActionResult | None] = []
app = App()
screen = NotificationCenterScreen([], suppressed=set())
async with app.run_test() as pilot:
app.push_screen(screen, results.append)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert screen.settings_expanded
await pilot.press("escape")
await pilot.pause()
assert app.screen is screen
assert not screen.settings_expanded
assert not list(screen.query(Checkbox))
assert results == []
await pilot.press("escape")
await pilot.pause()
assert results == [None]
async def test_settings_expand_focuses_first_checkbox(self) -> None:
"""Expanding settings hands key focus to the first warning toggle."""
app = App()
screen = NotificationCenterScreen([], suppressed=set())
async with app.run_test() as pilot:
app.push_screen(screen)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert app.focused is screen.query(Checkbox).first()
@pytest.mark.parametrize(
("key", "expected_index"),
[
("down", 1),
("j", 1),
("tab", 1),
("up", len(WARNING_TOGGLES) - 1),
("k", len(WARNING_TOGGLES) - 1),
("shift+tab", len(WARNING_TOGGLES) - 1),
],
)
async def test_expanded_settings_nav_cycles_checkboxes(
self, key: str, expected_index: int
) -> None:
"""Navigation keys move between warning checkboxes, not the row cursor.
With the section expanded and a checkbox focused, up/down/tab (and
j/k) must cycle the checkboxes and wrap; the center's row cursor
must stay parked on the settings row.
"""
app = App()
screen = NotificationCenterScreen([_dep_entry()], suppressed=set())
async with app.run_test() as pilot:
app.push_screen(screen)
await pilot.pause()
await pilot.press("down") # select the settings row
await pilot.press("enter") # expand
await pilot.pause()
boxes = list(screen.query(Checkbox))
assert app.focused is boxes[0]
await pilot.press(key)
await pilot.pause()
assert app.focused is boxes[expected_index]
# Row cursor stays on the settings row; it never moved.
assert screen._selected == len(screen._rows) - 1
async def test_expanded_settings_nav_wraps_at_end(self) -> None:
"""Down from the last checkbox wraps back to the first."""
app = App()
screen = NotificationCenterScreen([], suppressed=set())
async with app.run_test() as pilot:
app.push_screen(screen)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
boxes = list(screen.query(Checkbox))
boxes[-1].focus()
await pilot.pause()
await pilot.press("down")
await pilot.pause()
assert app.focused is boxes[0]
async def test_settings_expand_collapse_round_trip_restores_help(self) -> None:
"""The footer hint tracks expansion so the Esc verb stays accurate."""
app = App()
screen = NotificationCenterScreen([], suppressed=set())
async with app.run_test() as pilot:
app.push_screen(screen)
await pilot.pause()
help_widget = screen.query_one(".nc-help", Static)
assert "Esc close" in str(help_widget.content)
await pilot.press("enter")
await pilot.pause()
assert "Esc collapse" in str(screen.query_one(".nc-help", Static).content)
await pilot.press("escape")
await pilot.pause()
assert "Esc close" in str(screen.query_one(".nc-help", Static).content)
async def test_rapid_double_esc_does_not_duplicate_settings_group(self) -> None:
"""Two Esc presses in one batch must not mount a second settings group.
`run_worker(..., group="nc-settings")` is not exclusive by default,
so without serialization the second Esc starts an expand while the
first Esc's collapse is still awaiting `remove()` — mounting a
duplicate `#nc-settings-group` raises `DuplicateIds` and kills the
app via `WorkerFailed`.
"""
app = App()
screen = NotificationCenterScreen([], suppressed=set())
async with app.run_test() as pilot:
app.push_screen(screen)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert screen.settings_expanded
await pilot.press("escape", "escape")
for _ in range(20):
await pilot.pause()
assert len(screen.query("#nc-settings-group")) <= 1
async def test_reload_keeps_settings_expanded(self) -> None:
"""A row-list refresh must not collapse the open settings section."""
app = App()
screen = NotificationCenterScreen([_dep_entry()], suppressed=set())
async with app.run_test() as pilot:
app.push_screen(screen)
await pilot.pause()
await pilot.press("down") # select the settings row
await pilot.press("enter")
await pilot.pause()
assert screen.settings_expanded
await screen.reload([_dep_entry()])
await pilot.pause()
await pilot.pause()
await pilot.pause()
assert screen.settings_expanded
assert len(screen.query(Checkbox)) == len(WARNING_TOGGLES)
assert screen._selected == len(screen._rows) - 1
async def test_enter_preloads_api_key_screen_before_detail(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -277,8 +467,8 @@ class TestNotificationCenterScreen:
]
assert screen._selected == 0
async def test_reload_with_empty_list_dismisses_center(self) -> None:
"""`reload([])` closes the center with None."""
async def test_reload_with_empty_list_keeps_settings_reachable(self) -> None:
"""`reload([])` retains the hub with the settings row selected."""
results: list[NotificationActionResult | None] = []
app = App()
@@ -292,7 +482,12 @@ class TestNotificationCenterScreen:
await screen.reload([])
await pilot.pause()
assert results == [None]
assert app.screen is screen
assert not list(screen.query(_NotificationRow))
assert len(screen.query(_NotificationSettingsRow)) == 1
assert screen._selected == 0
assert results == []
async def test_detail_esc_returns_to_center(self) -> None:
"""Esc in the detail modal keeps the notification center open."""
@@ -320,8 +515,8 @@ class TestNotificationCenterScreen:
assert screen._selected == 1
@pytest.mark.parametrize("key", ["up", "k"])
async def test_up_or_k_wraps_to_last_row(self, key: str) -> None:
"""Navigating up from row 0 wraps to the last notification."""
async def test_up_or_k_wraps_to_settings_row(self, key: str) -> None:
"""Navigating up from row 0 wraps to notification settings."""
app = App()
screen = NotificationCenterScreen([_dep_entry(), _update_entry()])
async with app.run_test() as pilot:
@@ -329,7 +524,7 @@ class TestNotificationCenterScreen:
await pilot.pause()
await pilot.press(key)
await pilot.pause()
assert screen._selected == 1
assert screen._selected == 2
async def test_escape_dismisses_with_none(self) -> None:
"""Esc on the center (no detail open) returns `None`."""
@@ -1,4 +1,4 @@
"""Tests for NotificationSettingsScreen."""
"""Tests for the notification center's inline settings section."""
from __future__ import annotations
@@ -9,33 +9,31 @@ from textual.widgets import Checkbox, Static
from deepagents_code.approval_mode import YOLO_WARNING_KEY
from deepagents_code.model_config import is_warning_suppressed, suppress_warning
from deepagents_code.tui.widgets.notification_settings import (
WARNING_TOGGLES,
NotificationSettingsScreen,
)
from deepagents_code.tui.widgets.notification_center import NotificationCenterScreen
from deepagents_code.tui.widgets.notification_settings import WARNING_TOGGLES
class _NotificationSettingsHost(App[None]):
"""Minimal host app for mounting `NotificationSettingsScreen` in tests."""
"""Minimal host app for mounting `NotificationCenterScreen` in tests."""
async def test_notification_settings_dims_underlying_content() -> None:
async def test_notification_center_dims_underlying_content() -> None:
"""The modal must inherit the translucent `ModalScreen` backdrop.
Like the selector modals, the notification settings dialog should dim the
content underneath rather than render a fully transparent overlay. The
alpha is in (0, 1) only under a non-ansi theme, so pin `textual-dark`.
Like the selector modals, the notification hub should dim the content
underneath rather than render a fully transparent overlay. The alpha is
in (0, 1) only under a non-ansi theme, so pin `textual-dark`.
"""
app = _NotificationSettingsHost()
async with app.run_test() as pilot:
app.theme = "textual-dark"
await pilot.pause()
await app.push_screen(NotificationSettingsScreen(suppressed=set()))
await app.push_screen(NotificationCenterScreen([], suppressed=set()))
await pilot.pause()
assert 0 < app.screen.styles.background.a < 1
async def test_enter_toggles_focused_warning_without_closing() -> None:
async def test_enter_toggles_focused_warning_without_collapsing() -> None:
"""Enter must toggle the focused warning, matching the footer hint.
Textual's `ToggleButton` binds `enter,space` to its toggle action, so Enter
@@ -44,9 +42,11 @@ async def test_enter_toggles_focused_warning_without_closing() -> None:
"""
app = _NotificationSettingsHost()
async with app.run_test() as pilot:
screen = NotificationSettingsScreen(suppressed=set())
screen = NotificationCenterScreen([], suppressed=set())
await app.push_screen(screen)
await pilot.pause()
await pilot.press("enter") # expand the settings section
await pilot.pause()
focused = screen.query(Checkbox).first()
assert app.focused is focused
assert focused.value is True
@@ -56,20 +56,23 @@ async def test_enter_toggles_focused_warning_without_closing() -> None:
assert focused.value is False
assert app.screen is screen
assert screen.settings_expanded
async def test_help_footer_documents_both_toggle_keys() -> None:
async def test_help_footer_documents_both_toggle_keys_when_expanded() -> None:
"""The footer advertises Enter alongside Space so the hint is complete."""
app = _NotificationSettingsHost()
async with app.run_test() as pilot:
screen = NotificationSettingsScreen(suppressed=set())
screen = NotificationCenterScreen([], suppressed=set())
await app.push_screen(screen)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
help_text = str(screen.query_one(".ns-help", Static).content)
help_text = str(screen.query_one(".nc-help", Static).content)
assert "Space/Enter toggle" in help_text
assert "Esc close" in help_text
assert "Esc collapse" in help_text
def test_cold_cache_warning_is_listed() -> None:
@@ -87,10 +90,13 @@ async def test_yolo_warning_is_toggleable() -> None:
app = _NotificationSettingsHost()
async with app.run_test() as pilot:
await app.push_screen(NotificationSettingsScreen(suppressed=set()))
screen = NotificationCenterScreen([], suppressed=set())
await app.push_screen(screen)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
checkbox = app.screen.query_one(f"#ns-{YOLO_WARNING_KEY}", Checkbox)
checkbox = screen.query_one(f"#ns-{YOLO_WARNING_KEY}", Checkbox)
assert checkbox.value is True
checkbox.value = False
await pilot.pause()
@@ -109,10 +115,13 @@ async def test_suppressed_key_renders_unchecked() -> None:
"""A key already in `[warnings].suppress` renders its row unchecked."""
app = _NotificationSettingsHost()
async with app.run_test() as pilot:
await app.push_screen(NotificationSettingsScreen(suppressed={YOLO_WARNING_KEY}))
screen = NotificationCenterScreen([], suppressed={YOLO_WARNING_KEY})
await app.push_screen(screen)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
checkbox = app.screen.query_one(f"#ns-{YOLO_WARNING_KEY}", Checkbox)
checkbox = screen.query_one(f"#ns-{YOLO_WARNING_KEY}", Checkbox)
assert checkbox.value is False
@@ -127,10 +136,13 @@ async def test_failed_unsuppress_reverts_the_checkbox() -> None:
app = _NotificationSettingsHost()
async with app.run_test() as pilot:
await app.push_screen(NotificationSettingsScreen(suppressed={YOLO_WARNING_KEY}))
screen = NotificationCenterScreen([], suppressed={YOLO_WARNING_KEY})
await app.push_screen(screen)
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
checkbox = app.screen.query_one(f"#ns-{YOLO_WARNING_KEY}", Checkbox)
checkbox = screen.query_one(f"#ns-{YOLO_WARNING_KEY}", Checkbox)
assert checkbox.value is False
with patch(