diff --git a/src/bub_face/plugin.py b/src/bub_face/plugin.py index af1b644..fac8856 100644 --- a/src/bub_face/plugin.py +++ b/src/bub_face/plugin.py @@ -1,13 +1,12 @@ import asyncio -import aiohttp from bub import hookimpl from bub.channels import Channel from bub.envelope import field_of from bub.types import Envelope, MessageHandler, State from loguru import logger -from bub_face.server import PORT +from bub_face.state import Emotion, shared_controller class FaceChannel(Channel): @@ -33,9 +32,12 @@ async def stop(self) -> None: self._ongoing_task = None -@hookimpl +@hookimpl(tryfirst=True) def provide_channels(message_handler: MessageHandler) -> list[Channel]: _ = message_handler # not used + from bub_face.terminal import patch_cli_channel + + patch_cli_channel() return [FaceChannel()] @@ -44,9 +46,5 @@ async def load_state(message: Envelope, session_id: str) -> State: _ = session_id # not used channel = field_of(message, "channel") if channel == "xiaoai": - async with aiohttp.ClientSession() as session: - async with session.post( - f"http://localhost:{PORT}/api/emotion", json={"emotion": "neutral"} - ): - pass + shared_controller().set_emotion(Emotion.NEUTRAL) return {} diff --git a/src/bub_face/server.py b/src/bub_face/server.py index 46eb0f8..e64940b 100644 --- a/src/bub_face/server.py +++ b/src/bub_face/server.py @@ -8,6 +8,7 @@ from aiohttp.typedefs import Handler from bub_face import StateController +from bub_face.state import shared_controller ROOT = Path(__file__).resolve().parent STATIC_DIR = ROOT / "static" @@ -28,7 +29,6 @@ async def set_emotion(request: web.Request) -> web.Response: payload = await request.json() emotion = payload["emotion"] state = controller.set_emotion(emotion) - await broadcast_state(request.app, source="emotion") return web.json_response( { "state": state.to_dict(), @@ -41,7 +41,6 @@ async def patch_state(request: web.Request) -> web.Response: controller: StateController = request.app["controller"] payload = await request.json() state = controller.patch(payload) - await broadcast_state(request.app, source="patch") return web.json_response( { "state": state.to_dict(), @@ -53,7 +52,6 @@ async def patch_state(request: web.Request) -> web.Response: async def reset_state(request: web.Request) -> web.Response: controller: StateController = request.app["controller"] state = controller.reset() - await broadcast_state(request.app, source="reset") return web.json_response( { "state": state.to_dict(), @@ -65,7 +63,6 @@ async def reset_state(request: web.Request) -> web.Response: async def sleep_state(request: web.Request) -> web.Response: controller: StateController = request.app["controller"] controller.sleep() - await broadcast_state(request.app, source="sleep") return web.json_response(controller.snapshot()) @@ -104,9 +101,6 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse: await ws.send_json( {"type": "error", "message": f"Unsupported action: {action}"} ) - continue - - await broadcast_state(request.app, source="ws") finally: sockets.discard(ws) @@ -147,16 +141,26 @@ async def on_shutdown(app: web.Application) -> None: async def on_startup(app: web.Application) -> None: + def _on_state_change() -> None: + asyncio.ensure_future(broadcast_state(app, source="controller")) + + controller: StateController = app["controller"] + controller.add_listener(_on_state_change) + app["_state_listener"] = _on_state_change + async def idle_watchdog() -> None: while True: await asyncio.sleep(1) - if app["controller"].maybe_sleep(): - await broadcast_state(app, source="idle_timeout") + controller.maybe_sleep() app["idle_watchdog_task"] = asyncio.create_task(idle_watchdog()) async def on_cleanup(app: web.Application) -> None: + listener = app.pop("_state_listener", None) + if listener is not None: + app["controller"].remove_listener(listener) + task: asyncio.Task[None] | None = app.get("idle_watchdog_task") if task is None: return @@ -169,7 +173,7 @@ async def on_cleanup(app: web.Application) -> None: def create_app() -> web.Application: app = web.Application(middlewares=[error_middleware]) - app["controller"] = StateController(idle_timeout_seconds=600) + app["controller"] = shared_controller() app["sockets"] = set() app.router.add_get("/", index) diff --git a/src/bub_face/state.py b/src/bub_face/state.py index 659a739..b52d47d 100644 --- a/src/bub_face/state.py +++ b/src/bub_face/state.py @@ -174,6 +174,17 @@ def __init__( self._time_fn = time_fn or monotonic self._display_mode = DisplayMode.FACE self._last_active_at = self._time_fn() + self._listeners: list[Callable[[], None]] = [] + + def add_listener(self, callback: Callable[[], None]) -> None: + self._listeners.append(callback) + + def remove_listener(self, callback: Callable[[], None]) -> None: + self._listeners.remove(callback) + + def _notify(self) -> None: + for listener in self._listeners: + listener() @property def state(self) -> EyeState: @@ -198,12 +209,14 @@ def snapshot(self) -> dict[str, Any]: def reset(self) -> EyeState: self.wake() self._state = self._preset(Emotion.NEUTRAL) + self._notify() return self._state def set_emotion(self, emotion: str | Emotion) -> EyeState: self.wake() parsed = emotion if isinstance(emotion, Emotion) else Emotion(emotion) self._state = self._preset(parsed) + self._notify() return self._state def patch(self, payload: dict[str, Any]) -> EyeState: @@ -232,6 +245,7 @@ def patch(self, payload: dict[str, Any]) -> EyeState: ) self._state = EyeState(**filtered) + self._notify() return self._state def list_emotions(self) -> list[str]: @@ -248,6 +262,7 @@ def sleep(self) -> bool: if self._display_mode is DisplayMode.CLOCK: return False self._display_mode = DisplayMode.CLOCK + self._notify() return True def maybe_sleep(self) -> bool: @@ -259,3 +274,16 @@ def maybe_sleep(self) -> bool: def _preset(self, emotion: Emotion) -> EyeState: return EyeState(emotion=emotion, **EMOTION_PRESETS[emotion]) + + +DEFAULT_IDLE_TIMEOUT_SECONDS = 600 +_shared_controller: StateController | None = None + + +def shared_controller() -> StateController: + global _shared_controller + if _shared_controller is None: + _shared_controller = StateController( + idle_timeout_seconds=DEFAULT_IDLE_TIMEOUT_SECONDS + ) + return _shared_controller diff --git a/src/bub_face/terminal.py b/src/bub_face/terminal.py new file mode 100644 index 0000000..25c1428 --- /dev/null +++ b/src/bub_face/terminal.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +import contextlib +from dataclasses import dataclass +from typing import Callable + +import bub.channels.cli as cli_module +from prompt_toolkit.formatted_text import FormattedText +from rich.align import Align +from rich.console import Console, Group, RenderableType +from rich.live import Live +from rich.panel import Panel +from rich.text import Text + +from bub.channels.cli import CliChannel as BubCliChannel +from bub.channels.cli.renderer import CliRenderer as BubCliRenderer +from bub.channels.message import ChannelMessage, MessageKind +from bub.envelope import field_of +from bub_face.state import Emotion, StateController, shared_controller + + +@dataclass(slots=True) +class TerminalSidebarSnapshot: + emotion: str + note: str + compact_face: str + face_lines: list[str] + + +@dataclass(frozen=True, slots=True) +class TerminalFaceStyle: + compact_face: str + face_lines: tuple[str, str, str] + + +TERMINAL_FACE_STYLES: dict[Emotion, TerminalFaceStyle] = { + Emotion.NEUTRAL: TerminalFaceStyle( + compact_face="o==o", + face_lines=( + " .--------. .--------. ", + " | o==o | | o==o | ", + " '--------' '--------' ", + ), + ), + Emotion.HAPPY: TerminalFaceStyle( + compact_face="\\^^/", + face_lines=( + " .--------. .--------. ", + " | \\^^/ | | \\^^/ | ", + " '--------' '--------' ", + ), + ), + Emotion.SAD: TerminalFaceStyle( + compact_face="\\~~/", + face_lines=( + " .--------. .--------. ", + " | \\~~/ | | \\~~/ | ", + " '--------' '--------' ", + ), + ), + Emotion.ANGRY: TerminalFaceStyle( + compact_face=">!!<", + face_lines=( + " /--------\\ /--------\\ ", + " | >!!< | | >!!< | ", + " '--------' '--------' ", + ), + ), + Emotion.SURPRISED: TerminalFaceStyle( + compact_face="(OO)", + face_lines=( + " .--------. .--------. ", + " | (OO) | | (OO) | ", + " '--------' '--------' ", + ), + ), + Emotion.SLEEPY: TerminalFaceStyle( + compact_face="-__-", + face_lines=( + " .--------. .--------. ", + " | -__- | | -__- | ", + " '--------' '--------' ", + ), + ), + Emotion.CURIOUS: TerminalFaceStyle( + compact_face="o==>", + face_lines=( + " .--------. .--------. ", + " | o==> | | <==o | ", + " '--------' '--------' ", + ), + ), + Emotion.LOVE: TerminalFaceStyle( + compact_face="<33>", + face_lines=( + " .--------. .--------. ", + " | <33> | | <33> | ", + " '--------' '--------' ", + ), + ), + Emotion.THINKING: TerminalFaceStyle( + compact_face="o..-", + face_lines=( + " .--------. .--------. ", + " | o..- | | -..o | ", + " '--------' '--------' ", + ), + ), +} + + +def _face_style(emotion: Emotion) -> TerminalFaceStyle: + return TERMINAL_FACE_STYLES[emotion] + + +def _make_sidebar_snapshot(controller: StateController) -> TerminalSidebarSnapshot: + state = controller.state + style = _face_style(state.emotion) + return TerminalSidebarSnapshot( + emotion=state.emotion.value, + note=state.note, + compact_face=style.compact_face, + face_lines=list(style.face_lines), + ) + + +class FaceCliRenderer(BubCliRenderer): + def __init__( + self, + console: Console, + sidebar_provider: Callable[[], TerminalSidebarSnapshot] | None = None, + ) -> None: + super().__init__(console) + self._sidebar_provider = sidebar_provider or self._default_sidebar_snapshot + + def welcome(self, *, model: str, workspace: str) -> None: + body = ( + f"workspace: {workspace}\n" + f"model: {model}\n" + "internal command prefix: ','\n" + "shell command prefix: ',' at line start (Ctrl-X for shell mode)\n" + "type ',help' for command list" + ) + self.console.print(self._integrated_panel(title="Bub", border_style="cyan", text=body)) + + def panel(self, kind: MessageKind, text: str) -> RenderableType: + title, border_style = self._panel_style(kind) + return self._integrated_panel(title=title, border_style=border_style, text=text) + + def command_output(self, text: str) -> None: + if not text.strip(): + return + self.console.print(self.panel("command", text)) + + def assistant_output(self, text: str) -> None: + if not text.strip(): + return + self.console.print(self.panel("normal", text)) + + def error(self, text: str) -> None: + if not text.strip(): + return + self.console.print(self.panel("error", text)) + + def start_stream(self, kind: MessageKind) -> Live: + live = Live( + self.panel(kind, ""), + console=self.console, + auto_refresh=False, + transient=False, + vertical_overflow="visible", + ) + live.start() + live.refresh() + return live + + def update_stream(self, live: Live, *, kind: MessageKind, text: str) -> None: + live.update(self.panel(kind, text), refresh=True) + + def finish_stream(self, live: Live, *, kind: MessageKind, text: str) -> None: + live.update(self.panel(kind, text), refresh=True) + live.stop() + + def _integrated_panel(self, *, title: str, border_style: str, text: str) -> RenderableType: + snapshot = self._sidebar_provider() + status_line = f"emotion:{snapshot.emotion} note:{snapshot.note}" + body = Group( + Text("\n".join(snapshot.face_lines), justify="center"), + Text(f"{snapshot.compact_face} {status_line}", justify="center"), + Text(""), + Text(text), + ) + panel_width = self._panel_width() + panel = Panel( + body, + title=title, + border_style=border_style, + expand=False, + width=panel_width, + ) + return Align.left(panel) + + @staticmethod + def _default_sidebar_snapshot() -> TerminalSidebarSnapshot: + return _make_sidebar_snapshot(shared_controller()) + + def _panel_width(self) -> int: + available = max(self.console.size.width - 2, 56) + return min(available, 92) + + @staticmethod + def _panel_style(kind: MessageKind) -> tuple[str, str]: + match kind: + case "error": + return "Error", "red" + case "command": + return "Command", "green" + case _: + return "Bub", "blue" + + +class FaceAwareCliChannel(BubCliChannel): + def __init__(self, *args, **kwargs) -> None: + self._face_controller = shared_controller() + super().__init__(*args, **kwargs) + self._renderer = FaceCliRenderer( + self._renderer.console, + sidebar_provider=self._build_sidebar_snapshot, + ) + + @contextlib.asynccontextmanager + async def message_lifespan(self, request_completed): + self._set_emotion(Emotion.CURIOUS) + try: + async with super().message_lifespan(request_completed): + yield + except Exception: + self._set_emotion(Emotion.ANGRY) + raise + + async def send(self, message: ChannelMessage) -> None: + self._set_emotion(self._outbound_emotion(message)) + await super().send(message) + + def _render_bottom_toolbar(self) -> FormattedText: + snapshot = self._build_sidebar_snapshot() + info = self._last_tape_info + toolbar = ( + f"{snapshot.compact_face} " + f"mood:{snapshot.note} " + f"mode:{self._mode} " + f"entries:{field_of(info, 'entries', '-')} " + f"anchors:{field_of(info, 'anchors', '-')} " + f"session:{self._message_template['session_id']}" + ) + return FormattedText([("", toolbar)]) + + def _build_sidebar_snapshot(self) -> TerminalSidebarSnapshot: + return _make_sidebar_snapshot(self._face_controller) + + def _set_emotion(self, emotion: Emotion) -> None: + self._face_controller.set_emotion(emotion) + + @staticmethod + def _outbound_emotion(message: ChannelMessage) -> Emotion: + if message.kind == "error": + return Emotion.ANGRY + if message.kind == "command": + return Emotion.CURIOUS + return Emotion.HAPPY + + +def patch_cli_channel() -> None: + if cli_module.CliChannel is FaceAwareCliChannel: + return + cli_module.CliChannel = FaceAwareCliChannel diff --git a/tests/test_terminal.py b/tests/test_terminal.py new file mode 100644 index 0000000..78c400b --- /dev/null +++ b/tests/test_terminal.py @@ -0,0 +1,82 @@ +import asyncio + +from bub.channels.message import ChannelMessage +from rich.console import Console + +from bub_face.state import Emotion, shared_controller +from bub_face.terminal import ( + BubCliChannel, + FaceAwareCliChannel, + FaceCliRenderer, + TerminalSidebarSnapshot, + _face_style, +) + + +def test_renderer_panel_includes_face_sidebar() -> None: + console = Console(record=True, width=140) + renderer = FaceCliRenderer( + console, + sidebar_provider=lambda: TerminalSidebarSnapshot( + emotion="thinking", + note="Reasoning", + compact_face="o..-", + face_lines=[ + " .--------. .--------. ", + " | o..- | | -..o | ", + " '--------' '--------' ", + ], + ), + ) + + console.print(renderer.panel("normal", "hello from bub")) + output = console.export_text() + + assert "Bub" in output + assert "emotion:thinking" in output + assert "workspace:bubbuild" not in output + assert ".--------." in output + assert "o..-" in output + assert "hello from bub" in output + + +def test_send_updates_emotion_via_legacy_cli_outbound(monkeypatch) -> None: + channel = object.__new__(FaceAwareCliChannel) + controller = shared_controller() + controller.reset() + channel._face_controller = controller + + captured: list[str] = [] + + async def fake_send(self, message: ChannelMessage) -> None: + captured.append(message.content) + + monkeypatch.setattr(BubCliChannel, "send", fake_send) + + asyncio.run( + FaceAwareCliChannel.send( + channel, + ChannelMessage( + session_id="cli_session", + channel="cli", + chat_id="local", + content="hello", + kind="normal", + ), + ) + ) + + assert captured == ["hello"] + assert controller.state.emotion is Emotion.HAPPY + + +def test_terminal_face_changes_with_emotion() -> None: + neutral = _face_style(Emotion.NEUTRAL) + happy = _face_style(Emotion.HAPPY) + angry = _face_style(Emotion.ANGRY) + + assert neutral != happy + assert happy != angry + assert neutral.compact_face == "o==o" + assert happy.compact_face == "\\^^/" + assert angry.compact_face == ">!!<"