fix(ui): avoid blocking paste and permission responses in terminal

This commit is contained in:
tjb-tech
2026-04-07 16:15:15 +00:00
parent 9770837b02
commit 69c85e411c
8 changed files with 80 additions and 7 deletions
+7
View File
@@ -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) {
+3
View File
@@ -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,
)
)
+3
View File
@@ -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)
+11 -7
View File
@@ -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:
+5
View File
@@ -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(),
+2
View File
@@ -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())
+16
View File
@@ -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"
+33
View File
@@ -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)