Skip to content

Commit 846c6f6

Browse files
authored
Merge pull request #9 from damassi/chore/add-ui-state
refactor: Add ui_state for managing ui state behaviors
2 parents fcd21c0 + e93c2c3 commit 846c6f6

File tree

6 files changed

+120
-82
lines changed

6 files changed

+120
-82
lines changed

src/agent_chat_cli/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from agent_chat_cli.core.agent_loop import AgentLoop
1313
from agent_chat_cli.core.message_bus import MessageBus
1414
from agent_chat_cli.core.actions import Actions
15+
from agent_chat_cli.core.ui_state import UIState
1516
from agent_chat_cli.utils.logger import setup_logging
1617

1718
from dotenv import load_dotenv
@@ -32,6 +33,7 @@ class AgentChatCLIApp(App):
3233
def __init__(self) -> None:
3334
super().__init__()
3435

36+
self.ui_state = UIState(app=self)
3537
self.message_bus = MessageBus(app=self)
3638
self.actions = Actions(app=self)
3739
self.agent_loop = AgentLoop(app=self)

src/agent_chat_cli/components/tool_permission_prompt.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
from textual.widget import Widget
44
from textual.app import ComposeResult
5-
from textual.widgets import Label, Input
5+
from textual.widgets import Label, TextArea
66
from textual.reactive import reactive
7+
from textual.binding import Binding
78

89
from agent_chat_cli.components.caret import Caret
910
from agent_chat_cli.components.flex import Flex
@@ -20,6 +21,10 @@ class ToolPermissionPrompt(Widget):
2021
tool_name = reactive("")
2122
tool_input: dict[str, Any] = reactive({}, init=False) # type: ignore[assignment]
2223

24+
BINDINGS = [
25+
Binding("enter", "submit", "Submit", priority=True),
26+
]
27+
2328
def __init__(self, actions: "Actions") -> None:
2429
super().__init__()
2530
self.actions = actions
@@ -32,14 +37,20 @@ def compose(self) -> ComposeResult:
3237

3338
with Flex():
3439
yield Caret()
35-
yield Input(placeholder="Yes", id="permission-input")
40+
yield TextArea(
41+
"",
42+
show_line_numbers=False,
43+
soft_wrap=True,
44+
placeholder="Yes",
45+
id="permission-input",
46+
)
3647

3748
def watch_is_visible(self, is_visible: bool) -> None:
3849
self.display = is_visible
3950

4051
if is_visible:
41-
input_widget = self.query_one("#permission-input", Input)
42-
input_widget.value = ""
52+
input_widget = self.query_one("#permission-input", TextArea)
53+
input_widget.clear()
4354
input_widget.focus()
4455

4556
def watch_tool_name(self, tool_name: str) -> None:
@@ -59,24 +70,25 @@ def watch_tool_name(self, tool_name: str) -> None:
5970

6071
tool_display_label.update(tool_display)
6172

62-
async def on_input_submitted(self, event: Input.Submitted) -> None:
63-
raw_value = event.value
64-
response = event.value.strip() or "yes"
73+
async def action_submit(self) -> None:
74+
input_widget = self.query_one("#permission-input", TextArea)
75+
raw_value = input_widget.text
76+
response = raw_value.strip() or "yes"
6577

6678
log_json(
6779
{
6880
"event": "permission_input_submitted",
6981
"raw_value": raw_value,
70-
"stripped_value": event.value.strip(),
82+
"stripped_value": raw_value.strip(),
7183
"final_response": response,
7284
}
7385
)
7486

7587
await self.actions.respond_to_tool_permission(response)
7688

77-
async def on_input_blurred(self, event: Input.Blurred) -> None:
89+
def on_descendant_blur(self) -> None:
7890
if self.is_visible:
79-
input_widget = self.query_one("#permission-input", Input)
91+
input_widget = self.query_one("#permission-input", TextArea)
8092
input_widget.focus()
8193

8294
async def on_key(self, event) -> None:
@@ -86,7 +98,8 @@ async def on_key(self, event) -> None:
8698
event.stop()
8799
event.prevent_default()
88100

89-
input_widget = self.query_one("#permission-input", Input)
90-
input_widget.value = "no"
101+
input_widget = self.query_one("#permission-input", TextArea)
102+
input_widget.clear()
103+
input_widget.insert("no")
91104

92-
await input_widget.action_submit()
105+
await self.action_submit()

src/agent_chat_cli/core/actions.py

Lines changed: 11 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
from typing import TYPE_CHECKING
22

3-
from textual.widgets import TextArea
4-
53
from agent_chat_cli.utils.enums import ControlCommand
64
from agent_chat_cli.components.chat_history import ChatHistory, MessagePosted
75
from agent_chat_cli.components.messages import Message
8-
from agent_chat_cli.components.thinking_indicator import ThinkingIndicator
96
from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt
107
from agent_chat_cli.utils.logger import log_json
118

@@ -20,19 +17,10 @@ def __init__(self, app: "AgentChatCLIApp") -> None:
2017
def quit(self) -> None:
2118
self.app.exit()
2219

23-
async def query(self, user_input: str) -> None:
24-
await self.app.agent_loop.query_queue.put(user_input)
25-
2620
async def submit_user_message(self, message: str) -> None:
2721
self.app.post_message(MessagePosted(Message.user(message)))
28-
29-
thinking_indicator = self.app.query_one(ThinkingIndicator)
30-
thinking_indicator.is_thinking = True
31-
32-
input_widget = self.app.query_one(TextArea)
33-
input_widget.cursor_blink = False
34-
35-
await self.query(message)
22+
self.app.ui_state.start_thinking()
23+
await self._query(message)
3624

3725
def post_system_message(self, message: str) -> None:
3826
self.app.post_message(MessagePosted(Message.system(message)))
@@ -45,24 +33,19 @@ async def interrupt(self) -> None:
4533
if permission_prompt.is_visible:
4634
return
4735

48-
self.app.agent_loop.interrupting = True
36+
self.app.ui_state.set_interrupting(True)
4937
await self.app.agent_loop.client.interrupt()
50-
51-
thinking_indicator = self.app.query_one(ThinkingIndicator)
52-
thinking_indicator.is_thinking = False
38+
self.app.ui_state.stop_thinking()
5339

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

5743
chat_history = self.app.query_one(ChatHistory)
5844
await chat_history.remove_children()
5945

60-
thinking_indicator = self.app.query_one(ThinkingIndicator)
61-
thinking_indicator.is_thinking = False
46+
self.app.ui_state.stop_thinking()
6247

6348
async def respond_to_tool_permission(self, response: str) -> None:
64-
from agent_chat_cli.components.user_input import UserInput
65-
6649
log_json(
6750
{
6851
"event": "permission_response_action",
@@ -72,23 +55,15 @@ async def respond_to_tool_permission(self, response: str) -> None:
7255

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

75-
permission_prompt = self.app.query_one(ToolPermissionPrompt)
76-
permission_prompt.is_visible = False
77-
78-
user_input = self.app.query_one(UserInput)
79-
user_input.display = True
80-
81-
input_widget = self.app.query_one(TextArea)
82-
input_widget.focus()
58+
self.app.ui_state.hide_permission_prompt()
59+
self.app.ui_state.start_thinking()
8360

84-
thinking_indicator = self.app.query_one(ThinkingIndicator)
85-
thinking_indicator.is_thinking = True
86-
input_widget.cursor_blink = False
87-
88-
# Check if it's a deny or custom response (anything except yes/allow)
8961
normalized = response.lower().strip()
9062
if normalized not in ["y", "yes", "allow", ""]:
9163
if normalized in ["n", "no", "deny"]:
92-
await self.query("The user has denied the tool")
64+
await self._query("The user has denied the tool")
9365
else:
9466
await self.submit_user_message(response)
67+
68+
async def _query(self, user_input: str) -> None:
69+
await self.app.agent_loop.query_queue.put(user_input)

src/agent_chat_cli/core/agent_loop.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ def __init__(
5555
self.permission_lock = asyncio.Lock()
5656

5757
self._running = False
58-
self.interrupting = False
5958

6059
async def start(self) -> None:
6160
mcp_servers = {
@@ -81,12 +80,12 @@ async def start(self) -> None:
8180
await self._initialize_client(mcp_servers=mcp_servers)
8281
continue
8382

84-
self.interrupting = False
83+
self.app.ui_state.set_interrupting(False)
8584

8685
await self.client.query(user_input)
8786

8887
async for message in self.client.receive_response():
89-
if self.interrupting:
88+
if self.app.ui_state.interrupting:
9089
continue
9190

9291
await self._handle_message(message)

src/agent_chat_cli/core/message_bus.py

Lines changed: 14 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import asyncio
22
from typing import TYPE_CHECKING
33

4-
from textual.widgets import Markdown, TextArea
4+
from textual.widgets import Markdown
55
from textual.containers import VerticalScroll
66

77
from agent_chat_cli.components.chat_history import ChatHistory, MessagePosted
8-
from agent_chat_cli.components.thinking_indicator import ThinkingIndicator
9-
from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt
108
from agent_chat_cli.components.messages import (
119
AgentMessage as AgentMessageWidget,
1210
Message,
@@ -46,11 +44,11 @@ async def handle_agent_message(self, message: AgentMessage) -> None:
4644
case AgentMessageType.RESULT:
4745
await self._handle_result()
4846

49-
async def _scroll_to_bottom(self) -> None:
50-
await asyncio.sleep(0.1)
47+
async def on_message_posted(self, event: MessagePosted) -> None:
48+
chat_history = self.app.query_one(ChatHistory)
49+
chat_history.add_message(event.message)
5150

52-
container = self.app.query_one(VerticalScroll)
53-
container.scroll_end(animate=False, immediate=True)
51+
await self._scroll_to_bottom()
5452

5553
async def _handle_stream_event(self, message: AgentMessage) -> None:
5654
text_chunk = message.data.get("text", "")
@@ -121,45 +119,31 @@ async def _handle_user(self, message: AgentMessage) -> None:
121119
await self._scroll_to_bottom()
122120

123121
async def _handle_tool_permission_request(self, message: AgentMessage) -> None:
124-
from agent_chat_cli.components.user_input import UserInput
125-
126122
log_json(
127123
{
128124
"event": "showing_permission_prompt",
129125
"tool_name": message.data.get("tool_name", ""),
130126
}
131127
)
132128

133-
thinking_indicator = self.app.query_one(ThinkingIndicator)
134-
thinking_indicator.is_thinking = False
135-
136-
permission_prompt = self.app.query_one(ToolPermissionPrompt)
137-
permission_prompt.tool_name = message.data.get("tool_name", "")
138-
permission_prompt.tool_input = message.data.get("tool_input", {})
139-
permission_prompt.is_visible = True
140-
141-
user_input = self.app.query_one(UserInput)
142-
user_input.display = False
129+
self.app.ui_state.show_permission_prompt(
130+
tool_name=message.data.get("tool_name", ""),
131+
tool_input=message.data.get("tool_input", {}),
132+
)
143133

144134
await self._scroll_to_bottom()
145135

146136
async def _handle_result(self) -> None:
147-
# Check if there's a queued message (e.g., from custom permission response)
148137
if not self.app.agent_loop.query_queue.empty():
149-
# Don't turn off thinking - there's more work to do
150138
return
151139

152-
thinking_indicator = self.app.query_one(ThinkingIndicator)
153-
thinking_indicator.is_thinking = False
154-
155-
input_widget = self.app.query_one(TextArea)
156-
input_widget.cursor_blink = True
140+
self.app.ui_state.stop_thinking()
157141

158142
self.current_agent_message = None
159143
self.current_response_text = ""
160144

161-
async def on_message_posted(self, event: MessagePosted) -> None:
162-
chat_history = self.app.query_one(ChatHistory)
163-
chat_history.add_message(event.message)
145+
async def _scroll_to_bottom(self) -> None:
146+
await asyncio.sleep(0.1)
164147

165-
await self._scroll_to_bottom()
148+
container = self.app.query_one(VerticalScroll)
149+
container.scroll_end(animate=False, immediate=True)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from typing import TYPE_CHECKING, Any
2+
3+
from textual.widgets import TextArea
4+
5+
from agent_chat_cli.components.thinking_indicator import ThinkingIndicator
6+
from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt
7+
8+
if TYPE_CHECKING:
9+
from agent_chat_cli.app import AgentChatCLIApp
10+
11+
12+
class UIState:
13+
def __init__(self, app: "AgentChatCLIApp") -> None:
14+
self.app = app
15+
self._interrupting = False
16+
17+
@property
18+
def interrupting(self) -> bool:
19+
return self._interrupting
20+
21+
def set_interrupting(self, value: bool) -> None:
22+
self._interrupting = value
23+
24+
def start_thinking(self) -> None:
25+
thinking_indicator = self.app.query_one(ThinkingIndicator)
26+
thinking_indicator.is_thinking = True
27+
28+
input_widget = self.app.query_one(TextArea)
29+
input_widget.cursor_blink = False
30+
31+
def stop_thinking(self, show_cursor: bool = True) -> None:
32+
thinking_indicator = self.app.query_one(ThinkingIndicator)
33+
thinking_indicator.is_thinking = False
34+
35+
if show_cursor:
36+
input_widget = self.app.query_one(TextArea)
37+
input_widget.cursor_blink = True
38+
39+
def show_permission_prompt(
40+
self, tool_name: str, tool_input: dict[str, Any]
41+
) -> None:
42+
from agent_chat_cli.components.user_input import UserInput
43+
44+
thinking_indicator = self.app.query_one(ThinkingIndicator)
45+
thinking_indicator.is_thinking = False
46+
47+
permission_prompt = self.app.query_one(ToolPermissionPrompt)
48+
permission_prompt.tool_name = tool_name
49+
permission_prompt.tool_input = tool_input
50+
permission_prompt.is_visible = True
51+
52+
user_input = self.app.query_one(UserInput)
53+
user_input.display = False
54+
55+
def hide_permission_prompt(self) -> None:
56+
from agent_chat_cli.components.user_input import UserInput
57+
58+
permission_prompt = self.app.query_one(ToolPermissionPrompt)
59+
permission_prompt.is_visible = False
60+
61+
user_input = self.app.query_one(UserInput)
62+
user_input.display = True
63+
64+
input_widget = self.app.query_one(TextArea)
65+
input_widget.focus()

0 commit comments

Comments
 (0)