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.
Files changed (65) hide show
  1. connectonion/__init__.py +17 -16
  2. connectonion/cli/browser_agent/browser.py +488 -145
  3. connectonion/cli/browser_agent/scroll_strategies.py +276 -0
  4. connectonion/cli/commands/copy_commands.py +24 -1
  5. connectonion/cli/commands/deploy_commands.py +15 -0
  6. connectonion/cli/commands/eval_commands.py +286 -0
  7. connectonion/cli/commands/project_cmd_lib.py +1 -1
  8. connectonion/cli/main.py +11 -0
  9. connectonion/console.py +5 -5
  10. connectonion/core/__init__.py +53 -0
  11. connectonion/{agent.py → core/agent.py} +18 -15
  12. connectonion/{llm.py → core/llm.py} +9 -19
  13. connectonion/{tool_executor.py → core/tool_executor.py} +3 -2
  14. connectonion/{tool_factory.py → core/tool_factory.py} +3 -1
  15. connectonion/debug/__init__.py +51 -0
  16. connectonion/{interactive_debugger.py → debug/auto_debug.py} +7 -7
  17. connectonion/{auto_debug_exception.py → debug/auto_debug_exception.py} +3 -3
  18. connectonion/{debugger_ui.py → debug/auto_debug_ui.py} +1 -1
  19. connectonion/{debug_explainer → debug/debug_explainer}/explain_agent.py +1 -1
  20. connectonion/{debug_explainer → debug/debug_explainer}/explain_context.py +1 -1
  21. connectonion/{execution_analyzer → debug/execution_analyzer}/execution_analysis.py +1 -1
  22. connectonion/debug/runtime_inspector/__init__.py +13 -0
  23. connectonion/{debug_agent → debug/runtime_inspector}/agent.py +1 -1
  24. connectonion/{xray.py → debug/xray.py} +1 -1
  25. connectonion/llm_do.py +1 -1
  26. connectonion/logger.py +305 -135
  27. connectonion/network/__init__.py +37 -0
  28. connectonion/{announce.py → network/announce.py} +1 -1
  29. connectonion/{asgi.py → network/asgi.py} +122 -2
  30. connectonion/{connect.py → network/connect.py} +1 -1
  31. connectonion/network/connection.py +123 -0
  32. connectonion/{host.py → network/host.py} +31 -11
  33. connectonion/{trust.py → network/trust.py} +1 -1
  34. connectonion/tui/__init__.py +22 -0
  35. connectonion/tui/chat.py +647 -0
  36. connectonion/useful_events_handlers/reflect.py +2 -2
  37. connectonion/useful_plugins/__init__.py +4 -3
  38. connectonion/useful_plugins/calendar_plugin.py +2 -2
  39. connectonion/useful_plugins/eval.py +2 -2
  40. connectonion/useful_plugins/gmail_plugin.py +2 -2
  41. connectonion/useful_plugins/image_result_formatter.py +2 -2
  42. connectonion/useful_plugins/re_act.py +2 -2
  43. connectonion/useful_plugins/shell_approval.py +2 -2
  44. connectonion/useful_plugins/ui_stream.py +164 -0
  45. {connectonion-0.5.10.dist-info → connectonion-0.6.1.dist-info}/METADATA +4 -3
  46. connectonion-0.6.1.dist-info/RECORD +123 -0
  47. connectonion/debug_agent/__init__.py +0 -13
  48. connectonion-0.5.10.dist-info/RECORD +0 -115
  49. /connectonion/{events.py → core/events.py} +0 -0
  50. /connectonion/{tool_registry.py → core/tool_registry.py} +0 -0
  51. /connectonion/{usage.py → core/usage.py} +0 -0
  52. /connectonion/{debug_explainer → debug/debug_explainer}/__init__.py +0 -0
  53. /connectonion/{debug_explainer → debug/debug_explainer}/explainer_prompt.md +0 -0
  54. /connectonion/{debug_explainer → debug/debug_explainer}/root_cause_analysis_prompt.md +0 -0
  55. /connectonion/{decorators.py → debug/decorators.py} +0 -0
  56. /connectonion/{execution_analyzer → debug/execution_analyzer}/__init__.py +0 -0
  57. /connectonion/{execution_analyzer → debug/execution_analyzer}/execution_analysis_prompt.md +0 -0
  58. /connectonion/{debug_agent → debug/runtime_inspector}/prompts/debug_assistant.md +0 -0
  59. /connectonion/{debug_agent → debug/runtime_inspector}/runtime_inspector.py +0 -0
  60. /connectonion/{relay.py → network/relay.py} +0 -0
  61. /connectonion/{static → network/static}/docs.html +0 -0
  62. /connectonion/{trust_agents.py → network/trust_agents.py} +0 -0
  63. /connectonion/{trust_functions.py → network/trust_functions.py} +0 -0
  64. {connectonion-0.5.10.dist-info → connectonion-0.6.1.dist-info}/WHEEL +0 -0
  65. {connectonion-0.5.10.dist-info → connectonion-0.6.1.dist-info}/entry_points.txt +0 -0
@@ -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