Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cecli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from packaging import version

__version__ = "0.96.5.dev"
__version__ = "0.96.6.dev"
safe_version = __version__

try:
Expand Down
12 changes: 9 additions & 3 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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("#")
)
Expand Down
179 changes: 179 additions & 0 deletions cecli/helpers/command_parser.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions cecli/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions cecli/tui/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
39 changes: 21 additions & 18 deletions cecli/tui/widgets/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
Loading