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)