connectonion 0.5.10__py3-none-any.whl → 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- connectonion/__init__.py +17 -16
- connectonion/cli/browser_agent/browser.py +488 -145
- connectonion/cli/browser_agent/scroll_strategies.py +276 -0
- connectonion/cli/commands/copy_commands.py +24 -1
- connectonion/cli/commands/deploy_commands.py +15 -0
- connectonion/cli/commands/eval_commands.py +286 -0
- connectonion/cli/commands/project_cmd_lib.py +1 -1
- connectonion/cli/main.py +11 -0
- connectonion/console.py +5 -5
- connectonion/core/__init__.py +53 -0
- connectonion/{agent.py → core/agent.py} +18 -15
- connectonion/{llm.py → core/llm.py} +9 -19
- connectonion/{tool_executor.py → core/tool_executor.py} +3 -2
- connectonion/{tool_factory.py → core/tool_factory.py} +3 -1
- connectonion/debug/__init__.py +51 -0
- connectonion/{interactive_debugger.py → debug/auto_debug.py} +7 -7
- connectonion/{auto_debug_exception.py → debug/auto_debug_exception.py} +3 -3
- connectonion/{debugger_ui.py → debug/auto_debug_ui.py} +1 -1
- connectonion/{debug_explainer → debug/debug_explainer}/explain_agent.py +1 -1
- connectonion/{debug_explainer → debug/debug_explainer}/explain_context.py +1 -1
- connectonion/{execution_analyzer → debug/execution_analyzer}/execution_analysis.py +1 -1
- connectonion/debug/runtime_inspector/__init__.py +13 -0
- connectonion/{debug_agent → debug/runtime_inspector}/agent.py +1 -1
- connectonion/{xray.py → debug/xray.py} +1 -1
- connectonion/llm_do.py +1 -1
- connectonion/logger.py +305 -135
- connectonion/network/__init__.py +37 -0
- connectonion/{announce.py → network/announce.py} +1 -1
- connectonion/{asgi.py → network/asgi.py} +122 -2
- connectonion/{connect.py → network/connect.py} +1 -1
- connectonion/network/connection.py +123 -0
- connectonion/{host.py → network/host.py} +31 -11
- connectonion/{trust.py → network/trust.py} +1 -1
- connectonion/tui/__init__.py +22 -0
- connectonion/tui/chat.py +647 -0
- connectonion/useful_events_handlers/reflect.py +2 -2
- connectonion/useful_plugins/__init__.py +4 -3
- connectonion/useful_plugins/calendar_plugin.py +2 -2
- connectonion/useful_plugins/eval.py +2 -2
- connectonion/useful_plugins/gmail_plugin.py +2 -2
- connectonion/useful_plugins/image_result_formatter.py +2 -2
- connectonion/useful_plugins/re_act.py +2 -2
- connectonion/useful_plugins/shell_approval.py +2 -2
- connectonion/useful_plugins/ui_stream.py +164 -0
- {connectonion-0.5.10.dist-info → connectonion-0.6.1.dist-info}/METADATA +4 -3
- connectonion-0.6.1.dist-info/RECORD +123 -0
- connectonion/debug_agent/__init__.py +0 -13
- connectonion-0.5.10.dist-info/RECORD +0 -115
- /connectonion/{events.py → core/events.py} +0 -0
- /connectonion/{tool_registry.py → core/tool_registry.py} +0 -0
- /connectonion/{usage.py → core/usage.py} +0 -0
- /connectonion/{debug_explainer → debug/debug_explainer}/__init__.py +0 -0
- /connectonion/{debug_explainer → debug/debug_explainer}/explainer_prompt.md +0 -0
- /connectonion/{debug_explainer → debug/debug_explainer}/root_cause_analysis_prompt.md +0 -0
- /connectonion/{decorators.py → debug/decorators.py} +0 -0
- /connectonion/{execution_analyzer → debug/execution_analyzer}/__init__.py +0 -0
- /connectonion/{execution_analyzer → debug/execution_analyzer}/execution_analysis_prompt.md +0 -0
- /connectonion/{debug_agent → debug/runtime_inspector}/prompts/debug_assistant.md +0 -0
- /connectonion/{debug_agent → debug/runtime_inspector}/runtime_inspector.py +0 -0
- /connectonion/{relay.py → network/relay.py} +0 -0
- /connectonion/{static → network/static}/docs.html +0 -0
- /connectonion/{trust_agents.py → network/trust_agents.py} +0 -0
- /connectonion/{trust_functions.py → network/trust_functions.py} +0 -0
- {connectonion-0.5.10.dist-info → connectonion-0.6.1.dist-info}/WHEEL +0 -0
- {connectonion-0.5.10.dist-info → connectonion-0.6.1.dist-info}/entry_points.txt +0 -0
connectonion/tui/chat.py
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
"""Chat - Terminal chat interface using Textual.
|
|
2
|
+
|
|
3
|
+
A simple, clean chat UI that works with the terminal medium rather than
|
|
4
|
+
fighting it. No fake "bubbles" - just clean text with color differentiation.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from connectonion.tui import Chat, CommandItem
|
|
8
|
+
|
|
9
|
+
chat = Chat(
|
|
10
|
+
agent=agent,
|
|
11
|
+
title="My Chat",
|
|
12
|
+
triggers={"/": [CommandItem(main="/help", prefix="?", id="/help")]},
|
|
13
|
+
welcome="Welcome!",
|
|
14
|
+
hints=["/ commands", "Enter send"],
|
|
15
|
+
status_segments=[("🤖", "Agent", "cyan")],
|
|
16
|
+
)
|
|
17
|
+
chat.run()
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from typing import Callable
|
|
21
|
+
|
|
22
|
+
from rich.text import Text
|
|
23
|
+
|
|
24
|
+
from textual import on, work
|
|
25
|
+
from textual.app import App, ComposeResult
|
|
26
|
+
from textual.containers import Container, VerticalScroll
|
|
27
|
+
from textual.geometry import Offset
|
|
28
|
+
from textual.reactive import reactive
|
|
29
|
+
from textual.widgets import Input, Markdown, Static
|
|
30
|
+
from textual_autocomplete import AutoComplete, DropdownItem
|
|
31
|
+
|
|
32
|
+
from connectonion import before_each_tool
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --- Widgets ---
|
|
36
|
+
|
|
37
|
+
class ChatStatusBar(Static):
|
|
38
|
+
"""Status bar with left/center/right layout showing agent info, status, and model."""
|
|
39
|
+
|
|
40
|
+
DEFAULT_CSS = """
|
|
41
|
+
ChatStatusBar {
|
|
42
|
+
width: 100%;
|
|
43
|
+
height: 1;
|
|
44
|
+
background: $surface;
|
|
45
|
+
}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
status = reactive("Ready")
|
|
49
|
+
tokens = reactive(0)
|
|
50
|
+
cost = reactive(0.0)
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
agent_name: str = "Agent",
|
|
55
|
+
model: str = "",
|
|
56
|
+
segments: list[tuple[str, str, str]] = None, # Legacy support
|
|
57
|
+
):
|
|
58
|
+
super().__init__()
|
|
59
|
+
self.agent_name = agent_name
|
|
60
|
+
self.model = model
|
|
61
|
+
# Legacy: if segments provided, extract info
|
|
62
|
+
if segments and not agent_name:
|
|
63
|
+
self.agent_name = segments[0][1] if segments else "Agent"
|
|
64
|
+
|
|
65
|
+
def render(self) -> Text:
|
|
66
|
+
# Calculate available width (approximate)
|
|
67
|
+
width = self.size.width if self.size.width > 0 else 80
|
|
68
|
+
|
|
69
|
+
# Left: Agent name
|
|
70
|
+
left = Text()
|
|
71
|
+
left.append("🤖 ", style="bold cyan")
|
|
72
|
+
left.append(self.agent_name, style="cyan")
|
|
73
|
+
|
|
74
|
+
# Center: Status with icon based on state
|
|
75
|
+
center = Text()
|
|
76
|
+
if self.status == "Ready":
|
|
77
|
+
center.append("● ", style="green")
|
|
78
|
+
center.append("Ready", style="dim")
|
|
79
|
+
elif self.status.startswith("Thinking"):
|
|
80
|
+
center.append("◐ ", style="yellow")
|
|
81
|
+
center.append(self.status, style="yellow italic")
|
|
82
|
+
elif "(" in self.status and "/" in self.status:
|
|
83
|
+
# Tool call with iteration: "tool_name (1/10)"
|
|
84
|
+
center.append("⚡ ", style="yellow")
|
|
85
|
+
center.append(self.status, style="yellow")
|
|
86
|
+
else:
|
|
87
|
+
center.append(self.status, style="dim")
|
|
88
|
+
|
|
89
|
+
# Right: Model + tokens/cost
|
|
90
|
+
right = Text()
|
|
91
|
+
if self.model:
|
|
92
|
+
right.append(self.model, style="dim")
|
|
93
|
+
if self.tokens > 0:
|
|
94
|
+
right.append(f" {self.tokens:,} tok", style="dim")
|
|
95
|
+
if self.cost > 0:
|
|
96
|
+
right.append(f" ${self.cost:.4f}", style="dim")
|
|
97
|
+
|
|
98
|
+
# Compose with spacing
|
|
99
|
+
left_str = left.plain
|
|
100
|
+
center_str = center.plain
|
|
101
|
+
right_str = right.plain
|
|
102
|
+
|
|
103
|
+
# Calculate padding
|
|
104
|
+
total_content = len(left_str) + len(center_str) + len(right_str)
|
|
105
|
+
remaining = max(0, width - total_content)
|
|
106
|
+
left_pad = remaining // 2
|
|
107
|
+
right_pad = remaining - left_pad
|
|
108
|
+
|
|
109
|
+
# Build final text
|
|
110
|
+
result = Text()
|
|
111
|
+
result.append_text(left)
|
|
112
|
+
result.append(" " * left_pad)
|
|
113
|
+
result.append_text(center)
|
|
114
|
+
result.append(" " * right_pad)
|
|
115
|
+
result.append_text(right)
|
|
116
|
+
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class HintsFooter(Static):
|
|
121
|
+
"""Single-line hints bar."""
|
|
122
|
+
|
|
123
|
+
DEFAULT_CSS = """
|
|
124
|
+
HintsFooter {
|
|
125
|
+
width: 100%;
|
|
126
|
+
height: 1;
|
|
127
|
+
background: $surface;
|
|
128
|
+
text-align: center;
|
|
129
|
+
color: $text-muted;
|
|
130
|
+
}
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(self, hints: list[str] = None):
|
|
134
|
+
super().__init__()
|
|
135
|
+
self.hints = hints or []
|
|
136
|
+
|
|
137
|
+
def render(self) -> Text:
|
|
138
|
+
return Text(" • ".join(self.hints), style="dim")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class WelcomeMessage(Static):
|
|
142
|
+
"""Welcome message - compact centered box."""
|
|
143
|
+
|
|
144
|
+
DEFAULT_CSS = """
|
|
145
|
+
WelcomeMessage {
|
|
146
|
+
margin: 1 2;
|
|
147
|
+
padding: 0 1;
|
|
148
|
+
background: $surface;
|
|
149
|
+
border: round $primary-darken-2;
|
|
150
|
+
height: auto;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
WelcomeMessage Markdown {
|
|
154
|
+
margin: 0;
|
|
155
|
+
padding: 0;
|
|
156
|
+
}
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(self, content: str):
|
|
160
|
+
super().__init__()
|
|
161
|
+
self._content = content
|
|
162
|
+
|
|
163
|
+
def compose(self) -> ComposeResult:
|
|
164
|
+
yield Markdown(self._content)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class UserMessageContainer(Container):
|
|
168
|
+
"""Container to right-align user messages."""
|
|
169
|
+
|
|
170
|
+
DEFAULT_CSS = """
|
|
171
|
+
UserMessageContainer {
|
|
172
|
+
width: 100%;
|
|
173
|
+
height: auto;
|
|
174
|
+
align: right middle;
|
|
175
|
+
}
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class UserMessage(Static):
|
|
180
|
+
"""User message - compact right-aligned bubble."""
|
|
181
|
+
|
|
182
|
+
DEFAULT_CSS = """
|
|
183
|
+
UserMessage {
|
|
184
|
+
background: $primary 20%;
|
|
185
|
+
border: round $primary 50%;
|
|
186
|
+
padding: 0 2;
|
|
187
|
+
width: auto;
|
|
188
|
+
max-width: 80%;
|
|
189
|
+
}
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(self, content: str) -> None:
|
|
193
|
+
super().__init__()
|
|
194
|
+
self.content = content
|
|
195
|
+
|
|
196
|
+
def render(self) -> Text:
|
|
197
|
+
text = Text()
|
|
198
|
+
text.append("You: ", style="bold cyan")
|
|
199
|
+
text.append(self.content)
|
|
200
|
+
return text
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class AssistantMessage(Static):
|
|
204
|
+
"""Assistant message - left-aligned with success border."""
|
|
205
|
+
|
|
206
|
+
DEFAULT_CSS = """
|
|
207
|
+
AssistantMessage {
|
|
208
|
+
border-left: wide $success;
|
|
209
|
+
background: $success 10%;
|
|
210
|
+
margin: 1 2 1 1;
|
|
211
|
+
padding: 0 1;
|
|
212
|
+
height: auto;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
AssistantMessage Markdown {
|
|
216
|
+
margin: 0;
|
|
217
|
+
padding: 0;
|
|
218
|
+
}
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def __init__(self, content: str):
|
|
222
|
+
super().__init__()
|
|
223
|
+
self._content = content
|
|
224
|
+
|
|
225
|
+
def compose(self) -> ComposeResult:
|
|
226
|
+
yield Markdown(self._content)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class ThinkingIndicator(Static):
|
|
230
|
+
"""Animated thinking indicator with elapsed time tracking."""
|
|
231
|
+
|
|
232
|
+
DEFAULT_CSS = """
|
|
233
|
+
ThinkingIndicator {
|
|
234
|
+
color: $success;
|
|
235
|
+
text-style: italic;
|
|
236
|
+
background: $success 10%;
|
|
237
|
+
border-left: wide $success;
|
|
238
|
+
margin: 1 2 1 1;
|
|
239
|
+
padding: 0 2;
|
|
240
|
+
height: auto;
|
|
241
|
+
}
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
245
|
+
frame_no = reactive(0)
|
|
246
|
+
message = reactive("Thinking...")
|
|
247
|
+
function_call = reactive("") # e.g., search_emails("aaron")
|
|
248
|
+
elapsed = reactive(0) # Elapsed seconds
|
|
249
|
+
show_elapsed = reactive(True) # Show elapsed time for LLM thinking
|
|
250
|
+
|
|
251
|
+
def __init__(self, message: str = "Thinking...", show_elapsed: bool = True):
|
|
252
|
+
super().__init__()
|
|
253
|
+
self.message = message
|
|
254
|
+
self.show_elapsed = show_elapsed
|
|
255
|
+
self._elapsed_timer = None
|
|
256
|
+
|
|
257
|
+
def on_mount(self):
|
|
258
|
+
self.set_interval(0.1, self._advance_frame)
|
|
259
|
+
self._elapsed_timer = self.set_interval(1.0, self._advance_elapsed)
|
|
260
|
+
|
|
261
|
+
def _advance_frame(self):
|
|
262
|
+
self.frame_no += 1
|
|
263
|
+
|
|
264
|
+
def _advance_elapsed(self):
|
|
265
|
+
self.elapsed += 1
|
|
266
|
+
|
|
267
|
+
def reset_elapsed(self):
|
|
268
|
+
"""Reset elapsed timer (called when switching between thinking/tool)."""
|
|
269
|
+
self.elapsed = 0
|
|
270
|
+
|
|
271
|
+
def render(self) -> str:
|
|
272
|
+
frame = self.frames[self.frame_no % len(self.frames)]
|
|
273
|
+
|
|
274
|
+
# Build main line
|
|
275
|
+
if self.show_elapsed and self.elapsed > 0:
|
|
276
|
+
hint = " (usually 3-10s)" if "Thinking" in self.message else ""
|
|
277
|
+
main_line = f"{frame} {self.message} {self.elapsed}s{hint}"
|
|
278
|
+
else:
|
|
279
|
+
main_line = f"{frame} {self.message}"
|
|
280
|
+
|
|
281
|
+
# Add function call on second line with tree connector
|
|
282
|
+
if self.function_call:
|
|
283
|
+
return f"{main_line}\n └─ {self.function_call}"
|
|
284
|
+
return main_line
|
|
285
|
+
|
|
286
|
+
def watch_function_call(self, new_value: str) -> None:
|
|
287
|
+
"""Force layout refresh when function_call changes (for height resize)."""
|
|
288
|
+
self.refresh(layout=True)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# --- Autocomplete ---
|
|
292
|
+
|
|
293
|
+
class TriggerAutoComplete(AutoComplete):
|
|
294
|
+
"""AutoComplete that activates on a trigger character (like / or @)."""
|
|
295
|
+
|
|
296
|
+
def __init__(self, target: Input, trigger: str, candidates: list[DropdownItem]):
|
|
297
|
+
super().__init__(target, candidates=candidates)
|
|
298
|
+
self.trigger = trigger
|
|
299
|
+
self._candidates = candidates
|
|
300
|
+
|
|
301
|
+
def _find_trigger_position(self, text: str) -> int:
|
|
302
|
+
return text.rfind(self.trigger)
|
|
303
|
+
|
|
304
|
+
def get_search_string(self, target_state) -> str:
|
|
305
|
+
text = target_state.text
|
|
306
|
+
pos = self._find_trigger_position(text)
|
|
307
|
+
if pos == -1:
|
|
308
|
+
return ""
|
|
309
|
+
return text[pos + 1:]
|
|
310
|
+
|
|
311
|
+
def get_candidates(self, target_state) -> list[DropdownItem]:
|
|
312
|
+
text = target_state.text
|
|
313
|
+
if self._find_trigger_position(text) == -1:
|
|
314
|
+
return []
|
|
315
|
+
return self._candidates
|
|
316
|
+
|
|
317
|
+
def should_show_dropdown(self, search_string: str) -> bool:
|
|
318
|
+
"""Show dropdown when trigger is present, even with empty search string."""
|
|
319
|
+
option_list = self.option_list
|
|
320
|
+
if option_list.option_count == 0:
|
|
321
|
+
return False
|
|
322
|
+
# Check if trigger exists in current text
|
|
323
|
+
target_state = self._get_target_state()
|
|
324
|
+
return self._find_trigger_position(target_state.text) != -1
|
|
325
|
+
|
|
326
|
+
def _align_to_target(self) -> None:
|
|
327
|
+
"""Position dropdown ABOVE the input (for bottom-docked inputs)."""
|
|
328
|
+
x, y = self.target.cursor_screen_offset
|
|
329
|
+
dropdown = self.option_list
|
|
330
|
+
_width, height = dropdown.outer_size
|
|
331
|
+
# Position above cursor instead of below
|
|
332
|
+
self.absolute_offset = Offset(x - 1, y - height)
|
|
333
|
+
|
|
334
|
+
def apply_completion(self, value: str, target_state) -> str:
|
|
335
|
+
text = target_state.text
|
|
336
|
+
pos = self._find_trigger_position(text)
|
|
337
|
+
if pos == -1:
|
|
338
|
+
return text
|
|
339
|
+
|
|
340
|
+
completion = value
|
|
341
|
+
for item in self._candidates:
|
|
342
|
+
item_value = item.main if isinstance(item.main, str) else item.main.plain
|
|
343
|
+
if item_value == value and item.id:
|
|
344
|
+
completion = item.id
|
|
345
|
+
break
|
|
346
|
+
|
|
347
|
+
return text[:pos] + completion
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# --- Main Chat App ---
|
|
351
|
+
|
|
352
|
+
class Chat(App):
|
|
353
|
+
"""Clean terminal chat interface."""
|
|
354
|
+
|
|
355
|
+
CSS = """
|
|
356
|
+
Screen {
|
|
357
|
+
background: $background;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
ChatStatusBar {
|
|
361
|
+
dock: top;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
HintsFooter {
|
|
365
|
+
dock: bottom;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#messages {
|
|
369
|
+
scrollbar-gutter: stable;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
#input-container {
|
|
373
|
+
dock: bottom;
|
|
374
|
+
height: auto;
|
|
375
|
+
padding: 0 1;
|
|
376
|
+
margin-bottom: 1;
|
|
377
|
+
background: $surface;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#input-container Input {
|
|
381
|
+
width: 100%;
|
|
382
|
+
border: round $primary-darken-1;
|
|
383
|
+
padding: 0 1;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
#input-container Input:focus {
|
|
387
|
+
border: round $primary;
|
|
388
|
+
}
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
BINDINGS = [
|
|
392
|
+
("ctrl+c", "quit", "Quit"),
|
|
393
|
+
("ctrl+d", "quit", "Quit"),
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
def __init__(
|
|
397
|
+
self,
|
|
398
|
+
agent=None,
|
|
399
|
+
handler: Callable[[str], str] = None,
|
|
400
|
+
title: str = "Chat",
|
|
401
|
+
subtitle: str = None,
|
|
402
|
+
on_error: Callable[[Exception], str] = None,
|
|
403
|
+
triggers: dict[str, list[DropdownItem]] = None,
|
|
404
|
+
welcome: str = None,
|
|
405
|
+
hints: list[str] = None,
|
|
406
|
+
status_segments: list[tuple[str, str, str]] = None,
|
|
407
|
+
):
|
|
408
|
+
super().__init__()
|
|
409
|
+
self.agent = agent
|
|
410
|
+
self.handler = handler or (agent.input if agent else lambda x: x)
|
|
411
|
+
self._title = title
|
|
412
|
+
self._subtitle = subtitle or (f"co/{agent.llm.model}" if agent else "")
|
|
413
|
+
self._commands: dict[str, Callable[[str], str]] = {}
|
|
414
|
+
self._thinking_widget: ThinkingIndicator | None = None
|
|
415
|
+
self._processing = False # Prevent multiple simultaneous requests
|
|
416
|
+
self._on_error = on_error
|
|
417
|
+
self._triggers = triggers or {}
|
|
418
|
+
self._welcome = welcome
|
|
419
|
+
self._hints = hints or ["Enter send", "Ctrl+D quit"]
|
|
420
|
+
self._status_segments = status_segments
|
|
421
|
+
|
|
422
|
+
# Extract agent info for status bar
|
|
423
|
+
self._agent_name = agent.name if agent else title
|
|
424
|
+
self._model = agent.llm.model if agent else ""
|
|
425
|
+
|
|
426
|
+
# Register event handlers for status updates
|
|
427
|
+
if self.agent:
|
|
428
|
+
from connectonion import before_llm, after_llm, on_complete
|
|
429
|
+
chat = self # Capture for closure
|
|
430
|
+
|
|
431
|
+
@before_llm
|
|
432
|
+
def _show_llm_thinking(agent):
|
|
433
|
+
iteration = agent.current_session.get('iteration', 1)
|
|
434
|
+
max_iter = agent.max_iterations
|
|
435
|
+
chat.call_from_thread(chat._update_status, f"Thinking ({iteration}/{max_iter})")
|
|
436
|
+
chat.call_from_thread(chat._update_thinking, "Thinking...", show_elapsed=True, reset=True, function_call="")
|
|
437
|
+
|
|
438
|
+
@before_each_tool
|
|
439
|
+
def _show_tool_progress(agent):
|
|
440
|
+
tool_info = agent.current_session.get('pending_tool', {})
|
|
441
|
+
tool_name = tool_info.get('name', 'tool')
|
|
442
|
+
description = tool_info.get('description', '')
|
|
443
|
+
arguments = tool_info.get('arguments', {})
|
|
444
|
+
iteration = agent.current_session.get('iteration', 1)
|
|
445
|
+
max_iter = agent.max_iterations
|
|
446
|
+
chat.call_from_thread(chat._update_status, f"{tool_name} ({iteration}/{max_iter})")
|
|
447
|
+
|
|
448
|
+
# Format function call: search_emails("aaron") or Bash("ps -ef")
|
|
449
|
+
def _truncate(v, max_len=30):
|
|
450
|
+
s = f'"{v}"' if isinstance(v, str) else str(v)
|
|
451
|
+
return s[:max_len] + "..." if len(s) > max_len else s
|
|
452
|
+
|
|
453
|
+
if arguments:
|
|
454
|
+
args_str = ", ".join(_truncate(v) for v in arguments.values())
|
|
455
|
+
fn_call = f"{tool_name}({args_str})"
|
|
456
|
+
else:
|
|
457
|
+
fn_call = f"{tool_name}()"
|
|
458
|
+
|
|
459
|
+
# Line 1: description, Line 2: function call
|
|
460
|
+
msg = description if description else f"Calling {tool_name}"
|
|
461
|
+
chat.call_from_thread(chat._update_thinking, msg, show_elapsed=False, function_call=fn_call)
|
|
462
|
+
|
|
463
|
+
@after_llm
|
|
464
|
+
def _update_tokens(agent):
|
|
465
|
+
if agent.last_usage:
|
|
466
|
+
total_tokens = agent.last_usage.input_tokens + agent.last_usage.output_tokens
|
|
467
|
+
chat.call_from_thread(chat._update_tokens, total_tokens, agent.total_cost)
|
|
468
|
+
|
|
469
|
+
@on_complete
|
|
470
|
+
def _on_done(agent):
|
|
471
|
+
chat.call_from_thread(chat._update_status, "Ready")
|
|
472
|
+
|
|
473
|
+
self.agent._register_event(_show_llm_thinking)
|
|
474
|
+
self.agent._register_event(_show_tool_progress)
|
|
475
|
+
self.agent._register_event(_update_tokens)
|
|
476
|
+
self.agent._register_event(_on_done)
|
|
477
|
+
|
|
478
|
+
def compose(self) -> ComposeResult:
|
|
479
|
+
# Always show status bar with agent info
|
|
480
|
+
yield ChatStatusBar(
|
|
481
|
+
agent_name=self._agent_name,
|
|
482
|
+
model=self._model,
|
|
483
|
+
segments=self._status_segments, # Legacy support
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
yield VerticalScroll(id="messages")
|
|
487
|
+
|
|
488
|
+
with Container(id="input-container"):
|
|
489
|
+
text_input = Input(placeholder="Type a message... (/ for commands)", id="input")
|
|
490
|
+
yield text_input
|
|
491
|
+
for trigger, items in self._triggers.items():
|
|
492
|
+
yield TriggerAutoComplete(text_input, trigger, items)
|
|
493
|
+
|
|
494
|
+
if self._hints:
|
|
495
|
+
yield HintsFooter(self._hints)
|
|
496
|
+
|
|
497
|
+
def on_mount(self) -> None:
|
|
498
|
+
self.title = self._title
|
|
499
|
+
self.sub_title = self._subtitle
|
|
500
|
+
self.query_one(Input).focus()
|
|
501
|
+
|
|
502
|
+
if self._welcome:
|
|
503
|
+
messages = self.query_one("#messages", VerticalScroll)
|
|
504
|
+
messages.mount(WelcomeMessage(self._welcome))
|
|
505
|
+
|
|
506
|
+
def command(self, name: str, handler: Callable[[str], str]):
|
|
507
|
+
"""Register a slash command handler."""
|
|
508
|
+
self._commands[name.lower()] = handler
|
|
509
|
+
|
|
510
|
+
def _find_command(self, text: str) -> tuple[str, Callable[[str], str]] | None:
|
|
511
|
+
cmd_lower = text.lower()
|
|
512
|
+
for cmd_name, handler in self._commands.items():
|
|
513
|
+
if cmd_lower.startswith(cmd_name):
|
|
514
|
+
if cmd_lower == cmd_name or cmd_lower[len(cmd_name):].startswith(" "):
|
|
515
|
+
return cmd_name, handler
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
def _scroll_to_bottom(self) -> None:
|
|
519
|
+
messages = self.query_one("#messages", VerticalScroll)
|
|
520
|
+
messages.scroll_end(animate=False)
|
|
521
|
+
|
|
522
|
+
def _update_thinking(self, message: str, show_elapsed: bool = True, reset: bool = False, function_call: str = "") -> None:
|
|
523
|
+
"""Update thinking indicator message (called from worker thread)."""
|
|
524
|
+
if self._thinking_widget:
|
|
525
|
+
self._thinking_widget.message = message
|
|
526
|
+
self._thinking_widget.function_call = function_call
|
|
527
|
+
self._thinking_widget.show_elapsed = show_elapsed
|
|
528
|
+
if reset:
|
|
529
|
+
self._thinking_widget.reset_elapsed()
|
|
530
|
+
|
|
531
|
+
def _update_status(self, status: str) -> None:
|
|
532
|
+
"""Update status bar status (called from worker thread)."""
|
|
533
|
+
status_bar = self.query_one(ChatStatusBar)
|
|
534
|
+
status_bar.status = status
|
|
535
|
+
|
|
536
|
+
def _update_tokens(self, tokens: int, cost: float) -> None:
|
|
537
|
+
"""Update status bar token/cost display (called from worker thread)."""
|
|
538
|
+
status_bar = self.query_one(ChatStatusBar)
|
|
539
|
+
status_bar.tokens = tokens
|
|
540
|
+
status_bar.cost = cost
|
|
541
|
+
|
|
542
|
+
def _set_input_enabled(self, enabled: bool) -> None:
|
|
543
|
+
"""Enable or disable input while processing."""
|
|
544
|
+
input_widget = self.query_one("#input", Input)
|
|
545
|
+
input_widget.disabled = not enabled
|
|
546
|
+
if enabled:
|
|
547
|
+
input_widget.placeholder = "Type a message... (/ for commands)"
|
|
548
|
+
input_widget.focus()
|
|
549
|
+
else:
|
|
550
|
+
input_widget.placeholder = "Waiting for response..."
|
|
551
|
+
|
|
552
|
+
@on(Input.Submitted)
|
|
553
|
+
async def handle_input(self, event: Input.Submitted) -> None:
|
|
554
|
+
text = event.value.strip()
|
|
555
|
+
if not text or self._processing:
|
|
556
|
+
return
|
|
557
|
+
|
|
558
|
+
event.input.clear()
|
|
559
|
+
|
|
560
|
+
messages = self.query_one("#messages", VerticalScroll)
|
|
561
|
+
user_container = UserMessageContainer(UserMessage(text))
|
|
562
|
+
await messages.mount(user_container)
|
|
563
|
+
self.call_later(self._scroll_to_bottom)
|
|
564
|
+
|
|
565
|
+
cmd_lower = text.lower()
|
|
566
|
+
if cmd_lower in ("/quit", "/exit", "/q"):
|
|
567
|
+
self.exit()
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
cmd_match = self._find_command(text)
|
|
571
|
+
if cmd_match:
|
|
572
|
+
_, handler = cmd_match
|
|
573
|
+
self._processing = True
|
|
574
|
+
self._set_input_enabled(False)
|
|
575
|
+
self._thinking_widget = ThinkingIndicator("Processing...")
|
|
576
|
+
self._update_status("Processing...")
|
|
577
|
+
await messages.mount(self._thinking_widget)
|
|
578
|
+
self.call_later(self._scroll_to_bottom)
|
|
579
|
+
self.run_command(handler, text)
|
|
580
|
+
return
|
|
581
|
+
|
|
582
|
+
if text.startswith("/"):
|
|
583
|
+
await messages.mount(AssistantMessage(f"Unknown command: `{text.split()[0]}`. Try `/help`."))
|
|
584
|
+
self.call_later(self._scroll_to_bottom)
|
|
585
|
+
return
|
|
586
|
+
|
|
587
|
+
self._processing = True
|
|
588
|
+
self._set_input_enabled(False)
|
|
589
|
+
self._thinking_widget = ThinkingIndicator()
|
|
590
|
+
self._update_status("Thinking...")
|
|
591
|
+
await messages.mount(self._thinking_widget)
|
|
592
|
+
self.call_later(self._scroll_to_bottom)
|
|
593
|
+
self.process_message(text)
|
|
594
|
+
|
|
595
|
+
@work(thread=True)
|
|
596
|
+
def run_command(self, handler: Callable[[str], str], text: str) -> None:
|
|
597
|
+
try:
|
|
598
|
+
result = handler(text)
|
|
599
|
+
self.call_from_thread(self._show_response, result)
|
|
600
|
+
except Exception as e:
|
|
601
|
+
self.call_from_thread(self._show_error, e)
|
|
602
|
+
|
|
603
|
+
@work(thread=True)
|
|
604
|
+
def process_message(self, text: str) -> None:
|
|
605
|
+
try:
|
|
606
|
+
response = self.handler(text)
|
|
607
|
+
self.call_from_thread(self._show_response, response)
|
|
608
|
+
except Exception as e:
|
|
609
|
+
self.call_from_thread(self._show_error, e)
|
|
610
|
+
|
|
611
|
+
def _show_response(self, response: str) -> None:
|
|
612
|
+
messages = self.query_one("#messages", VerticalScroll)
|
|
613
|
+
|
|
614
|
+
if self._thinking_widget:
|
|
615
|
+
self._thinking_widget.remove()
|
|
616
|
+
self._thinking_widget = None
|
|
617
|
+
|
|
618
|
+
messages.mount(AssistantMessage(response))
|
|
619
|
+
self.call_later(self._scroll_to_bottom)
|
|
620
|
+
|
|
621
|
+
# Re-enable input
|
|
622
|
+
self._processing = False
|
|
623
|
+
self._set_input_enabled(True)
|
|
624
|
+
self._update_status("Ready")
|
|
625
|
+
|
|
626
|
+
def _show_error(self, error: Exception) -> None:
|
|
627
|
+
messages = self.query_one("#messages", VerticalScroll)
|
|
628
|
+
|
|
629
|
+
if self._thinking_widget:
|
|
630
|
+
self._thinking_widget.remove()
|
|
631
|
+
self._thinking_widget = None
|
|
632
|
+
|
|
633
|
+
if self._on_error:
|
|
634
|
+
error_msg = self._on_error(error)
|
|
635
|
+
messages.mount(AssistantMessage(f"**Error**\n\n{error_msg}"))
|
|
636
|
+
else:
|
|
637
|
+
self.notify(str(error), title="Error", severity="error", timeout=10)
|
|
638
|
+
|
|
639
|
+
self.call_later(self._scroll_to_bottom)
|
|
640
|
+
|
|
641
|
+
# Re-enable input
|
|
642
|
+
self._processing = False
|
|
643
|
+
self._set_input_enabled(True)
|
|
644
|
+
self._update_status("Ready")
|
|
645
|
+
|
|
646
|
+
def action_quit(self) -> None:
|
|
647
|
+
self.exit()
|
|
@@ -18,11 +18,11 @@ Usage:
|
|
|
18
18
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
from typing import TYPE_CHECKING, List, Dict
|
|
21
|
-
from ..events import after_tools
|
|
21
|
+
from ..core.events import after_tools
|
|
22
22
|
from ..llm_do import llm_do
|
|
23
23
|
|
|
24
24
|
if TYPE_CHECKING:
|
|
25
|
-
from ..agent import Agent
|
|
25
|
+
from ..core.agent import Agent
|
|
26
26
|
|
|
27
27
|
# Path to reflect prompt (inside connectonion package for proper packaging)
|
|
28
28
|
REFLECT_PROMPT = Path(__file__).parent.parent / "prompt_files" / "reflect.md"
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Purpose: Export pre-built plugins that extend agent behavior via event hooks
|
|
3
3
|
LLM-Note:
|
|
4
|
-
Dependencies: imports from [re_act, image_result_formatter, shell_approval, gmail_plugin, calendar_plugin] | imported by [__init__.py main package] | re-exports plugins for agent consumption
|
|
4
|
+
Dependencies: imports from [re_act, image_result_formatter, shell_approval, gmail_plugin, calendar_plugin, ui_stream] | imported by [__init__.py main package] | re-exports plugins for agent consumption
|
|
5
5
|
Data flow: agent imports plugin → passes to Agent(plugins=[plugin]) → plugin event handlers fire on agent lifecycle events
|
|
6
6
|
State/Effects: no state | pure re-exports | plugins modify agent behavior at runtime
|
|
7
|
-
Integration: exposes re_act (ReAct prompting), image_result_formatter (base64 image handling), shell_approval (user confirmation for shell commands), gmail_plugin (Gmail OAuth flow), calendar_plugin (Google Calendar integration) | plugins are lists of event handlers
|
|
7
|
+
Integration: exposes re_act (ReAct prompting), image_result_formatter (base64 image handling), shell_approval (user confirmation for shell commands), gmail_plugin (Gmail OAuth flow), calendar_plugin (Google Calendar integration), ui_stream (WebSocket event streaming) | plugins are lists of event handlers
|
|
8
8
|
Errors: ImportError if underlying plugin dependencies not installed
|
|
9
9
|
|
|
10
10
|
Pre-built plugins that can be easily imported and used across agents.
|
|
@@ -16,5 +16,6 @@ from .image_result_formatter import image_result_formatter
|
|
|
16
16
|
from .shell_approval import shell_approval
|
|
17
17
|
from .gmail_plugin import gmail_plugin
|
|
18
18
|
from .calendar_plugin import calendar_plugin
|
|
19
|
+
from .ui_stream import ui_stream
|
|
19
20
|
|
|
20
|
-
__all__ = ['re_act', 'eval', 'image_result_formatter', 'shell_approval', 'gmail_plugin', 'calendar_plugin']
|
|
21
|
+
__all__ = ['re_act', 'eval', 'image_result_formatter', 'shell_approval', 'gmail_plugin', 'calendar_plugin', 'ui_stream']
|
|
@@ -19,14 +19,14 @@ Usage:
|
|
|
19
19
|
"""
|
|
20
20
|
|
|
21
21
|
from typing import TYPE_CHECKING
|
|
22
|
-
from ..events import before_each_tool
|
|
22
|
+
from ..core.events import before_each_tool
|
|
23
23
|
from ..tui import pick
|
|
24
24
|
from rich.console import Console
|
|
25
25
|
from rich.panel import Panel
|
|
26
26
|
from rich.text import Text
|
|
27
27
|
|
|
28
28
|
if TYPE_CHECKING:
|
|
29
|
-
from ..agent import Agent
|
|
29
|
+
from ..core.agent import Agent
|
|
30
30
|
|
|
31
31
|
_console = Console()
|
|
32
32
|
|