zrb 1.15.3__py3-none-any.whl → 2.0.0a4__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.

Potentially problematic release.


This version of zrb might be problematic. Click here for more details.

Files changed (204) hide show
  1. zrb/__init__.py +118 -133
  2. zrb/attr/type.py +10 -7
  3. zrb/builtin/__init__.py +55 -1
  4. zrb/builtin/git.py +12 -1
  5. zrb/builtin/group.py +31 -15
  6. zrb/builtin/llm/chat.py +147 -0
  7. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/entity/add_entity_util.py +7 -7
  8. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/add_module_util.py +5 -5
  9. zrb/builtin/project/add/fastapp/fastapp_util.py +1 -1
  10. zrb/builtin/searxng/config/settings.yml +5671 -0
  11. zrb/builtin/searxng/start.py +21 -0
  12. zrb/builtin/shell/autocomplete/bash.py +4 -3
  13. zrb/builtin/shell/autocomplete/zsh.py +4 -3
  14. zrb/callback/callback.py +8 -1
  15. zrb/cmd/cmd_result.py +2 -1
  16. zrb/config/config.py +555 -169
  17. zrb/config/helper.py +84 -0
  18. zrb/config/web_auth_config.py +50 -35
  19. zrb/context/any_shared_context.py +20 -3
  20. zrb/context/context.py +39 -5
  21. zrb/context/print_fn.py +13 -0
  22. zrb/context/shared_context.py +17 -8
  23. zrb/group/any_group.py +3 -3
  24. zrb/group/group.py +3 -3
  25. zrb/input/any_input.py +5 -1
  26. zrb/input/base_input.py +18 -6
  27. zrb/input/option_input.py +41 -1
  28. zrb/input/text_input.py +7 -24
  29. zrb/llm/agent/__init__.py +9 -0
  30. zrb/llm/agent/agent.py +215 -0
  31. zrb/llm/agent/summarizer.py +20 -0
  32. zrb/llm/app/__init__.py +10 -0
  33. zrb/llm/app/completion.py +281 -0
  34. zrb/llm/app/confirmation/allow_tool.py +66 -0
  35. zrb/llm/app/confirmation/handler.py +178 -0
  36. zrb/llm/app/confirmation/replace_confirmation.py +77 -0
  37. zrb/llm/app/keybinding.py +34 -0
  38. zrb/llm/app/layout.py +117 -0
  39. zrb/llm/app/lexer.py +155 -0
  40. zrb/llm/app/redirection.py +28 -0
  41. zrb/llm/app/style.py +16 -0
  42. zrb/llm/app/ui.py +733 -0
  43. zrb/llm/config/__init__.py +4 -0
  44. zrb/llm/config/config.py +122 -0
  45. zrb/llm/config/limiter.py +247 -0
  46. zrb/llm/history_manager/__init__.py +4 -0
  47. zrb/llm/history_manager/any_history_manager.py +23 -0
  48. zrb/llm/history_manager/file_history_manager.py +91 -0
  49. zrb/llm/history_processor/summarizer.py +108 -0
  50. zrb/llm/note/__init__.py +3 -0
  51. zrb/llm/note/manager.py +122 -0
  52. zrb/llm/prompt/__init__.py +29 -0
  53. zrb/llm/prompt/claude_compatibility.py +92 -0
  54. zrb/llm/prompt/compose.py +55 -0
  55. zrb/llm/prompt/default.py +51 -0
  56. zrb/llm/prompt/markdown/file_extractor.md +112 -0
  57. zrb/llm/prompt/markdown/mandate.md +23 -0
  58. zrb/llm/prompt/markdown/persona.md +3 -0
  59. zrb/llm/prompt/markdown/repo_extractor.md +112 -0
  60. zrb/llm/prompt/markdown/repo_summarizer.md +29 -0
  61. zrb/llm/prompt/markdown/summarizer.md +21 -0
  62. zrb/llm/prompt/note.py +41 -0
  63. zrb/llm/prompt/system_context.py +46 -0
  64. zrb/llm/prompt/zrb.py +41 -0
  65. zrb/llm/skill/__init__.py +3 -0
  66. zrb/llm/skill/manager.py +86 -0
  67. zrb/llm/task/__init__.py +4 -0
  68. zrb/llm/task/llm_chat_task.py +316 -0
  69. zrb/llm/task/llm_task.py +245 -0
  70. zrb/llm/tool/__init__.py +39 -0
  71. zrb/llm/tool/bash.py +75 -0
  72. zrb/llm/tool/code.py +266 -0
  73. zrb/llm/tool/file.py +419 -0
  74. zrb/llm/tool/note.py +70 -0
  75. zrb/{builtin/llm → llm}/tool/rag.py +33 -37
  76. zrb/llm/tool/search/brave.py +53 -0
  77. zrb/llm/tool/search/searxng.py +47 -0
  78. zrb/llm/tool/search/serpapi.py +47 -0
  79. zrb/llm/tool/skill.py +19 -0
  80. zrb/llm/tool/sub_agent.py +70 -0
  81. zrb/llm/tool/web.py +97 -0
  82. zrb/llm/tool/zrb_task.py +66 -0
  83. zrb/llm/util/attachment.py +101 -0
  84. zrb/llm/util/prompt.py +104 -0
  85. zrb/llm/util/stream_response.py +178 -0
  86. zrb/runner/cli.py +21 -20
  87. zrb/runner/common_util.py +24 -19
  88. zrb/runner/web_route/task_input_api_route.py +5 -5
  89. zrb/runner/web_util/user.py +7 -3
  90. zrb/session/any_session.py +12 -9
  91. zrb/session/session.py +38 -17
  92. zrb/task/any_task.py +24 -3
  93. zrb/task/base/context.py +42 -22
  94. zrb/task/base/execution.py +67 -55
  95. zrb/task/base/lifecycle.py +14 -7
  96. zrb/task/base/monitoring.py +12 -7
  97. zrb/task/base_task.py +113 -50
  98. zrb/task/base_trigger.py +16 -6
  99. zrb/task/cmd_task.py +6 -0
  100. zrb/task/http_check.py +11 -5
  101. zrb/task/make_task.py +5 -3
  102. zrb/task/rsync_task.py +30 -10
  103. zrb/task/scaffolder.py +7 -4
  104. zrb/task/scheduler.py +7 -4
  105. zrb/task/tcp_check.py +6 -4
  106. zrb/util/ascii_art/art/bee.txt +17 -0
  107. zrb/util/ascii_art/art/cat.txt +9 -0
  108. zrb/util/ascii_art/art/ghost.txt +16 -0
  109. zrb/util/ascii_art/art/panda.txt +17 -0
  110. zrb/util/ascii_art/art/rose.txt +14 -0
  111. zrb/util/ascii_art/art/unicorn.txt +15 -0
  112. zrb/util/ascii_art/banner.py +92 -0
  113. zrb/util/attr.py +54 -39
  114. zrb/util/cli/markdown.py +32 -0
  115. zrb/util/cli/text.py +30 -0
  116. zrb/util/cmd/command.py +33 -10
  117. zrb/util/file.py +61 -33
  118. zrb/util/git.py +2 -2
  119. zrb/util/{llm/prompt.py → markdown.py} +2 -3
  120. zrb/util/match.py +78 -0
  121. zrb/util/run.py +3 -3
  122. zrb/util/string/conversion.py +1 -1
  123. zrb/util/truncate.py +23 -0
  124. zrb/util/yaml.py +204 -0
  125. zrb/xcom/xcom.py +10 -0
  126. {zrb-1.15.3.dist-info → zrb-2.0.0a4.dist-info}/METADATA +41 -27
  127. {zrb-1.15.3.dist-info → zrb-2.0.0a4.dist-info}/RECORD +129 -131
  128. {zrb-1.15.3.dist-info → zrb-2.0.0a4.dist-info}/WHEEL +1 -1
  129. zrb/attr/__init__.py +0 -0
  130. zrb/builtin/llm/chat_session.py +0 -311
  131. zrb/builtin/llm/history.py +0 -71
  132. zrb/builtin/llm/input.py +0 -27
  133. zrb/builtin/llm/llm_ask.py +0 -187
  134. zrb/builtin/llm/previous-session.js +0 -21
  135. zrb/builtin/llm/tool/__init__.py +0 -0
  136. zrb/builtin/llm/tool/api.py +0 -71
  137. zrb/builtin/llm/tool/cli.py +0 -38
  138. zrb/builtin/llm/tool/code.py +0 -254
  139. zrb/builtin/llm/tool/file.py +0 -626
  140. zrb/builtin/llm/tool/sub_agent.py +0 -137
  141. zrb/builtin/llm/tool/web.py +0 -195
  142. zrb/builtin/project/__init__.py +0 -0
  143. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/__init__.py +0 -0
  144. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/_zrb/module/template/app_template/module/my_module/service/__init__.py +0 -0
  145. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/common/__init__.py +0 -0
  146. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/__init__.py +0 -0
  147. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/__init__.py +0 -0
  148. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/permission/__init__.py +0 -0
  149. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/role/__init__.py +0 -0
  150. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/module/auth/service/user/__init__.py +0 -0
  151. zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/schema/__init__.py +0 -0
  152. zrb/builtin/project/create/__init__.py +0 -0
  153. zrb/builtin/shell/__init__.py +0 -0
  154. zrb/builtin/shell/autocomplete/__init__.py +0 -0
  155. zrb/callback/__init__.py +0 -0
  156. zrb/cmd/__init__.py +0 -0
  157. zrb/config/default_prompt/file_extractor_system_prompt.md +0 -12
  158. zrb/config/default_prompt/interactive_system_prompt.md +0 -35
  159. zrb/config/default_prompt/persona.md +0 -1
  160. zrb/config/default_prompt/repo_extractor_system_prompt.md +0 -112
  161. zrb/config/default_prompt/repo_summarizer_system_prompt.md +0 -10
  162. zrb/config/default_prompt/summarization_prompt.md +0 -16
  163. zrb/config/default_prompt/system_prompt.md +0 -32
  164. zrb/config/llm_config.py +0 -243
  165. zrb/config/llm_context/config.py +0 -129
  166. zrb/config/llm_context/config_parser.py +0 -46
  167. zrb/config/llm_rate_limitter.py +0 -137
  168. zrb/content_transformer/__init__.py +0 -0
  169. zrb/context/__init__.py +0 -0
  170. zrb/dot_dict/__init__.py +0 -0
  171. zrb/env/__init__.py +0 -0
  172. zrb/group/__init__.py +0 -0
  173. zrb/input/__init__.py +0 -0
  174. zrb/runner/__init__.py +0 -0
  175. zrb/runner/web_route/__init__.py +0 -0
  176. zrb/runner/web_route/home_page/__init__.py +0 -0
  177. zrb/session/__init__.py +0 -0
  178. zrb/session_state_log/__init__.py +0 -0
  179. zrb/session_state_logger/__init__.py +0 -0
  180. zrb/task/__init__.py +0 -0
  181. zrb/task/base/__init__.py +0 -0
  182. zrb/task/llm/__init__.py +0 -0
  183. zrb/task/llm/agent.py +0 -243
  184. zrb/task/llm/config.py +0 -103
  185. zrb/task/llm/conversation_history.py +0 -128
  186. zrb/task/llm/conversation_history_model.py +0 -242
  187. zrb/task/llm/default_workflow/coding.md +0 -24
  188. zrb/task/llm/default_workflow/copywriting.md +0 -17
  189. zrb/task/llm/default_workflow/researching.md +0 -18
  190. zrb/task/llm/error.py +0 -95
  191. zrb/task/llm/history_summarization.py +0 -216
  192. zrb/task/llm/print_node.py +0 -101
  193. zrb/task/llm/prompt.py +0 -325
  194. zrb/task/llm/tool_wrapper.py +0 -220
  195. zrb/task/llm/typing.py +0 -3
  196. zrb/task/llm_task.py +0 -341
  197. zrb/task_status/__init__.py +0 -0
  198. zrb/util/__init__.py +0 -0
  199. zrb/util/cli/__init__.py +0 -0
  200. zrb/util/cmd/__init__.py +0 -0
  201. zrb/util/codemod/__init__.py +0 -0
  202. zrb/util/string/__init__.py +0 -0
  203. zrb/xcom/__init__.py +0 -0
  204. {zrb-1.15.3.dist-info → zrb-2.0.0a4.dist-info}/entry_points.txt +0 -0
zrb/llm/app/ui.py ADDED
@@ -0,0 +1,733 @@
1
+ import asyncio
2
+ import contextlib
3
+ import os
4
+ import re
5
+ from collections.abc import Callable
6
+ from datetime import datetime
7
+ from typing import TYPE_CHECKING, Any, TextIO
8
+
9
+ from prompt_toolkit import Application
10
+ from prompt_toolkit.application import get_app
11
+ from prompt_toolkit.document import Document
12
+ from prompt_toolkit.formatted_text import AnyFormattedText
13
+ from prompt_toolkit.key_binding import KeyBindings
14
+ from prompt_toolkit.layout import Layout
15
+ from prompt_toolkit.lexers import Lexer
16
+ from prompt_toolkit.patch_stdout import patch_stdout
17
+ from prompt_toolkit.styles import Style
18
+
19
+ from zrb.context.shared_context import SharedContext
20
+ from zrb.llm.app.confirmation.handler import (
21
+ ConfirmationHandler,
22
+ ConfirmationMiddleware,
23
+ last_confirmation,
24
+ )
25
+ from zrb.llm.app.keybinding import create_output_keybindings
26
+ from zrb.llm.app.layout import create_input_field, create_layout, create_output_field
27
+ from zrb.llm.app.redirection import StreamToUI
28
+ from zrb.llm.app.style import create_style
29
+ from zrb.llm.history_manager.any_history_manager import AnyHistoryManager
30
+ from zrb.llm.task.llm_task import LLMTask
31
+ from zrb.session.any_session import AnySession
32
+ from zrb.session.session import Session
33
+ from zrb.task.any_task import AnyTask
34
+ from zrb.util.ascii_art.banner import create_banner
35
+ from zrb.util.cli.markdown import render_markdown
36
+ from zrb.util.cli.style import stylize_faint
37
+ from zrb.util.string.name import get_random_name
38
+
39
+ if TYPE_CHECKING:
40
+ from pydantic_ai import UserContent
41
+ from pydantic_ai.models import Model
42
+ from rich.theme import Theme
43
+
44
+
45
+ class UI:
46
+ def __init__(
47
+ self,
48
+ greeting: str,
49
+ assistant_name: str,
50
+ ascii_art: str,
51
+ jargon: str,
52
+ output_lexer: Lexer,
53
+ llm_task: LLMTask,
54
+ history_manager: AnyHistoryManager,
55
+ initial_message: Any = "",
56
+ initial_attachments: "list[UserContent]" = [],
57
+ conversation_session_name: str = "",
58
+ yolo: bool = False,
59
+ triggers: list[Callable[[], Any]] = [],
60
+ confirmation_middlewares: list[ConfirmationMiddleware] = [],
61
+ markdown_theme: "Theme | None" = None,
62
+ summarize_commands: list[str] = [],
63
+ attach_commands: list[str] = [],
64
+ exit_commands: list[str] = [],
65
+ info_commands: list[str] = [],
66
+ save_commands: list[str] = [],
67
+ load_commands: list[str] = [],
68
+ redirect_output_commands: list[str] = [],
69
+ yolo_toggle_commands: list[str] = [],
70
+ exec_commands: list[str] = [],
71
+ model: "Model | str | None" = None,
72
+ ):
73
+ self._is_thinking = False
74
+ self._running_llm_task: asyncio.Task | None = None
75
+ self._llm_task = llm_task
76
+ self._history_manager = history_manager
77
+ self._assistant_name = assistant_name
78
+ self._ascii_art = ascii_art
79
+ self._jargon = jargon
80
+ self._initial_message = initial_message
81
+ self._conversation_session_name = conversation_session_name
82
+ if not self._conversation_session_name:
83
+ self._conversation_session_name = get_random_name()
84
+ self._yolo = yolo
85
+ self._model = model
86
+ self._triggers = triggers
87
+ self._markdown_theme = markdown_theme
88
+ self._summarize_commands = summarize_commands
89
+ self._attach_commands = attach_commands
90
+ self._exit_commands = exit_commands
91
+ self._info_commands = info_commands
92
+ self._save_commands = save_commands
93
+ self._load_commands = load_commands
94
+ self._redirect_output_commands = redirect_output_commands
95
+ self._yolo_toggle_commands = yolo_toggle_commands
96
+ self._exec_commands = exec_commands
97
+ self._trigger_tasks: list[asyncio.Task] = []
98
+ self._last_result_data: str | None = None
99
+ # Attachments
100
+ self._pending_attachments: "list[UserContent]" = list(initial_attachments)
101
+ # Confirmation Handler
102
+ self._confirmation_handler = ConfirmationHandler(
103
+ middlewares=confirmation_middlewares + [last_confirmation]
104
+ )
105
+ # Confirmation state (Used by ask_user and keybindings)
106
+ self._waiting_for_confirmation = False
107
+ self._confirmation_future: asyncio.Future[str] | None = None
108
+ # UI Styles
109
+ self._style = create_style()
110
+ # Input Area
111
+ self._input_field = create_input_field(
112
+ history_manager=self._history_manager,
113
+ attach_commands=self._attach_commands,
114
+ exit_commands=self._exit_commands,
115
+ info_commands=self._info_commands,
116
+ save_commands=self._save_commands,
117
+ load_commands=self._load_commands,
118
+ redirect_output_commands=self._redirect_output_commands,
119
+ summarize_commands=self._summarize_commands,
120
+ exec_commands=self._exec_commands,
121
+ )
122
+ # Output Area (Read-only chat history)
123
+ help_text = self._get_help_text()
124
+ full_greeting = create_banner(self._ascii_art, f"{greeting}\n{help_text}")
125
+ self._output_field = create_output_field(full_greeting, output_lexer)
126
+ self._output_field.control.key_bindings = create_output_keybindings(
127
+ self._input_field
128
+ )
129
+ self._layout = create_layout(
130
+ title=self._assistant_name,
131
+ jargon=self._jargon,
132
+ input_field=self._input_field,
133
+ output_field=self._output_field,
134
+ info_bar_text=self._get_info_bar_text,
135
+ status_bar_text=self._get_status_bar_text,
136
+ )
137
+ # Key Bindings
138
+ self._app_kb = KeyBindings()
139
+ self._setup_app_keybindings(
140
+ app_keybindings=self._app_kb, llm_task=self._llm_task
141
+ )
142
+ # Application
143
+ self._application = self._create_application(
144
+ layout=self._layout, keybindings=self._app_kb, style=self._style
145
+ )
146
+ # Send message if first_message is provided. Make sure only run at most once
147
+ if self._initial_message:
148
+ self._application.after_render.add_handler(self._on_first_render)
149
+
150
+ async def run_async(self):
151
+ """Run the application and manage triggers."""
152
+ import logging
153
+
154
+ # Start triggers
155
+ for trigger_fn in self._triggers:
156
+ trigger_task = self._application.create_background_task(
157
+ self._trigger_loop(trigger_fn)
158
+ )
159
+ self._trigger_tasks.append(trigger_task)
160
+
161
+ # Setup logging redirection to UI
162
+ root_logger = logging.getLogger()
163
+ original_handlers = root_logger.handlers[:]
164
+
165
+ ui_stream = StreamToUI(self.append_to_output)
166
+ ui_handler = logging.StreamHandler(ui_stream)
167
+ formatter = logging.Formatter("[%(levelname)s] %(name)s: %(message)s")
168
+ ui_handler.setFormatter(formatter)
169
+
170
+ root_logger.handlers = [ui_handler]
171
+
172
+ try:
173
+ with patch_stdout():
174
+ return await self._application.run_async()
175
+ finally:
176
+ # Restore handlers
177
+ root_logger.handlers = original_handlers
178
+
179
+ # Stop triggers
180
+ for trigger_task in self._trigger_tasks:
181
+ trigger_task.cancel()
182
+ self._trigger_tasks.clear()
183
+
184
+ async def _trigger_loop(self, trigger_fn: Callable[[], Any]):
185
+ """Handle external triggers and submit user message when trigger activated"""
186
+ while True:
187
+ try:
188
+ if asyncio.iscoroutinefunction(trigger_fn):
189
+ result = await trigger_fn()
190
+ else:
191
+ result = await asyncio.to_thread(trigger_fn)
192
+
193
+ if result:
194
+ self._submit_user_message(self._llm_task, str(result))
195
+
196
+ except asyncio.CancelledError:
197
+ break
198
+ except Exception:
199
+ # Keep running on error, maybe log it
200
+ pass
201
+
202
+ def _on_first_render(self, app: Application):
203
+ """Handle initial message (the message sent when creating the UI)"""
204
+ self._application.after_render.remove_handler(self._on_first_render)
205
+ self._submit_user_message(self._llm_task, self._initial_message)
206
+
207
+ async def ask_user(self, prompt: str) -> str:
208
+ """Prompts the user for input via the main input field, blocking until provided."""
209
+ self._waiting_for_confirmation = True
210
+ self._confirmation_future = asyncio.Future()
211
+
212
+ if prompt:
213
+ self.append_to_output(prompt, end="")
214
+
215
+ get_app().invalidate()
216
+
217
+ try:
218
+ return await self._confirmation_future
219
+ finally:
220
+ self._waiting_for_confirmation = False
221
+ self._confirmation_future = None
222
+
223
+ async def _confirm_tool_execution(self, call: Any) -> Any:
224
+ return await self._confirmation_handler.handle(self, call)
225
+
226
+ @property
227
+ def triggers(self) -> list[Callable[[], Any]]:
228
+ return self._triggers
229
+
230
+ @triggers.setter
231
+ def triggers(self, value: list[Callable[[], Any]]):
232
+ self._triggers = value
233
+
234
+ @property
235
+ def application(self) -> Application:
236
+ return self._application
237
+
238
+ @property
239
+ def last_output(self) -> str:
240
+ if self._last_result_data is None:
241
+ return ""
242
+ return self._last_result_data
243
+
244
+ def _create_application(
245
+ self,
246
+ layout: Layout,
247
+ keybindings: KeyBindings,
248
+ style: Style,
249
+ ) -> Application:
250
+ return Application(
251
+ layout=layout,
252
+ key_bindings=keybindings,
253
+ style=style,
254
+ full_screen=True,
255
+ mouse_support=True,
256
+ )
257
+
258
+ def _get_help_text(self) -> str:
259
+ help_lines = ["\nAvailable Commands:"]
260
+
261
+ def add_cmd_help(commands: list[str], description: str):
262
+ if commands and len(commands) > 0:
263
+ cmd = commands[0]
264
+ description = description.replace("{cmd}", cmd)
265
+ help_lines.append(f" {cmd:<10} : {description}")
266
+
267
+ add_cmd_help(self._exit_commands, "Exit the application")
268
+ add_cmd_help(self._info_commands, "Show this help message")
269
+ add_cmd_help(self._attach_commands, "Attach file (usage: {cmd} <path>)")
270
+ add_cmd_help(self._save_commands, "Save conversation (usage: {cmd} <name>)")
271
+ add_cmd_help(self._load_commands, "Load conversation (usage: {cmd} <name>)")
272
+ add_cmd_help(
273
+ self._redirect_output_commands,
274
+ "Save last output to file (usage: {cmd} <file>)",
275
+ )
276
+ add_cmd_help(self._summarize_commands, "Summarize conversation history")
277
+ add_cmd_help(self._yolo_toggle_commands, "Toggle YOLO mode")
278
+ add_cmd_help(
279
+ self._exec_commands, "Execute shell command (usage: {cmd} <command>)"
280
+ )
281
+
282
+ return "\n".join(help_lines) + "\n"
283
+
284
+ def _get_info_bar_text(self) -> AnyFormattedText:
285
+ from prompt_toolkit.formatted_text import HTML
286
+
287
+ model_name = "Unknown"
288
+ if self._model:
289
+ if isinstance(self._model, str):
290
+ model_name = self._model
291
+ elif hasattr(self._model, "model_name"):
292
+ model_name = getattr(self._model, "model_name")
293
+ else:
294
+ model_name = str(self._model)
295
+ yolo_text = (
296
+ "<style color='ansired'><b>ON</b></style>"
297
+ if self._yolo
298
+ else "<style color='ansigreen'>OFF</style>"
299
+ )
300
+ return HTML(
301
+ f" šŸ¤– <b>Model:</b> {model_name} "
302
+ f"| šŸ—£ļø <b>Session:</b> {self._conversation_session_name} "
303
+ f"| 🤠 <b>YOLO:</b> {yolo_text} "
304
+ )
305
+
306
+ def _get_status_bar_text(self) -> AnyFormattedText:
307
+ if self._is_thinking:
308
+ return [("class:thinking", f" {self._assistant_name} is thinking... ")]
309
+ return [("class:status", " Ready ")]
310
+
311
+ def _setup_app_keybindings(self, app_keybindings: KeyBindings, llm_task: AnyTask):
312
+ @app_keybindings.add("c-c")
313
+ def _(event):
314
+ # If text is selected, copy it instead of exiting
315
+ buffer = event.app.current_buffer
316
+ if buffer.selection_state:
317
+ data = buffer.copy_selection()
318
+ event.app.clipboard.set_data(data)
319
+ buffer.exit_selection()
320
+ return
321
+ # If buffer is not empty, clear it
322
+ if buffer.text != "":
323
+ buffer.reset()
324
+ return
325
+ event.app.exit()
326
+
327
+ @app_keybindings.add("escape")
328
+ def _(event):
329
+ if self._running_llm_task and not self._running_llm_task.done():
330
+ self._running_llm_task.cancel()
331
+ self.append_to_output("\n<Esc> Canceled")
332
+
333
+ @app_keybindings.add("enter")
334
+ def _(event):
335
+ # Handle confirmation and multiline
336
+ if self._handle_confirmation(event):
337
+ return
338
+ if self._handle_multiline(event):
339
+ return
340
+
341
+ # Handle empty inputs
342
+ buff = event.current_buffer
343
+ text = buff.text
344
+ if not text.strip():
345
+ return
346
+
347
+ # Handle other commands
348
+ if self._handle_exit_command(event):
349
+ return
350
+ if self._handle_info_command(event):
351
+ return
352
+ if self._handle_save_command(event):
353
+ return
354
+ if self._handle_load_command(event):
355
+ return
356
+ if self._handle_redirect_command(event):
357
+ return
358
+ if self._handle_attach_command(event):
359
+ return
360
+ if self._handle_toggle_yolo(event):
361
+ return
362
+ if self._handle_exec_command(event):
363
+ return
364
+
365
+ # If we are thinking, ignore input
366
+ if self._is_thinking:
367
+ return
368
+ self._submit_user_message(llm_task, text)
369
+ buff.reset()
370
+
371
+ @app_keybindings.add("c-j") # Ctrl+J
372
+ @app_keybindings.add("c-space") # Ctrl+Space (Fallback)
373
+ def _(event):
374
+ event.current_buffer.insert_text("\n")
375
+
376
+ def _handle_exec_command(self, event) -> bool:
377
+ buff = event.current_buffer
378
+ text = buff.text
379
+ for cmd in self._exec_commands:
380
+ prefix = f"{cmd} "
381
+ if text.strip().lower().startswith(prefix):
382
+ if self._is_thinking:
383
+ return False
384
+
385
+ shell_cmd = text.strip()[len(prefix) :].strip()
386
+ if not shell_cmd:
387
+ return True
388
+
389
+ buff.reset()
390
+ # Run in background
391
+ self._running_llm_task = asyncio.create_task(
392
+ self._run_shell_command(shell_cmd)
393
+ )
394
+ return True
395
+ return False
396
+
397
+ async def _run_shell_command(self, cmd: str):
398
+ self._is_thinking = True
399
+ get_app().invalidate()
400
+ timestamp = datetime.now().strftime("%H:%M")
401
+
402
+ try:
403
+ self.append_to_output(f"\nšŸ’» {timestamp} >>\n$ {cmd}\n")
404
+ self.append_to_output(f"\n šŸ”¢ Executing...\n")
405
+
406
+ # Create subprocess
407
+ process = await asyncio.create_subprocess_shell(
408
+ cmd,
409
+ stdout=asyncio.subprocess.PIPE,
410
+ stderr=asyncio.subprocess.PIPE,
411
+ )
412
+ is_first_output = True
413
+
414
+ # Read output streams
415
+ async def read_stream(stream, is_stderr=False):
416
+ while True:
417
+ nonlocal is_first_output
418
+ line = await stream.readline()
419
+ if not line:
420
+ break
421
+ decoded_line = line.decode("utf-8", errors="replace")
422
+ decoded_line = decoded_line.replace("\n", "\n ").replace(
423
+ "\r", "\r "
424
+ )
425
+ if is_first_output:
426
+ decoded_line = f" {decoded_line}"
427
+ is_first_output = False
428
+ # Could use a different color for stderr if desired
429
+ self.append_to_output(decoded_line, end="")
430
+
431
+ await asyncio.gather(
432
+ read_stream(process.stdout), read_stream(process.stderr, is_stderr=True)
433
+ )
434
+
435
+ return_code = await process.wait()
436
+
437
+ if return_code == 0:
438
+ self.append_to_output(f"\n āœ… Command finished successfully.\n")
439
+ else:
440
+ self.append_to_output(
441
+ f"\n āŒ Command failed with exit code {return_code}.\n"
442
+ )
443
+
444
+ except asyncio.CancelledError:
445
+ self.append_to_output("\n[Cancelled]\n")
446
+ except Exception as e:
447
+ self.append_to_output(f"\n[Error: {e}]\n")
448
+ finally:
449
+ self._is_thinking = False
450
+ self._running_llm_task = None
451
+ get_app().invalidate()
452
+
453
+ def _handle_toggle_yolo(self, event) -> bool:
454
+ buff = event.current_buffer
455
+ text = buff.text
456
+ if text.strip().lower() in self._yolo_toggle_commands:
457
+ if self._is_thinking:
458
+ return False
459
+ self._yolo = not self._yolo
460
+ buff.reset()
461
+ return True
462
+ return False
463
+
464
+ def _handle_multiline(self, event) -> bool:
465
+ buff = event.current_buffer
466
+ text = buff.text
467
+ # Check for multiline indicator (trailing backslash)
468
+ if text.strip().endswith("\\"):
469
+ # If cursor is at the end, remove backslash and insert newline
470
+ if buff.cursor_position == len(text):
471
+ # Remove the backslash (assuming it's the last char)
472
+ # We need to be careful with whitespace after backslash if we used strip()
473
+ # Let's just check the character before cursor
474
+ if text.endswith("\\"):
475
+ buff.delete_before_cursor(count=1)
476
+ buff.insert_text("\n")
477
+ return True
478
+ return False
479
+
480
+ def _handle_confirmation(self, event) -> bool:
481
+ buff = event.current_buffer
482
+ text = buff.text
483
+ if self._waiting_for_confirmation and self._confirmation_future:
484
+ # Echo the user input
485
+ self.append_to_output(text + "\n")
486
+ if not self._confirmation_future.done():
487
+ self._confirmation_future.set_result(text)
488
+ buff.reset()
489
+ return True
490
+ return False
491
+
492
+ def _handle_exit_command(self, event) -> bool:
493
+ buff = event.current_buffer
494
+ text = buff.text
495
+ if text.strip().lower() in self._exit_commands:
496
+ event.app.exit()
497
+ return True
498
+ return False
499
+
500
+ def _handle_info_command(self, event) -> bool:
501
+ buff = event.current_buffer
502
+ text = buff.text
503
+ if text.strip().lower() in self._info_commands:
504
+ self.append_to_output(stylize_faint(self._get_help_text()))
505
+ buff.reset()
506
+ return True
507
+ return False
508
+
509
+ def _handle_save_command(self, event) -> bool:
510
+ buff = event.current_buffer
511
+ text = buff.text.strip()
512
+ for cmd in self._save_commands:
513
+ prefix = f"{cmd} "
514
+ if text.lower().startswith(prefix):
515
+ name = text[len(prefix) :].strip()
516
+ if not name:
517
+ continue
518
+ try:
519
+ history = self._history_manager.load(
520
+ self._conversation_session_name
521
+ )
522
+ self._history_manager.update(name, history)
523
+ self._history_manager.save(name)
524
+ self.append_to_output(f"\n šŸ’¾ Conversation saved as: {name}\n")
525
+ except Exception as e:
526
+ self.append_to_output(f"\n āŒ Failed to save conversation: {e}\n")
527
+ buff.reset()
528
+ return True
529
+ return False
530
+
531
+ def _handle_load_command(self, event) -> bool:
532
+ buff = event.current_buffer
533
+ text = buff.text.strip()
534
+ for cmd in self._load_commands:
535
+ prefix = f"{cmd} "
536
+ if text.lower().startswith(prefix):
537
+ name = text[len(prefix) :].strip()
538
+ if not name:
539
+ continue
540
+ self._conversation_session_name = name
541
+ self.append_to_output(
542
+ f"\n šŸ“‚ Conversation session switched to: {name}\n"
543
+ )
544
+ buff.reset()
545
+ return True
546
+ return False
547
+
548
+ def _handle_redirect_command(self, event) -> bool:
549
+ buff = event.current_buffer
550
+ text = buff.text.strip()
551
+ for cmd in self._redirect_output_commands:
552
+ prefix = f"{cmd} "
553
+ if text.lower().startswith(prefix):
554
+ path = text[len(prefix) :].strip()
555
+ if not path:
556
+ continue
557
+
558
+ content = self.last_output
559
+ if not content:
560
+ self.append_to_output(
561
+ "\n āŒ No AI response available to redirect.\n"
562
+ )
563
+ buff.reset()
564
+ return True
565
+
566
+ try:
567
+ # Write to file
568
+ expanded_path = os.path.abspath(os.path.expanduser(path))
569
+ os.makedirs(os.path.dirname(expanded_path), exist_ok=True)
570
+ with open(expanded_path, "w", encoding="utf-8") as f:
571
+ f.write(content)
572
+ self.append_to_output(f"\n šŸ“ Last output redirected to: {path}\n")
573
+ except Exception as e:
574
+ self.append_to_output(f"\n āŒ Failed to redirect output: {e}\n")
575
+
576
+ buff.reset()
577
+ return True
578
+ return False
579
+
580
+ def _handle_attach_command(self, event) -> bool:
581
+ buff = event.current_buffer
582
+ text = buff.text
583
+ for attach_command in self._attach_commands:
584
+ if text.strip().lower().startswith(f"{attach_command} "):
585
+ path = text.strip()[8:].strip()
586
+ self._submit_attachment(path)
587
+ buff.reset()
588
+ return True
589
+ return False
590
+
591
+ def _submit_attachment(self, path: str):
592
+ # Validate path
593
+ self.append_to_output(f"\n šŸ”¢ Attach {path}...\n")
594
+ expanded_path = os.path.abspath(os.path.expanduser(path))
595
+ if not os.path.exists(expanded_path):
596
+ self.append_to_output(f"\n āŒ File not found: {path}\n")
597
+ return
598
+ if expanded_path not in self._pending_attachments:
599
+ self._pending_attachments.append(expanded_path)
600
+ self.append_to_output(f"\n šŸ“Ž Attached: {path}\n")
601
+ else:
602
+ self.append_to_output(f"\n šŸ“Ž Already attached: {path}\n")
603
+
604
+ def append_to_output(
605
+ self,
606
+ *values: object,
607
+ sep: str = " ",
608
+ end: str = "\n",
609
+ file: TextIO | None = None,
610
+ flush: bool = False,
611
+ ):
612
+ # Helper to safely append to read-only buffer
613
+ current_text = self._output_field.text
614
+
615
+ # Construct the new content
616
+ content = sep.join([str(value) for value in values]) + end
617
+
618
+ # Handle carriage returns (\r) for status updates
619
+ if "\r" in content:
620
+ # Find the start of the last line in the current text
621
+ last_newline = current_text.rfind("\n")
622
+ if last_newline == -1:
623
+ previous = ""
624
+ last = current_text
625
+ else:
626
+ previous = current_text[: last_newline + 1]
627
+ last = current_text[last_newline + 1 :]
628
+
629
+ combined = last + content
630
+ # Remove content before \r on the same line
631
+ # [^\n]* matches any character except newline
632
+ resolved = re.sub(r"[^\n]*\r", "", combined)
633
+
634
+ new_text = previous + resolved
635
+ else:
636
+ new_text = current_text + content
637
+
638
+ # Update content directly
639
+ # We use bypass_readonly=True by constructing a Document
640
+ self._output_field.buffer.set_document(
641
+ Document(new_text, cursor_position=len(new_text)), bypass_readonly=True
642
+ )
643
+ get_app().invalidate()
644
+
645
+ def _submit_user_message(self, llm_task: AnyTask, user_message: str):
646
+ timestamp = datetime.now().strftime("%H:%M")
647
+ # 1. Render User Message
648
+ self.append_to_output(f"\nšŸ’¬ {timestamp} >>\n{user_message.strip()}\n")
649
+ # 2. Trigger AI Response
650
+ attachments = list(self._pending_attachments)
651
+ self._pending_attachments.clear()
652
+ self._running_llm_task = asyncio.create_task(
653
+ self._stream_ai_response(llm_task, user_message, attachments)
654
+ )
655
+
656
+ async def _stream_ai_response(
657
+ self,
658
+ llm_task: AnyTask,
659
+ user_message: str,
660
+ attachments: "list[UserContent]" = [],
661
+ ):
662
+ from zrb.llm.agent.agent import tool_confirmation_var
663
+
664
+ self._is_thinking = True
665
+ get_app().invalidate() # Update status bar
666
+ try:
667
+ timestamp = datetime.now().strftime("%H:%M")
668
+ # Header first
669
+ self.append_to_output(f"\nšŸ¤– {timestamp} >>\n")
670
+ session = self._create_sesion_for_llm_task(user_message, attachments)
671
+
672
+ # Run the task with stdout/stderr redirected to UI
673
+ self.append_to_output("\n šŸ”¢ Streaming response...\n")
674
+ stdout_capture = StreamToUI(self.append_to_output)
675
+
676
+ # Set context var for tool confirmation
677
+ token = tool_confirmation_var.set(self._confirm_tool_execution)
678
+ try:
679
+ with contextlib.redirect_stdout(
680
+ stdout_capture
681
+ ), contextlib.redirect_stderr(stdout_capture):
682
+ result_data = await llm_task.async_run(session)
683
+ finally:
684
+ tool_confirmation_var.reset(token)
685
+
686
+ # Check for final text output
687
+ if result_data is not None:
688
+ if isinstance(result_data, str):
689
+ self._last_result_data = result_data
690
+ width = self._get_output_field_width()
691
+ self.append_to_output("\n")
692
+ self.append_to_output(
693
+ render_markdown(
694
+ result_data, width=width, theme=self._markdown_theme
695
+ )
696
+ )
697
+
698
+ except asyncio.CancelledError:
699
+ self.append_to_output("\n[Cancelled]\n")
700
+ except Exception as e:
701
+ with open("zrb_debug.log", "a") as f:
702
+ f.write(f"[{datetime.now()}] Error: {e}\n")
703
+ self.append_to_output(f"\n[Error: {e}]\n")
704
+ finally:
705
+ self._is_thinking = False
706
+ self._running_llm_task = None
707
+ get_app().invalidate()
708
+
709
+ def _create_sesion_for_llm_task(
710
+ self, user_message: str, attachments: "list[UserContent]"
711
+ ) -> AnySession:
712
+ """Create session to run LLMTask"""
713
+ session_input = {
714
+ "message": user_message,
715
+ "session": self._conversation_session_name,
716
+ "yolo": self._yolo,
717
+ "attachments": attachments,
718
+ }
719
+ shared_ctx = SharedContext(
720
+ input=session_input,
721
+ print_fn=self.append_to_output,
722
+ is_web_mode=True,
723
+ )
724
+ return Session(shared_ctx)
725
+
726
+ def _get_output_field_width(self) -> int | None:
727
+ try:
728
+ width = get_app().output.get_size().columns - 4
729
+ if width < 10:
730
+ width = None
731
+ except Exception:
732
+ width = None
733
+ return width