diff --git a/cecli/__init__.py b/cecli/__init__.py index 21130385bd7..138c0fd8873 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.96.5.dev" +__version__ = "0.96.6.dev" safe_version = __version__ try: diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 330a22ce2c6..8dfca4db0d8 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -38,7 +38,7 @@ from cecli import __version__, models, urls, utils from cecli.commands import Commands, SwitchCoderSignal from cecli.exceptions import LiteLLMExceptions -from cecli.helpers import coroutines, nested +from cecli.helpers import command_parser, coroutines, nested from cecli.helpers.conversation import ( ConversationChunks, ConversationManager, @@ -2865,7 +2865,13 @@ async def check_for_file_mentions(self, content): if self.args.tui: message = f"Add file to the chat? ({rel_fname})" - if await self.io.confirm_ask(message, subject=rel_fname, group=group, allow_never=True): + if await self.io.confirm_ask( + message, + subject=rel_fname, + group=group, + group_response=str(new_mentions), + allow_never=True, + ): self.add_rel_fname(rel_fname) added_fnames.append(rel_fname) else: @@ -3841,7 +3847,7 @@ async def run_shell_commands(self): self.commands.cmd_running_event.set() # Command finished async def handle_shell_commands(self, commands_str, group): - commands = commands_str.strip().split(";") + commands = command_parser.split_shell_commands(commands_str) command_count = sum( 1 for cmd in commands if cmd.strip() and not cmd.strip().startswith("#") ) diff --git a/cecli/helpers/command_parser.py b/cecli/helpers/command_parser.py new file mode 100644 index 00000000000..d9dd1431511 --- /dev/null +++ b/cecli/helpers/command_parser.py @@ -0,0 +1,179 @@ +from typing import List + + +def split_shell_commands(commands_str: str) -> List[str]: + """ + Split shell commands on semicolons, respecting quoted strings and heredoc boundaries. + + Args: + commands_str: String containing shell commands separated by semicolons + + Returns: + List of command strings split at semicolons outside quotes/heredocs + """ + if not commands_str: + return [] + + commands: List[str] = [] + current_command = [] + + # State tracking + in_single_quote = False + in_double_quote = False + in_heredoc = False + heredoc_delimiter = "" + heredoc_ignore_tabs = False # True for <<-EOF (dash before delimiter) + heredoc_seen_newline = False # Have we seen a newline since entering heredoc? + escaped = False + + i = 0 + n = len(commands_str) + + while i < n: + char = commands_str[i] + + if escaped: + # Current character is escaped, treat it as literal + current_command.append(char) + escaped = False + i += 1 + continue + + if char == "\\": + # Escape character + escaped = True + current_command.append(char) + i += 1 + continue + + if not in_heredoc: + # Not in heredoc, handle quotes and semicolons + if char == "'" and not in_double_quote: + in_single_quote = not in_single_quote + current_command.append(char) + elif char == '"' and not in_single_quote: + in_double_quote = not in_double_quote + current_command.append(char) + elif char == ";" and not in_single_quote and not in_double_quote: + # Split at semicolon outside quotes + command = "".join(current_command).strip() + if command: + commands.append(command) + current_command = [] + else: + # Check for heredoc start (<<) + if char == "<" and i + 1 < n and commands_str[i + 1] == "<": + # Remember position for capturing the heredoc start + heredoc_start_pos = i + i += 2 # Skip "<<" + + # Check for dash (<<-) + if i < n and commands_str[i] == "-": + heredoc_ignore_tabs = True + i += 1 + + # Skip whitespace before delimiter + while i < n and commands_str[i] in (" ", "\t"): + i += 1 + + # Parse delimiter (can be quoted or unquoted) + delimiter_start = i + + # Check for quotes + if i < n and commands_str[i] in ("'", '"'): + quote_char = commands_str[i] + i += 1 + delimiter_start = i + + # Find closing quote + while i < n and commands_str[i] != quote_char: + i += 1 + + heredoc_delimiter = commands_str[delimiter_start:i] + if i < n: + i += 1 # Skip closing quote + else: + # Unquoted delimiter - alphanumeric and underscore only + while i < n and (commands_str[i].isalnum() or commands_str[i] == "_"): + i += 1 + heredoc_delimiter = commands_str[delimiter_start:i] + + if heredoc_delimiter: + # Valid heredoc found - capture the entire heredoc start + heredoc_start_text = commands_str[heredoc_start_pos:i] + current_command.append(heredoc_start_text) + + # Enter heredoc mode + in_heredoc = True + heredoc_seen_newline = False + continue + else: + # Not a valid heredoc, treat as normal "<<" + current_command.append("<<") + continue + else: + current_command.append(char) + else: + # Inside heredoc + current_command.append(char) + + # Track if we've seen a newline (heredoc content starts after newline) + if char == "\n": + heredoc_seen_newline = True + + # Look ahead to see if next line starts with delimiter + j = i + 1 + + # Skip leading whitespace (tabs if heredoc_ignore_tabs) + while j < n and commands_str[j] in (" ", "\t"): + if heredoc_ignore_tabs and commands_str[j] == "\t": + j += 1 + elif not heredoc_ignore_tabs: + j += 1 + else: + break + + # Check if we have the delimiter at this position + if ( + j + len(heredoc_delimiter) <= n + and commands_str[j : j + len(heredoc_delimiter)] == heredoc_delimiter + ): + # Check what comes after delimiter + k = j + len(heredoc_delimiter) + if k >= n or commands_str[k] in (" ", "\t", "\n", "\r", ";"): + # Found heredoc end + in_heredoc = False + heredoc_delimiter = "" + heredoc_ignore_tabs = False + heredoc_seen_newline = False + elif char == ";" and not heredoc_seen_newline: + # Semicolon before seeing a newline in heredoc mode + # This means heredoc start was immediately followed by semicolon + # Not a real heredoc - split at this semicolon + in_heredoc = False + heredoc_delimiter = "" + heredoc_ignore_tabs = False + heredoc_seen_newline = False + + # Remove the semicolon from current_command + current_command.pop() + + # Split at this semicolon + command = "".join(current_command).strip() + if command: + commands.append(command) + current_command = [] + continue + + i += 1 + + # Add the last command if any + if escaped: + # Handle trailing escape character + current_command.append("\\") + + command = "".join(current_command).strip() + if command: + commands.append(command) + + return commands diff --git a/cecli/io.py b/cecli/io.py index f7f86f14b26..9aef7d983a2 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -1264,11 +1264,11 @@ async def _confirm_ask( if self.yes is True and not explicit_yes_required: res = "y" + elif group_response and group_response in self.group_responses: + return self.group_responses[group_response] elif group and group.preference: res = group.preference self.user_input(f"{question} - {res}", log_only=False) - elif group_response and group_response in self.group_responses: - return self.group_responses[group_response] else: # Ring the bell if needed self.ring_bell() diff --git a/cecli/tui/io.py b/cecli/tui/io.py index 9068226f827..76f763123f5 100644 --- a/cecli/tui/io.py +++ b/cecli/tui/io.py @@ -473,11 +473,11 @@ async def confirm_ask( hist = f"{question.strip()} {res}" self.append_chat_history(hist, linebreak=True, blockquote=True) return True + elif group_response and group_response in self.group_responses: + return self.group_responses[group_response] elif group and group.preference: res = group.preference self.user_input(f"{question} - {res}", log_only=False) - elif group_response and group_response in self.group_responses: - return self.group_responses[group_response] else: # Send confirmation request to TUI with full options self.output_queue.put( diff --git a/cecli/tui/widgets/output.py b/cecli/tui/widgets/output.py index 810c777aae4..041c03e8965 100644 --- a/cecli/tui/widgets/output.py +++ b/cecli/tui/widgets/output.py @@ -199,12 +199,9 @@ def add_output_styled(self, text: str, styles=None): if not styles: styles = dict() - style = RichStyle(**styles) - with self.app.console.capture() as capture: - self.app.console.print(Text(text), style=style) - capture_text = capture.get() - - self.output(Padding(capture_text, (0, 0, 0, 2))) + # Create styled Text object directly + styled_text = Text(text, style=RichStyle(**styles)) + self.output(Padding(styled_text, (0, 0, 0, 2))) def add_tool_call(self, lines: list): """Add a tool call with themed styling. @@ -289,23 +286,29 @@ def output(self, text, check_duplicates=True, render_markdown=False): check_duplicates: If True, check for duplicate newlines before writing render_markdown: If True and app config allows, render as markdown """ + # Get plain text for duplicate checking BEFORE any markdown conversion + plain_text = "" + if isinstance(text, str): + plain_text = text + elif isinstance(text, Text): + plain_text = text.plain + elif isinstance(text, Markdown): + # For Markdown objects, we need to get the source markdown text + # Markdown objects have a .markup attribute with the source + plain_text = text.markup if hasattr(text, "markup") else str(text) + else: + # For other types, convert to string + plain_text = str(text) + # Check if we should render as markdown if render_markdown and hasattr(self.app, "render_markdown") and self.app.render_markdown: # Only render string content as markdown if isinstance(text, str): text = Markdown(text) - with self.app.console.capture() as capture: - self.app.console.print(text) - check = Text(capture.get()).plain - - # self.write(str(self._write_history)) - # self.write(repr(check)) - - # Check for duplicate newlines - + # Check for duplicate newlines using the plain text we extracted if check_duplicates and len(self._write_history) >= 2: - nl_check = check in ["", "\n", "\\n"] + nl_check = plain_text in ["", "\n", "\\n"] nl_last = self._write_history[-1] in ["", "\n", "\\n"] nl_penultimate = self._write_history[-2] in ["", "\n", "\\n"] or self._write_history[ -2 @@ -317,8 +320,8 @@ def output(self, text, check_duplicates=True, render_markdown=False): # Call the actual write method self.write(text) - # Log the write - self._write_history.append(check) + # Log the write using the plain text + self._write_history.append(plain_text) # Keep history size manageable if len(self._write_history) > 5: