diff --git a/frontend/terminal/src/App.tsx b/frontend/terminal/src/App.tsx index 79883a5..1a5f809 100644 --- a/frontend/terminal/src/App.tsx +++ b/frontend/terminal/src/App.tsx @@ -177,6 +177,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element { useInput((chunk, key) => { const isPaste = chunk.length > 1 && !key.ctrl && !key.meta; + const isEscape = key.escape || chunk === '\u001B'; // Ctrl+C → exit if (key.ctrl && chunk === 'c') { @@ -252,7 +253,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element { session.setModal(null); return; } - if (chunk.toLowerCase() === 'n' || key.escape) { + if (chunk.toLowerCase() === 'n' || isEscape) { session.sendRequest({ type: 'permission_response', request_id: session.modal.request_id, @@ -269,6 +270,12 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element { return; // Let TextInput in ModalHost handle input } + if (session.busy && isEscape) { + session.sendRequest({type: 'interrupt'}); + session.setBusyLabel('Stopping current operation...'); + return; + } + // --- Ignore input while busy --- if (session.busy) { return; @@ -305,13 +312,13 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element { } return; } - if (key.escape) { + if (isEscape) { setInput(''); return; } } - if (key.escape) { + if (isEscape) { const now = Date.now(); if (input && now - lastEscapeAt < 500) { setInput(''); @@ -463,6 +470,7 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element { enter send{' '} / commands{' '} {'\u2191\u2193'} history{' '} + esc stop{' '} ctrl+c exit diff --git a/frontend/terminal/src/hooks/useBackendSession.ts b/frontend/terminal/src/hooks/useBackendSession.ts index f67b0c4..6fc84b7 100644 --- a/frontend/terminal/src/hooks/useBackendSession.ts +++ b/frontend/terminal/src/hooks/useBackendSession.ts @@ -454,6 +454,7 @@ export function useBackendSession(config: FrontendConfig, onExit: (code?: number setModal, setSelectRequest, setBusy, + setBusyLabel, sendRequest, }), [assistantBuffer, bridgeSessions, busy, busyLabel, commands, mcpServers, modal, ready, selectRequest, status, swarmNotifications, swarmTeammates, tasks, todoMarkdown, transcript] diff --git a/src/openharness/ui/backend_host.py b/src/openharness/ui/backend_host.py index abc50ee..baad796 100644 --- a/src/openharness/ui/backend_host.py +++ b/src/openharness/ui/backend_host.py @@ -10,6 +10,7 @@ import os import sys from dataclasses import dataclass from pathlib import Path +from typing import Any, Coroutine from uuid import uuid4 from openharness.api.client import SupportsStreamingMessages @@ -75,6 +76,7 @@ class ReactBackendHost: self._permission_lock = asyncio.Lock() self._busy = False self._running = True + self._active_request_task: asyncio.Task[bool] | None = None # Track last tool input per name for rich event emission self._last_tool_inputs: dict[str, dict] = {} @@ -116,6 +118,9 @@ class ReactBackendHost: if request.type == "shutdown": await self._emit(BackendEvent(type="shutdown")) break + if request.type == "interrupt": + await self._interrupt_active_request() + continue if request.type in ("permission_response", "question_response"): continue if request.type == "list_sessions": @@ -130,9 +135,11 @@ class ReactBackendHost: continue self._busy = True try: - should_continue = await self._apply_select_command( - request.command or "", - request.value or "", + should_continue = await self._run_active_request( + self._apply_select_command( + request.command or "", + request.value or "", + ) ) finally: self._busy = False @@ -151,7 +158,7 @@ class ReactBackendHost: continue self._busy = True try: - should_continue = await self._process_line(line) + should_continue = await self._run_active_request(self._process_line(line)) finally: self._busy = False if not should_continue: @@ -189,8 +196,37 @@ class ReactBackendHost: if not future.done(): future.set_result(request.answer or "") continue + if request.type == "interrupt": + await self._interrupt_active_request() + continue await self._request_queue.put(request) + async def _run_active_request(self, awaitable: Coroutine[Any, Any, bool]) -> bool: + task = asyncio.create_task(awaitable) + self._active_request_task = task + try: + return await task + except asyncio.CancelledError: + await self._emit( + BackendEvent( + type="transcript_item", + item=TranscriptItem(role="system", text="Interrupted by user."), + ) + ) + await self._emit(self._status_snapshot()) + await self._emit(BackendEvent.tasks_snapshot(get_task_manager().list_tasks())) + await self._emit(BackendEvent(type="line_complete")) + return True + finally: + if self._active_request_task is task: + self._active_request_task = None + + async def _interrupt_active_request(self) -> None: + task = self._active_request_task + if task is None or task.done(): + return + task.cancel() + async def _process_line(self, line: str, *, transcript_line: str | None = None) -> bool: assert self._bundle is not None await self._emit( diff --git a/src/openharness/ui/protocol.py b/src/openharness/ui/protocol.py index 3f46aad..82dd5e0 100644 --- a/src/openharness/ui/protocol.py +++ b/src/openharness/ui/protocol.py @@ -22,6 +22,7 @@ class FrontendRequest(BaseModel): "list_sessions", "select_command", "apply_select_command", + "interrupt", "shutdown", ] line: str | None = None diff --git a/tests/test_ui/test_react_backend.py b/tests/test_ui/test_react_backend.py index 9238eb7..08c86be 100644 --- a/tests/test_ui/test_react_backend.py +++ b/tests/test_ui/test_react_backend.py @@ -106,6 +106,78 @@ async def test_read_requests_resolves_permission_response_without_queueing(monke assert host._request_queue.empty() +@pytest.mark.asyncio +async def test_read_requests_interrupt_cancels_active_request(monkeypatch): + host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) + + async def _long_running(): + await asyncio.Event().wait() + + task = asyncio.create_task(_long_running()) + host._active_request_task = task + + class _FakeBuffer: + def __init__(self): + self._reads = 0 + + def readline(self): + self._reads += 1 + if self._reads == 1: + return b'{"type":"interrupt"}\n' + return b"" + + class _FakeStdin: + buffer = _FakeBuffer() + + monkeypatch.setattr("openharness.ui.backend_host.sys.stdin", _FakeStdin()) + + await host._read_requests() + + with pytest.raises(asyncio.CancelledError): + await task + queued = await host._request_queue.get() + assert queued.type == "shutdown" + + +@pytest.mark.asyncio +async def test_run_active_request_recovers_from_cancel(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("OPENHARNESS_DATA_DIR", str(tmp_path / "data")) + + host = ReactBackendHost(BackendHostConfig(api_client=StaticApiClient("unused"))) + host._bundle = await build_runtime(api_client=StaticApiClient("unused")) + events = [] + + async def _emit(event): + events.append(event) + + host._emit = _emit # type: ignore[method-assign] + await start_runtime(host._bundle) + + async def _long_running(): + await asyncio.Event().wait() + return True + + try: + runner = asyncio.create_task(host._run_active_request(_long_running())) + while host._active_request_task is None: + await asyncio.sleep(0) + await host._interrupt_active_request() + assert await runner is True + finally: + await close_runtime(host._bundle) + + assert any( + event.type == "transcript_item" + and event.item + and event.item.role == "system" + and "Interrupted" in event.item.text + for event in events + ) + assert any(event.type == "line_complete" for event in events) + + @pytest.mark.asyncio async def test_backend_host_processes_command(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path)