diff --git a/dev-notes/notifications.md b/dev-notes/notifications.md new file mode 100644 index 000000000..90a58c9b4 --- /dev/null +++ b/dev-notes/notifications.md @@ -0,0 +1,15 @@ +# Desktop Notifications for Tau + +Fires a desktop notification when the agent finishes its turn and the terminal +is not focused (enabled via `notifications: true` in `~/.tau/tui.json`). + +Uses **OSC 9** escape sequences — supported by Ghostty, iTerm2, Kitty, WezTerm, +and Warp. A `\r\x1b[K` cleanup sequence appended after the notification prevents +text leakage on terminals without OSC 9 support (e.g. macOS Terminal.app). +The notification fires in `TauTuiApp._run_prompt` after the agent event stream +completes normally. Suppressed via Textual's `App.app_focus` when the terminal +is frontmost. + +```bash +uv run pytest tests/test_notification.py tests/test_tui_config.py +``` diff --git a/src/tau_coding/notification.py b/src/tau_coding/notification.py new file mode 100644 index 000000000..42f22f2ae --- /dev/null +++ b/src/tau_coding/notification.py @@ -0,0 +1,16 @@ +"""Desktop notification via OSC 9 terminal escape sequence.""" + +import contextlib +import os +import sys + + +def send_notification(message: str) -> None: + stderr = sys.__stderr__ + if stderr is None: + return + with contextlib.suppress(OSError): + os.write( + stderr.fileno(), + f"\x1b]9;{message.replace(chr(7), '').replace(chr(27), '')}\x07\r\x1b[K".encode(), + ) diff --git a/src/tau_coding/tui/app.py b/src/tau_coding/tui/app.py index f2199da72..c7d2fb347 100644 --- a/src/tau_coding/tui/app.py +++ b/src/tau_coding/tui/app.py @@ -83,6 +83,7 @@ SlotWidgetContent, SlotWidgetFactory, ) +from tau_coding.notification import send_notification from tau_coding.oauth import login_openai_codex from tau_coding.oauth_registry import get_oauth_provider, oauth_provider_ids from tau_coding.oauth_types import ( @@ -3872,6 +3873,10 @@ async def _run_prompt( ): _attach_diagnostic_log_path_to_error(self.state, self.session) await self._apply_streaming_transcript_event(event) + if self.tui_settings.notifications and not self.app_focus: + send_notification( + "Waiting for your input", + ) except Exception as exc: # noqa: BLE001 - surface unexpected worker errors in the TUI if active_run_id != self._prompt_run_id: return diff --git a/src/tau_coding/tui/config.py b/src/tau_coding/tui/config.py index 21c95e864..2d59843e6 100644 --- a/src/tau_coding/tui/config.py +++ b/src/tau_coding/tui/config.py @@ -245,6 +245,7 @@ class TuiSettings: keybindings: TuiKeybindings = field(default_factory=TuiKeybindings) theme: TuiThemeName = "tau-dark" auto_copy_selection: bool = False + notifications: bool = False sidebar_position: Literal["left", "right", "off"] = "left" def to_json(self) -> dict[str, Any]: @@ -252,6 +253,7 @@ def to_json(self) -> dict[str, Any]: return { "auto_copy_selection": self.auto_copy_selection, "keybindings": self.keybindings.to_json(), + "notifications": self.notifications, "sidebar_position": self.sidebar_position, "theme": self.theme, } @@ -288,7 +290,13 @@ def save_tui_settings(settings: TuiSettings, paths: TauPaths | None = None) -> P def tui_settings_from_json(data: dict[str, Any]) -> TuiSettings: """Parse TUI settings from JSON-compatible data.""" - allowed_fields = {"auto_copy_selection", "keybindings", "sidebar_position", "theme"} + allowed_fields = { + "auto_copy_selection", + "keybindings", + "notifications", + "sidebar_position", + "theme", + } unknown_fields = set(data) - allowed_fields if unknown_fields: raise TuiConfigError(f"Unknown TUI settings field: {sorted(unknown_fields)[0]}") @@ -306,6 +314,10 @@ def tui_settings_from_json(data: dict[str, Any]) -> TuiSettings: data.get("auto_copy_selection", False), "auto_copy_selection", ), + notifications=_bool_setting( + data.get("notifications", False), + "notifications", + ), sidebar_position=cast(Literal["left", "right", "off"], raw_sidebar), ) diff --git a/tests/test_notification.py b/tests/test_notification.py new file mode 100644 index 000000000..3e4de17cd --- /dev/null +++ b/tests/test_notification.py @@ -0,0 +1,21 @@ +from unittest.mock import patch + +from tau_coding.notification import send_notification + + +def test_send_notification_writes_osc9() -> None: + with patch("tau_coding.notification.os.write") as mock_write: + send_notification("Waiting for your input") + mock_write.assert_called_once_with(2, b"\x1b]9;Waiting for your input\x07\r\x1b[K") + + +def test_send_notification_escape_stx() -> None: + """Verify control chars in message are stripped.""" + with patch("tau_coding.notification.os.write") as mock_write: + send_notification("Bell: \x07, Esc: \x1b") + mock_write.assert_called_once_with(2, b"\x1b]9;Bell: , Esc: \x07\r\x1b[K") + + +def test_send_notification_handles_oserror() -> None: + with patch("tau_coding.notification.os.write", side_effect=OSError()): + send_notification("Test") # Should not raise diff --git a/tests/test_tui_config.py b/tests/test_tui_config.py index fbd278b2b..c04dcd8dd 100644 --- a/tests/test_tui_config.py +++ b/tests/test_tui_config.py @@ -183,3 +183,22 @@ def test_tui_sidebar_position_rejects_invalid() -> None: with pytest.raises(TuiConfigError, match="sidebar_position"): tui_settings_from_json({"sidebar_position": 123}) + + +def test_tui_notifications_defaults_to_false() -> None: + assert TuiSettings().notifications is False + + +def test_tui_notifications_roundtrips() -> None: + settings = tui_settings_from_json({"notifications": True}) + assert settings.notifications is True + assert settings.to_json()["notifications"] is True + + settings = tui_settings_from_json({"notifications": False}) + assert settings.notifications is False + assert settings.to_json()["notifications"] is False + + +def test_tui_notifications_rejects_non_bool() -> None: + with pytest.raises(TuiConfigError, match="notifications"): + tui_settings_from_json({"notifications": "yes"})