zrb 1.21.28__py3-none-any.whl → 1.21.37__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 (36) hide show
  1. zrb/builtin/llm/chat_completion.py +199 -222
  2. zrb/builtin/llm/chat_session.py +1 -1
  3. zrb/builtin/llm/chat_session_cmd.py +28 -11
  4. zrb/builtin/llm/chat_trigger.py +3 -4
  5. zrb/builtin/llm/tool/cli.py +45 -14
  6. zrb/builtin/llm/tool/code.py +5 -1
  7. zrb/builtin/llm/tool/file.py +17 -0
  8. zrb/builtin/llm/tool/note.py +7 -7
  9. zrb/builtin/llm/tool/search/__init__.py +1 -0
  10. zrb/builtin/llm/tool/search/brave.py +66 -0
  11. zrb/builtin/llm/tool/search/searxng.py +61 -0
  12. zrb/builtin/llm/tool/search/serpapi.py +61 -0
  13. zrb/builtin/llm/tool/sub_agent.py +4 -1
  14. zrb/builtin/llm/tool/web.py +17 -72
  15. zrb/cmd/cmd_result.py +2 -1
  16. zrb/config/config.py +5 -1
  17. zrb/config/default_prompt/interactive_system_prompt.md +15 -12
  18. zrb/config/default_prompt/system_prompt.md +16 -18
  19. zrb/config/llm_rate_limitter.py +36 -13
  20. zrb/input/option_input.py +30 -2
  21. zrb/task/cmd_task.py +3 -0
  22. zrb/task/llm/agent_runner.py +6 -2
  23. zrb/task/llm/history_list.py +13 -0
  24. zrb/task/llm/history_processor.py +4 -13
  25. zrb/task/llm/print_node.py +64 -23
  26. zrb/task/llm/tool_confirmation_completer.py +41 -0
  27. zrb/task/llm/tool_wrapper.py +6 -4
  28. zrb/task/llm/workflow.py +41 -14
  29. zrb/task/llm_task.py +4 -5
  30. zrb/task/rsync_task.py +2 -0
  31. zrb/util/cmd/command.py +33 -10
  32. zrb/util/match.py +71 -0
  33. {zrb-1.21.28.dist-info → zrb-1.21.37.dist-info}/METADATA +1 -1
  34. {zrb-1.21.28.dist-info → zrb-1.21.37.dist-info}/RECORD +36 -29
  35. {zrb-1.21.28.dist-info → zrb-1.21.37.dist-info}/WHEEL +0 -0
  36. {zrb-1.21.28.dist-info → zrb-1.21.37.dist-info}/entry_points.txt +0 -0
@@ -1,7 +1,5 @@
1
1
  import os
2
-
3
- from prompt_toolkit.completion import CompleteEvent, Completer, Completion
4
- from prompt_toolkit.document import Document
2
+ from typing import TYPE_CHECKING
5
3
 
6
4
  from zrb.builtin.llm.chat_session_cmd import (
7
5
  ADD_SUB_CMD,
@@ -35,240 +33,219 @@ from zrb.builtin.llm.chat_session_cmd import (
35
33
  YOLO_SET_FALSE_CMD_DESC,
36
34
  YOLO_SET_TRUE_CMD_DESC,
37
35
  )
36
+ from zrb.util.match import fuzzy_match
37
+
38
+ if TYPE_CHECKING:
39
+ from prompt_toolkit.completion import Completer
40
+
41
+
42
+ def get_chat_completer() -> "Completer":
43
+
44
+ from prompt_toolkit.completion import CompleteEvent, Completer, Completion
45
+ from prompt_toolkit.document import Document
38
46
 
47
+ class ChatCompleter(Completer):
39
48
 
40
- class ChatCompleter(Completer):
49
+ def get_completions(self, document: Document, complete_event: CompleteEvent):
50
+ # Slash command
51
+ for completion in self._complete_slash_command(document):
52
+ yield completion
53
+ for completion in self._complete_slash_file_command(document):
54
+ yield completion
55
+ # Appendix
56
+ for completion in self._complete_appendix(document):
57
+ yield completion
41
58
 
42
- def get_completions(self, document: Document, complete_event: CompleteEvent):
43
- # Slash command
44
- for completion in self._complete_slash_command(document):
45
- yield completion
46
- for completion in self._complete_slash_file_command(document):
47
- yield completion
48
- # Appendix
49
- for completion in self._complete_appendix(document):
50
- yield completion
59
+ def _complete_slash_file_command(self, document: Document):
60
+ text = document.text_before_cursor
61
+ prefixes = []
62
+ for cmd in ATTACHMENT_CMD:
63
+ for subcmd in ADD_SUB_CMD:
64
+ prefixes.append(f"{cmd} {subcmd} ")
65
+ for prefix in prefixes:
66
+ if text.startswith(prefix):
67
+ pattern = text[len(prefix) :]
68
+ potential_options = self._fuzzy_path_search(pattern, dirs=True)
69
+ for prefixed_option in [
70
+ f"{prefix}{option}" for option in potential_options
71
+ ]:
72
+ yield Completion(
73
+ prefixed_option,
74
+ start_position=-len(text),
75
+ )
51
76
 
52
- def _complete_slash_file_command(self, document: Document):
53
- text = document.text_before_cursor
54
- prefixes = []
55
- for cmd in ATTACHMENT_CMD:
56
- for subcmd in ADD_SUB_CMD:
57
- prefixes.append(f"{cmd} {subcmd} ")
58
- for prefix in prefixes:
59
- if text.startswith(prefix):
60
- pattern = text[len(prefix) :]
61
- potential_options = self._fuzzy_path_search(pattern, dirs=False)
62
- for prefixed_option in [
63
- f"{prefix}{option}" for option in potential_options
64
- ]:
77
+ def _complete_slash_command(self, document: Document):
78
+ text = document.text_before_cursor
79
+ if not text.startswith("/"):
80
+ return
81
+ for command, description in self._get_cmd_options().items():
82
+ if command.lower().startswith(text.lower()):
65
83
  yield Completion(
66
- prefixed_option,
84
+ command,
67
85
  start_position=-len(text),
86
+ display_meta=description,
68
87
  )
69
88
 
70
- def _complete_slash_command(self, document: Document):
71
- text = document.text_before_cursor
72
- if not text.startswith("/"):
73
- return
74
- for command, description in self._get_cmd_options().items():
75
- if command.lower().startswith(text.lower()):
89
+ def _complete_appendix(self, document: Document):
90
+ token = document.get_word_before_cursor(WORD=True)
91
+ prefix = "@"
92
+ if not token.startswith(prefix):
93
+ return
94
+ pattern = token[len(prefix) :]
95
+ potential_options = self._fuzzy_path_search(pattern, dirs=True)
96
+ for prefixed_option in [
97
+ f"{prefix}{option}" for option in potential_options
98
+ ]:
76
99
  yield Completion(
77
- command,
78
- start_position=-len(text),
79
- display_meta=description,
100
+ prefixed_option,
101
+ start_position=-len(token),
80
102
  )
81
103
 
82
- def _complete_appendix(self, document: Document):
83
- token = document.get_word_before_cursor(WORD=True)
84
- prefix = "@"
85
- if not token.startswith(prefix):
86
- return
87
- pattern = token[len(prefix) :]
88
- potential_options = self._fuzzy_path_search(pattern, dirs=False)
89
- for prefixed_option in [f"{prefix}{option}" for option in potential_options]:
90
- yield Completion(
91
- prefixed_option,
92
- start_position=-len(token),
93
- )
104
+ def _get_cmd_options(self):
105
+ cmd_options = {}
106
+ # Add all commands with their descriptions
107
+ for cmd in MULTILINE_START_CMD:
108
+ cmd_options[cmd] = MULTILINE_START_CMD_DESC
109
+ for cmd in MULTILINE_END_CMD:
110
+ cmd_options[cmd] = MULTILINE_END_CMD_DESC
111
+ for cmd in QUIT_CMD:
112
+ cmd_options[cmd] = QUIT_CMD_DESC
113
+ for cmd in WORKFLOW_CMD:
114
+ cmd_options[cmd] = WORKFLOW_CMD_DESC
115
+ for subcmd in ADD_SUB_CMD:
116
+ cmd_options[f"{cmd} {subcmd}"] = WORKFLOW_ADD_SUB_CMD_DESC
117
+ for subcmd in CLEAR_SUB_CMD:
118
+ cmd_options[f"{cmd} {subcmd}"] = WORKFLOW_CLEAR_SUB_CMD_DESC
119
+ for subcmd in SET_SUB_CMD:
120
+ cmd_options[f"{cmd} {subcmd}"] = WORKFLOW_SET_SUB_CMD_DESC
121
+ for cmd in SAVE_CMD:
122
+ cmd_options[cmd] = SAVE_CMD_DESC
123
+ for cmd in ATTACHMENT_CMD:
124
+ cmd_options[cmd] = ATTACHMENT_CMD_DESC
125
+ for subcmd in ADD_SUB_CMD:
126
+ cmd_options[f"{cmd} {subcmd}"] = ATTACHMENT_ADD_SUB_CMD_DESC
127
+ for subcmd in CLEAR_SUB_CMD:
128
+ cmd_options[f"{cmd} {subcmd}"] = ATTACHMENT_CLEAR_SUB_CMD_DESC
129
+ for subcmd in SET_SUB_CMD:
130
+ cmd_options[f"{cmd} {subcmd}"] = ATTACHMENT_SET_SUB_CMD_DESC
131
+ for cmd in YOLO_CMD:
132
+ cmd_options[cmd] = YOLO_CMD_DESC
133
+ for subcmd in SET_SUB_CMD:
134
+ cmd_options[f"{cmd} {subcmd} true"] = YOLO_SET_TRUE_CMD_DESC
135
+ cmd_options[f"{cmd} {subcmd} false"] = YOLO_SET_FALSE_CMD_DESC
136
+ cmd_options[f"{cmd} {subcmd}"] = YOLO_SET_CMD_DESC
137
+ for cmd in HELP_CMD:
138
+ cmd_options[cmd] = HELP_CMD_DESC
139
+ for cmd in RUN_CLI_CMD:
140
+ cmd_options[cmd] = RUN_CLI_CMD_DESC
141
+ return dict(sorted(cmd_options.items()))
94
142
 
95
- def _get_cmd_options(self):
96
- cmd_options = {}
97
- # Add all commands with their descriptions
98
- for cmd in MULTILINE_START_CMD:
99
- cmd_options[cmd] = MULTILINE_START_CMD_DESC
100
- for cmd in MULTILINE_END_CMD:
101
- cmd_options[cmd] = MULTILINE_END_CMD_DESC
102
- for cmd in QUIT_CMD:
103
- cmd_options[cmd] = QUIT_CMD_DESC
104
- for cmd in WORKFLOW_CMD:
105
- cmd_options[cmd] = WORKFLOW_CMD_DESC
106
- for subcmd in ADD_SUB_CMD:
107
- cmd_options[f"{cmd} {subcmd}"] = WORKFLOW_ADD_SUB_CMD_DESC
108
- for subcmd in CLEAR_SUB_CMD:
109
- cmd_options[f"{cmd} {subcmd}"] = WORKFLOW_CLEAR_SUB_CMD_DESC
110
- for subcmd in SET_SUB_CMD:
111
- cmd_options[f"{cmd} {subcmd}"] = WORKFLOW_SET_SUB_CMD_DESC
112
- for cmd in SAVE_CMD:
113
- cmd_options[cmd] = SAVE_CMD_DESC
114
- for cmd in ATTACHMENT_CMD:
115
- cmd_options[cmd] = ATTACHMENT_CMD_DESC
116
- for subcmd in ADD_SUB_CMD:
117
- cmd_options[f"{cmd} {subcmd}"] = ATTACHMENT_ADD_SUB_CMD_DESC
118
- for subcmd in CLEAR_SUB_CMD:
119
- cmd_options[f"{cmd} {subcmd}"] = ATTACHMENT_CLEAR_SUB_CMD_DESC
120
- for subcmd in SET_SUB_CMD:
121
- cmd_options[f"{cmd} {subcmd}"] = ATTACHMENT_SET_SUB_CMD_DESC
122
- for cmd in YOLO_CMD:
123
- cmd_options[cmd] = YOLO_CMD_DESC
124
- for subcmd in SET_SUB_CMD:
125
- cmd_options[f"{cmd} {subcmd} true"] = YOLO_SET_TRUE_CMD_DESC
126
- cmd_options[f"{cmd} {subcmd} false"] = YOLO_SET_FALSE_CMD_DESC
127
- cmd_options[f"{cmd} {subcmd}"] = YOLO_SET_CMD_DESC
128
- for cmd in HELP_CMD:
129
- cmd_options[cmd] = HELP_CMD_DESC
130
- for cmd in RUN_CLI_CMD:
131
- cmd_options[cmd] = RUN_CLI_CMD_DESC
132
- return dict(sorted(cmd_options.items()))
143
+ def _fuzzy_path_search(
144
+ self,
145
+ pattern: str,
146
+ root: str | None = None,
147
+ max_results: int = 20,
148
+ include_hidden: bool = False,
149
+ case_sensitive: bool = False,
150
+ dirs: bool = True,
151
+ files: bool = True,
152
+ ) -> list[str]:
153
+ """
154
+ Return a list of filesystem paths under `root` that fuzzy-match `pattern`.
155
+ - pattern: e.g. "./some/x" or "proj util/io"
156
+ - include_hidden: if False skip files/dirs starting with '.'
157
+ - dirs/files booleans let you restrict results
158
+ - returns list of relative paths (from root), sorted best-first
159
+ """
160
+ root, search_pattern = self._get_root_and_search_pattern(pattern, root)
161
+ # specific ignore list
162
+ try:
163
+ abs_root = os.path.abspath(os.path.expanduser(root))
164
+ abs_cwd = os.path.abspath(os.getcwd())
165
+ # Check if root is a subdirectory of cwd or is cwd itself
166
+ is_recursive = (
167
+ abs_root.startswith(abs_cwd + os.sep) or abs_root == abs_cwd
168
+ )
169
+ except Exception:
170
+ is_recursive = False
171
+ # walk filesystem
172
+ candidates: list[tuple[float, str]] = []
173
+ for dirpath, dirnames, filenames in os.walk(root):
174
+ # Filter directories
175
+ if not include_hidden:
176
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
177
+ rel_dir = os.path.relpath(dirpath, root)
178
+ # treat '.' as empty prefix
179
+ if rel_dir == ".":
180
+ rel_dir = ""
181
+ # build list of entries to test depending on files/dirs flags
182
+ entries = []
183
+ if dirs:
184
+ entries.extend([os.path.join(rel_dir, d) for d in dirnames])
185
+ if files:
186
+ entries.extend([os.path.join(rel_dir, f) for f in filenames])
187
+ if not is_recursive:
188
+ dirnames[:] = []
189
+ for ent in entries:
190
+ # Normalize presentation: use ./ prefix for relative paths
191
+ display_path = ent if ent else "."
192
+ # Skip hidden entries unless requested (double check for rel path segments)
193
+ if not include_hidden:
194
+ if any(
195
+ seg.startswith(".")
196
+ for seg in display_path.split(os.sep)
197
+ if seg
198
+ ):
199
+ continue
200
+ cand = display_path.replace(os.sep, "/") # unify separator
201
+ matched, score = fuzzy_match(cand, search_pattern)
202
+ if matched:
203
+ out = (
204
+ cand
205
+ if os.path.abspath(cand) == cand
206
+ else os.path.join(root, cand)
207
+ )
208
+ candidates.append((score, out))
209
+ # sort by score then lexicographically and return top results
210
+ candidates.sort(key=lambda x: (x[0], x[1]))
211
+ return [p for _, p in candidates[:max_results]]
133
212
 
134
- def _fuzzy_path_search(
135
- self,
136
- pattern: str,
137
- root: str | None = None,
138
- max_results: int = 20,
139
- include_hidden: bool = False,
140
- case_sensitive: bool = False,
141
- dirs: bool = True,
142
- files: bool = True,
143
- ) -> list[str]:
144
- """
145
- Return a list of filesystem paths under `root` that fuzzy-match `pattern`.
146
- - pattern: e.g. "./some/x" or "proj util/io"
147
- - include_hidden: if False skip files/dirs starting with '.'
148
- - dirs/files booleans let you restrict results
149
- - returns list of relative paths (from root), sorted best-first
150
- """
151
- search_pattern = pattern
152
- if root is None:
153
- # Determine root and adjust pattern if necessary
154
- expanded_pattern = os.path.expanduser(pattern)
155
- if os.path.isabs(expanded_pattern) or pattern.startswith("~"):
156
- # For absolute paths, find the deepest existing directory
157
- if os.path.isdir(expanded_pattern):
158
- root = expanded_pattern
159
- search_pattern = ""
160
- else:
213
+ def _get_root_and_search_pattern(
214
+ self,
215
+ pattern: str,
216
+ root: str | None = None,
217
+ ) -> tuple[str, str]:
218
+ search_pattern = pattern
219
+ if root is None:
220
+ # Determine root and adjust pattern if necessary
221
+ expanded_pattern = os.path.expanduser(pattern)
222
+ if os.path.isabs(expanded_pattern) or pattern.startswith("~"):
223
+ # For absolute paths, find the deepest existing directory
224
+ if os.path.isdir(expanded_pattern):
225
+ root = expanded_pattern
226
+ return (root, "")
161
227
  root = os.path.dirname(expanded_pattern)
162
228
  while root and not os.path.isdir(root) and len(root) > 1:
163
229
  root = os.path.dirname(root)
164
230
  if not os.path.isdir(root):
165
- root = "." # Fallback
166
- search_pattern = pattern
167
- else:
168
- try:
169
- search_pattern = os.path.relpath(expanded_pattern, root)
170
- if search_pattern == ".":
171
- search_pattern = ""
172
- except ValueError:
173
- search_pattern = os.path.basename(pattern)
174
- else:
175
- root = "."
176
- search_pattern = pattern
177
- # Normalize pattern -> tokens split on path separators or whitespace
178
- search_pattern = search_pattern.strip()
179
- if search_pattern:
180
- raw_tokens = [t for t in search_pattern.split(os.path.sep) if t]
181
- else:
182
- raw_tokens = []
183
- # prepare tokens (case)
184
- if not case_sensitive:
185
- tokens = [t.lower() for t in raw_tokens]
186
- else:
187
- tokens = raw_tokens
188
- # specific ignore list
189
- try:
190
- is_recursive = os.path.abspath(os.path.expanduser(root)).startswith(
191
- os.path.abspath(os.getcwd())
192
- )
193
- except Exception:
194
- is_recursive = False
195
- # walk filesystem
196
- candidates: list[tuple[float, str]] = []
197
- for dirpath, dirnames, filenames in os.walk(root):
198
- # Filter directories
199
- if not include_hidden:
200
- dirnames[:] = [d for d in dirnames if not d.startswith(".")]
201
- rel_dir = os.path.relpath(dirpath, root)
202
- # treat '.' as empty prefix
203
- if rel_dir == ".":
204
- rel_dir = ""
205
- # build list of entries to test depending on files/dirs flags
206
- entries = []
207
- if dirs:
208
- entries.extend([os.path.join(rel_dir, d) for d in dirnames])
209
- if files:
210
- entries.extend([os.path.join(rel_dir, f) for f in filenames])
211
- if not is_recursive:
212
- dirnames[:] = []
213
- for ent in entries:
214
- # Normalize presentation: use ./ prefix for relative paths
215
- display_path = ent if ent else "."
216
- # Skip hidden entries unless requested (double check for rel path segments)
217
- if not include_hidden:
218
- if any(
219
- seg.startswith(".") for seg in display_path.split(os.sep) if seg
220
- ):
221
- continue
222
- cand = display_path.replace(os.sep, "/") # unify separator
223
- cand_cmp = cand if case_sensitive else cand.lower()
224
- last_pos = 0
225
- score = 0.0
226
- matched_all = True
227
- for token in tokens:
228
- # try contiguous substring search first
229
- idx = cand_cmp.find(token, last_pos)
230
- if idx != -1:
231
- # good match: reward contiguous early matches
232
- score += idx # smaller idx preferred
233
- last_pos = idx + len(token)
234
- else:
235
- # fallback to subsequence matching
236
- pos = self._find_subsequence_pos(cand_cmp, token, last_pos)
237
- if pos is None:
238
- matched_all = False
239
- break
240
- # subsequence match is less preferred than contiguous substring
241
- score += pos + 0.5 * len(token)
242
- last_pos = pos + len(token)
243
- if matched_all:
244
- # prefer shorter paths when score ties, so include length as tiebreaker
245
- score += 0.01 * len(cand)
246
- out = (
247
- cand
248
- if os.path.abspath(cand) == cand
249
- else os.path.join(root, cand)
250
- )
251
- candidates.append((score, out))
252
- # sort by score then lexicographically and return top results
253
- candidates.sort(key=lambda x: (x[0], x[1]))
254
- return [p for _, p in candidates[:max_results]]
231
+ return (".", pattern) # Fallback
232
+ try:
233
+ search_pattern = os.path.relpath(expanded_pattern, root)
234
+ if search_pattern == ".":
235
+ return (root, "")
236
+ except ValueError:
237
+ return (root, os.path.basename(pattern))
238
+ # Handle redundant current directory prefixes (e.g., ./ or .\)
239
+ if pattern.startswith(f".{os.sep}"):
240
+ return (".", pattern[len(f".{os.sep}") :])
241
+ if os.sep != "/" and pattern.startswith("./"):
242
+ return (".", pattern[2:])
243
+ return (".", pattern)
244
+
245
+ if pattern.startswith(f".{os.sep}"):
246
+ pattern = pattern[len(f".{os.sep}") :]
247
+ elif os.sep != "/" and pattern.startswith("./"):
248
+ pattern = pattern[2:]
249
+ return (root, pattern)
255
250
 
256
- def _find_subsequence_pos(
257
- self, hay: str, needle: str, start: int = 0
258
- ) -> int | None:
259
- """
260
- Try to locate needle in hay as a subsequence starting at `start`.
261
- Returns the index of the first matched character of the subsequence or None if not match.
262
- """
263
- if not needle:
264
- return start
265
- i = start
266
- j = 0
267
- first_pos = None
268
- while i < len(hay) and j < len(needle):
269
- if hay[i] == needle[j]:
270
- if first_pos is None:
271
- first_pos = i
272
- j += 1
273
- i += 1
274
- return first_pos if j == len(needle) else None
251
+ return ChatCompleter()
@@ -101,7 +101,7 @@ async def read_user_prompt(ctx: AnyContext) -> str:
101
101
  print_current_yolo_mode(ctx, current_yolo_mode)
102
102
  continue
103
103
  elif is_command_match(user_input, RUN_CLI_CMD):
104
- run_cli_command(ctx, user_input)
104
+ await run_cli_command(ctx, user_input)
105
105
  continue
106
106
  elif is_command_match(user_input, HELP_CMD):
107
107
  print_commands(ctx)
@@ -1,6 +1,7 @@
1
1
  import os
2
- import subprocess
2
+ from typing import Callable
3
3
 
4
+ from zrb.config.config import CFG
4
5
  from zrb.context.any_context import AnyContext
5
6
  from zrb.task.llm.workflow import get_available_workflows
6
7
  from zrb.util.cli.markdown import render_markdown
@@ -10,6 +11,7 @@ from zrb.util.cli.style import (
10
11
  stylize_error,
11
12
  stylize_faint,
12
13
  )
14
+ from zrb.util.cmd.command import run_command
13
15
  from zrb.util.file import write_file
14
16
  from zrb.util.markdown import make_markdown_section
15
17
  from zrb.util.string.conversion import FALSE_STRS, TRUE_STRS, to_boolean
@@ -118,13 +120,12 @@ def save_final_result(ctx: AnyContext, user_input: str, final_result: str) -> No
118
120
  ctx.print(f"Response saved to {save_path}", plain=True)
119
121
 
120
122
 
121
- def run_cli_command(ctx: AnyContext, user_input: str) -> None:
123
+ async def run_cli_command(ctx: AnyContext, user_input: str) -> None:
122
124
  command = get_command_param(user_input, RUN_CLI_CMD)
123
- result = subprocess.run(
124
- command,
125
- shell=True,
126
- capture_output=True,
127
- text=True,
125
+ cmd_result, return_code = await run_command(
126
+ [CFG.DEFAULT_SHELL, "-c", command],
127
+ print_method=_create_faint_print(ctx),
128
+ timeout=3600,
128
129
  )
129
130
  ctx.print(
130
131
  render_markdown(
@@ -132,10 +133,14 @@ def run_cli_command(ctx: AnyContext, user_input: str) -> None:
132
133
  f"`{command}`",
133
134
  "\n".join(
134
135
  [
135
- make_markdown_section("📤 Stdout", result.stdout, as_code=True),
136
- make_markdown_section("🚫 Stderr", result.stderr, as_code=True),
137
136
  make_markdown_section(
138
- "🎯 Return code", f"Return Code: {result.returncode}"
137
+ "📤 Stdout", cmd_result.output, as_code=True
138
+ ),
139
+ make_markdown_section(
140
+ "🚫 Stderr", cmd_result.error, as_code=True
141
+ ),
142
+ make_markdown_section(
143
+ "🎯 Return code", f"Return Code: {return_code}"
139
144
  ),
140
145
  ]
141
146
  ),
@@ -146,8 +151,20 @@ def run_cli_command(ctx: AnyContext, user_input: str) -> None:
146
151
  ctx.print("", plain=True)
147
152
 
148
153
 
154
+ def _create_faint_print(ctx: AnyContext) -> Callable[..., None]:
155
+ def print_faint(text: str):
156
+ ctx.print(stylize_faint(f" {text}"), plain=True)
157
+
158
+ return print_faint
159
+
160
+
149
161
  def get_new_yolo_mode(old_yolo_mode: str | bool, user_input: str) -> str | bool:
150
- new_yolo_mode = get_command_param(user_input, YOLO_CMD)
162
+ if not is_command_match(user_input, YOLO_CMD):
163
+ return old_yolo_mode
164
+ if is_command_match(user_input, YOLO_CMD, SET_SUB_CMD):
165
+ new_yolo_mode = get_command_param(user_input, YOLO_CMD, SET_SUB_CMD)
166
+ else:
167
+ new_yolo_mode = get_command_param(user_input, YOLO_CMD)
151
168
  if new_yolo_mode != "":
152
169
  if new_yolo_mode in TRUE_STRS or new_yolo_mode in FALSE_STRS:
153
170
  return to_boolean(new_yolo_mode)
@@ -3,6 +3,7 @@ import os
3
3
  from asyncio import StreamReader
4
4
  from typing import TYPE_CHECKING, Any, Callable, Coroutine
5
5
 
6
+ from zrb.builtin.llm.chat_completion import get_chat_completer
6
7
  from zrb.context.any_context import AnyContext
7
8
  from zrb.util.run import run_async
8
9
 
@@ -57,13 +58,11 @@ class LLMChatTrigger:
57
58
  """Reads one line of input using the provided reader."""
58
59
  from prompt_toolkit import PromptSession
59
60
 
60
- from zrb.builtin.llm.chat_completion import ChatCompleter
61
-
62
61
  try:
63
62
  if isinstance(reader, PromptSession):
64
- bottom_toolbar = f"📁 Current directory: {os.getcwd()}"
63
+ bottom_toolbar = f"📌 Current directory: {os.getcwd()}"
65
64
  return await reader.prompt_async(
66
- completer=ChatCompleter(), bottom_toolbar=bottom_toolbar
65
+ completer=get_chat_completer(), bottom_toolbar=bottom_toolbar
67
66
  )
68
67
  line_bytes = await reader.readline()
69
68
  if not line_bytes:
@@ -1,5 +1,11 @@
1
- import subprocess
1
+ import asyncio
2
2
  import sys
3
+ from typing import Callable
4
+
5
+ from zrb.config.config import CFG
6
+ from zrb.context.any_context import AnyContext
7
+ from zrb.util.cli.style import stylize_faint
8
+ from zrb.util.cmd.command import run_command
3
9
 
4
10
  if sys.version_info >= (3, 12):
5
11
  from typing import TypedDict
@@ -15,38 +21,63 @@ class ShellCommandResult(TypedDict):
15
21
  return_code: The return code, 0 indicating no error
16
22
  stdout: Standard output
17
23
  stderr: Standard error
24
+ display: Combination of standard output and standard error, interlaced
18
25
  """
19
26
 
20
27
  return_code: int
21
28
  stdout: str
22
29
  stderr: str
30
+ display: str
23
31
 
24
32
 
25
- def run_shell_command(command: str) -> ShellCommandResult:
33
+ async def run_shell_command(
34
+ ctx: AnyContext, command: str, timeout: int = 30
35
+ ) -> ShellCommandResult:
26
36
  """
27
37
  Executes a non-interactive shell command on the user's machine.
28
38
 
39
+ **EFFICIENCY TIP:**
40
+ Combine multiple shell commands into a single call using `&&` or `;` to save steps.
41
+ Example: `mkdir new_dir && cd new_dir && touch file.txt`
42
+
29
43
  CRITICAL: This tool runs with user-level permissions. Explain commands that modify
30
44
  the system (e.g., `git`, `pip`) and ask for confirmation.
31
45
  IMPORTANT: Long-running processes should be run in the background (e.g., `command &`).
32
46
 
33
47
  Example:
34
- run_shell_command(command='ls -l')
48
+ run_shell_command(command='ls -l', timeout=30)
35
49
 
36
50
  Args:
37
51
  command (str): The exact shell command to be executed.
52
+ timeout (int): The maximum time in seconds to wait for the command to finish.
53
+ Defaults to 30.
38
54
 
39
55
  Returns:
40
56
  dict: return_code, stdout, and stderr.
41
57
  """
42
- result = subprocess.run(
43
- command,
44
- shell=True,
45
- capture_output=True,
46
- text=True,
47
- )
48
- return {
49
- "return_code": result.returncode,
50
- "stdout": result.stdout,
51
- "stderr": result.stderr,
52
- }
58
+ try:
59
+ cmd_result, return_code = await run_command(
60
+ [CFG.DEFAULT_SHELL, "-c", command],
61
+ print_method=_create_faint_print(ctx),
62
+ timeout=timeout,
63
+ )
64
+ return {
65
+ "return_code": return_code,
66
+ "stdout": cmd_result.output,
67
+ "stderr": cmd_result.error,
68
+ "display": cmd_result.display,
69
+ }
70
+ except asyncio.TimeoutError:
71
+ return {
72
+ "return_code": 124,
73
+ "stdout": "",
74
+ "stderr": f"Command timeout after {timeout} seconds",
75
+ "display": f"Command timeout after {timeout} seconds",
76
+ }
77
+
78
+
79
+ def _create_faint_print(ctx: AnyContext) -> Callable[..., None]:
80
+ def print_faint(text: str):
81
+ ctx.print(stylize_faint(f" {text}"), plain=True)
82
+
83
+ return print_faint