From 69c85e411c4fc2692bbee8c1b4f36e3f9f9c8a3c Mon Sep 17 00:00:00 2001 From: tjb-tech <1193992557@qq.com> Date: Tue, 7 Apr 2026 16:15:15 +0000 Subject: [PATCH] fix(ui): avoid blocking paste and permission responses in terminal --- frontend/terminal/src/App.tsx | 7 ++++++ src/openharness/cli.py | 3 +++ src/openharness/ui/app.py | 3 +++ src/openharness/ui/backend_host.py | 18 +++++++++------ src/openharness/ui/react_launcher.py | 5 +++++ src/openharness/ui/runtime.py | 2 ++ tests/test_commands/test_cli.py | 16 ++++++++++++++ tests/test_ui/test_react_backend.py | 33 ++++++++++++++++++++++++++++ 8 files changed, 80 insertions(+), 7 deletions(-) diff --git a/frontend/terminal/src/App.tsx b/frontend/terminal/src/App.tsx index 268c12e..5ae9ece 100644 --- a/frontend/terminal/src/App.tsx +++ b/frontend/terminal/src/App.tsx @@ -167,6 +167,8 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element { }; useInput((chunk, key) => { + const isPaste = chunk.length > 1 && !key.ctrl && !key.meta; + // Ctrl+C → exit if (key.ctrl && chunk === 'c') { session.sendRequest({type: 'shutdown'}); @@ -174,6 +176,11 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element { return; } + // Let ink-text-input handle pasted text directly. + if (isPaste) { + return; + } + // --- Select modal (permissions picker etc.) --- if (selectModal) { if (key.upArrow) { diff --git a/src/openharness/cli.py b/src/openharness/cli.py index 2949269..ab13c8d 100644 --- a/src/openharness/cli.py +++ b/src/openharness/cli.py @@ -1343,6 +1343,8 @@ def main( system_prompt=session_data.get("system_prompt") or system_prompt, api_key=api_key, restore_messages=session_data.get("messages"), + permission_mode=permission_mode, + api_format=api_format, ) ) return @@ -1380,5 +1382,6 @@ def main( system_prompt=system_prompt, api_key=api_key, api_format=api_format, + permission_mode=permission_mode, ) ) diff --git a/src/openharness/ui/app.py b/src/openharness/ui/app.py index 3d5b8d9..7a43dd4 100644 --- a/src/openharness/ui/app.py +++ b/src/openharness/ui/app.py @@ -25,6 +25,7 @@ async def run_repl( api_client: SupportsStreamingMessages | None = None, backend_only: bool = False, restore_messages: list[dict] | None = None, + permission_mode: str | None = None, ) -> None: """Run the default OpenHarness interactive application (React TUI).""" if backend_only: @@ -39,6 +40,7 @@ async def run_repl( api_client=api_client, restore_messages=restore_messages, enforce_max_turns=max_turns is not None, + permission_mode=permission_mode, ) return @@ -51,6 +53,7 @@ async def run_repl( system_prompt=system_prompt, api_key=api_key, api_format=api_format, + permission_mode=permission_mode, ) if exit_code != 0: raise SystemExit(exit_code) diff --git a/src/openharness/ui/backend_host.py b/src/openharness/ui/backend_host.py index 54f890b..fba5a3d 100644 --- a/src/openharness/ui/backend_host.py +++ b/src/openharness/ui/backend_host.py @@ -103,13 +103,7 @@ class ReactBackendHost: if request.type == "shutdown": await self._emit(BackendEvent(type="shutdown")) break - if request.type == "permission_response": - if request.request_id in self._permission_requests: - self._permission_requests[request.request_id].set_result(bool(request.allowed)) - continue - if request.type == "question_response": - if request.request_id in self._question_requests: - self._question_requests[request.request_id].set_result(request.answer or "") + if request.type in ("permission_response", "question_response"): continue if request.type == "list_sessions": await self._handle_list_sessions() @@ -172,6 +166,16 @@ class ReactBackendHost: except Exception as exc: # pragma: no cover - defensive protocol handling await self._emit(BackendEvent(type="error", message=f"Invalid request: {exc}")) continue + if request.type == "permission_response" and request.request_id in self._permission_requests: + future = self._permission_requests[request.request_id] + if not future.done(): + future.set_result(bool(request.allowed)) + continue + if request.type == "question_response" and request.request_id in self._question_requests: + future = self._question_requests[request.request_id] + if not future.done(): + future.set_result(request.answer or "") + continue await self._request_queue.put(request) async def _process_line(self, line: str, *, transcript_line: str | None = None) -> bool: diff --git a/src/openharness/ui/react_launcher.py b/src/openharness/ui/react_launcher.py index 278e1a0..6ead4cf 100644 --- a/src/openharness/ui/react_launcher.py +++ b/src/openharness/ui/react_launcher.py @@ -87,6 +87,7 @@ def build_backend_command( system_prompt: str | None = None, api_key: str | None = None, api_format: str | None = None, + permission_mode: str | None = None, ) -> list[str]: """Return the command used by the React frontend to spawn the backend host.""" command = [sys.executable, "-m", "openharness", "--backend-only"] @@ -104,6 +105,8 @@ def build_backend_command( command.extend(["--api-key", api_key]) if api_format: command.extend(["--api-format", api_format]) + if permission_mode: + command.extend(["--permission-mode", permission_mode]) return command @@ -117,6 +120,7 @@ async def launch_react_tui( system_prompt: str | None = None, api_key: str | None = None, api_format: str | None = None, + permission_mode: str | None = None, ) -> int: """Launch the React terminal frontend as the default UI.""" frontend_dir = get_frontend_dir() @@ -148,6 +152,7 @@ async def launch_react_tui( system_prompt=system_prompt, api_key=api_key, api_format=api_format, + permission_mode=permission_mode, ), "initial_prompt": prompt, "theme": _resolve_theme(), diff --git a/src/openharness/ui/runtime.py b/src/openharness/ui/runtime.py index 4dee828..46f271f 100644 --- a/src/openharness/ui/runtime.py +++ b/src/openharness/ui/runtime.py @@ -170,6 +170,7 @@ async def build_runtime( restore_messages: list[dict] | None = None, enforce_max_turns: bool = True, session_backend: SessionBackend | None = None, + permission_mode: str | None = None, ) -> RuntimeBundle: """Build the shared runtime for an OpenHarness session.""" settings_overrides: dict[str, Any] = { @@ -180,6 +181,7 @@ async def build_runtime( "api_key": api_key, "api_format": api_format, "active_profile": active_profile, + "permission_mode": permission_mode, } settings = load_settings().merge_cli_overrides(**settings_overrides) cwd = str(Path.cwd()) diff --git a/tests/test_commands/test_cli.py b/tests/test_commands/test_cli.py index 0099d17..9d9baf9 100644 --- a/tests/test_commands/test_cli.py +++ b/tests/test_commands/test_cli.py @@ -9,6 +9,7 @@ from typer.testing import CliRunner import openharness.cli as cli from openharness.config import load_settings + app = cli.app @@ -113,3 +114,18 @@ def test_setup_flow_creates_kimi_profile_with_profile_scoped_key(tmp_path: Path, from openharness.auth.storage import load_credential assert load_credential("profile:kimi-anthropic", "api_key") == "sk-kimi-test" + + +def test_dangerously_skip_permissions_passes_full_auto_to_run_repl(monkeypatch): + runner = CliRunner() + captured = {} + + async def fake_run_repl(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr("openharness.ui.app.run_repl", fake_run_repl) + + result = runner.invoke(app, ["--dangerously-skip-permissions"]) + + assert result.exit_code == 0 + assert captured["permission_mode"] == "full_auto" diff --git a/tests/test_ui/test_react_backend.py b/tests/test_ui/test_react_backend.py index 038feec..964c1df 100644 --- a/tests/test_ui/test_react_backend.py +++ b/tests/test_ui/test_react_backend.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import io import json @@ -53,6 +54,38 @@ class FakeBinaryStdout: return None +@pytest.mark.asyncio +async def test_read_requests_resolves_permission_response_without_queueing(monkeypatch): + host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) + fut = asyncio.get_running_loop().create_future() + host._permission_requests["req-1"] = fut + + payload = b'{"type":"permission_response","request_id":"req-1","allowed":true}\n' + + class _FakeBuffer: + def __init__(self): + self._reads = 0 + + def readline(self): + self._reads += 1 + if self._reads == 1: + return payload + return b"" + + class _FakeStdin: + buffer = _FakeBuffer() + + monkeypatch.setattr("openharness.ui.backend_host.sys.stdin", _FakeStdin()) + + await host._read_requests() + + assert fut.done() + assert fut.result() is True + queued = await host._request_queue.get() + assert queued.type == "shutdown" + assert host._request_queue.empty() + + @pytest.mark.asyncio async def test_backend_host_processes_command(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path)