Skip to content
Merged
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
6 changes: 5 additions & 1 deletion src/bub/program_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
Expand All @@ -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()
3 changes: 2 additions & 1 deletion tests/test_builtin_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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": []}


Expand Down
34 changes: 33 additions & 1 deletion tests/test_program_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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() == ""
2 changes: 1 addition & 1 deletion website/src/content/docs/docs/reference/python-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
Loading