ac2359cd35
Symptom (Ghostty, display.pet on, display.bell_on_complete on): at the end of a turn the
input line fills with several rows of base64 and a stale copy of the status bar + pet
stays above the response panel.
Root cause: _ring_bell runs on the agent thread and terminal_notify wrote the OSC 9 /
OSC 777 sequence through its own open("/dev/tty") (or sys.stdout). The prompt_toolkit
loop thread may at that moment be mid-write of a 12 KB kitty APC pet frame, which the tty
drains ~1 KB at a time. The second writer splices into the frame; the foreign ESC aborts
the APC and the terminal paints the remainder of the payload as text at the input cursor.
The wrapped garbage scrolls the screen, so the panel that follows is printed against a
stale cursor position and the old chrome survives above it.
Change: when the CLI's Application is running, _ring_bell hands "\a" + the notification
sequence to the app loop (_run_on_app_loop -> _write_terminal_sequence), serializing it
behind the renderer and the after_render frame writer. terminal_notify.notify() keeps the
/dev/tty path for callers without a running app; the sequence builder is split out as
notification_sequence().
Verification: pty A/B with the real Application + after_render frame writer, 400 rings
vs 91 frames — base: 3 leaks (4,324 base64 chars painted); fixed: 0 leaks, 400/400
notifications delivered, 0 aborted frames.
81 lines
3.4 KiB
Python
81 lines
3.4 KiB
Python
"""Terminal-native desktop notifications: OSC 9 and Warp's OSC 777 CLI-agent protocol.
|
|
|
|
OSC 9 (``ESC ] 9 ; <body> BEL``): Ghostty, iTerm2, Kitty and WezTerm raise an OS notification;
|
|
others drop it. OSC 777 (``ESC ] 777 ; notify ; warp://cli-agent ; <json> BEL``): Warp's
|
|
structured CLI-agent protocol (tab status + notification mailbox).
|
|
|
|
Sequences are written to ``/dev/tty`` because prompt_toolkit's stdout wrapper can buffer or strip
|
|
raw escapes; when ``/dev/tty`` can't be opened (Windows, no controlling terminal) they fall back to
|
|
``sys.stdout``. Never raises.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
_C0_AND_DEL = re.compile(r"[\x00-\x1f\x7f]")
|
|
_WARP_PROTOCOL_VERSION = 1
|
|
# Last Warp release per channel that set WARP_CLI_AGENT_PROTOCOL_VERSION but could not render
|
|
# structured payloads (Warp's should-use-structured.sh). Bash compares lexicographically; so do we.
|
|
_WARP_LAST_BROKEN = {"stable": "v0.2026.03.25.08.24.stable_05", "preview": "v0.2026.03.25.08.24.preview_05"}
|
|
|
|
|
|
def _write_tty(seq: str) -> None:
|
|
"""Write raw escapes to /dev/tty, falling back to sys.stdout. Never raises."""
|
|
try:
|
|
with open("/dev/tty", "w", encoding="utf-8") as tty:
|
|
tty.write(seq)
|
|
return
|
|
except OSError:
|
|
pass
|
|
try:
|
|
sys.stdout.write(seq)
|
|
sys.stdout.flush()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def osc9(body: str) -> str:
|
|
"""OSC 9 sequence with C0 controls and DEL stripped from the body."""
|
|
return f"\x1b]9;{_C0_AND_DEL.sub('', body)}\x07"
|
|
|
|
|
|
def warp_supported(env=None) -> bool:
|
|
"""True when running in a Warp build that can render OSC 777 agent payloads."""
|
|
env = os.environ if env is None else env
|
|
client = env.get("WARP_CLIENT_VERSION", "")
|
|
if env.get("TERM_PROGRAM") != "WarpTerminal" or not env.get("WARP_CLI_AGENT_PROTOCOL_VERSION") or not client:
|
|
return False
|
|
return not any(channel in client and client <= last_broken for channel, last_broken in _WARP_LAST_BROKEN.items())
|
|
|
|
|
|
def warp_osc777(event: str, detail: str, session_id: str = "") -> str:
|
|
"""OSC 777 ``warp://cli-agent`` notification; ``event`` is ``stop`` or ``permission_request``."""
|
|
try:
|
|
advertised = int(os.environ.get("WARP_CLI_AGENT_PROTOCOL_VERSION", "1"))
|
|
except ValueError:
|
|
advertised = 1
|
|
cwd = os.getcwd()
|
|
payload = {"v": min(advertised, _WARP_PROTOCOL_VERSION), "agent": "hermes", "event": event,
|
|
"session_id": session_id, "cwd": cwd, "project": os.path.basename(cwd)}
|
|
payload["summary" if event == "permission_request" else "response"] = detail[:200]
|
|
return f"\x1b]777;notify;warp://cli-agent;{json.dumps(payload, separators=(',', ':'))}\x07"
|
|
|
|
|
|
def notification_sequence(context: str, *, prompt: bool, session_id: str = "", detail: str = "") -> str:
|
|
"""OSC 9 (plus Warp OSC 777 when supported) for a blocking prompt or turn end."""
|
|
seq = osc9(f"Hermes: {context}")
|
|
if warp_supported():
|
|
event = "permission_request" if prompt else "stop"
|
|
seq += warp_osc777(event, detail or context, session_id)
|
|
return seq
|
|
|
|
|
|
def notify(context: str, *, prompt: bool, session_id: str = "", detail: str = "") -> None:
|
|
"""Emit the notification straight to the tty. Only for callers that do not own a running
|
|
prompt_toolkit app; inside the CLI, ``_ring_bell`` routes it through the app output instead."""
|
|
_write_tty(notification_sequence(context, prompt=prompt, session_id=session_id, detail=detail))
|