Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions src/agent_chat_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,9 @@ class AgentChatCLIApp(App):
def __init__(self) -> None:
super().__init__()

self.message_bus = MessageBus(self)

self.agent_loop = AgentLoop(
on_message=self.message_bus.handle_agent_message,
)

self.actions = Actions(self)
self.message_bus = MessageBus(app=self)
self.actions = Actions(app=self)
self.agent_loop = AgentLoop(app=self)
self.pending_tool_permission: dict | None = None

def compose(self) -> ComposeResult:
Expand Down
20 changes: 1 addition & 19 deletions src/agent_chat_cli/components/user_input.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import asyncio

from textual.widget import Widget
from textual.app import ComposeResult
from textual.widgets import Input

from agent_chat_cli.components.caret import Caret
from agent_chat_cli.components.flex import Flex
from agent_chat_cli.components.chat_history import MessagePosted
from agent_chat_cli.components.thinking_indicator import ThinkingIndicator
from agent_chat_cli.components.messages import Message
from agent_chat_cli.system.actions import Actions
from agent_chat_cli.utils.enums import ControlCommand

Expand Down Expand Up @@ -49,22 +44,9 @@ async def on_input_submitted(self, event: Input.Submitted) -> None:
await self.actions.new()
return

# Post to chat history
self.post_message(MessagePosted(Message.user(user_message)))

# Run agent query in background
asyncio.create_task(self.query_agent(user_message))
await self.actions.submit_user_message(user_message)

async def on_input_blurred(self, event: Input.Blurred) -> None:
if self.display:
input_widget = self.query_one(Input)
input_widget.focus()

async def query_agent(self, user_input: str) -> None:
thinking_indicator = self.app.query_one(ThinkingIndicator)
thinking_indicator.is_thinking = True

input_widget = self.query_one(Input)
input_widget.cursor_blink = False

await self.actions.query(user_input)
57 changes: 40 additions & 17 deletions src/agent_chat_cli/system/actions.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,61 @@
from typing import TYPE_CHECKING

from textual.widgets import Input

from agent_chat_cli.system.agent_loop import AgentLoop
from agent_chat_cli.utils.enums import ControlCommand
from agent_chat_cli.components.chat_history import ChatHistory
from agent_chat_cli.components.chat_history import ChatHistory, MessagePosted
from agent_chat_cli.components.messages import Message
from agent_chat_cli.components.thinking_indicator import ThinkingIndicator
from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt
from agent_chat_cli.utils.logger import log_json

if TYPE_CHECKING:
from agent_chat_cli.app import AgentChatCLIApp


class Actions:
def __init__(self, app) -> None:
def __init__(self, app: "AgentChatCLIApp") -> None:
self.app = app
self.agent_loop: AgentLoop = app.agent_loop

def quit(self) -> None:
self.app.exit()

async def query(self, user_input: str) -> None:
await self.agent_loop.query_queue.put(user_input)
await self.app.agent_loop.query_queue.put(user_input)

async def submit_user_message(self, message: str) -> None:
from agent_chat_cli.components.user_input import UserInput

self.app.post_message(MessagePosted(Message.user(message)))

thinking_indicator = self.app.query_one(ThinkingIndicator)
thinking_indicator.is_thinking = True

user_input = self.app.query_one(UserInput)
input_widget = user_input.query_one(Input)
input_widget.cursor_blink = False

await self.query(message)

def post_system_message(self, message: str) -> None:
self.app.post_message(MessagePosted(Message.system(message)))

async def handle_agent_message(self, message) -> None:
await self.app.message_bus.handle_agent_message(message)

async def interrupt(self) -> None:
permission_prompt = self.app.query_one(ToolPermissionPrompt)
if permission_prompt.is_visible:
return

self.agent_loop.interrupting = True
await self.agent_loop.client.interrupt()
self.app.agent_loop.interrupting = True
await self.app.agent_loop.client.interrupt()

thinking_indicator = self.app.query_one(ThinkingIndicator)
thinking_indicator.is_thinking = False

async def new(self) -> None:
await self.agent_loop.query_queue.put(ControlCommand.NEW_CONVERSATION)
await self.app.agent_loop.query_queue.put(ControlCommand.NEW_CONVERSATION)

chat_history = self.app.query_one(ChatHistory)
await chat_history.remove_children()
Expand All @@ -49,26 +73,25 @@ async def respond_to_tool_permission(self, response: str) -> None:
}
)

await self.agent_loop.permission_response_queue.put(response)
await self.app.agent_loop.permission_response_queue.put(response)

permission_prompt = self.app.query_one(ToolPermissionPrompt)
permission_prompt.is_visible = False

user_input = self.app.query_one(UserInput)
user_input.display = True

input_widget = user_input.query_one(Input)
input_widget.focus()

thinking_indicator = self.app.query_one(ThinkingIndicator)
thinking_indicator.is_thinking = True
input_widget.cursor_blink = False

# Check if it's a deny or custom response (anything except yes/allow)
normalized = response.lower().strip()
if normalized not in ["y", "yes", "allow", ""]:
# Handle like a normal user query
thinking_indicator = self.app.query_one(ThinkingIndicator)
thinking_indicator.is_thinking = True
input_widget.cursor_blink = False

if normalized in ["n", "no", "deny"]:
denial_message = "The user has denied the tool"
await self.query(denial_message)
await self.query("The user has denied the tool")
else:
await self.query(response)
await self.submit_user_message(response)
42 changes: 17 additions & 25 deletions src/agent_chat_cli/system/agent_loop.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import asyncio
from typing import Callable, Awaitable, Any
from typing import Any, TYPE_CHECKING
from dataclasses import dataclass

from claude_agent_sdk import (
Expand All @@ -26,6 +26,9 @@
from agent_chat_cli.system.mcp_inference import infer_mcp_servers
from agent_chat_cli.utils.logger import log_json

if TYPE_CHECKING:
from agent_chat_cli.app import AgentChatCLIApp


@dataclass
class AgentMessage:
Expand All @@ -36,17 +39,17 @@ class AgentMessage:
class AgentLoop:
def __init__(
self,
on_message: Callable[[AgentMessage], Awaitable[None]],
app: "AgentChatCLIApp",
session_id: str | None = None,
) -> None:
self.app = app
self.config = load_config()
self.session_id = session_id
self.available_servers = get_available_servers()
self.inferred_servers: set[str] = set()

self.client: ClaudeSDKClient

self.on_message = on_message
self.query_queue: asyncio.Queue[str | ControlCommand] = asyncio.Queue()
self.permission_response_queue: asyncio.Queue[str] = asyncio.Queue()
self.permission_lock = asyncio.Lock()
Expand Down Expand Up @@ -104,11 +107,8 @@ async def start(self) -> None:
if inference_result["new_servers"]:
server_list = ", ".join(inference_result["new_servers"])

await self.on_message(
AgentMessage(
type=AgentMessageType.SYSTEM,
data=f"Connecting to {server_list}...",
)
self.app.actions.post_system_message(
f"Connecting to {server_list}..."
)

await asyncio.sleep(0.1)
Expand Down Expand Up @@ -136,7 +136,9 @@ async def start(self) -> None:

await self._handle_message(message)

await self.on_message(AgentMessage(type=AgentMessageType.RESULT, data=None))
await self.app.actions.handle_agent_message(
AgentMessage(type=AgentMessageType.RESULT, data=None)
)

async def _initialize_client(self, mcp_servers: dict) -> None:
sdk_config = get_sdk_config(self.config)
Expand Down Expand Up @@ -174,7 +176,7 @@ async def _handle_message(self, message: Any) -> None:
text_chunk = delta.get("text", "")

if text_chunk:
await self.on_message(
await self.app.actions.handle_agent_message(
AgentMessage(
type=AgentMessageType.STREAM_EVENT,
data={"text": text_chunk},
Expand Down Expand Up @@ -202,7 +204,7 @@ async def _handle_message(self, message: Any) -> None:
)

# Finally, post the agent assistant response
await self.on_message(
await self.app.actions.handle_agent_message(
AgentMessage(
type=AgentMessageType.ASSISTANT,
data={"content": content},
Expand All @@ -219,7 +221,7 @@ async def _can_use_tool(

# Handle permission request queue
async with self.permission_lock:
await self.on_message(
await self.app.actions.handle_agent_message(
AgentMessage(
type=AgentMessageType.TOOL_PERMISSION_REQUEST,
data={
Expand Down Expand Up @@ -252,11 +254,8 @@ async def _can_use_tool(
)

if DENY:
await self.on_message(
AgentMessage(
type=AgentMessageType.SYSTEM,
data=f"Permission denied for {tool_name}",
)
self.app.actions.post_system_message(
f"Permission denied for {tool_name}"
)

return PermissionResultDeny(
Expand All @@ -266,14 +265,7 @@ async def _can_use_tool(
)

# If a user instead typed in a message (instead of confirming or denying)
# post it to chat. actions.respond_to_tool_permission will handle querying.
await self.on_message(
AgentMessage(
type=AgentMessageType.USER,
data=user_response,
)
)

# actions.respond_to_tool_permission will handle posting and querying.
return PermissionResultDeny(
behavior="deny",
message=user_response,
Expand Down