Skip to content
7 changes: 7 additions & 0 deletions openhands_cli/acp_impl/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ def get_available_slash_commands() -> list[AvailableCommand]:
root=UnstructuredCommandInput(hint=mode_options),
),
),
AvailableCommand(
name="btw",
description="Ask the agent a side question without derailing the main task",
input=AvailableCommandInput(
root=UnstructuredCommandInput(hint="Your question"),
),
),
]


Expand Down
31 changes: 31 additions & 0 deletions openhands_cli/auth/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,37 @@ async def create_conversation(
) -> httpx.Response:
return await self.post("/api/v1/app-conversations", self._headers, json_data)

async def ask_agent(self, conversation_id: str, question: str) -> dict[str, Any]:
Comment thread
juanmichelini marked this conversation as resolved.
"""Ask the agent a side question without queuing a full turn.

Args:
conversation_id: The conversation ID.
question: The side question to ask.

Returns:
Dict containing the agent's response with key "response".

Raises:
ValueError: If the question is empty or exceeds the length limit.
"""
stripped = question.strip()
if not stripped:
raise ValueError("Question cannot be empty.")
max_length = 4096
if len(stripped) > max_length:
raise ValueError(
f"Question exceeds maximum length of {max_length} characters."
)

path = f"/api/conversations/{conversation_id}/ask_agent"
response = await self.post(
path,
self._headers,
json_data={"question": stripped},
)
Comment thread
juanmichelini marked this conversation as resolved.
response.raise_for_status()
return response.json()

async def get_conversation_info(
self, conversation_id: str, endpoint: str = ""
) -> dict[str, Any] | None:
Expand Down
98 changes: 98 additions & 0 deletions openhands_cli/tui/core/btw_interceptor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""BTW (By The Way) interceptor for handling side-channel questions.

This module provides a class that intercepts /btw commands and routes them
through the ask_agent side-channel without disrupting the main task flow.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

from openhands_cli.tui.core.btw_store import BtwStore


if TYPE_CHECKING:
from openhands_cli.tui.core.btw_store import BtwEntry


# The /btw command prefix
BTW_COMMAND = "/btw"
BTW_PREFIX = f"{BTW_COMMAND} "


@dataclass
class BtwResult:
"""Result of processing a message through the BTW interceptor."""

is_btw: bool
question: str | None = None
entry_id: str | None = None


class BtwInterceptor:
"""Interceptor for /btw commands.

Handles command parsing and store management for side-channel questions.
The actual API call is the caller's responsibility.
"""

def __init__(
self,
store: BtwStore,
conversation_id: str | None = None,
) -> None:
self._store = store
self._conversation_id = conversation_id

def set_conversation_id(self, conversation_id: str | None) -> None:
"""Update the conversation ID."""
self._conversation_id = conversation_id

def process(self, message: str) -> BtwResult:
"""Process a message to check if it's a BTW command.

Returns:
BtwResult indicating if it's a BTW command and details.
"""
trimmed = message.strip()
is_btw = trimmed == BTW_COMMAND or trimmed.startswith(BTW_PREFIX)

if not self._conversation_id or not is_btw:
return BtwResult(is_btw=False)

question = trimmed[len(BTW_COMMAND) :].strip()
if not question:
return BtwResult(is_btw=False)

entry_id = self._store.add_pending(self._conversation_id, question)

return BtwResult(
is_btw=True,
question=question,
entry_id=entry_id,
)

async def resolve(self, entry_id: str, response: str) -> None:
"""Resolve a BTW entry with the agent's response."""
if self._conversation_id is None:
return
self._store.resolve(self._conversation_id, entry_id, response)

async def fail(self, entry_id: str, error: str) -> None:
"""Mark a BTW entry as failed."""
if self._conversation_id is None:
return
self._store.fail(self._conversation_id, entry_id, error)

def get_entries(self) -> list[BtwEntry]:
"""Get all BTW entries for the current conversation."""
if self._conversation_id is None:
return []
return self._store.get_entries(self._conversation_id)

def dismiss(self, entry_id: str) -> None:
"""Dismiss a BTW entry."""
if self._conversation_id is None:
return
self._store.dismiss(self._conversation_id, entry_id)
123 changes: 123 additions & 0 deletions openhands_cli/tui/core/btw_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""BTW (By The Way) store for tracking side-channel questions.

This module provides a store for managing BTW entries - questions that users
ask via /btw command without disrupting the main task flow.
"""

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum


class BtwStatus(Enum):
"""Status of a BTW entry."""

PENDING = "pending"
DONE = "done"
ERROR = "error"


@dataclass
class BtwEntry:
"""A single BTW entry representing a side-channel question."""

id: str
question: str
response: str | None = None
status: BtwStatus = BtwStatus.PENDING


class BtwStore:
"""Store for BTW entries scoped to conversations.

This is a simple in-memory store that tracks pending, resolved, and failed
BTW entries per conversation.
"""

def __init__(self) -> None:
self._entries_by_conversation: dict[str, list[BtwEntry]] = {}

def add_pending(self, conversation_id: str, question: str) -> str:
"""Add a pending BTW entry.

Args:
conversation_id: The conversation ID.
question: The question to ask.

Returns:
The ID of the new entry.
"""
import uuid

entry_id = str(uuid.uuid4())
entries = self._entries_by_conversation.setdefault(conversation_id, [])
entries.append(
BtwEntry(id=entry_id, question=question, status=BtwStatus.PENDING)
)
return entry_id

def resolve(self, conversation_id: str, entry_id: str, response: str) -> None:
"""Mark a BTW entry as resolved.

Args:
conversation_id: The conversation ID.
entry_id: The entry ID.
response: The agent's response.
"""
entries = self._entries_by_conversation.get(conversation_id, [])
for entry in entries:
if entry.id == entry_id:
entry.response = response
entry.status = BtwStatus.DONE
break

def fail(self, conversation_id: str, entry_id: str, error: str) -> None:
"""Mark a BTW entry as failed.

Args:
conversation_id: The conversation ID.
entry_id: The entry ID.
error: The error message.
"""
entries = self._entries_by_conversation.get(conversation_id, [])
for entry in entries:
if entry.id == entry_id:
entry.response = error
entry.status = BtwStatus.ERROR
break

def dismiss(self, conversation_id: str, entry_id: str) -> None:
"""Dismiss (remove) a BTW entry.

Args:
conversation_id: The conversation ID.
entry_id: The entry ID to dismiss.
"""
entries = self._entries_by_conversation.get(conversation_id, [])
self._entries_by_conversation[conversation_id] = [
e for e in entries if e.id != entry_id
]

def get_entries(self, conversation_id: str) -> list[BtwEntry]:
"""Get all BTW entries for a conversation.

Args:
conversation_id: The conversation ID.

Returns:
List of BTW entries.
"""
return list(self._entries_by_conversation.get(conversation_id, []))

def clear(self, conversation_id: str | None = None) -> None:
"""Clear BTW entries.

Args:
conversation_id: If provided, clear only entries for this conversation.
If None, clear all entries.
"""
if conversation_id is None:
self._entries_by_conversation.clear()
elif conversation_id in self._entries_by_conversation:
del self._entries_by_conversation[conversation_id]
83 changes: 81 additions & 2 deletions openhands_cli/tui/core/conversation_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

from openhands.sdk.security.confirmation_policy import ConfirmationPolicyBase
from openhands_cli.conversations.protocols import ConversationStore
from openhands_cli.tui.core.btw_interceptor import BtwInterceptor
from openhands_cli.tui.core.btw_store import BtwStore
from openhands_cli.tui.core.confirmation_flow_controller import (
ConfirmationFlowController,
)
Expand All @@ -43,6 +45,7 @@


if TYPE_CHECKING:
from openhands_cli.auth.api_client import OpenHandsApiClient
from openhands_cli.tui.core.conversation_runner import ConversationRunner
from openhands_cli.tui.core.state import ConversationContainer

Expand Down Expand Up @@ -176,6 +179,11 @@ def notification_callback(
run_worker=self.run_worker,
)

# BTW side-channel: interceptor is created eagerly, API client lazily
self._btw_store = BtwStore()
self._btw_interceptor = BtwInterceptor(store=self._btw_store)
self._api_client: OpenHandsApiClient | None = None

# ---- Properties ----

@property
Expand All @@ -195,13 +203,84 @@ async def _on_send_message(self, event: SendMessage) -> None:
"""Handle SendMessage - the primary entry point for user messages.

This handler:
1. Resets the refinement iteration counter (new user turn)
2. Delegates to UserMessageController for rendering and processing
1. Checks for /btw (side-channel) commands and handles them separately
2. Resets the refinement iteration counter (new user turn)
3. Delegates to UserMessageController for rendering and processing
"""
event.stop()
self._refinement_controller.reset_iteration()

# Check for BTW (side-channel) command
self._btw_interceptor.set_conversation_id(
str(self._state.conversation_id) if self._state.conversation_id else None
)
result = self._btw_interceptor.process(event.content)

Comment thread
juanmichelini marked this conversation as resolved.
if result.is_btw and result.entry_id:
self.run_worker(self._handle_btw_message(result.question, result.entry_id))
return # Don't process as regular message

await self._message_controller.handle_user_message(event.content)

async def _handle_btw_message(
self,
question: str | None,
entry_id: str,
) -> None:
"""Handle a BTW (side-channel) message by calling the ask_agent API."""
if question is None:
await self._btw_interceptor.fail(entry_id, "No question provided")
return

try:
conversation_id = (
str(self._state.conversation_id)
if self._state.conversation_id
else None
)
if not conversation_id:
await self._btw_interceptor.fail(entry_id, "No conversation ID")
return
Comment thread
juanmichelini marked this conversation as resolved.

if self._api_client is None:
self._ensure_api_client()

assert self._api_client is not None
response = await self._api_client.ask_agent(conversation_id, question)
response_text = response.get("response", "")
await self._btw_interceptor.resolve(entry_id, response_text)

display_text = (
response_text[:200] + "…" if len(response_text) > 200 else response_text
)
self.notify(
Comment thread
juanmichelini marked this conversation as resolved.
f"BTW response: {display_text}",
title="Side Channel Response",
)
except Exception as e:
await self._btw_interceptor.fail(entry_id, str(e))
self.notify(
f"BTW failed: {e}",
title="Error",
severity="error",
)

def _ensure_api_client(self) -> None:
"""Initialize the API client lazily on first /btw use."""
import os

from openhands_cli.auth.api_client import OpenHandsApiClient
from openhands_cli.auth.token_storage import TokenStorage

token_storage = TokenStorage()
api_key = token_storage.get_api_key()

if not api_key:
raise RuntimeError("Not authenticated. Please run 'openhands login' first.")

server_url = os.getenv("OPENHANDS_CLOUD_URL", "https://app.all-hands.dev")
self._api_client = OpenHandsApiClient(server_url, api_key)

@on(SendRefinementMessage)
async def _on_send_refinement_message(self, event: SendRefinementMessage) -> None:
"""Handle SendRefinementMessage for iterative refinement.
Expand Down
Loading
Loading