From d575570b7f130f82b814896f77f379ef32d4badb Mon Sep 17 00:00:00 2001 From: qiaoborui Date: Thu, 5 Nov 2026 11:31:59 +0800 Subject: [PATCH] fix: clear OSC 7501 status when a command ends Bub reported a final state on exit but never cleared it, so the shell left in the terminal kept showing as an agent. Report the final state, then state=clear. --- src/bub/program_status.py | 6 +++- tests/test_builtin_cli.py | 3 +- tests/test_program_status.py | 34 ++++++++++++++++++- .../docs/docs/reference/python-api.mdx | 2 +- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/bub/program_status.py b/src/bub/program_status.py index 7a4804bc0..f196f0242 100644 --- a/src/bub/program_status.py +++ b/src/bub/program_status.py @@ -7,6 +7,7 @@ from bub.core.events import Event _RESULTS = {None: "working", "succeeded": "done", "failed": "error", "cancelled": "idle"} +_CLEAR = "\x1b]7501;state=clear\x1b\\" class ProgramStatus: @@ -16,9 +17,11 @@ def __init__(self, output: TextIO | None) -> None: def __call__(self, event: Event) -> None: kind: PromptKind | None = None + finished = False match event: case CommandChanged(outcome): state = "idle" if outcome == "succeeded" and self.listener else _RESULTS[outcome] + finished = outcome is not None case WorkChanged(busy, completed): self.listener = True state = "working" if busy else _RESULTS[completed] if completed else "idle" @@ -32,5 +35,6 @@ def __call__(self, event: Event) -> None: fields = f"state={state}:app=bub" if kind is not None: fields += f":kind={kind}" - self.output.write(f"\x1b]7501;{fields}\x1b\\") + # The terminal outlives the command, so drop the record once the command ends. + self.output.write(f"\x1b]7501;{fields}\x1b\\{_CLEAR if finished else ''}") self.output.flush() diff --git a/tests/test_builtin_cli.py b/tests/test_builtin_cli.py index cb4e2ad96..d50ba1650 100644 --- a/tests/test_builtin_cli.py +++ b/tests/test_builtin_cli.py @@ -40,7 +40,7 @@ def _terminal_output(monkeypatch: pytest.MonkeyPatch) -> StringIO: def _program_status(output: StringIO) -> dict[str, str]: frames = re.findall(r"\x1b]7501;([^\x1b\x07]*)(?:\x1b\\|\x07)", output.getvalue()) - return dict(pair.split("=", 1) for pair in frames[-1].split(":")) + return dict(pair.split("=", 1) for pair in [frame for frame in frames if frame != "state=clear"][-1].split(":")) def _fake_result(answer: Any, command: str | None = "enter") -> InquirerResult[Any]: @@ -322,6 +322,7 @@ def install_gateway(workspace: Path, channels: list[str]) -> SimpleNamespace: assert f"Saved config to {config_file.resolve()}" in terminal_output.getvalue() assert "Installed and started the gateway with systemd" in terminal_output.getvalue() assert _program_status(terminal_output)["state"] == "done" + assert terminal_output.getvalue().endswith("\x1b]7501;state=clear\x1b\\") assert observed == {"workspace": tmp_path.resolve(), "channels": []} diff --git a/tests/test_program_status.py b/tests/test_program_status.py index 8ef3ef6e6..29d23f0a0 100644 --- a/tests/test_program_status.py +++ b/tests/test_program_status.py @@ -9,10 +9,12 @@ import typer from typer.main import get_command +from bub.application import CommandChanged from bub.channels.base import Channel from bub.channels.message import ChannelMessage from bub.framework import BubFramework from bub.hooks import hookimpl +from bub.program_status import ProgramStatus class MessageChannel(Channel): @@ -93,5 +95,35 @@ async def run_model(self, prompt, session_id, state): get_command(app).main(["--workspace", str(tmp_path), "gateway", "--enable-channel", "test"], standalone_mode=False) - assert reports(terminal)[-1]["state"] == "idle" + assert [item["state"] for item in reports(terminal)[-2:]] == ["idle", "clear"] assert all(item["state"] != "error" for item in reports(terminal)) + + +@pytest.mark.parametrize( + ("outcome", "listener", "state"), + [ + ("succeeded", False, "done"), + ("succeeded", True, "idle"), + ("failed", False, "error"), + ("cancelled", True, "idle"), + ], +) +def test_finished_command_clears_its_record(outcome, listener, state, monkeypatch): + terminal = StringIO() + monkeypatch.setattr(terminal, "isatty", lambda: True) + reporter = ProgramStatus(terminal) + reporter.listener = listener + + reporter(CommandChanged()) + assert reports(terminal)[-1]["state"] == "working" + + reporter(CommandChanged(outcome)) + assert [item["state"] for item in reports(terminal)[-2:]] == [state, "clear"] + + +def test_clear_is_not_written_to_redirected_output(): + output = StringIO() + + ProgramStatus(output)(CommandChanged("succeeded")) + + assert output.getvalue() == "" diff --git a/website/src/content/docs/docs/reference/python-api.mdx b/website/src/content/docs/docs/reference/python-api.mdx index 0c7228818..75bfe4c65 100644 --- a/website/src/content/docs/docs/reference/python-api.mdx +++ b/website/src/content/docs/docs/reference/python-api.mdx @@ -478,7 +478,7 @@ These events are published on `framework.events`, independently of `agent.events They are suitable for status bars and external UIs and do not carry detailed model-call payloads. -`bub.program_status.ProgramStatus` is the builtin application event handler. The CLI automatically registers it on `framework.events` to emit OSC 7501 states on a TTY: `idle`, `working`, `blocked`, `done`, and `error`; prompt states also carry `kind`. Non-TTY output does not produce these control sequences. +`bub.program_status.ProgramStatus` is the builtin application event handler. The CLI automatically registers it on `framework.events` to emit OSC 7501 states on a TTY: `idle`, `working`, `blocked`, `done`, and `error`; prompt states also carry `kind`. When a command ends it reports its final state and then `state=clear`, so a shell left in the terminal is not shown as an agent. Non-TTY output does not produce these control sequences. ### Interactive input {#inquirer}