From 49a12817165d99ee56ddaa62742067a5310eb19f Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 7 Mar 2026 14:48:00 +0300 Subject: [PATCH 01/12] Branch for P6-T2: build broker daemon terminal frontend From efa22ccae6a5de34ce6cc93a9b69726bb6eb7d04 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 7 Mar 2026 14:48:22 +0300 Subject: [PATCH 02/12] Select task P6-T2: Build a terminal frontend for broker daemon monitoring and control --- SPECS/INPROGRESS/next.md | 9 +++------ SPECS/Workplan.md | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index 2b31432..18b8dc3 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -2,17 +2,14 @@ **Priority:** P1 **Phase:** Phase 6: Explicit Broker Frontend +**Effort:** 6h **Dependencies:** P6-T1 -**Status:** Ready +**Status:** Selected ## Description Implement a terminal-first operator interface for the broker daemon so users can explicitly see whether the daemon is running, whether upstream Xcode connectivity is healthy, which clients are attached, and what recent reconnect/error events occurred. The interface should give a clearer operational model than auto-spawn alone. -## Recently Archived - -- `P6-T1` — Add explicit broker runtime status surface for frontend consumers (`PASS`, archived 2026-03-07) - ## Next Step -Run the PLAN command for `P6-T2` and define the TUI scope, entrypoint, and control flow against the new broker runtime status API. +Run the PLAN command to generate the implementation-ready PRD. diff --git a/SPECS/Workplan.md b/SPECS/Workplan.md index 8917c96..933bce7 100644 --- a/SPECS/Workplan.md +++ b/SPECS/Workplan.md @@ -438,7 +438,7 @@ Add new tasks using the canonical template in [TASK_TEMPLATE.md](TASK_TEMPLATE.m - [x] Status makes reconnecting/not-ready states explicit so a frontend can distinguish them from a healthy shared daemon - [x] Automated tests cover both healthy and degraded broker runtime status responses -#### ⬜️ P6-T2: Build a terminal frontend for broker daemon monitoring and control +#### ⬜️ P6-T2: Build a terminal frontend for broker daemon monitoring and control **INPROGRESS** - **Description:** Implement a terminal-first operator interface for the broker daemon so users can explicitly see whether the daemon is running, whether upstream Xcode connectivity is healthy, which clients are attached, and what recent reconnect/error events occurred. The interface should give a clearer operational model than auto-spawn alone. - **Priority:** P1 - **Dependencies:** P6-T1 From db0cb6c9edd52bb65614ebd87625001dcb0a379b Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 7 Mar 2026 14:49:17 +0300 Subject: [PATCH 03/12] Select task P6-T2: Build a terminal frontend for broker daemon monitoring and control From 0d5ab9637bbb7b44fd3f01c7202bd3523c3eb621 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 7 Mar 2026 14:51:12 +0300 Subject: [PATCH 04/12] Plan task P6-T2: Build a terminal frontend for broker daemon monitoring and control --- ...or_broker_daemon_monitoring_and_control.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 SPECS/INPROGRESS/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md diff --git a/SPECS/INPROGRESS/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md b/SPECS/INPROGRESS/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md new file mode 100644 index 0000000..0072dc8 --- /dev/null +++ b/SPECS/INPROGRESS/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md @@ -0,0 +1,134 @@ +# P6-T2 — Build a terminal frontend for broker daemon monitoring and control + +## Objective Summary + +Broker mode now has an explicit runtime-status surface through the broker-hosted +Web UI API, but operators still need to open a browser or tail `broker.log` to +understand whether the shared daemon is healthy. This task adds a terminal-first +frontend that makes broker health visible from one command. The frontend should +work as an operator tool, not as another MCP client: it must show the broker's +lifecycle state, core runtime identifiers, active-client count, and recent +reconnect/error signals, and it must expose at least one daemon control action. + +The implementation should not add a heavy TUI framework unless the existing +stdlib capabilities are clearly insufficient. The project currently ships only +base and optional Web UI dependencies, so the frontend should prefer a +dependency-free approach that reads the broker's local state, optionally calls +the local Web UI status API when available, and renders a live terminal screen +with simple key controls. + +## Deliverables + +- Add a broker terminal frontend module under `src/mcpbridge_wrapper/` that + renders a live terminal view and can be launched from the main CLI. +- Add CLI wiring for a dedicated operator flag (for example `--broker-tui`) + without disturbing existing direct, broker, or Web UI modes. +- Show broker lifecycle status, daemon/upstream PID information, connected + client count, and recent broker event/reconnect indicators in the UI. +- Expose at least one explicit lifecycle control from the TUI, with stop as the + minimum acceptable action. +- Add automated tests covering argument parsing, state snapshot/rendering, and + main-branch CLI wiring for the terminal frontend. + +## Success Criteria + +- Users can launch a terminal UI directly from the wrapper package to inspect + broker runtime state without manually tailing `~/.mcpbridge_wrapper/broker.log`. +- The UI shows, at minimum, broker state, daemon PID, upstream PID when known, + connected client count, and recent reconnect/error indicators. +- The UI exposes at least one daemon lifecycle control action and handles the + action safely with clear terminal feedback. +- The solution does not require introducing a new third-party TUI dependency. + +## Test-First Plan + +1. Add unit tests for a new terminal frontend snapshot/renderer module so the + expected output sections are fixed before CLI wiring. +2. Add tests for broker-argument parsing and `main()` behavior around the new + terminal frontend flag, including isolation from other broker-only commands. +3. Add tests for the control path so the TUI can request broker shutdown + without duplicating fragile signal logic inline. +4. Implement production code only after the expected snapshot/render/control + contracts are pinned in tests. +5. Run the required quality gates: `pytest`, `ruff check src/`, `mypy src/`, + and `pytest --cov`. + +## Execution Plan + +### Phase 1: Define terminal frontend data sources and command contract + +Inputs: +- `src/mcpbridge_wrapper/__main__.py` broker lifecycle branches +- `src/mcpbridge_wrapper/broker/types.py` default state paths +- `src/mcpbridge_wrapper/webui/config.py` and `/api/broker/status` contract +- `docs/broker-mode.md` operational expectations for logs, status, and stop + +Outputs: +- Final CLI flag and launch contract for the terminal frontend +- Snapshot model describing broker state, API availability, and recent events +- Decision on how the frontend discovers Web UI host/port/auth vs local files + +Verification: +- The frontend can operate even when the browser dashboard is not open +- Existing broker lifecycle commands remain separate and backward-compatible + +### Phase 2: Implement terminal frontend module and CLI wiring + +Inputs: +- Broker status/control helpers in `src/mcpbridge_wrapper/__main__.py` +- Broker runtime status API and local broker state files + +Outputs: +- New terminal frontend module with: + - snapshot collection + - terminal rendering + - simple key handling / refresh loop + - stop control integration +- `main()` wiring for the new launch flag + +Verification: +- A user can run one command and get a refreshing terminal dashboard +- The stop control exits cleanly and reports result in the terminal + +### Phase 3: Lock behavior with tests and validation + +Inputs: +- New frontend module +- Updated `main()` and parsing logic +- Unit tests for terminal rendering/control behavior + +Outputs: +- Passing unit tests for CLI/TUI behavior +- Validation report with full quality-gate results + +Verification: +- The terminal frontend is covered without relying on an interactive real TTY +- Coverage remains at or above the repository threshold + +## Acceptance Tests + +- `pytest tests/unit/test_main.py -k broker_tui` +- `pytest tests/unit/test_broker_tui.py` +- `pytest` +- `ruff check src/` +- `mypy src/` +- `pytest --cov` + +## Decision Points + +- Use a stdlib terminal loop with ANSI screen clears and non-blocking key reads + instead of introducing `textual`, `rich`, or another new UI dependency. +- Treat the broker-hosted Web UI API as the richest optional data source, but + keep local PID/socket/version/log inspection as a fallback so the frontend is + still useful when the dashboard is unavailable. +- Reuse or extract broker stop logic from `__main__.py` rather than duplicating + signal-handling code in the terminal frontend. +- Review subject name for this task: `broker_terminal_frontend`. + +## Notes + +- User-facing documentation updates for the dedicated-host workflow belong in + `P6-T3`, unless a small inline CLI help note is required for correctness. +- Keep the first iteration deliberately narrow: live status + recent events + + one control action are enough to satisfy the operator need without turning + this task into a full dashboard rewrite. From 8ca8b62473ef6347030e9a23eef0308299949d78 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 7 Mar 2026 14:51:33 +0300 Subject: [PATCH 05/12] Plan task P6-T2: Build a terminal frontend for broker daemon monitoring and control --- ...or_broker_daemon_monitoring_and_control.md | 165 +++++++++--------- 1 file changed, 81 insertions(+), 84 deletions(-) diff --git a/SPECS/INPROGRESS/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md b/SPECS/INPROGRESS/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md index 0072dc8..1dd99a8 100644 --- a/SPECS/INPROGRESS/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md +++ b/SPECS/INPROGRESS/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md @@ -2,113 +2,110 @@ ## Objective Summary -Broker mode now has an explicit runtime-status surface through the broker-hosted -Web UI API, but operators still need to open a browser or tail `broker.log` to -understand whether the shared daemon is healthy. This task adds a terminal-first -frontend that makes broker health visible from one command. The frontend should -work as an operator tool, not as another MCP client: it must show the broker's -lifecycle state, core runtime identifiers, active-client count, and recent -reconnect/error signals, and it must expose at least one daemon control action. - -The implementation should not add a heavy TUI framework unless the existing -stdlib capabilities are clearly insufficient. The project currently ships only -base and optional Web UI dependencies, so the frontend should prefer a -dependency-free approach that reads the broker's local state, optionally calls -the local Web UI status API when available, and renders a live terminal screen -with simple key controls. +Users who adopt the dedicated broker host pattern still need a first-class +operator surface that does not require a browser. This task adds a standalone +terminal frontend that attaches to the existing broker-hosted Web UI, reads the +broker runtime status API introduced in `P6-T1`, and presents broker health in +one explicit place. The frontend should make it obvious whether the shared +daemon is healthy, reconnecting, awaiting approval, or no longer reachable. + +The implementation should stay dependency-light. Prefer the Python standard +library (`curses`, `urllib`, `json`) and existing `WebUIConfig` loading over a +new third-party terminal framework. The TUI may require an already running +broker-hosted Web UI; that constraint is acceptable as long as failure modes are +clear and actionable. ## Deliverables -- Add a broker terminal frontend module under `src/mcpbridge_wrapper/` that - renders a live terminal view and can be launched from the main CLI. -- Add CLI wiring for a dedicated operator flag (for example `--broker-tui`) - without disturbing existing direct, broker, or Web UI modes. -- Show broker lifecycle status, daemon/upstream PID information, connected - client count, and recent broker event/reconnect indicators in the UI. -- Expose at least one explicit lifecycle control from the TUI, with stop as the - minimum acceptable action. -- Add automated tests covering argument parsing, state snapshot/rendering, and - main-branch CLI wiring for the terminal frontend. +- Add a standalone `--tui` runtime mode that launches an interactive terminal + dashboard instead of proxy, bridge, or daemon execution. +- Implement a TUI module that polls `GET /api/broker/status`, inspects control + capability, and renders broker state, PID information, connected client + counts, readiness flags, and reconnect indicators. +- Surface recent broker activity by tailing the recommended local + `~/.mcpbridge_wrapper/broker.log` file inside the terminal UI. +- Expose at least one lifecycle control action from the TUI, with `stop` backed + by `POST /api/control/stop`. +- Add automated tests for argument parsing, HTTP/status handling, and terminal + rendering/control logic. ## Success Criteria -- Users can launch a terminal UI directly from the wrapper package to inspect - broker runtime state without manually tailing `~/.mcpbridge_wrapper/broker.log`. -- The UI shows, at minimum, broker state, daemon PID, upstream PID when known, - connected client count, and recent reconnect/error indicators. -- The UI exposes at least one daemon lifecycle control action and handles the - action safely with clear terminal feedback. -- The solution does not require introducing a new third-party TUI dependency. +- Users can run `mcpbridge-wrapper --tui` to inspect broker status without + tailing logs manually. +- The terminal UI shows broker state, daemon PID, upstream PID, connected + client count, readiness/cached-tool status, and reconnect attempt count. +- The UI displays recent broker log lines or equivalent reconnect indicators in + the same screen. +- The UI exposes an explicit stop control and handles unavailable/unauthorized + backends with clear messaging. +- No new runtime dependency is required for the terminal frontend. ## Test-First Plan -1. Add unit tests for a new terminal frontend snapshot/renderer module so the - expected output sections are fixed before CLI wiring. -2. Add tests for broker-argument parsing and `main()` behavior around the new - terminal frontend flag, including isolation from other broker-only commands. -3. Add tests for the control path so the TUI can request broker shutdown - without duplicating fragile signal logic inline. -4. Implement production code only after the expected snapshot/render/control - contracts are pinned in tests. -5. Run the required quality gates: `pytest`, `ruff check src/`, `mypy src/`, +1. Add parser/main tests that lock down `--tui` mode, including invalid + flag combinations with `--broker*` and `--web-ui`. +2. Add unit tests for a pure rendering/presentation layer so the terminal + layout is testable without a real curses session. +3. Add client tests for status fetches, stop requests, auth header behavior, + and log tail handling using mocks or a lightweight HTTP stub. +4. Implement the production TUI only after the expected runtime contract and + screen sections are pinned in tests. +5. Run required quality gates: `pytest`, `ruff check src/`, `mypy src/`, and `pytest --cov`. ## Execution Plan -### Phase 1: Define terminal frontend data sources and command contract +### Phase 1: Standalone TUI mode and configuration Inputs: -- `src/mcpbridge_wrapper/__main__.py` broker lifecycle branches -- `src/mcpbridge_wrapper/broker/types.py` default state paths -- `src/mcpbridge_wrapper/webui/config.py` and `/api/broker/status` contract -- `docs/broker-mode.md` operational expectations for logs, status, and stop +- `src/mcpbridge_wrapper/__main__.py` +- `src/mcpbridge_wrapper/webui/config.py` +- existing broker/Web UI CLI flag behavior Outputs: -- Final CLI flag and launch contract for the terminal frontend -- Snapshot model describing broker state, API availability, and recent events -- Decision on how the frontend discovers Web UI host/port/auth vs local files +- `--tui` argument parsing and validation +- Web UI endpoint resolution for host, port, and optional auth credentials +- clear errors for unsupported flag combinations Verification: -- The frontend can operate even when the browser dashboard is not open -- Existing broker lifecycle commands remain separate and backward-compatible +- `main()` routes cleanly into TUI mode +- `--tui` does not accidentally start bridge, proxy, or broker-daemon codepaths -### Phase 2: Implement terminal frontend module and CLI wiring +### Phase 2: Terminal frontend runtime Inputs: -- Broker status/control helpers in `src/mcpbridge_wrapper/__main__.py` -- Broker runtime status API and local broker state files +- `GET /api/broker/status` +- `POST /api/control/stop` +- broker log path conventions from `BrokerConfig.default()` Outputs: -- New terminal frontend module with: - - snapshot collection - - terminal rendering - - simple key handling / refresh loop - - stop control integration -- `main()` wiring for the new launch flag +- new `src/mcpbridge_wrapper/tui.py` module +- polling client + screen model + curses runner +- keyboard actions for refresh, quit, and stop Verification: -- A user can run one command and get a refreshing terminal dashboard -- The stop control exits cleanly and reports result in the terminal +- healthy and degraded states render distinct operator-facing output +- unreachable backend and auth failures produce actionable terminal messages -### Phase 3: Lock behavior with tests and validation +### Phase 3: Validation and integration hardening Inputs: -- New frontend module -- Updated `main()` and parsing logic -- Unit tests for terminal rendering/control behavior +- TUI runtime implementation +- parser/main tests and pure rendering tests Outputs: -- Passing unit tests for CLI/TUI behavior -- Validation report with full quality-gate results +- unit test coverage for TUI rendering, HTTP control, and CLI wiring +- validation report with required quality gate results Verification: -- The terminal frontend is covered without relying on an interactive real TTY -- Coverage remains at or above the repository threshold +- the TUI remains dependency-free and CI-stable +- quality gates remain green with coverage at or above project threshold ## Acceptance Tests -- `pytest tests/unit/test_main.py -k broker_tui` -- `pytest tests/unit/test_broker_tui.py` +- `pytest tests/unit/test_tui.py` +- `pytest tests/unit/test_main_tui.py` - `pytest` - `ruff check src/` - `mypy src/` @@ -116,19 +113,19 @@ Verification: ## Decision Points -- Use a stdlib terminal loop with ANSI screen clears and non-blocking key reads - instead of introducing `textual`, `rich`, or another new UI dependency. -- Treat the broker-hosted Web UI API as the richest optional data source, but - keep local PID/socket/version/log inspection as a fallback so the frontend is - still useful when the dashboard is unavailable. -- Reuse or extract broker stop logic from `__main__.py` rather than duplicating - signal-handling code in the terminal frontend. -- Review subject name for this task: `broker_terminal_frontend`. +- The TUI should be a standalone mode (`--tui`), not a secondary view embedded + into `--broker-daemon`, so operator lifecycle stays explicit and predictable. +- The TUI should consume the existing broker-hosted Web UI APIs rather than + opening the broker socket directly; that keeps one runtime contract for both + browser and terminal frontends. +- Recent activity should come from the recommended local `broker.log` tail. The + status API already covers health, while the log tail adds human-readable event + context without expanding the HTTP schema again in `P6-T2`. ## Notes -- User-facing documentation updates for the dedicated-host workflow belong in - `P6-T3`, unless a small inline CLI help note is required for correctness. -- Keep the first iteration deliberately narrow: live status + recent events + - one control action are enough to satisfy the operator need without turning - this task into a full dashboard rewrite. +- Keep user-facing documentation mostly scoped to `P6-T3`, aside from any small + inline CLI/help text needed for correctness. +- Prefer pure helper functions for layout and formatting so the curses shell is + thin and easy to test. +- Review subject name for this task: `broker_terminal_frontend`. From e42c43a60542cc228e7dbab1857b2c7d28e012bf Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 7 Mar 2026 15:02:20 +0300 Subject: [PATCH 06/12] Implement P6-T2: add broker daemon terminal frontend --- SPECS/INPROGRESS/P6-T2_Validation_Report.md | 80 +++ src/mcpbridge_wrapper/__main__.py | 49 +- src/mcpbridge_wrapper/tui.py | 426 +++++++++++++++ tests/unit/test_main_tui.py | 107 ++++ tests/unit/test_tui.py | 544 ++++++++++++++++++++ 5 files changed, 1205 insertions(+), 1 deletion(-) create mode 100644 SPECS/INPROGRESS/P6-T2_Validation_Report.md create mode 100644 src/mcpbridge_wrapper/tui.py create mode 100644 tests/unit/test_main_tui.py create mode 100644 tests/unit/test_tui.py diff --git a/SPECS/INPROGRESS/P6-T2_Validation_Report.md b/SPECS/INPROGRESS/P6-T2_Validation_Report.md new file mode 100644 index 0000000..3ae5b47 --- /dev/null +++ b/SPECS/INPROGRESS/P6-T2_Validation_Report.md @@ -0,0 +1,80 @@ +# P6-T2 Validation Report + +**Task:** P6-T2 — Build a terminal frontend for broker daemon monitoring and control +**Date:** 2026-03-07 +**Verdict:** PASS + +## Summary + +Implemented a terminal frontend for the broker daemon by: + +- adding a curses-backed TUI module in `src/mcpbridge_wrapper/tui.py` +- wiring `--tui` into `src/mcpbridge_wrapper/__main__.py` +- resolving dashboard endpoint/auth settings from existing Web UI config +- surfacing broker runtime details from `/api/control` and `/api/broker/status` +- adding local PID/socket/version fallback details and broker-log tailing +- adding dedicated unit coverage for runtime resolution, HTTP aggregation, + rendering, interactive loop behavior, and main CLI integration + +## Files Validated + +- `src/mcpbridge_wrapper/tui.py` +- `src/mcpbridge_wrapper/__main__.py` +- `tests/unit/test_tui.py` +- `tests/unit/test_main_tui.py` + +## Targeted Verification + +```bash +PYTHONPATH=src pytest tests/unit/test_tui.py tests/unit/test_main_tui.py -q +``` + +- Result: `32 passed` + +```bash +ruff check src/mcpbridge_wrapper/tui.py src/mcpbridge_wrapper/__main__.py tests/unit/test_tui.py tests/unit/test_main_tui.py +``` + +- Result: `All checks passed!` + +```bash +mypy src/mcpbridge_wrapper/tui.py src/mcpbridge_wrapper/__main__.py +``` + +- Result: `Success: no issues found in 2 source files` + +## Required Quality Gates + +```bash +PYTHONPATH=src pytest +``` + +- Result: `819 passed, 5 skipped in 8.31s` + +```bash +ruff check src/ +``` + +- Result: `All checks passed!` + +```bash +mypy src/ +``` + +- Result: `Success: no issues found in 19 source files` + +```bash +PYTHONPATH=src pytest --cov +``` + +- Result: `819 passed, 5 skipped in 9.12s` +- Coverage: `90.86%` + +## Notes + +- `PYTHONPATH=src` was required for local `pytest` invocations because the + package is not installed into the active interpreter environment. +- The TUI depends only on the Python standard library plus the existing local + Web UI API surface; no new project dependency was introduced. +- Remaining warnings are pre-existing `websockets` / `uvicorn` deprecations and + are not introduced by this task. diff --git a/src/mcpbridge_wrapper/__main__.py b/src/mcpbridge_wrapper/__main__.py index 9255827..53e415f 100644 --- a/src/mcpbridge_wrapper/__main__.py +++ b/src/mcpbridge_wrapper/__main__.py @@ -128,6 +128,20 @@ def _parse_webui_args( return web_ui, web_ui_only, web_ui_restart, port, config_path, remaining +def _parse_tui_args(args: list) -> Tuple[bool, list]: + """Parse terminal frontend arguments from command-line args.""" + tui_enabled = False + remaining = [] + + for arg in args: + if arg == "--tui": + tui_enabled = True + else: + remaining.append(arg) + + return tui_enabled, remaining + + def _find_listener_pids_for_port(port: int) -> Set[int]: """Return listener PIDs bound to TCP port, or empty set when none found.""" try: @@ -495,10 +509,27 @@ def main() -> int: print(f"Error: {exc}", file=sys.stderr) return 2 + tui_enabled, after_tui_args = _parse_tui_args(after_webui_args) broker_daemon, broker_connect, broker_spawn, broker_status, broker_stop, bridge_args = ( - _parse_broker_args(after_webui_args) + _parse_broker_args(after_tui_args) ) + if tui_enabled and web_ui_enabled: + print( + "Error: --tui cannot be combined with --web-ui flags. " + "Use --web-ui-port/--web-ui-config to target an existing dashboard.", + file=sys.stderr, + ) + return 2 + + if tui_enabled and (broker_daemon or broker_connect or broker_status or broker_stop): + print("Error: --tui cannot be combined with broker mode flags.", file=sys.stderr) + return 2 + + if tui_enabled and bridge_args: + print("Error: --tui does not accept bridge arguments.", file=sys.stderr) + return 2 + if web_ui_only and (broker_daemon or broker_connect): print( "Error: --web-ui-only cannot be combined with broker mode flags.", @@ -506,6 +537,22 @@ def main() -> int: ) return 2 + if tui_enabled: + from mcpbridge_wrapper.tui import build_tui_runtime, run_tui + + if not sys.stdin.isatty() or not sys.stdout.isatty(): + print("Error: --tui requires an interactive terminal.", file=sys.stderr) + return 2 + + tui_runtime = build_tui_runtime( + web_ui_port=web_ui_port, + web_ui_config=web_ui_config, + ) + try: + return run_tui(tui_runtime) + except KeyboardInterrupt: + return 0 + # --broker-status: print broker daemon status and exit if broker_status: from mcpbridge_wrapper import __version__ diff --git a/src/mcpbridge_wrapper/tui.py b/src/mcpbridge_wrapper/tui.py new file mode 100644 index 0000000..78e8f57 --- /dev/null +++ b/src/mcpbridge_wrapper/tui.py @@ -0,0 +1,426 @@ +"""Terminal frontend for broker daemon monitoring and control.""" + +from __future__ import annotations + +import base64 +import contextlib +import json +import os +import textwrap +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from mcpbridge_wrapper.broker.types import BrokerConfig +from mcpbridge_wrapper.webui.config import WebUIConfig + + +@dataclass +class TUIRuntimeConfig: + """Resolved runtime settings for the broker terminal UI.""" + + base_url: str + auth_header: str | None + log_path: Path + pid_file: Path = field(default_factory=lambda: BrokerConfig.default().pid_file) + socket_path: Path = field(default_factory=lambda: BrokerConfig.default().socket_path) + version_file: Path = field(default_factory=lambda: BrokerConfig.default().version_file) + timeout_seconds: float = 1.5 + refresh_interval_seconds: float = 1.0 + recent_log_lines: int = 8 + + +@dataclass +class BrokerTUISnapshot: + """Operator-facing snapshot rendered by the broker terminal UI.""" + + base_url: str + service_name: str + can_stop: bool + available: bool + broker: dict[str, Any] | None + recent_events: list[str] + local_pid: int | None = None + local_daemon_running: bool = False + local_socket_present: bool = False + local_daemon_version: str | None = None + local_pid_file: str = "n/a" + local_socket_path: str = "n/a" + local_version_file: str = "n/a" + error_message: str | None = None + status_message: str | None = None + refreshed_at: float = field(default_factory=time.time) + + +def build_tui_runtime( + *, + web_ui_port: int | None, + web_ui_config: str | None, +) -> TUIRuntimeConfig: + """Resolve endpoint, auth, and log-path settings for TUI mode.""" + config = WebUIConfig(config_path=web_ui_config) + if web_ui_port is not None: + config._data["port"] = web_ui_port + + auth_header: str | None = None + if config.auth_enabled: + raw_credentials = f"{config.auth_username}:{config.auth_password}".encode() + token = base64.b64encode(raw_credentials).decode("ascii") + auth_header = f"Basic {token}" + + broker_state_dir = BrokerConfig.default().pid_file.parent + return TUIRuntimeConfig( + base_url=f"http://{config.host}:{config.port}", + auth_header=auth_header, + log_path=broker_state_dir / "broker.log", + pid_file=broker_state_dir / "broker.pid", + socket_path=broker_state_dir / "broker.sock", + version_file=broker_state_dir / "broker.version", + ) + + +def tail_log_lines(log_path: Path, max_lines: int = 8) -> list[str]: + """Return the last ``max_lines`` from the broker log with friendly fallbacks.""" + if max_lines <= 0: + return [] + + if not log_path.exists(): + return [f"(no broker log at {log_path})"] + + text = log_path.read_text(encoding="utf-8", errors="replace") + lines = text.splitlines() + if not lines: + return ["(broker log is empty)"] + + return lines[-max_lines:] + + +class BrokerTUIClient: + """HTTP-backed client used by the terminal frontend.""" + + def __init__(self, runtime: TUIRuntimeConfig) -> None: + """Store runtime settings for later polling and control calls.""" + self._runtime = runtime + + def fetch_snapshot(self, status_message: str | None = None) -> BrokerTUISnapshot: + """Fetch control + broker status and merge them into one render snapshot.""" + recent_events = tail_log_lines( + self._runtime.log_path, + max_lines=self._runtime.recent_log_lines, + ) + local_pid, local_running = _read_local_pid(self._runtime.pid_file) + local_version = _read_local_version(self._runtime.version_file) + + try: + control = self._request_json("/api/control") + broker_status = self._request_json("/api/broker/status") + except RuntimeError as exc: + return BrokerTUISnapshot( + base_url=self._runtime.base_url, + service_name="unavailable", + can_stop=False, + available=False, + broker=None, + recent_events=recent_events, + local_pid=local_pid, + local_daemon_running=local_running, + local_socket_present=self._runtime.socket_path.exists(), + local_daemon_version=local_version, + local_pid_file=str(self._runtime.pid_file), + local_socket_path=str(self._runtime.socket_path), + local_version_file=str(self._runtime.version_file), + error_message=str(exc), + status_message=status_message, + ) + + service_name = str( + broker_status.get("service_name") + or control.get("service_name") + or "mcpbridge-wrapper" + ) + broker_payload = broker_status.get("broker") + broker = broker_payload if isinstance(broker_payload, dict) else None + status_error = broker_status.get("error") + + return BrokerTUISnapshot( + base_url=self._runtime.base_url, + service_name=service_name, + can_stop=bool(control.get("can_stop")), + available=bool(broker_status.get("available")), + broker=broker, + recent_events=recent_events, + local_pid=local_pid, + local_daemon_running=local_running, + local_socket_present=self._runtime.socket_path.exists(), + local_daemon_version=local_version, + local_pid_file=str(self._runtime.pid_file), + local_socket_path=str(self._runtime.socket_path), + local_version_file=str(self._runtime.version_file), + error_message=status_error if isinstance(status_error, str) and status_error else None, + status_message=status_message, + ) + + def request_stop(self) -> tuple[bool, str]: + """Request broker shutdown through the control API.""" + try: + payload = self._request_json("/api/control/stop", method="POST") + except RuntimeError as exc: + return False, str(exc) + + message = payload.get("message") + if not isinstance(message, str) or not message: + message = "Shutdown requested." + return True, message + + def _request_json(self, path: str, method: str = "GET") -> dict[str, Any]: + """Perform a JSON request against the local Web UI API.""" + url = f"{self._runtime.base_url.rstrip('/')}{path}" + request = urllib.request.Request(url, method=method) + request.add_header("Accept", "application/json") + if self._runtime.auth_header: + request.add_header("Authorization", self._runtime.auth_header) + + try: + with urllib.request.urlopen(request, timeout=self._runtime.timeout_seconds) as response: + payload = response.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + payload = exc.read().decode("utf-8", errors="replace") + detail = _extract_http_error(payload) or str(exc.reason or exc) + raise RuntimeError(f"{method} {path} failed: {detail}") from exc + except urllib.error.URLError as exc: + reason = exc.reason if exc.reason is not None else exc + raise RuntimeError(f"Cannot reach {self._runtime.base_url}: {reason}") from exc + + try: + data = json.loads(payload) + except json.JSONDecodeError as exc: + raise RuntimeError(f"{method} {path} returned invalid JSON.") from exc + + if not isinstance(data, dict): + raise RuntimeError(f"{method} {path} returned an unexpected payload.") + + return data + + +def render_screen(snapshot: BrokerTUISnapshot, width: int) -> list[str]: + """Build wrapped screen lines for the current broker snapshot.""" + broker = snapshot.broker or {} + lines = [ + "mcpbridge-wrapper Broker TUI", + "Keys: q quit | r refresh | s stop broker", + "", + f"Endpoint: {snapshot.base_url}", + f"Service: {snapshot.service_name}", + f"Stop Control: {_availability(snapshot.can_stop)}", + "", + "Local Broker Files", + f"Local PID: {_display_value(snapshot.local_pid)}" + + (" (running)" if snapshot.local_daemon_running else " (not running)"), + f"Local Version: {_display_value(snapshot.local_daemon_version)}", + f"Local Socket Present: {_yes_no(snapshot.local_socket_present)}", + f"PID File: {_display_value(snapshot.local_pid_file)}", + f"Socket Path: {_display_value(snapshot.local_socket_path)}", + f"Version File: {_display_value(snapshot.local_version_file)}", + "", + "Broker Runtime", + ] + + if snapshot.available and broker: + lines.extend( + [ + f"State: {_display_value(broker.get('state'))}", + f"Daemon PID: {_display_value(broker.get('pid'))}", + f"Upstream PID: {_display_value(broker.get('upstream_pid'))}", + f"Connected Clients: {_display_value(broker.get('connected_clients'))}", + f"Upstream Alive: {_yes_no(broker.get('upstream_alive'))}", + f"Initialized: {_yes_no(broker.get('upstream_initialized'))}", + f"Tools Cached: {_yes_no(broker.get('tools_list_cached'))}", + f"Reconnect Attempt: {_display_value(broker.get('reconnect_attempt'))}", + f"Shutdown Requested: {_yes_no(broker.get('shutdown_requested'))}", + f"Socket: {_display_value(broker.get('socket_path'))}", + ] + ) + else: + lines.append("Broker runtime is unavailable.") + if snapshot.error_message: + lines.append(f"Error: {snapshot.error_message}") + + lines.extend(["", "Recent Broker Events"]) + lines.extend(snapshot.recent_events or ["(no broker events found)"]) + + if snapshot.error_message and (snapshot.available and broker): + lines.extend(["", f"Warning: {snapshot.error_message}"]) + + lines.extend( + [ + "", + "Last Refresh: " + + time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(snapshot.refreshed_at)), + ] + ) + if snapshot.status_message: + lines.append(f"Message: {snapshot.status_message}") + + return _wrap_lines(lines, width=max(20, width)) + + +class BrokerTUI: + """Thin curses shell around the broker TUI client and render helpers.""" + + def __init__(self, client: BrokerTUIClient, refresh_interval_seconds: float = 1.0) -> None: + """Store the polling client and refresh interval.""" + self._client = client + self._refresh_interval_seconds = refresh_interval_seconds + self._status_message: str | None = None + + def run(self) -> int: + """Start the interactive terminal session.""" + import curses + + return int(curses.wrapper(self._run_loop)) + + def _run_loop(self, stdscr: Any) -> int: + """Drive the refresh and key-handling loop.""" + import curses + + with contextlib.suppress(Exception): + curses.curs_set(0) + + stdscr.nodelay(True) + stdscr.timeout(100) + + snapshot = self._client.fetch_snapshot(self._status_message) + last_refresh = time.monotonic() + + while True: + self._render(stdscr, snapshot) + key = stdscr.getch() + if key in (ord("q"), ord("Q")): + return 0 + if key in (ord("r"), ord("R")): + snapshot = self._client.fetch_snapshot(self._status_message) + last_refresh = time.monotonic() + continue + if key in (ord("s"), ord("S")): + _, self._status_message = self._client.request_stop() + snapshot = self._client.fetch_snapshot(self._status_message) + last_refresh = time.monotonic() + continue + + if time.monotonic() - last_refresh >= self._refresh_interval_seconds: + snapshot = self._client.fetch_snapshot(self._status_message) + last_refresh = time.monotonic() + + def _render(self, stdscr: Any, snapshot: BrokerTUISnapshot) -> None: + """Render the current snapshot into the terminal window.""" + stdscr.erase() + height, width = stdscr.getmaxyx() + lines = render_screen(snapshot, width=max(20, width - 1)) + + # Keep the curses layer thin: rendering is pure and tested separately. + for row, line in enumerate(lines[: max(1, height - 1)]): + with contextlib.suppress(Exception): + stdscr.addnstr(row, 0, line, max(1, width - 1)) + + stdscr.refresh() + + +def run_tui(runtime: TUIRuntimeConfig) -> int: + """Run the broker terminal frontend for the resolved runtime.""" + client = BrokerTUIClient(runtime) + ui = BrokerTUI(client, refresh_interval_seconds=runtime.refresh_interval_seconds) + return ui.run() + + +def _extract_http_error(payload: str) -> str | None: + """Extract a human-readable detail string from an error payload.""" + try: + data = json.loads(payload) + except json.JSONDecodeError: + return payload.strip() or None + + if not isinstance(data, dict): + return payload.strip() or None + + detail = data.get("detail") + if isinstance(detail, str) and detail: + return detail + + message = data.get("message") + if isinstance(message, str) and message: + return message + + error = data.get("error") + if isinstance(error, str) and error: + return error + + return payload.strip() or None + + +def _wrap_lines(lines: list[str], width: int) -> list[str]: + """Wrap screen lines to fit the available width.""" + wrapped: list[str] = [] + effective_width = max(1, width) + for line in lines: + if not line: + wrapped.append("") + continue + wrapped.extend( + textwrap.wrap( + line, + width=effective_width, + replace_whitespace=False, + drop_whitespace=False, + ) + or [""] + ) + return wrapped + + +def _availability(value: bool) -> str: + """Format availability for operator output.""" + return "available" if value else "unavailable" + + +def _yes_no(value: Any) -> str: + """Format booleans for operator output.""" + return "yes" if bool(value) else "no" + + +def _display_value(value: Any) -> str: + """Format possibly-missing values for operator output.""" + if value is None or value == "": + return "n/a" + return str(value) + + +def _read_local_pid(pid_file: Path) -> tuple[int | None, bool]: + """Read broker PID from file and report whether it is still alive.""" + if not pid_file.exists(): + return None, False + + try: + pid = int(pid_file.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + return None, False + + try: + os.kill(pid, 0) + except ProcessLookupError: + return pid, False + except PermissionError: + return pid, True + return pid, True + + +def _read_local_version(version_file: Path) -> str | None: + """Read broker version file if present.""" + if not version_file.exists(): + return None + try: + return version_file.read_text(encoding="utf-8").strip() or None + except OSError: + return None diff --git a/tests/unit/test_main_tui.py b/tests/unit/test_main_tui.py new file mode 100644 index 0000000..026c9cc --- /dev/null +++ b/tests/unit/test_main_tui.py @@ -0,0 +1,107 @@ +"""Tests for __main__.py terminal frontend integration.""" + +import json +from unittest.mock import patch + +from mcpbridge_wrapper.__main__ import _parse_tui_args, main + + +class TestParseTUIArgs: + """Tests for _parse_tui_args.""" + + def test_parse_tui_flag(self) -> None: + enabled, remaining = _parse_tui_args(["--tui", "--foo"]) + + assert enabled is True + assert remaining == ["--foo"] + + def test_parse_tui_flag_absent(self) -> None: + enabled, remaining = _parse_tui_args(["--foo"]) + + assert enabled is False + assert remaining == ["--foo"] + + +class TestMainTUI: + """Tests for main() behavior in standalone terminal frontend mode.""" + + def test_main_tui_runs_terminal_frontend(self, tmp_path) -> None: + config_path = tmp_path / "webui.json" + config_path.write_text( + json.dumps( + { + "host": "127.0.0.1", + "port": 9091, + "auth": { + "enabled": True, + "username": "alice", + "password": "secret", + }, + } + ) + ) + + with patch( + "mcpbridge_wrapper.__main__.sys.argv", + ["mcpbridge-wrapper", "--tui", "--web-ui-config", str(config_path)], + ), patch( + "mcpbridge_wrapper.__main__.sys.stdin" + ) as mock_stdin, patch( + "mcpbridge_wrapper.__main__.sys.stdout" + ) as mock_stdout, patch( + "mcpbridge_wrapper.tui.run_tui", return_value=0 + ) as run_tui: + mock_stdin.isatty.return_value = True + mock_stdout.isatty.return_value = True + + result = main() + + assert result == 0 + runtime = run_tui.call_args.args[0] + assert runtime.base_url == "http://127.0.0.1:9091" + assert runtime.auth_header is not None + assert runtime.auth_header.startswith("Basic ") + + def test_main_tui_rejects_broker_flags(self, capsys) -> None: + with patch( + "mcpbridge_wrapper.__main__.sys.argv", + ["mcpbridge-wrapper", "--tui", "--broker"], + ): + result = main() + + assert result == 2 + assert "--tui cannot be combined with broker mode flags" in capsys.readouterr().err + + def test_main_tui_rejects_webui_flags(self, capsys) -> None: + with patch( + "mcpbridge_wrapper.__main__.sys.argv", + ["mcpbridge-wrapper", "--tui", "--web-ui"], + ): + result = main() + + assert result == 2 + assert "--tui cannot be combined with --web-ui flags" in capsys.readouterr().err + + def test_main_tui_rejects_bridge_args(self, capsys) -> None: + with patch( + "mcpbridge_wrapper.__main__.sys.argv", + ["mcpbridge-wrapper", "--tui", "--", "--foo"], + ): + result = main() + + assert result == 2 + assert "--tui does not accept bridge arguments" in capsys.readouterr().err + + def test_main_tui_requires_interactive_terminal(self, capsys) -> None: + with patch( + "mcpbridge_wrapper.__main__.sys.argv", ["mcpbridge-wrapper", "--tui"] + ), patch("mcpbridge_wrapper.__main__.sys.stdin") as mock_stdin, patch( + "mcpbridge_wrapper.__main__.sys.stdout" + ) as mock_stdout: + mock_stdin.isatty.return_value = False + mock_stdout.isatty.return_value = True + + result = main() + + assert result == 2 + assert "--tui requires an interactive terminal" in capsys.readouterr().err diff --git a/tests/unit/test_tui.py b/tests/unit/test_tui.py new file mode 100644 index 0000000..30d70f2 --- /dev/null +++ b/tests/unit/test_tui.py @@ -0,0 +1,544 @@ +"""Tests for the broker terminal frontend.""" + +import base64 +import io +import json +import sys +import urllib.error +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import call, patch + +import pytest + +from mcpbridge_wrapper.tui import ( + BrokerTUI, + BrokerTUIClient, + BrokerTUISnapshot, + TUIRuntimeConfig, + _extract_http_error, + build_tui_runtime, + render_screen, + run_tui, + tail_log_lines, +) + + +class _FakeHTTPResponse: + def __init__(self, payload: str) -> None: + self._payload = payload.encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + return False + + def read(self) -> bytes: + return self._payload + + +class _FakeWindow: + def __init__(self, keys: list[int]) -> None: + self._keys = list(keys) + self.lines: list[tuple[int, int, str, int]] = [] + self.timeout_value = None + self.nodelay_value = None + self.refreshed = False + + def nodelay(self, value: bool) -> None: + self.nodelay_value = value + + def timeout(self, value: int) -> None: + self.timeout_value = value + + def getmaxyx(self) -> tuple[int, int]: + return (20, 80) + + def getch(self) -> int: + if not self._keys: + return -1 + return self._keys.pop(0) + + def erase(self) -> None: + return None + + def addnstr(self, row: int, col: int, text: str, width: int) -> None: + self.lines.append((row, col, text, width)) + + def refresh(self) -> None: + self.refreshed = True + + +class TestBuildTUIRuntime: + """Tests for runtime resolution helpers.""" + + def test_build_runtime_uses_config_and_auth(self, tmp_path: Path) -> None: + config_path = tmp_path / "webui.json" + config_path.write_text( + json.dumps( + { + "host": "127.0.0.1", + "port": 9090, + "auth": { + "enabled": True, + "username": "alice", + "password": "secret", + }, + } + ) + ) + + runtime = build_tui_runtime(web_ui_port=None, web_ui_config=str(config_path)) + + assert runtime.base_url == "http://127.0.0.1:9090" + expected = "Basic " + base64.b64encode(b"alice:secret").decode("ascii") + assert runtime.auth_header == expected + assert runtime.log_path.name == "broker.log" + assert runtime.pid_file.name == "broker.pid" + assert runtime.socket_path.name == "broker.sock" + assert runtime.version_file.name == "broker.version" + + def test_build_runtime_port_override_wins(self, tmp_path: Path) -> None: + config_path = tmp_path / "webui.json" + config_path.write_text(json.dumps({"port": 8080})) + + runtime = build_tui_runtime(web_ui_port=9191, web_ui_config=str(config_path)) + + assert runtime.base_url == "http://127.0.0.1:9191" + + +class TestTailLogLines: + """Tests for broker log tailing.""" + + def test_tail_log_lines_reads_recent_entries(self, tmp_path: Path) -> None: + log_path = tmp_path / "broker.log" + log_path.write_text("line-1\nline-2\nline-3\n") + + assert tail_log_lines(log_path, max_lines=2) == ["line-2", "line-3"] + + def test_tail_log_lines_reports_missing_log(self, tmp_path: Path) -> None: + lines = tail_log_lines(tmp_path / "missing.log", max_lines=3) + + assert len(lines) == 1 + assert "no broker log" in lines[0] + + def test_tail_log_lines_handles_zero_limit(self, tmp_path: Path) -> None: + assert tail_log_lines(tmp_path / "broker.log", max_lines=0) == [] + + def test_tail_log_lines_reports_empty_log(self, tmp_path: Path) -> None: + log_path = tmp_path / "broker.log" + log_path.write_text("") + + assert tail_log_lines(log_path, max_lines=3) == ["(broker log is empty)"] + + +class TestBrokerTUIClient: + """Tests for HTTP aggregation and control helpers.""" + + def test_fetch_snapshot_combines_control_status_and_log_tail(self) -> None: + runtime = TUIRuntimeConfig( + base_url="http://127.0.0.1:8080", + auth_header=None, + log_path=Path("/tmp/broker.log"), + pid_file=Path("/tmp/broker.pid"), + socket_path=Path("/tmp/broker.sock"), + version_file=Path("/tmp/broker.version"), + ) + client = BrokerTUIClient(runtime) + + with patch.object( + client, + "_request_json", + side_effect=[ + {"service_name": "broker-daemon", "can_stop": True}, + { + "available": True, + "service_name": "broker-daemon", + "broker": { + "state": "ready", + "pid": 101, + "upstream_pid": 202, + "connected_clients": 3, + }, + }, + ], + ) as request_json, patch( + "mcpbridge_wrapper.tui.tail_log_lines", return_value=["ready"] + ) as tail_lines: + snapshot = client.fetch_snapshot("Refreshed.") + + assert snapshot.service_name == "broker-daemon" + assert snapshot.can_stop is True + assert snapshot.available is True + assert snapshot.broker is not None + assert snapshot.broker["connected_clients"] == 3 + assert snapshot.recent_events == ["ready"] + assert snapshot.status_message == "Refreshed." + assert snapshot.local_pid is None + assert snapshot.local_socket_present is False + assert request_json.call_args_list == [call("/api/control"), call("/api/broker/status")] + tail_lines.assert_called_once_with(runtime.log_path, max_lines=runtime.recent_log_lines) + + def test_fetch_snapshot_surfaces_runtime_errors(self) -> None: + runtime = TUIRuntimeConfig( + base_url="http://127.0.0.1:8080", + auth_header=None, + log_path=Path("/tmp/broker.log"), + pid_file=Path("/tmp/broker.pid"), + socket_path=Path("/tmp/broker.sock"), + version_file=Path("/tmp/broker.version"), + ) + client = BrokerTUIClient(runtime) + + with patch.object( + client, "_request_json", side_effect=RuntimeError("boom") + ), patch("mcpbridge_wrapper.tui.tail_log_lines", return_value=["event"]): + snapshot = client.fetch_snapshot() + + assert snapshot.available is False + assert snapshot.error_message == "boom" + assert snapshot.recent_events == ["event"] + + def test_request_stop_returns_backend_message(self) -> None: + runtime = TUIRuntimeConfig( + base_url="http://127.0.0.1:8080", + auth_header=None, + log_path=Path("/tmp/broker.log"), + pid_file=Path("/tmp/broker.pid"), + socket_path=Path("/tmp/broker.sock"), + version_file=Path("/tmp/broker.version"), + ) + client = BrokerTUIClient(runtime) + + with patch.object( + client, + "_request_json", + return_value={"status": "accepted", "message": "Shutdown requested for broker-daemon."}, + ): + ok, message = client.request_stop() + + assert ok is True + assert message == "Shutdown requested for broker-daemon." + + def test_request_stop_returns_default_message_when_backend_omits_one(self) -> None: + runtime = TUIRuntimeConfig( + base_url="http://127.0.0.1:8080", + auth_header=None, + log_path=Path("/tmp/broker.log"), + ) + client = BrokerTUIClient(runtime) + + with patch.object(client, "_request_json", return_value={"status": "accepted"}): + ok, message = client.request_stop() + + assert ok is True + assert message == "Shutdown requested." + + def test_request_stop_surfaces_runtime_error(self) -> None: + runtime = TUIRuntimeConfig( + base_url="http://127.0.0.1:8080", + auth_header=None, + log_path=Path("/tmp/broker.log"), + ) + client = BrokerTUIClient(runtime) + + with patch.object(client, "_request_json", side_effect=RuntimeError("stop unavailable")): + ok, message = client.request_stop() + + assert ok is False + assert message == "stop unavailable" + + def test_request_json_success_includes_auth_header(self) -> None: + runtime = TUIRuntimeConfig( + base_url="http://127.0.0.1:8080", + auth_header="Basic token", + log_path=Path("/tmp/broker.log"), + timeout_seconds=2.0, + ) + client = BrokerTUIClient(runtime) + captured: dict[str, object] = {} + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + captured["timeout"] = timeout + captured["auth"] = request.headers.get("Authorization") + return _FakeHTTPResponse('{"status": "ok"}') + + with patch("mcpbridge_wrapper.tui.urllib.request.urlopen", side_effect=fake_urlopen): + payload = client._request_json("/api/control") + + assert payload == {"status": "ok"} + assert captured == { + "url": "http://127.0.0.1:8080/api/control", + "timeout": 2.0, + "auth": "Basic token", + } + + def test_request_json_http_error_uses_detail_message(self) -> None: + runtime = TUIRuntimeConfig( + base_url="http://127.0.0.1:8080", + auth_header=None, + log_path=Path("/tmp/broker.log"), + ) + client = BrokerTUIClient(runtime) + error = urllib.error.HTTPError( + url="http://127.0.0.1:8080/api/control/stop", + code=409, + msg="Conflict", + hdrs=None, + fp=io.BytesIO(b'{"detail":"Stop control is not available."}'), + ) + + with patch( + "mcpbridge_wrapper.tui.urllib.request.urlopen", + side_effect=error, + ), pytest.raises(RuntimeError, match="Stop control is not available"): + client._request_json("/api/control/stop", method="POST") + + def test_request_json_url_error_is_actionable(self) -> None: + runtime = TUIRuntimeConfig( + base_url="http://127.0.0.1:8080", + auth_header=None, + log_path=Path("/tmp/broker.log"), + ) + client = BrokerTUIClient(runtime) + + with patch( + "mcpbridge_wrapper.tui.urllib.request.urlopen", + side_effect=urllib.error.URLError("refused"), + ), pytest.raises(RuntimeError, match="Cannot reach http://127.0.0.1:8080: refused"): + client._request_json("/api/control") + + def test_request_json_rejects_invalid_json(self) -> None: + runtime = TUIRuntimeConfig( + base_url="http://127.0.0.1:8080", + auth_header=None, + log_path=Path("/tmp/broker.log"), + ) + client = BrokerTUIClient(runtime) + + with patch( + "mcpbridge_wrapper.tui.urllib.request.urlopen", + return_value=_FakeHTTPResponse("not json"), + ), pytest.raises(RuntimeError, match="returned invalid JSON"): + client._request_json("/api/control") + + def test_request_json_rejects_non_mapping_payload(self) -> None: + runtime = TUIRuntimeConfig( + base_url="http://127.0.0.1:8080", + auth_header=None, + log_path=Path("/tmp/broker.log"), + ) + client = BrokerTUIClient(runtime) + + with patch( + "mcpbridge_wrapper.tui.urllib.request.urlopen", + return_value=_FakeHTTPResponse('["bad"]'), + ), pytest.raises(RuntimeError, match="unexpected payload"): + client._request_json("/api/control") + + +class TestRenderScreen: + """Tests for pure screen rendering helpers.""" + + def test_render_screen_includes_runtime_fields(self) -> None: + snapshot = BrokerTUISnapshot( + base_url="http://127.0.0.1:8080", + service_name="broker-daemon", + can_stop=True, + available=True, + broker={ + "state": "ready", + "pid": 111, + "upstream_pid": 222, + "connected_clients": 2, + "upstream_alive": True, + "upstream_initialized": True, + "tools_list_cached": True, + "reconnect_attempt": 0, + "shutdown_requested": False, + "socket_path": "/tmp/broker.sock", + }, + recent_events=["ready", "tools/list cached"], + local_pid=111, + local_daemon_running=True, + local_socket_present=True, + local_daemon_version="0.4.1", + local_pid_file="/tmp/broker.pid", + local_socket_path="/tmp/broker.sock", + local_version_file="/tmp/broker.version", + status_message="Watching broker.", + ) + + output = "\n".join(render_screen(snapshot, width=80)) + + assert "Broker Runtime" in output + assert "Local Broker Files" in output + assert "Local PID: 111 (running)" in output + assert "State: ready" in output + assert "Connected Clients: 2" in output + assert "Recent Broker Events" in output + assert "Watching broker." in output + + def test_render_screen_handles_unavailable_backend(self) -> None: + snapshot = BrokerTUISnapshot( + base_url="http://127.0.0.1:8080", + service_name="unavailable", + can_stop=False, + available=False, + broker=None, + recent_events=["(no broker log)"], + local_pid=1234, + local_daemon_running=False, + local_socket_present=False, + local_daemon_version="0.4.1", + local_pid_file="/tmp/broker.pid", + local_socket_path="/tmp/broker.sock", + local_version_file="/tmp/broker.version", + error_message="Cannot reach http://127.0.0.1:8080: refused", + ) + + output = "\n".join(render_screen(snapshot, width=80)) + + assert "Broker runtime is unavailable." in output + assert "Cannot reach http://127.0.0.1:8080: refused" in output + + def test_render_screen_shows_runtime_warning_when_broker_is_available(self) -> None: + snapshot = BrokerTUISnapshot( + base_url="http://127.0.0.1:8080", + service_name="broker-daemon", + can_stop=True, + available=True, + broker={"state": "reconnecting", "pid": None, "socket_path": ""}, + recent_events=[], + error_message="degraded runtime", + ) + + output = "\n".join(render_screen(snapshot, width=60)) + + assert "Warning: degraded runtime" in output + assert "(no broker events found)" in output + + +class TestBrokerTUI: + """Tests for the thin curses shell.""" + + def _snapshot(self) -> BrokerTUISnapshot: + return BrokerTUISnapshot( + base_url="http://127.0.0.1:8080", + service_name="broker-daemon", + can_stop=True, + available=True, + broker={"state": "ready", "pid": 1, "socket_path": "/tmp/broker.sock"}, + recent_events=["ready"], + ) + + def test_run_loop_renders_and_exits_on_q(self) -> None: + snapshot = self._snapshot() + + class _Client: + def fetch_snapshot(self, status_message): + del status_message + return snapshot + + window = _FakeWindow([ord("q")]) + fake_curses = SimpleNamespace(curs_set=lambda *_args, **_kwargs: None) + ui = BrokerTUI(_Client()) + + with patch.dict(sys.modules, {"curses": fake_curses}): + result = ui._run_loop(window) + + assert result == 0 + assert window.timeout_value == 100 + assert window.nodelay_value is True + assert window.refreshed is True + assert any("Broker Runtime" in line[2] for line in window.lines) + + def test_run_loop_handles_refresh_and_stop_keys(self) -> None: + snapshot = self._snapshot() + + class _Client: + def __init__(self): + self.fetch_calls = [] + self.request_stop_calls = 0 + + def fetch_snapshot(self, status_message): + self.fetch_calls.append(status_message) + return snapshot + + def request_stop(self): + self.request_stop_calls += 1 + return True, "stop requested" + + inner_client = _Client() + window = _FakeWindow([ord("r"), ord("s"), ord("q")]) + fake_curses = SimpleNamespace(curs_set=lambda *_args, **_kwargs: None) + ui = BrokerTUI(inner_client) + + with patch.dict(sys.modules, {"curses": fake_curses}): + result = ui._run_loop(window) + + assert result == 0 + assert inner_client.fetch_calls == [None, None, "stop requested"] + assert inner_client.request_stop_calls == 1 + + def test_run_loop_refreshes_on_timer(self) -> None: + snapshot = self._snapshot() + + class _Client: + def __init__(self): + self.fetch_calls = [] + + def fetch_snapshot(self, status_message): + self.fetch_calls.append(status_message) + return snapshot + + def request_stop(self): + return True, "unused" + + inner_client = _Client() + window = _FakeWindow([-1, ord("q")]) + fake_curses = SimpleNamespace(curs_set=lambda *_args, **_kwargs: None) + ui = BrokerTUI(inner_client, refresh_interval_seconds=1.0) + + with patch( + "mcpbridge_wrapper.tui.time.monotonic", + side_effect=[0.0, 2.0, 2.0], + ), patch.dict(sys.modules, {"curses": fake_curses}): + result = ui._run_loop(window) + + assert result == 0 + assert inner_client.fetch_calls == [None, None] + + def test_run_uses_curses_wrapper(self) -> None: + client = SimpleNamespace() + fake_curses = SimpleNamespace( + wrapper=lambda func: 7, + curs_set=lambda *_args, **_kwargs: None, + ) + ui = BrokerTUI(client) + + with patch.dict(sys.modules, {"curses": fake_curses}): + assert ui.run() == 7 + + def test_run_tui_builds_ui_and_runs_it(self) -> None: + runtime = TUIRuntimeConfig( + base_url="http://127.0.0.1:8080", + auth_header=None, + log_path=Path("/tmp/broker.log"), + ) + + with patch("mcpbridge_wrapper.tui.BrokerTUI.run", return_value=5) as run_method: + assert run_tui(runtime) == 5 + + run_method.assert_called_once() + + +class TestHelpers: + """Tests for small formatting helpers.""" + + def test_extract_http_error_prefers_known_fields(self) -> None: + assert _extract_http_error('{"message":"hello"}') == "hello" + assert _extract_http_error('{"error":"boom"}') == "boom" + assert _extract_http_error("plain text") == "plain text" From 22e7a013214b7505a7a217d60b681ddd8a4514bc Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 7 Mar 2026 15:04:24 +0300 Subject: [PATCH 07/12] Implement P6-T2: cover local broker fallback in TUI tests --- ...or_broker_daemon_monitoring_and_control.md | 0 .../P6-T2_Validation_Report.md | 0 tests/unit/test_tui.py | 229 +++++++++--------- 3 files changed, 116 insertions(+), 113 deletions(-) rename SPECS/{INPROGRESS => ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control}/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md (100%) rename SPECS/{INPROGRESS => ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control}/P6-T2_Validation_Report.md (100%) diff --git a/SPECS/INPROGRESS/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md b/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md similarity index 100% rename from SPECS/INPROGRESS/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md rename to SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md diff --git a/SPECS/INPROGRESS/P6-T2_Validation_Report.md b/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Validation_Report.md similarity index 100% rename from SPECS/INPROGRESS/P6-T2_Validation_Report.md rename to SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Validation_Report.md diff --git a/tests/unit/test_tui.py b/tests/unit/test_tui.py index 30d70f2..7af914e 100644 --- a/tests/unit/test_tui.py +++ b/tests/unit/test_tui.py @@ -17,6 +17,8 @@ BrokerTUISnapshot, TUIRuntimeConfig, _extract_http_error, + _read_local_pid, + _read_local_version, build_tui_runtime, render_screen, run_tui, @@ -70,6 +72,41 @@ def refresh(self) -> None: self.refreshed = True +def _runtime( + *, + auth_header: str | None = None, + timeout_seconds: float = 1.5, + base_url: str = "http://127.0.0.1:8080", +) -> TUIRuntimeConfig: + return TUIRuntimeConfig( + base_url=base_url, + auth_header=auth_header, + log_path=Path("/tmp/broker.log"), + pid_file=Path("/tmp/broker.pid"), + socket_path=Path("/tmp/broker.sock"), + version_file=Path("/tmp/broker.version"), + timeout_seconds=timeout_seconds, + ) + + +def _snapshot() -> BrokerTUISnapshot: + return BrokerTUISnapshot( + base_url="http://127.0.0.1:8080", + service_name="broker-daemon", + can_stop=True, + available=True, + broker={"state": "ready", "pid": 1, "socket_path": "/tmp/broker.sock"}, + recent_events=["ready"], + local_pid=1, + local_daemon_running=True, + local_socket_present=True, + local_daemon_version="0.4.1", + local_pid_file="/tmp/broker.pid", + local_socket_path="/tmp/broker.sock", + local_version_file="/tmp/broker.version", + ) + + class TestBuildTUIRuntime: """Tests for runtime resolution helpers.""" @@ -137,14 +174,7 @@ class TestBrokerTUIClient: """Tests for HTTP aggregation and control helpers.""" def test_fetch_snapshot_combines_control_status_and_log_tail(self) -> None: - runtime = TUIRuntimeConfig( - base_url="http://127.0.0.1:8080", - auth_header=None, - log_path=Path("/tmp/broker.log"), - pid_file=Path("/tmp/broker.pid"), - socket_path=Path("/tmp/broker.sock"), - version_file=Path("/tmp/broker.version"), - ) + runtime = _runtime() client = BrokerTUIClient(runtime) with patch.object( @@ -176,20 +206,12 @@ def test_fetch_snapshot_combines_control_status_and_log_tail(self) -> None: assert snapshot.recent_events == ["ready"] assert snapshot.status_message == "Refreshed." assert snapshot.local_pid is None - assert snapshot.local_socket_present is False + assert snapshot.local_daemon_running is False assert request_json.call_args_list == [call("/api/control"), call("/api/broker/status")] tail_lines.assert_called_once_with(runtime.log_path, max_lines=runtime.recent_log_lines) def test_fetch_snapshot_surfaces_runtime_errors(self) -> None: - runtime = TUIRuntimeConfig( - base_url="http://127.0.0.1:8080", - auth_header=None, - log_path=Path("/tmp/broker.log"), - pid_file=Path("/tmp/broker.pid"), - socket_path=Path("/tmp/broker.sock"), - version_file=Path("/tmp/broker.version"), - ) - client = BrokerTUIClient(runtime) + client = BrokerTUIClient(_runtime()) with patch.object( client, "_request_json", side_effect=RuntimeError("boom") @@ -201,15 +223,7 @@ def test_fetch_snapshot_surfaces_runtime_errors(self) -> None: assert snapshot.recent_events == ["event"] def test_request_stop_returns_backend_message(self) -> None: - runtime = TUIRuntimeConfig( - base_url="http://127.0.0.1:8080", - auth_header=None, - log_path=Path("/tmp/broker.log"), - pid_file=Path("/tmp/broker.pid"), - socket_path=Path("/tmp/broker.sock"), - version_file=Path("/tmp/broker.version"), - ) - client = BrokerTUIClient(runtime) + client = BrokerTUIClient(_runtime()) with patch.object( client, @@ -222,12 +236,7 @@ def test_request_stop_returns_backend_message(self) -> None: assert message == "Shutdown requested for broker-daemon." def test_request_stop_returns_default_message_when_backend_omits_one(self) -> None: - runtime = TUIRuntimeConfig( - base_url="http://127.0.0.1:8080", - auth_header=None, - log_path=Path("/tmp/broker.log"), - ) - client = BrokerTUIClient(runtime) + client = BrokerTUIClient(_runtime()) with patch.object(client, "_request_json", return_value={"status": "accepted"}): ok, message = client.request_stop() @@ -236,12 +245,7 @@ def test_request_stop_returns_default_message_when_backend_omits_one(self) -> No assert message == "Shutdown requested." def test_request_stop_surfaces_runtime_error(self) -> None: - runtime = TUIRuntimeConfig( - base_url="http://127.0.0.1:8080", - auth_header=None, - log_path=Path("/tmp/broker.log"), - ) - client = BrokerTUIClient(runtime) + client = BrokerTUIClient(_runtime()) with patch.object(client, "_request_json", side_effect=RuntimeError("stop unavailable")): ok, message = client.request_stop() @@ -250,19 +254,14 @@ def test_request_stop_surfaces_runtime_error(self) -> None: assert message == "stop unavailable" def test_request_json_success_includes_auth_header(self) -> None: - runtime = TUIRuntimeConfig( - base_url="http://127.0.0.1:8080", - auth_header="Basic token", - log_path=Path("/tmp/broker.log"), - timeout_seconds=2.0, - ) - client = BrokerTUIClient(runtime) + client = BrokerTUIClient(_runtime(auth_header="Basic token", timeout_seconds=2.0)) captured: dict[str, object] = {} def fake_urlopen(request, timeout): + headers = dict(request.header_items()) captured["url"] = request.full_url captured["timeout"] = timeout - captured["auth"] = request.headers.get("Authorization") + captured["auth"] = headers.get("Authorization") return _FakeHTTPResponse('{"status": "ok"}') with patch("mcpbridge_wrapper.tui.urllib.request.urlopen", side_effect=fake_urlopen): @@ -276,12 +275,7 @@ def fake_urlopen(request, timeout): } def test_request_json_http_error_uses_detail_message(self) -> None: - runtime = TUIRuntimeConfig( - base_url="http://127.0.0.1:8080", - auth_header=None, - log_path=Path("/tmp/broker.log"), - ) - client = BrokerTUIClient(runtime) + client = BrokerTUIClient(_runtime()) error = urllib.error.HTTPError( url="http://127.0.0.1:8080/api/control/stop", code=409, @@ -291,18 +285,12 @@ def test_request_json_http_error_uses_detail_message(self) -> None: ) with patch( - "mcpbridge_wrapper.tui.urllib.request.urlopen", - side_effect=error, + "mcpbridge_wrapper.tui.urllib.request.urlopen", side_effect=error ), pytest.raises(RuntimeError, match="Stop control is not available"): client._request_json("/api/control/stop", method="POST") def test_request_json_url_error_is_actionable(self) -> None: - runtime = TUIRuntimeConfig( - base_url="http://127.0.0.1:8080", - auth_header=None, - log_path=Path("/tmp/broker.log"), - ) - client = BrokerTUIClient(runtime) + client = BrokerTUIClient(_runtime()) with patch( "mcpbridge_wrapper.tui.urllib.request.urlopen", @@ -311,12 +299,7 @@ def test_request_json_url_error_is_actionable(self) -> None: client._request_json("/api/control") def test_request_json_rejects_invalid_json(self) -> None: - runtime = TUIRuntimeConfig( - base_url="http://127.0.0.1:8080", - auth_header=None, - log_path=Path("/tmp/broker.log"), - ) - client = BrokerTUIClient(runtime) + client = BrokerTUIClient(_runtime()) with patch( "mcpbridge_wrapper.tui.urllib.request.urlopen", @@ -325,12 +308,7 @@ def test_request_json_rejects_invalid_json(self) -> None: client._request_json("/api/control") def test_request_json_rejects_non_mapping_payload(self) -> None: - runtime = TUIRuntimeConfig( - base_url="http://127.0.0.1:8080", - auth_header=None, - log_path=Path("/tmp/broker.log"), - ) - client = BrokerTUIClient(runtime) + client = BrokerTUIClient(_runtime()) with patch( "mcpbridge_wrapper.tui.urllib.request.urlopen", @@ -373,9 +351,8 @@ def test_render_screen_includes_runtime_fields(self) -> None: output = "\n".join(render_screen(snapshot, width=80)) - assert "Broker Runtime" in output assert "Local Broker Files" in output - assert "Local PID: 111 (running)" in output + assert "Local Version: 0.4.1" in output assert "State: ready" in output assert "Connected Clients: 2" in output assert "Recent Broker Events" in output @@ -389,10 +366,10 @@ def test_render_screen_handles_unavailable_backend(self) -> None: available=False, broker=None, recent_events=["(no broker log)"], - local_pid=1234, + local_pid=None, local_daemon_running=False, local_socket_present=False, - local_daemon_version="0.4.1", + local_daemon_version=None, local_pid_file="/tmp/broker.pid", local_socket_path="/tmp/broker.sock", local_version_file="/tmp/broker.version", @@ -403,6 +380,7 @@ def test_render_screen_handles_unavailable_backend(self) -> None: assert "Broker runtime is unavailable." in output assert "Cannot reach http://127.0.0.1:8080: refused" in output + assert "Local Socket Present: no" in output def test_render_screen_shows_runtime_warning_when_broker_is_available(self) -> None: snapshot = BrokerTUISnapshot( @@ -412,6 +390,13 @@ def test_render_screen_shows_runtime_warning_when_broker_is_available(self) -> N available=True, broker={"state": "reconnecting", "pid": None, "socket_path": ""}, recent_events=[], + local_pid=None, + local_daemon_running=False, + local_socket_present=False, + local_daemon_version=None, + local_pid_file="/tmp/broker.pid", + local_socket_path="/tmp/broker.sock", + local_version_file="/tmp/broker.version", error_message="degraded runtime", ) @@ -424,18 +409,8 @@ def test_render_screen_shows_runtime_warning_when_broker_is_available(self) -> N class TestBrokerTUI: """Tests for the thin curses shell.""" - def _snapshot(self) -> BrokerTUISnapshot: - return BrokerTUISnapshot( - base_url="http://127.0.0.1:8080", - service_name="broker-daemon", - can_stop=True, - available=True, - broker={"state": "ready", "pid": 1, "socket_path": "/tmp/broker.sock"}, - recent_events=["ready"], - ) - def test_run_loop_renders_and_exits_on_q(self) -> None: - snapshot = self._snapshot() + snapshot = _snapshot() class _Client: def fetch_snapshot(self, status_message): @@ -456,11 +431,11 @@ def fetch_snapshot(self, status_message): assert any("Broker Runtime" in line[2] for line in window.lines) def test_run_loop_handles_refresh_and_stop_keys(self) -> None: - snapshot = self._snapshot() + snapshot = _snapshot() class _Client: - def __init__(self): - self.fetch_calls = [] + def __init__(self) -> None: + self.fetch_calls: list[str | None] = [] self.request_stop_calls = 0 def fetch_snapshot(self, status_message): @@ -471,24 +446,24 @@ def request_stop(self): self.request_stop_calls += 1 return True, "stop requested" - inner_client = _Client() + client = _Client() window = _FakeWindow([ord("r"), ord("s"), ord("q")]) fake_curses = SimpleNamespace(curs_set=lambda *_args, **_kwargs: None) - ui = BrokerTUI(inner_client) + ui = BrokerTUI(client) with patch.dict(sys.modules, {"curses": fake_curses}): result = ui._run_loop(window) assert result == 0 - assert inner_client.fetch_calls == [None, None, "stop requested"] - assert inner_client.request_stop_calls == 1 + assert client.fetch_calls == [None, None, "stop requested"] + assert client.request_stop_calls == 1 def test_run_loop_refreshes_on_timer(self) -> None: - snapshot = self._snapshot() + snapshot = _snapshot() class _Client: - def __init__(self): - self.fetch_calls = [] + def __init__(self) -> None: + self.fetch_calls: list[str | None] = [] def fetch_snapshot(self, status_message): self.fetch_calls.append(status_message) @@ -497,48 +472,76 @@ def fetch_snapshot(self, status_message): def request_stop(self): return True, "unused" - inner_client = _Client() + client = _Client() window = _FakeWindow([-1, ord("q")]) fake_curses = SimpleNamespace(curs_set=lambda *_args, **_kwargs: None) - ui = BrokerTUI(inner_client, refresh_interval_seconds=1.0) + ui = BrokerTUI(client, refresh_interval_seconds=1.0) with patch( - "mcpbridge_wrapper.tui.time.monotonic", - side_effect=[0.0, 2.0, 2.0], + "mcpbridge_wrapper.tui.time.monotonic", side_effect=[0.0, 2.0, 2.0] ), patch.dict(sys.modules, {"curses": fake_curses}): result = ui._run_loop(window) assert result == 0 - assert inner_client.fetch_calls == [None, None] + assert client.fetch_calls == [None, None] def test_run_uses_curses_wrapper(self) -> None: - client = SimpleNamespace() fake_curses = SimpleNamespace( wrapper=lambda func: 7, curs_set=lambda *_args, **_kwargs: None, ) - ui = BrokerTUI(client) + ui = BrokerTUI(SimpleNamespace()) with patch.dict(sys.modules, {"curses": fake_curses}): assert ui.run() == 7 def test_run_tui_builds_ui_and_runs_it(self) -> None: - runtime = TUIRuntimeConfig( - base_url="http://127.0.0.1:8080", - auth_header=None, - log_path=Path("/tmp/broker.log"), - ) - with patch("mcpbridge_wrapper.tui.BrokerTUI.run", return_value=5) as run_method: - assert run_tui(runtime) == 5 + assert run_tui(_runtime()) == 5 run_method.assert_called_once() class TestHelpers: - """Tests for small formatting helpers.""" + """Tests for small formatting and local-state helpers.""" def test_extract_http_error_prefers_known_fields(self) -> None: + assert _extract_http_error('{"detail":"fine"}') == "fine" assert _extract_http_error('{"message":"hello"}') == "hello" assert _extract_http_error('{"error":"boom"}') == "boom" assert _extract_http_error("plain text") == "plain text" + + def test_read_local_pid_and_version_helpers(self, tmp_path: Path) -> None: + pid_file = tmp_path / "broker.pid" + version_file = tmp_path / "broker.version" + pid_file.write_text("1234") + version_file.write_text("0.4.1") + + with patch("mcpbridge_wrapper.tui.os.kill") as os_kill: + assert _read_local_pid(pid_file) == (1234, True) + os_kill.assert_called_once_with(1234, 0) + + assert _read_local_version(version_file) == "0.4.1" + + def test_read_local_pid_handles_missing_stale_and_permission(self, tmp_path: Path) -> None: + missing = tmp_path / "missing.pid" + assert _read_local_pid(missing) == (None, False) + + stale = tmp_path / "stale.pid" + stale.write_text("9876") + with patch("mcpbridge_wrapper.tui.os.kill", side_effect=ProcessLookupError): + assert _read_local_pid(stale) == (9876, False) + + protected = tmp_path / "protected.pid" + protected.write_text("4321") + with patch("mcpbridge_wrapper.tui.os.kill", side_effect=PermissionError): + assert _read_local_pid(protected) == (4321, True) + + def test_read_local_version_handles_missing_and_read_error(self, tmp_path: Path) -> None: + missing = tmp_path / "missing.version" + assert _read_local_version(missing) is None + + broken = tmp_path / "broken.version" + broken.write_text("0.4.1") + with patch.object(Path, "read_text", side_effect=OSError): + assert _read_local_version(broken) is None From 8671c603134dbe9dd54512f806674063c21ab9f8 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 7 Mar 2026 15:05:20 +0300 Subject: [PATCH 08/12] Archive task P6-T2: Build a terminal frontend for broker daemon monitoring and control (PASS) --- SPECS/ARCHIVE/INDEX.md | 4 +++- ...nd_for_broker_daemon_monitoring_and_control.md | 4 ++++ SPECS/INPROGRESS/next.md | 14 +++++++------- SPECS/Workplan.md | 15 ++++++++------- src/mcpbridge_wrapper/__main__.py | 1 + 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index fee60df..b067a25 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -1,11 +1,12 @@ # mcpbridge-wrapper Tasks Archive -**Last Updated:** 2026-03-07 (REVIEW_broker_runtime_status_surface archived) +**Last Updated:** 2026-03-07 (P6-T2 archived) ## Archived Tasks | Task ID | Folder | Archived | Verdict | |---------|--------|----------|---------| +| P6-T2 | [P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/](P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/) | 2026-03-07 | PASS | | P6-T1 | [P6-T1_Add_explicit_broker_runtime_status_surface_for_frontend_consumers/](P6-T1_Add_explicit_broker_runtime_status_surface_for_frontend_consumers/) | 2026-03-07 | PASS | | P1-T12 | [P1-T12_Improve_troubleshooting_docs_for_Zed_broker_startup_timeouts/](P1-T12_Improve_troubleshooting_docs_for_Zed_broker_startup_timeouts/) | 2026-03-07 | PASS | | P5-T2 | [P5-T2_Release_0.4.1_to_PyPI_and_MCP_Registry/](P5-T2_Release_0.4.1_to_PyPI_and_MCP_Registry/) | 2026-03-06 | PASS | @@ -331,6 +332,7 @@ | Date | Task ID | Action | |------|---------|--------| +| 2026-03-07 | P6-T2 | Archived task artifacts and validation report | | 2026-03-07 | P6-T1 | Archived REVIEW_broker_runtime_status_surface report | | 2026-03-07 | P6-T1 | Archived task artifacts and validation report | | 2026-03-07 | P1-T12 | Archived task artifacts and validation report | diff --git a/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md b/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md index 1dd99a8..7396a9a 100644 --- a/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md +++ b/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md @@ -129,3 +129,7 @@ Verification: - Prefer pure helper functions for layout and formatting so the curses shell is thin and easy to test. - Review subject name for this task: `broker_terminal_frontend`. + +--- +**Archived:** 2026-03-07 +**Verdict:** PASS diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index 18b8dc3..2e80c6c 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -1,15 +1,15 @@ -# Next Task: P6-T2 — Build a terminal frontend for broker daemon monitoring and control +# Next Task: P6-T3 — Document the explicit dedicated-host frontend workflow -**Priority:** P1 +**Priority:** P2 **Phase:** Phase 6: Explicit Broker Frontend -**Effort:** 6h -**Dependencies:** P6-T1 -**Status:** Selected +**Effort:** 4h +**Dependencies:** P6-T1, P6-T2 +**Status:** Ready ## Description -Implement a terminal-first operator interface for the broker daemon so users can explicitly see whether the daemon is running, whether upstream Xcode connectivity is healthy, which clients are attached, and what recent reconnect/error events occurred. The interface should give a clearer operational model than auto-spawn alone. +Update the operator docs so the recommended path for multi-editor setups is an explicit dedicated broker host plus a single monitoring frontend. The docs should explain when to prefer a dedicated host over implicit auto-spawn, how to verify that both editors share one daemon, and how the new frontend fits into that workflow. ## Next Step -Run the PLAN command to generate the implementation-ready PRD. +Run the PLAN command to document the dedicated-host workflow, multi-editor validation steps, and terminal frontend usage. diff --git a/SPECS/Workplan.md b/SPECS/Workplan.md index 933bce7..ddf8ece 100644 --- a/SPECS/Workplan.md +++ b/SPECS/Workplan.md @@ -438,19 +438,20 @@ Add new tasks using the canonical template in [TASK_TEMPLATE.md](TASK_TEMPLATE.m - [x] Status makes reconnecting/not-ready states explicit so a frontend can distinguish them from a healthy shared daemon - [x] Automated tests cover both healthy and degraded broker runtime status responses -#### ⬜️ P6-T2: Build a terminal frontend for broker daemon monitoring and control **INPROGRESS** +#### ✅ P6-T2: Build a terminal frontend for broker daemon monitoring and control +- **Status:** ✅ Completed (2026-03-07) - **Description:** Implement a terminal-first operator interface for the broker daemon so users can explicitly see whether the daemon is running, whether upstream Xcode connectivity is healthy, which clients are attached, and what recent reconnect/error events occurred. The interface should give a clearer operational model than auto-spawn alone. - **Priority:** P1 - **Dependencies:** P6-T1 - **Parallelizable:** no - **Outputs/Artifacts:** - - `src/mcpbridge_wrapper/` TUI entrypoint/module for broker monitoring and control - - Tests covering the TUI status rendering and control integration where practical - - CLI/docs wiring for launching the TUI + - `src/mcpbridge_wrapper/tui.py` terminal frontend for broker monitoring and stop control + - `src/mcpbridge_wrapper/__main__.py` CLI wiring for `--tui` + - `tests/unit/test_tui.py` and `tests/unit/test_main_tui.py` covering runtime resolution, rendering, control requests, and CLI integration - **Acceptance Criteria:** - - [ ] Users can launch a terminal UI from the wrapper package to inspect broker runtime state without tailing logs manually - - [ ] The TUI shows at minimum broker state, daemon/upstream PIDs, connected client count, and recent broker events or reconnect indicators - - [ ] The TUI exposes at least one explicit control action for the daemon lifecycle (for example stop or restart) + - [x] Users can launch a terminal UI from the wrapper package to inspect broker runtime state without tailing logs manually + - [x] The TUI shows at minimum broker state, daemon/upstream PIDs, connected client count, and recent broker events or reconnect indicators + - [x] The TUI exposes at least one explicit control action for the daemon lifecycle (for example stop or restart) #### ⬜️ P6-T3: Document the explicit dedicated-host frontend workflow - **Description:** Update the operator docs so the recommended path for multi-editor setups is an explicit dedicated broker host plus a single monitoring frontend. The docs should explain when to prefer a dedicated host over implicit auto-spawn, how to verify that both editors share one daemon, and how the new frontend fits into that workflow. diff --git a/src/mcpbridge_wrapper/__main__.py b/src/mcpbridge_wrapper/__main__.py index 53e415f..409a323 100644 --- a/src/mcpbridge_wrapper/__main__.py +++ b/src/mcpbridge_wrapper/__main__.py @@ -488,6 +488,7 @@ def main() -> int: and outputs unbuffered results to stdout. Supports optional --web-ui flag to start a monitoring dashboard. + Supports optional --tui flag for standalone broker terminal monitoring. Supports optional --broker-daemon flag to start a persistent broker host. Supports optional --broker flag for proxy mode. From 6aa4276b55e05e2fdc0f3c63e0aebc2376880f5a Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 7 Mar 2026 15:14:00 +0300 Subject: [PATCH 09/12] Implement P6-T2: harden TUI endpoint and log tail handling --- .../P6-T2_Validation_Report.md | 11 +++-- src/mcpbridge_wrapper/tui.py | 41 ++++++++++++++++++- tests/unit/test_tui.py | 40 ++++++++++++++++++ 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Validation_Report.md b/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Validation_Report.md index 3ae5b47..9c25c2c 100644 --- a/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Validation_Report.md +++ b/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Validation_Report.md @@ -13,6 +13,9 @@ Implemented a terminal frontend for the broker daemon by: - resolving dashboard endpoint/auth settings from existing Web UI config - surfacing broker runtime details from `/api/control` and `/api/broker/status` - adding local PID/socket/version fallback details and broker-log tailing +- normalizing wildcard/IPv6 dashboard bind hosts into client-safe TUI endpoints +- tailing broker logs from the end of the file so refresh cost stays bounded +- degrading gracefully when `broker.log` is temporarily unreadable - adding dedicated unit coverage for runtime resolution, HTTP aggregation, rendering, interactive loop behavior, and main CLI integration @@ -29,7 +32,7 @@ Implemented a terminal frontend for the broker daemon by: PYTHONPATH=src pytest tests/unit/test_tui.py tests/unit/test_main_tui.py -q ``` -- Result: `32 passed` +- Result: `40 passed` ```bash ruff check src/mcpbridge_wrapper/tui.py src/mcpbridge_wrapper/__main__.py tests/unit/test_tui.py tests/unit/test_main_tui.py @@ -49,7 +52,7 @@ mypy src/mcpbridge_wrapper/tui.py src/mcpbridge_wrapper/__main__.py PYTHONPATH=src pytest ``` -- Result: `819 passed, 5 skipped in 8.31s` +- Result: `827 passed, 5 skipped in 8.06s` ```bash ruff check src/ @@ -67,8 +70,8 @@ mypy src/ PYTHONPATH=src pytest --cov ``` -- Result: `819 passed, 5 skipped in 9.12s` -- Coverage: `90.86%` +- Result: `827 passed, 5 skipped in 9.16s` +- Coverage: `91.52%` ## Notes diff --git a/src/mcpbridge_wrapper/tui.py b/src/mcpbridge_wrapper/tui.py index 78e8f57..1355002 100644 --- a/src/mcpbridge_wrapper/tui.py +++ b/src/mcpbridge_wrapper/tui.py @@ -4,6 +4,7 @@ import base64 import contextlib +import ipaddress import json import os import textwrap @@ -71,9 +72,10 @@ def build_tui_runtime( token = base64.b64encode(raw_credentials).decode("ascii") auth_header = f"Basic {token}" + client_host = _client_host_for_base_url(config.host) broker_state_dir = BrokerConfig.default().pid_file.parent return TUIRuntimeConfig( - base_url=f"http://{config.host}:{config.port}", + base_url=f"http://{client_host}:{config.port}", auth_header=auth_header, log_path=broker_state_dir / "broker.log", pid_file=broker_state_dir / "broker.pid", @@ -90,7 +92,25 @@ def tail_log_lines(log_path: Path, max_lines: int = 8) -> list[str]: if not log_path.exists(): return [f"(no broker log at {log_path})"] - text = log_path.read_text(encoding="utf-8", errors="replace") + try: + chunks: list[bytes] = [] + newline_count = 0 + with log_path.open("rb") as handle: + handle.seek(0, os.SEEK_END) + position = handle.tell() + + while position > 0 and newline_count <= max_lines: + read_size = min(4096, position) + position -= read_size + handle.seek(position) + chunk = handle.read(read_size) + chunks.append(chunk) + newline_count += chunk.count(b"\n") + + text = b"".join(reversed(chunks)).decode("utf-8", errors="replace") + except OSError as exc: + return [f"(cannot read broker log at {log_path}: {exc})"] + lines = text.splitlines() if not lines: return ["(broker log is empty)"] @@ -360,6 +380,23 @@ def _extract_http_error(payload: str) -> str | None: return payload.strip() or None +def _client_host_for_base_url(host: str) -> str: + """Convert a bind host into a client-friendly URL host component.""" + normalized_host = host.strip() + if not normalized_host: + return "127.0.0.1" + + with contextlib.suppress(ValueError): + parsed_host = ipaddress.ip_address(normalized_host) + if parsed_host.is_unspecified: + return "[::1]" if parsed_host.version == 6 else "127.0.0.1" + if parsed_host.version == 6: + return f"[{normalized_host}]" + return normalized_host + + return normalized_host + + def _wrap_lines(lines: list[str], width: int) -> list[str]: """Wrap screen lines to fit the available width.""" wrapped: list[str] = [] diff --git a/tests/unit/test_tui.py b/tests/unit/test_tui.py index 7af914e..d71e9c9 100644 --- a/tests/unit/test_tui.py +++ b/tests/unit/test_tui.py @@ -16,6 +16,7 @@ BrokerTUIClient, BrokerTUISnapshot, TUIRuntimeConfig, + _client_host_for_base_url, _extract_http_error, _read_local_pid, _read_local_version, @@ -144,6 +145,22 @@ def test_build_runtime_port_override_wins(self, tmp_path: Path) -> None: assert runtime.base_url == "http://127.0.0.1:9191" + def test_build_runtime_normalizes_unspecified_host_to_loopback(self, tmp_path: Path) -> None: + config_path = tmp_path / "webui.json" + config_path.write_text(json.dumps({"host": "0.0.0.0", "port": 9090})) + + runtime = build_tui_runtime(web_ui_port=None, web_ui_config=str(config_path)) + + assert runtime.base_url == "http://127.0.0.1:9090" + + def test_build_runtime_brackets_ipv6_host(self, tmp_path: Path) -> None: + config_path = tmp_path / "webui.json" + config_path.write_text(json.dumps({"host": "::1", "port": 9090})) + + runtime = build_tui_runtime(web_ui_port=None, web_ui_config=str(config_path)) + + assert runtime.base_url == "http://[::1]:9090" + class TestTailLogLines: """Tests for broker log tailing.""" @@ -169,6 +186,22 @@ def test_tail_log_lines_reports_empty_log(self, tmp_path: Path) -> None: assert tail_log_lines(log_path, max_lines=3) == ["(broker log is empty)"] + def test_tail_log_lines_handles_read_error(self, tmp_path: Path) -> None: + log_path = tmp_path / "broker.log" + log_path.write_text("line-1\n") + + with patch.object(Path, "open", side_effect=OSError("denied")): + lines = tail_log_lines(log_path, max_lines=3) + + assert len(lines) == 1 + assert "cannot read broker log" in lines[0] + + def test_tail_log_lines_reads_tail_from_large_log(self, tmp_path: Path) -> None: + log_path = tmp_path / "broker.log" + log_path.write_text("".join(f"line-{index}\n" for index in range(1000))) + + assert tail_log_lines(log_path, max_lines=3) == ["line-997", "line-998", "line-999"] + class TestBrokerTUIClient: """Tests for HTTP aggregation and control helpers.""" @@ -511,6 +544,13 @@ def test_extract_http_error_prefers_known_fields(self) -> None: assert _extract_http_error('{"error":"boom"}') == "boom" assert _extract_http_error("plain text") == "plain text" + def test_client_host_for_base_url_normalizes_special_hosts(self) -> None: + assert _client_host_for_base_url("127.0.0.1") == "127.0.0.1" + assert _client_host_for_base_url("0.0.0.0") == "127.0.0.1" + assert _client_host_for_base_url("::1") == "[::1]" + assert _client_host_for_base_url("::") == "[::1]" + assert _client_host_for_base_url(" localhost ") == "localhost" + def test_read_local_pid_and_version_helpers(self, tmp_path: Path) -> None: pid_file = tmp_path / "broker.pid" version_file = tmp_path / "broker.version" From b19b4f5609246a97db26616c040a30ad69a5b413 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 7 Mar 2026 15:14:12 +0300 Subject: [PATCH 10/12] Review P6-T2: broker terminal frontend --- .../REVIEW_broker_terminal_frontend.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 SPECS/INPROGRESS/REVIEW_broker_terminal_frontend.md diff --git a/SPECS/INPROGRESS/REVIEW_broker_terminal_frontend.md b/SPECS/INPROGRESS/REVIEW_broker_terminal_frontend.md new file mode 100644 index 0000000..305063b --- /dev/null +++ b/SPECS/INPROGRESS/REVIEW_broker_terminal_frontend.md @@ -0,0 +1,70 @@ +## REVIEW REPORT — Broker Terminal Frontend + +**Scope:** `origin/main..HEAD` +**Files:** 9 +**Date:** 2026-03-07 + +--- + +### Summary Verdict +- [x] Approve +- [ ] Approve with comments +- [ ] Request changes +- [ ] Block + +--- + +### Critical Issues + +None. + +--- + +### Secondary Issues + +None. + +--- + +### Architectural Notes + +- The standalone `--tui` mode stays consistent with the project's existing + hand-rolled mode dispatch in `__main__.py` and avoids introducing any new CLI + or terminal UI dependency. +- The TUI reuses the broker-hosted Web UI APIs (`/api/control`, + `/api/control/stop`, `/api/broker/status`) instead of opening another broker + transport, so browser and terminal frontends share one runtime contract. +- The final implementation hardens two operational edges that matter for a + broker dashboard: bind hosts are normalized into client-safe URLs + (wildcard/IPv6-safe), and broker log tailing now degrades gracefully on read + errors while keeping refresh work bounded to tail-sized reads. + +--- + +### Tests + +- Validation report confirms: + - `PYTHONPATH=src pytest tests/unit/test_tui.py tests/unit/test_main_tui.py -q` + -> `40 passed` + - `ruff check src/mcpbridge_wrapper/tui.py src/mcpbridge_wrapper/__main__.py tests/unit/test_tui.py tests/unit/test_main_tui.py` + -> pass + - `mypy src/mcpbridge_wrapper/tui.py src/mcpbridge_wrapper/__main__.py` + -> pass + - `PYTHONPATH=src pytest` -> `827 passed, 5 skipped` + - `ruff check src/` -> pass + - `mypy src/` -> pass + - `PYTHONPATH=src pytest --cov` -> `91.52%` +- The focused TUI tests now cover: + - CLI routing and invalid flag combinations + - auth/header propagation and HTTP error shaping + - wildcard/IPv6 host normalization for dashboard attachment + - bounded tail reads and unreadable `broker.log` fallback behavior + - curses loop behavior for refresh, stop, and quit actions + +--- + +### Next Steps + +- FOLLOW-UP skipped: no actionable review findings remain after the final + hardening pass. +- Proceed to `ARCHIVE-REVIEW`, then open the PR for `P6-T2`. From e802973aa14652bdba17cb0f18f39c08a6b0bf70 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 7 Mar 2026 15:15:00 +0300 Subject: [PATCH 11/12] Archive REVIEW_broker_terminal_frontend report --- SPECS/ARCHIVE/INDEX.md | 4 +++- .../_Historical}/REVIEW_broker_terminal_frontend.md | 0 2 files changed, 3 insertions(+), 1 deletion(-) rename SPECS/{INPROGRESS => ARCHIVE/_Historical}/REVIEW_broker_terminal_frontend.md (100%) diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index b067a25..895ced5 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -1,6 +1,6 @@ # mcpbridge-wrapper Tasks Archive -**Last Updated:** 2026-03-07 (P6-T2 archived) +**Last Updated:** 2026-03-07 (P6-T2 review archived) ## Archived Tasks @@ -195,6 +195,7 @@ | File | Description | |------|-------------| +| [REVIEW_broker_terminal_frontend.md](_Historical/REVIEW_broker_terminal_frontend.md) | Review report for P6-T2 | | [REVIEW_broker_runtime_status_surface.md](_Historical/REVIEW_broker_runtime_status_surface.md) | Review report for P6-T1 | | [REVIEW_p1_t12_zed_timeout_docs.md](_Historical/REVIEW_p1_t12_zed_timeout_docs.md) | Review report for P1-T12 | | [REVIEW_P4-T2_broker_readiness_cache.md](_Historical/REVIEW_P4-T2_broker_readiness_cache.md) | Review report for P4-T2 | @@ -595,3 +596,4 @@ | 2026-03-01 | P3-T11 | Archived REVIEW_p3_t11_webui_stop_control report | | 2026-03-06 | BUG-T9 | Archived Fix_broker_daemon_not_sending_notifications_initialized_before_tools_list_probe (PASS) | | 2026-03-06 | BUG-T9 | Archived REVIEW_BUG-T9 report | +| 2026-03-07 | P6-T2 | Archived REVIEW_broker_terminal_frontend report | diff --git a/SPECS/INPROGRESS/REVIEW_broker_terminal_frontend.md b/SPECS/ARCHIVE/_Historical/REVIEW_broker_terminal_frontend.md similarity index 100% rename from SPECS/INPROGRESS/REVIEW_broker_terminal_frontend.md rename to SPECS/ARCHIVE/_Historical/REVIEW_broker_terminal_frontend.md From f7c8cdedf76e09cec22a0aa912cd7d26c8a04eac Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 7 Mar 2026 15:18:58 +0300 Subject: [PATCH 12/12] Implement P6-T2: align TUI tests with CI formatter and Python 3.9 --- src/mcpbridge_wrapper/tui.py | 4 +--- tests/unit/test_main_tui.py | 16 +++++----------- tests/unit/test_tui.py | 14 ++++++++------ 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/mcpbridge_wrapper/tui.py b/src/mcpbridge_wrapper/tui.py index 1355002..bab47c8 100644 --- a/src/mcpbridge_wrapper/tui.py +++ b/src/mcpbridge_wrapper/tui.py @@ -157,9 +157,7 @@ def fetch_snapshot(self, status_message: str | None = None) -> BrokerTUISnapshot ) service_name = str( - broker_status.get("service_name") - or control.get("service_name") - or "mcpbridge-wrapper" + broker_status.get("service_name") or control.get("service_name") or "mcpbridge-wrapper" ) broker_payload = broker_status.get("broker") broker = broker_payload if isinstance(broker_payload, dict) else None diff --git a/tests/unit/test_main_tui.py b/tests/unit/test_main_tui.py index 026c9cc..489ce0e 100644 --- a/tests/unit/test_main_tui.py +++ b/tests/unit/test_main_tui.py @@ -44,13 +44,9 @@ def test_main_tui_runs_terminal_frontend(self, tmp_path) -> None: with patch( "mcpbridge_wrapper.__main__.sys.argv", ["mcpbridge-wrapper", "--tui", "--web-ui-config", str(config_path)], - ), patch( - "mcpbridge_wrapper.__main__.sys.stdin" - ) as mock_stdin, patch( + ), patch("mcpbridge_wrapper.__main__.sys.stdin") as mock_stdin, patch( "mcpbridge_wrapper.__main__.sys.stdout" - ) as mock_stdout, patch( - "mcpbridge_wrapper.tui.run_tui", return_value=0 - ) as run_tui: + ) as mock_stdout, patch("mcpbridge_wrapper.tui.run_tui", return_value=0) as run_tui: mock_stdin.isatty.return_value = True mock_stdout.isatty.return_value = True @@ -93,11 +89,9 @@ def test_main_tui_rejects_bridge_args(self, capsys) -> None: assert "--tui does not accept bridge arguments" in capsys.readouterr().err def test_main_tui_requires_interactive_terminal(self, capsys) -> None: - with patch( - "mcpbridge_wrapper.__main__.sys.argv", ["mcpbridge-wrapper", "--tui"] - ), patch("mcpbridge_wrapper.__main__.sys.stdin") as mock_stdin, patch( - "mcpbridge_wrapper.__main__.sys.stdout" - ) as mock_stdout: + with patch("mcpbridge_wrapper.__main__.sys.argv", ["mcpbridge-wrapper", "--tui"]), patch( + "mcpbridge_wrapper.__main__.sys.stdin" + ) as mock_stdin, patch("mcpbridge_wrapper.__main__.sys.stdout") as mock_stdout: mock_stdin.isatty.return_value = False mock_stdout.isatty.return_value = True diff --git a/tests/unit/test_tui.py b/tests/unit/test_tui.py index d71e9c9..8e00dd6 100644 --- a/tests/unit/test_tui.py +++ b/tests/unit/test_tui.py @@ -1,5 +1,7 @@ """Tests for the broker terminal frontend.""" +from __future__ import annotations + import base64 import io import json @@ -246,9 +248,9 @@ def test_fetch_snapshot_combines_control_status_and_log_tail(self) -> None: def test_fetch_snapshot_surfaces_runtime_errors(self) -> None: client = BrokerTUIClient(_runtime()) - with patch.object( - client, "_request_json", side_effect=RuntimeError("boom") - ), patch("mcpbridge_wrapper.tui.tail_log_lines", return_value=["event"]): + with patch.object(client, "_request_json", side_effect=RuntimeError("boom")), patch( + "mcpbridge_wrapper.tui.tail_log_lines", return_value=["event"] + ): snapshot = client.fetch_snapshot() assert snapshot.available is False @@ -510,9 +512,9 @@ def request_stop(self): fake_curses = SimpleNamespace(curs_set=lambda *_args, **_kwargs: None) ui = BrokerTUI(client, refresh_interval_seconds=1.0) - with patch( - "mcpbridge_wrapper.tui.time.monotonic", side_effect=[0.0, 2.0, 2.0] - ), patch.dict(sys.modules, {"curses": fake_curses}): + with patch("mcpbridge_wrapper.tui.time.monotonic", side_effect=[0.0, 2.0, 2.0]), patch.dict( + sys.modules, {"curses": fake_curses} + ): result = ui._run_loop(window) assert result == 0