Skip to content
Open
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
15 changes: 15 additions & 0 deletions dev-notes/notifications.md
Original file line number Diff line number Diff line change
@@ -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
```
16 changes: 16 additions & 0 deletions src/tau_coding/notification.py
Original file line number Diff line number Diff line change
@@ -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(),
)
5 changes: 5 additions & 0 deletions src/tau_coding/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion src/tau_coding/tui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,13 +245,15 @@ 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]:
"""Serialize these settings to JSON-compatible data."""
return {
"auto_copy_selection": self.auto_copy_selection,
"keybindings": self.keybindings.to_json(),
"notifications": self.notifications,
"sidebar_position": self.sidebar_position,
"theme": self.theme,
}
Expand Down Expand Up @@ -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]}")
Expand All @@ -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),
)

Expand Down
21 changes: 21 additions & 0 deletions tests/test_notification.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions tests/test_tui_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})