fix(tui): allow escape to interrupt active runs

Add an explicit frontend-to-backend interrupt request so Esc can stop the current agent turn without relying on platform-specific terminal signals.
This commit is contained in:
ancietyding
2026-04-27 14:07:43 +08:00
parent f683b4aed2
commit b731c3ede1
5 changed files with 125 additions and 7 deletions
+11 -3
View File
@@ -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 {
<Text color={theme.colors.primary}>enter</Text> send{' '}
<Text color={theme.colors.primary}>/</Text> commands{' '}
<Text color={theme.colors.primary}>{'\u2191\u2193'}</Text> history{' '}
<Text color={theme.colors.primary}>esc</Text> stop{' '}
<Text color={theme.colors.primary}>ctrl+c</Text> exit
</Text>
</Box>
@@ -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]
+40 -4
View File
@@ -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(
+1
View File
@@ -22,6 +22,7 @@ class FrontendRequest(BaseModel):
"list_sessions",
"select_command",
"apply_select_command",
"interrupt",
"shutdown",
]
line: str | None = None
+72
View File
@@ -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)