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:
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -22,6 +22,7 @@ class FrontendRequest(BaseModel):
|
||||
"list_sessions",
|
||||
"select_command",
|
||||
"apply_select_command",
|
||||
"interrupt",
|
||||
"shutdown",
|
||||
]
|
||||
line: str | None = None
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user