diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 94d61738442..c58ffb8da32 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -1666,6 +1666,11 @@ def get_background_command_output(self): command_str = command_info.get(command_key, {}).get("command", command_key) output += f"\n[bg: {command_str}]\n{cmd_output}\n" + # Clean up stale (finished) background commands after reading their output + for command_key, info in command_info.items(): + if not info.get("running", False): + BackgroundCommandManager.stop_background_command(command_key) + return output def get_git_status(self): diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index ceeb4862a3d..119fc80fe43 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -876,9 +876,8 @@ def get_announcements(self): env_items.append(f"{rel_repo_dir} ({num_files:,} files)") if num_files > 1000: env_items.append( - "Warning: For large repos, consider using --subtree-only and .cecli_ignore" + "Warning: For large repos, consider using --subtree-only and .cecli.ignore" ) - env_items.append(f"See: {urls.large_repos}") else: env_items.append("no git repo") @@ -4024,7 +4023,9 @@ def compute_costs_from_tokens( input_cost_per_token = self.get_active_model().info.get("input_cost_per_token") or 0 output_cost_per_token = self.get_active_model().info.get("output_cost_per_token") or 0 input_cost_per_token_cache_hit = ( - self.get_active_model().info.get("input_cost_per_token_cache_hit") or 0 + self.get_active_model().info.get("input_cost_per_token_cache_hit") + or self.get_active_model().info.get("cache_read_input_token_cost") + or 0 ) # deepseek @@ -4036,14 +4037,13 @@ def compute_costs_from_tokens( # == total tokens that were if input_cost_per_token_cache_hit: - # must be deepseek - cost += input_cost_per_token_cache_hit * cache_hit_tokens - cost += (prompt_tokens - input_cost_per_token_cache_hit) * input_cost_per_token + cost += cache_hit_tokens * input_cost_per_token_cache_hit + cost += (prompt_tokens - cache_hit_tokens) * input_cost_per_token else: # hard code the anthropic adjustments, no-ops for other models since cache_x_tokens==0 cost += cache_write_tokens * input_cost_per_token * 1.25 cost += cache_hit_tokens * input_cost_per_token * 0.10 - cost += prompt_tokens * input_cost_per_token + cost += (prompt_tokens - cache_hit_tokens) * input_cost_per_token cost += completion_tokens * output_cost_per_token return cost diff --git a/cecli/helpers/background_commands.py b/cecli/helpers/background_commands.py index 2789fec0ebc..825dd09ad50 100644 --- a/cecli/helpers/background_commands.py +++ b/cecli/helpers/background_commands.py @@ -5,7 +5,6 @@ in the background and capturing their output for injection into chat streams. """ -import codecs import os import platform import subprocess @@ -171,31 +170,86 @@ def _start_output_reader(self) -> None: """Start thread to read process output.""" def reader(): - try: - # Simple approach: read lines when available - # This will block on readline(), but that's OK because - # we're in a separate thread and the buffer will capture - # output as soon as it's available + import re + + # Regex to strip ANSI terminal escape sequences + _ansi_escape = re.compile( + r"\x1b\[[0-9;]*[a-zA-Z]|\x1b[\]^_]|[\x1b\x9b][][()#;?]*" + r"(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nqry=><~]" + ) + def _strip_ansi(text: str) -> str: + return _ansi_escape.sub("", text) + + try: if self.master_fd is not None: while not self._stop_event.is_set(): try: data = os.read(self.master_fd, 4096).decode(errors="replace") if not data: break - self.buffer.append(data) + # Strip ANSI escape sequences from PTY output + self.buffer.append(_strip_ansi(data)) except (OSError, EOFError): break else: - # Read stdout - for line in iter(self.process.stdout.readline, ""): - if line: - self.buffer.append(line) + has_stdout_fileno = hasattr(self.process.stdout, "fileno") + if has_stdout_fileno: + os.set_blocking(self.process.stdout.fileno(), False) + while not self._stop_event.is_set(): + try: + if has_stdout_fileno: + # Use os.read() instead of readline() to capture + # partial line output (e.g. REPL prompts without newlines) + data = os.read(self.process.stdout.fileno(), 4096).decode( + errors="replace" + ) + else: + # Fallback to readline when fileno() is unavailable + # (e.g. mock objects in tests) + data = self.process.stdout.readline() + if data: + self.buffer.append(data) + else: + # Check if process died + if not self.is_alive(): + if has_stdout_fileno: + # Read any remaining data + try: + remaining = os.read( + self.process.stdout.fileno(), 4096 + ).decode(errors="replace") + if remaining: + self.buffer.append(remaining) + except (OSError, EOFError): + pass + break + import time + + time.sleep(0.05) + except (OSError, EOFError, ValueError): + if not self.is_alive(): + break + import time + + time.sleep(0.05) - # Read stderr - for line in iter(self.process.stderr.readline, ""): - if line: - self.buffer.append(line) + # Also capture stderr (best-effort, non-blocking) + if hasattr(self.process.stderr, "fileno"): + try: + os.set_blocking(self.process.stderr.fileno(), False) + while True: + try: + err_data = os.read(self.process.stderr.fileno(), 4096).decode( + errors="replace" + ) + if not err_data: + break + self.buffer.append(err_data) + except (OSError, EOFError): + break + except Exception: + pass except Exception as e: self.buffer.append(f"\n[Error reading process output: {str(e)}]\n") @@ -369,6 +423,7 @@ def start_background_command( persist: bool = False, existing_input_buffer: Optional[InputBuffer] = None, use_pty: bool = False, + master_fd: Optional[int] = None, ) -> str: """ Start a command in background. @@ -390,9 +445,16 @@ def start_background_command( buffer = existing_buffer or CircularBuffer(max_size=max_buffer_size) # Use existing process or start new one - master_fd = None - if use_pty and HAS_PTY and platform.system() != "Windows": - master_fd, slave_fd = pty.openpty() + # Use provided master_fd (e.g., from _execute_with_timeout) or default to None + final_master_fd = master_fd + + # Only create a new PTY if no external master_fd was provided + # (prevents overwriting an externally-created PTY fd) + can_use_pty = ( + use_pty and master_fd is None and HAS_PTY and platform.system() != "Windows" + ) + if can_use_pty: + final_master_fd, slave_fd = pty.openpty() # Disable echo on the slave PTY attr = termios.tcgetattr(slave_fd) @@ -415,8 +477,13 @@ def start_background_command( elif existing_process: process = existing_process else: + # When PTY was requested but isn't available (e.g. Windows), + # wrap with stdbuf -oL to force line-buffered output at the libc level + resolved_command = ( + cls._wrap_line_buffered(command) if use_pty and not HAS_PTY else command + ) process = subprocess.Popen( - command, + resolved_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -434,7 +501,7 @@ def start_background_command( buffer, persist=persist, input_buffer=existing_input_buffer, - master_fd=master_fd, + master_fd=final_master_fd, ) # Generate unique key and store @@ -519,13 +586,8 @@ def send_command_input(cls, command_key: str, text: str) -> bool: bg_process = cls._background_commands.get(command_key) if not bg_process: return False - # Decode escape sequences (like \x1b) if present in the string - try: - text = codecs.decode(text, "unicode_escape") - except Exception: - pass - bg_process.send_input(text) - return True + bg_process.send_input(text) + return True @classmethod def get_all_command_outputs(cls, clear: bool = False) -> Dict[str, str]: @@ -625,6 +687,61 @@ def list_background_commands(cls) -> Dict[str, Dict[str, any]]: } return result + _line_buffered_tool = None + + @staticmethod + def _detect_line_buffered_tool() -> str: + """ + Detect the best available tool for forcing line-buffered stdout. + + Checks for `stdbuf` (GNU coreutils, most Linux distros) and falls + back to `unbuffer` (expect package, available on many systems). + If neither is available, returns None. + + Returns: + The tool command (e.g., 'stdbuf -oL' or 'unbuffer'), or None + """ + import shutil + + if shutil.which("stdbuf"): + return "stdbuf -oL" + + if shutil.which("unbuffer"): + return "unbuffer" + + return None + + @staticmethod + def _wrap_line_buffered(command: str) -> str: + """ + Wrap a command to force line-buffered stdout when PTY is unavailable. + + When stdout is connected to a pipe instead of a TTY, most programs + (Python, C, Ruby, etc.) use full buffering. The stdbuf command uses + LD_PRELOAD to override buffering at the libc level. The unbuffer + command (from expect) creates a PTY wrapper. + + The detected tool is cached after first check to avoid repeated + subprocess/shell calls. + + Args: + command: The shell command string + + Returns: + Command wrapped with line-buffering prefix, or the original + command if no tool is available + """ + if BackgroundCommandManager._line_buffered_tool is None: + BackgroundCommandManager._line_buffered_tool = ( + BackgroundCommandManager._detect_line_buffered_tool() + ) + + tool = BackgroundCommandManager._line_buffered_tool + if tool: + return f"{tool} {command}" + + return command + @staticmethod def save_paginated_output( output: str, diff --git a/cecli/helpers/conversation/files.py b/cecli/helpers/conversation/files.py index 8b703e674ae..78eee14028f 100644 --- a/cecli/helpers/conversation/files.py +++ b/cecli/helpers/conversation/files.py @@ -289,26 +289,12 @@ def update_file_diff(self, fname: str) -> Optional[str]: ), } - assistant_msg = { - "role": "assistant", - "content": ( - f"Thank you for sharing this {prefix_str}diff of the updates to {rel_fname}." - " I will review their contents." - ), - } - ConversationService.get_manager(coder).add_message( message_dict=diff_message, tag=MessageTag.DIFFS, hash_key=("file_diff_user", rel_fname, content_hash), ) - ConversationService.get_manager(coder).add_message( - message_dict=assistant_msg, - tag=MessageTag.DIFFS, - hash_key=("file_diff_assistant", rel_fname, content_hash), - ) - return diff def get_file_stub(self, fname: str) -> str: diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index a9381d4ecbb..da52ec5e6a6 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -527,10 +527,6 @@ def add_repo_map_messages(self) -> List[Dict[str, Any]]: # Create repository map messages dict_repo_messages = [ dict(role="user", content=repo_content), - dict( - role="assistant", - content="Thank you, these files will help with navigating the codebase.", - ), ] # Add messages to conversation manager with appropriate priority @@ -579,11 +575,6 @@ def add_rules_messages(self) -> List[Dict[str, Any]]: "role": "user", "content": f"Rules defined in {rel_fname}:\n\n{content}", } - # Create assistant message - assistant_msg = { - "role": "assistant", - "content": f"I understand the rules in {rel_fname} and will follow them.", - } # Add to ConversationManager with RULES tag ConversationService.get_manager(coder).add_message( @@ -594,15 +585,7 @@ def add_rules_messages(self) -> List[Dict[str, Any]]: update_timestamp=False, ) - ConversationService.get_manager(coder).add_message( - message_dict=assistant_msg, - tag=MessageTag.RULES, - hash_key=("rules_assistant", fname), - force=True, - update_timestamp=False, - ) - - messages.extend([user_msg, assistant_msg]) + messages.extend([user_msg]) return messages @@ -667,20 +650,6 @@ def add_readonly_files_messages(self) -> List[Dict[str, Any]]: ) messages.append(user_msg) - # Add assistant message with file path as hash_key - assistant_msg = { - "role": "assistant", - "content": f"Thank you for sharing the file contents for {rel_fname}.", - } - ConversationService.get_manager(coder).add_message( - message_dict=assistant_msg, - tag=MessageTag.READONLY_FILES, - hash_key=("file_assistant", fname), # Use file path as part of hash_key - force=True, - update_timestamp=False, - ) - messages.append(assistant_msg) - # Check if file has changed and add diff message if needed if ConversationService.get_files(coder).has_file_changed(fname): ConversationService.get_files(coder).update_file_diff(fname) @@ -692,13 +661,6 @@ def add_readonly_files_messages(self) -> List[Dict[str, Any]]: # Add individual image message to result messages.append(img_msg) - # Add individual assistant acknowledgment for each image - assistant_msg = { - "role": "assistant", - "content": "Ok, I will use this image as a reference.", - } - messages.append(assistant_msg) - # Get the file name from the message (stored in image_file key) fname = img_msg.get("image_file") if fname: @@ -708,11 +670,6 @@ def add_readonly_files_messages(self) -> List[Dict[str, Any]]: tag=MessageTag.READONLY_FILES, hash_key=("image_user", fname), ) - ConversationService.get_manager(coder).add_message( - message_dict=assistant_msg, - tag=MessageTag.READONLY_FILES, - hash_key=("image_assistant", fname), - ) return messages @@ -767,15 +724,9 @@ def add_chat_files_messages(self) -> Dict[str, Any]: "content": f"{file_preamble}\n{rel_fname}\n\n{content}\n\n{file_postamble}", } - # Create assistant message - assistant_msg = { - "role": "assistant", - "content": f"Thank you for sharing the file contents for {rel_fname}.", - } - # Determine tag based on editability tag = MessageTag.CHAT_FILES - result["chat_files"].extend([user_msg, assistant_msg]) + result["chat_files"].extend([user_msg]) # Add user message to ConversationManager with file path as hash_key ConversationService.get_manager(coder).add_message( @@ -786,15 +737,6 @@ def add_chat_files_messages(self) -> Dict[str, Any]: update_timestamp=False, ) - # Add assistant message to ConversationManager with file path as hash_key - ConversationService.get_manager(coder).add_message( - message_dict=assistant_msg, - tag=tag, - hash_key=("file_assistant", fname), # Use file path as part of hash_key - force=True, - update_timestamp=False, - ) - # Check if file has changed and add diff message if needed if ConversationService.get_files(coder).has_file_changed(fname): ConversationService.get_files(coder).update_file_diff(fname) @@ -806,13 +748,6 @@ def add_chat_files_messages(self) -> Dict[str, Any]: # Add individual image message to result result["chat_files"].append(img_msg) - # Add individual assistant acknowledgment for each image - assistant_msg = { - "role": "assistant", - "content": "Ok, I will use this image as a reference.", - } - result["chat_files"].append(assistant_msg) - # Get the file name from the message (stored in image_file key) fname = img_msg.get("image_file") if fname: @@ -822,11 +757,6 @@ def add_chat_files_messages(self) -> Dict[str, Any]: tag=MessageTag.CHAT_FILES, hash_key=("image_user", fname), ) - ConversationService.get_manager(coder).add_message( - message_dict=assistant_msg, - tag=MessageTag.CHAT_FILES, - hash_key=("image_assistant", fname), - ) return result @@ -858,11 +788,6 @@ def add_file_context_messages(self, promote_messages=True) -> None: "content": f"ID-Prefixed Context For:\n{rel_fname}\n\n{context_content}", } - assistant_msg = { - "role": "assistant", - "content": f"Thank you for sharing the prefixed file contents for {rel_fname}.", - } - # Add to conversation manager content_hash = xxhash.xxh3_128_hexdigest(context_content.encode("utf-8")) ConversationService.get_manager(coder).queue_message( @@ -871,12 +796,6 @@ def add_file_context_messages(self, promote_messages=True) -> None: hash_key=("file_context_user", file_path, content_hash), ) - ConversationService.get_manager(coder).queue_message( - message_dict=assistant_msg, - tag=MessageTag.FILE_CONTEXTS, - hash_key=("file_context_assistant", file_path, content_hash), - ) - def reset(self) -> None: """ Reset the entire conversation system to initial state. diff --git a/cecli/helpers/hashpos/hashpos.py b/cecli/helpers/hashpos/hashpos.py index 280e3394ec4..8077e900e3d 100644 --- a/cecli/helpers/hashpos/hashpos.py +++ b/cecli/helpers/hashpos/hashpos.py @@ -119,7 +119,11 @@ def format_content(self, use_private_ids: bool = False, start_line: int = 1) -> if use_private_ids else self.generate_public_id(line, i) ) - formatted_lines.append(f"{prefix}::{line}") + if line.strip(): + formatted_lines.append(f"{prefix}::{line}") + else: + formatted_lines.append(f"{line}") + return "\n".join(formatted_lines) def resolve_to_lines(self, public_id: str, start_line: int = 1) -> list[int]: diff --git a/cecli/io.py b/cecli/io.py index 4a703eaa22f..a140bd516a9 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -514,7 +514,6 @@ def __init__( "lexer": PygmentsLexer(MarkdownLexer), "editing_mode": self.editingmode, "bottom_toolbar": self.get_bottom_toolbar, - "refresh_interval": 0.1, } if self.editingmode == EditingMode.VI: session_kwargs["cursor"] = ModalCursorShapeConfig() diff --git a/cecli/models.py b/cecli/models.py index 3f593cb7b75..c06f0a1c92d 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -256,6 +256,8 @@ def get_model_from_cached_json_db(self, model): def get_model_info(self, model): cached_info = self.get_model_from_cached_json_db(model) + if cached_info: + return cached_info litellm_info = None if litellm._lazy_module or not cached_info: try: @@ -268,6 +270,10 @@ def get_model_info(self, model): return provider_info if litellm_info: return litellm_info + if not cached_info and model.startswith("openai/"): + stripped = model[7:] + if stripped: + return self.get_model_info(stripped) return cached_info def _resolve_via_provider(self, model, cached_info): diff --git a/cecli/tools/command.py b/cecli/tools/command.py index 646fd46d5ba..8fe49a4d4d6 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -3,6 +3,15 @@ import os import platform +# PTY support for interactive commands (avoids pipe buffering issues) +try: + import pty + import termios + + HAS_PTY = True +except ImportError: + HAS_PTY = False + import xxhash from cecli.helpers.background_commands import BackgroundCommandManager @@ -21,15 +30,15 @@ class Tool(BaseTool): "type": "function", "function": { "name": "Command", - "description": "Execute a shell command.", + "description": "Execute a shell command or interact with background processes.", "parameters": { "type": "object", "properties": { "command": { "type": "string", "description": ( - "The shell command to execute. To send stdin to an existing background" - " command, use the format 'command_key::{key}'." + "The shell command to execute. " + "Required unless background_key is provided." ), }, "background": { @@ -37,42 +46,44 @@ class Tool(BaseTool): "description": "Run command in background (non-blocking).", "default": False, }, - "stop": { - "type": "boolean", + "background_key": { + "type": "string", "description": ( - "If true, stop the background command specified in the 'command'" - " parameter (format 'command_key::{key}')." + "Key of an existing background command to interact with. " + "Use with 'action' (stdin/stop)." ), - "default": False, }, - "pty": { - "type": "boolean", + "action": { + "type": "string", + "enum": ["stdin", "stop"], "description": ( - "Run the command in a pseudo-terminal (PTY). Useful for interactive" - " programs like 'vi' or 'top'." + "Action on a background command. Requires background_key: " + "'stdin' to send input, 'stop' to terminate." ), - "default": False, }, "stdin": { "type": "string", "description": ( - "Input to send to the command's stdin. Supports escape sequences like" - " \\n, \\r, \\t, and hex escapes like \\x1b." + "Input to send. Use with background=True to send at " + "start time, or with background_key + action='stdin'." + ), + }, + "pty": { + "type": "boolean", + "description": ( + "Use a pseudo-terminal (PTY). Auto-enabled on Unix for " + "background commands. Useful for interactive programs " + "like 'vi' or 'top'." ), + "default": False, }, }, - "required": ["command"], + "required": [], }, }, } @staticmethod - def _parse_command_key(command): - """Extract command key from command string if it follows the pattern.""" - if command and command.startswith("command_key::"): - return command.split("::", 1)[1].strip() - return None - @staticmethod def _hash_command(command): """Compute an xxhash of the full command text for session tracking.""" @@ -83,38 +94,41 @@ def _hash_command(command): @classmethod async def execute( - cls, coder, command, background=False, stop=None, stdin=None, pty=False, **kwargs + cls, + coder, + command=None, + background=False, + background_key=None, + action=None, + stdin=None, + pty=False, + **kwargs, ): """ - Execute a shell command, optionally in background. - Commands run with timeout based on agent_config['command_timeout'] (default: 30 seconds). - """ - command_key = cls._parse_command_key(command) + Execute a shell command or interact with background processes. - # Handle stopping background commands - if stop: - if not command_key: - return ( - "Error: 'command' in format 'command_key::{key}' is required when 'stop' is" - " true." - ) - return await cls._stop_background_command(coder, command_key) + For new commands: provide 'command' (and optionally 'background', 'stdin', 'pty'). + For background interactions: provide 'background_key' + 'action' (stdin/stop). - # Handle sending stdin to an existing background command - if stdin: - if not command_key: - return ( - "Error: 'command' in format 'command_key::{key}' is required when using" - " 'stdin'." - ) + Commands run with timeout based on agent_config['command_timeout'] (default: 30 seconds). + """ + # Handle interactions with an existing background command + if background_key: + if action == "stdin": + if not stdin: + return "Error: 'stdin' is required when action='stdin'." + cls.clear_invocation_cache() + success = BackgroundCommandManager.send_command_input(background_key, stdin) + if success: + return f"Sent input to background command {background_key}: {stdin}" + else: + return f"Error: Background command {background_key} not found or not running." - cls.clear_invocation_cache() + elif action == "stop": + return await cls._stop_background_command(coder, background_key) - success = BackgroundCommandManager.send_command_input(command_key, stdin) - if success: - return f"Sent input to background command {command_key}: {stdin}" else: - return f"Error: Background command {command_key} not found or not running." + return f"Error: Unknown action '{action}'. " "Use one of: stdin, stop." if not command: return "Error: 'command' must be provided." @@ -143,7 +157,7 @@ async def execute( timeout = coder.agent_config.get("command_timeout", 30) if background: - return await cls._execute_background(coder, command, use_pty=pty) + return await cls._execute_background(coder, command, use_pty=pty, stdin=stdin) elif timeout > 0: return await cls._execute_with_timeout(coder, command, timeout, use_pty=pty) else: @@ -199,12 +213,20 @@ async def _get_confirmation(cls, coder, command_string, background): return confirmed @classmethod - async def _execute_background(cls, coder, command_string, use_pty=False): + async def _execute_background(cls, coder, command_string, use_pty=None, stdin=None): """ Execute command in background. + + Args: + stdin: Optional text to send to the command's stdin after starting """ coder.io.tool_output(f"⛭ Starting background command: {command_string}", type="tool-result") + # Default to PTY on Unix platforms for proper line-buffered output + # (Python and other programs buffer output aggressively on pipes) + if use_pty is None: + use_pty = platform.system() != "Windows" + # Use static manager to start background command command_key = BackgroundCommandManager.start_background_command( command_string, @@ -214,6 +236,10 @@ async def _execute_background(cls, coder, command_string, use_pty=False): use_pty=use_pty, ) + # Send stdin to the background command if provided + if stdin: + BackgroundCommandManager.send_command_input(command_key, stdin) + return ( f"Background command started: {command_string}\n" f"Command key: {command_key}\n" @@ -221,13 +247,13 @@ async def _execute_background(cls, coder, command_string, use_pty=False): ) @classmethod - async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=False): + async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=None): """ Execute command with timeout. If timeout elapses, move to background. - IMPORTANT: We use a different approach to avoid pipe conflicts. - Instead of reading pipes directly, we let BackgroundCommandManager - handle all pipe reading from the start. + When use_pty is True (or auto-defaulted on Unix), a pseudo-terminal + is used to avoid the full-buffering issue that occurs when stdout is + connected to a pipe instead of a TTY. """ import asyncio import subprocess @@ -239,24 +265,59 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal f"⛭ Executing shell command with {timeout}s timeout.", type="tool-result" ) - shell = os.environ.get("SHELL", "/bin/sh") + # Auto-default to PTY on Unix unless explicitly set otherwise + if use_pty is None: + use_pty = platform.system() != "Windows" # Create output buffer buffer = CircularBuffer(max_size=4096) - # Start process with pipes for output capture - process = subprocess.Popen( - command_string, - shell=True, - executable=shell if platform.system() != "Windows" else None, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - stdin=subprocess.PIPE, - cwd=coder.root, - text=True, - bufsize=1, - universal_newlines=True, - ) + # Decide whether to use PTY + master_fd = None + + if use_pty and HAS_PTY and platform.system() != "Windows": + master_fd, slave_fd = pty.openpty() + + # Disable echo on the slave PTY + attr = termios.tcgetattr(slave_fd) + attr[3] = attr[3] & ~termios.ECHO + termios.tcsetattr(slave_fd, termios.TCSANOW, attr) + + process = subprocess.Popen( + command_string, + shell=True, + executable=os.environ.get("SHELL", "/bin/sh"), + stdout=slave_fd, + stderr=slave_fd, + stdin=slave_fd, + cwd=coder.root, + close_fds=True, + text=True, + bufsize=1, + universal_newlines=True, + ) + os.close(slave_fd) + else: + # Start process with pipes for output capture + # When PTY was requested but unavailable, wrap with stdbuf for line-buffered output + resolved_cmd = ( + BackgroundCommandManager._wrap_line_buffered(command_string) + if use_pty and not HAS_PTY + else command_string + ) + shell = os.environ.get("SHELL", "/bin/sh") + process = subprocess.Popen( + resolved_cmd, + shell=True, + executable=shell if platform.system() != "Windows" else None, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=subprocess.PIPE, + cwd=coder.root, + text=True, + bufsize=1, + universal_newlines=True, + ) # Immediately register with background manager to handle pipe reading command_key = BackgroundCommandManager.start_background_command( @@ -267,6 +328,7 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal existing_process=process, existing_buffer=buffer, persist=True, + master_fd=master_fd, ) # Now monitor the process with timeout @@ -340,7 +402,7 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal if elapsed >= timeout: # Timeout elapsed, process continues in background coder.io.tool_output( - f"⏱️ Command exceeded {timeout}s timeout, continuing in background...", + f"\u23f1\ufe0f Command exceeded {timeout}s timeout, continuing in background...", type="tool-result", ) @@ -428,6 +490,7 @@ async def _stop_background_command(cls, coder, command_key): else: return output # Error message from manager + @classmethod async def _handle_errors(cls, coder, command_string, e): """Handle errors during command execution.""" coder.io.tool_error(f"Error executing shell command: {str(e)}") @@ -451,7 +514,8 @@ def format_output(cls, coder, mcp_server, tool_response): command = params.get("command", "") background = params.get("background", False) - stop = params.get("stop", False) + background_key = params.get("background_key") + action = params.get("action") stdin = params.get("stdin") pty = params.get("pty", False) @@ -461,8 +525,8 @@ def format_output(cls, coder, mcp_server, tool_response): extras = [] if background: extras.append("background=True") - if stop: - extras.append("stop=True") + if action: + extras.append(f"action={action}") if pty: extras.append("pty=True") @@ -473,8 +537,13 @@ def format_output(cls, coder, mcp_server, tool_response): coder.io.tool_output(f"{color_start}Stdin:{color_end}") coder.io.tool_output(stdin) - coder.io.tool_output(f"{color_start}Command:{color_end}") - coder.io.tool_output(command) + if background_key and action: + coder.io.tool_output(f"{color_start}Background Key:{color_end} {background_key}") + coder.io.tool_output(f"{color_start}Action:{color_end} {action}") + elif command: + coder.io.tool_output(f"{color_start}Command:{color_end}") + coder.io.tool_output(command) + coder.io.tool_output("") # Output footer diff --git a/cecli/urls.py b/cecli/urls.py index d2e30182dc9..9a5c44c0593 100644 --- a/cecli/urls.py +++ b/cecli/urls.py @@ -1,16 +1,14 @@ website = "https://cecli.dev/" -add_all_files = "https://cecli.dev/docs/faq.html#how-can-i-add-all-the-files-to-the-chat" edit_errors = "https://cecli.dev/docs/troubleshooting/edit-errors.html" git = "https://cecli.dev/docs/git.html" -enable_playwright = "https://cecli.dev/docs/install/optional.html#enable-playwright" +enable_playwright = "https://cecli.dev/docs/usage/optional.html#enable-playwright" favicon = "https://cecli.dev/assets/cecli-temp-logo-favicon.svg" model_warnings = "https://cecli.dev/docs/llms/warnings.html" token_limits = "https://cecli.dev/docs/troubleshooting/token-limits.html" llms = "https://cecli.dev/docs/llms.html" -large_repos = "https://cecli.dev/docs/faq.html#can-i-use-cecli-in-a-large-mono-repo" -github_issues = "https://github.com/dwash96/cecli/issues/new" +github_issues = "https://github.com/cecli-dev/cecli/issues/new" git_index_version = "https://github.com/Aider-AI/aider/issues/211" install_properly = "https://cecli.dev/docs/troubleshooting/imports.html" -release_notes = "https://github.com/dwash96/cecli/releases/latest" +release_notes = "https://github.com/cecli-dev/cecli/releases/latest" edit_formats = "https://cecli.dev/docs/more/edit-formats.html" models_and_keys = "https://cecli.dev/docs/troubleshooting/models-and-keys.html" diff --git a/cecli/versioncheck.py b/cecli/versioncheck.py index 05d13fe964f..95ff02b32b7 100644 --- a/cecli/versioncheck.py +++ b/cecli/versioncheck.py @@ -21,7 +21,7 @@ async def install_from_main_branch(io): io, None, "Install the development version of cecli from the main branch?", - ["git+https://github.com/dwash96/cecli.git"], + ["git+https://github.com/cecli-dev/cecli.git"], self_update=True, ) diff --git a/cecli/website/_config.yml b/cecli/website/_config.yml index f88ca4c1b1f..5b664e866ae 100644 --- a/cecli/website/_config.yml +++ b/cecli/website/_config.yml @@ -24,7 +24,7 @@ exclude: aux_links: "GitHub": - - "https://github.com/dwash96/cecli" + - "https://github.com/cecli-dev/cecli" "Discord": - "https://discord.gg/AX9ZEA7nJn" "Support": @@ -32,7 +32,7 @@ aux_links: nav_external_links: - title: "GitHub" - url: "https://github.com/dwash96/cecli" + url: "https://github.com/cecli-dev/cecli" - title: "Discord" url: "https://discord.gg/AX9ZEA7nJn" - title: "Support" diff --git a/cecli/website/_includes/help.md b/cecli/website/_includes/help.md index 89955bac765..2999741f739 100644 --- a/cecli/website/_includes/help.md +++ b/cecli/website/_includes/help.md @@ -1,5 +1,5 @@ If you need more help, please check our -[GitHub issues](https://github.com/dwash96/cecli/issues) +[GitHub issues](https://github.com/cecli-dev/cecli/issues) and file a new issue if your problem isn't discussed. Or drop into our [Discord](https://discord.gg/g4bF53fSWF) diff --git a/cecli/website/docs/benchmarks-1106.md b/cecli/website/docs/benchmarks-1106.md index 1555ef3bd55..a66892d1351 100644 --- a/cecli/website/docs/benchmarks-1106.md +++ b/cecli/website/docs/benchmarks-1106.md @@ -19,7 +19,7 @@ and there's a lot of interest about their ability to code compared to the previous versions. With that in mind, I've been benchmarking the new models. -[cecli](https://github.com/dwash96/cecli) +[cecli](https://github.com/cecli-dev/cecli) is an open source command line chat tool that lets you work with GPT to edit code in your local git repo. To do this, cecli needs to be able to reliably recognize when GPT wants to edit diff --git a/cecli/website/docs/benchmarks-speed-1106.md b/cecli/website/docs/benchmarks-speed-1106.md index 36c3edd8c52..db91a9e7959 100644 --- a/cecli/website/docs/benchmarks-speed-1106.md +++ b/cecli/website/docs/benchmarks-speed-1106.md @@ -20,7 +20,7 @@ and there's a lot of interest about their capabilities and performance. With that in mind, I've been benchmarking the new models. -[cecli](https://github.com/dwash96/cecli) +[cecli](https://github.com/cecli-dev/cecli) is an open source command line chat tool that lets you work with GPT to edit code in your local git repo. cecli relies on a diff --git a/cecli/website/docs/benchmarks.md b/cecli/website/docs/benchmarks.md index 108cc67ca3c..8226a276266 100644 --- a/cecli/website/docs/benchmarks.md +++ b/cecli/website/docs/benchmarks.md @@ -168,7 +168,7 @@ requests: ### whole The -[whole](https://github.com/dwash96/cecli/blob/main/cecli/coders/wholefile_prompts.py) +[whole](https://github.com/cecli-dev/cecli/blob/main/cecli/coders/wholefile_prompts.py) format asks GPT to return an updated copy of the entire file, including any changes. The file should be formatted with normal markdown triple-backtick fences, inlined with the rest of its response text. @@ -187,7 +187,7 @@ def main(): ### diff -The [diff](https://github.com/dwash96/cecli/blob/main/cecli/coders/editblock_prompts.py) +The [diff](https://github.com/cecli-dev/cecli/blob/main/cecli/coders/editblock_prompts.py) format also asks GPT to return edits as part of the normal response text, in a simple diff format. Each edit is a fenced code block that @@ -209,7 +209,7 @@ demo.py ### whole-func -The [whole-func](https://github.com/dwash96/cecli/blob/main/cecli/coders/wholefile_func_coder.py) +The [whole-func](https://github.com/cecli-dev/cecli/blob/main/cecli/coders/wholefile_func_coder.py) format requests updated copies of whole files to be returned using the function call API. @@ -227,7 +227,7 @@ format requests updated copies of whole files to be returned using the function ### diff-func The -[diff-func](https://github.com/dwash96/cecli/blob/main/cecli/coders/editblock_func_coder.py) +[diff-func](https://github.com/cecli-dev/cecli/blob/main/cecli/coders/editblock_func_coder.py) format requests a list of original/updated style edits to be returned using the function call API. diff --git a/cecli/website/docs/config/adv-model-settings.md b/cecli/website/docs/config/adv-model-settings.md index a96d8b80cbd..e2d4c6c5997 100644 --- a/cecli/website/docs/config/adv-model-settings.md +++ b/cecli/website/docs/config/adv-model-settings.md @@ -122,7 +122,7 @@ These settings will be merged with any model-specific settings, with the Below are all the pre-configured model settings to give a sense for the settings which are supported. You can also look at the `ModelSettings` class in -[models.py](https://github.com/dwash96/cecli/blob/main/cecli/models.py) +[models.py](https://github.com/cecli-dev/cecli/blob/main/cecli/models.py) file for more details about all of the model setting that cecli supports. The first entry shows all the settings, with their default values. diff --git a/cecli/website/docs/config/agent-mode.md b/cecli/website/docs/config/agent-mode.md index f0d7e01fa02..5f3f69af14d 100644 --- a/cecli/website/docs/config/agent-mode.md +++ b/cecli/website/docs/config/agent-mode.md @@ -306,7 +306,7 @@ agent-config: skills_init: ["python-refactoring"] ``` -For complete documentation on creating and using skills, including skill directory structure, SKILL.md format, and best practices, see the [Skills documentation](https://github.com/dwash96/cecli/blob/main/cecli/website/docs/config/skills.md). +For complete documentation on creating and using skills, including skill directory structure, SKILL.md format, and best practices, see the [Skills documentation](https://github.com/cecli-dev/cecli/blob/main/cecli/website/docs/config/skills.md). ### Benefits - **Autonomous operation**: Reduces need for manual file management diff --git a/cecli/website/docs/config/aider_conf.md b/cecli/website/docs/config/conf.md similarity index 92% rename from cecli/website/docs/config/aider_conf.md rename to cecli/website/docs/config/conf.md index b4d0c31aa47..efcb199eb1e 100644 --- a/cecli/website/docs/config/aider_conf.md +++ b/cecli/website/docs/config/conf.md @@ -40,7 +40,7 @@ read: [CONVENTIONS.md, anotherfile.txt, thirdfile.py] Below is a sample of the YAML config file, which you can also -[download from GitHub](https://github.com/dwash96/cecli/blob/main/cecli/website/assets/sample.cecli.conf.yml). +[download from GitHub](https://github.com/cecli-dev/cecli/blob/main/cecli/website/assets/sample.cecli.conf.yml). diff --git a/cecli/website/docs/config/dotenv.md b/cecli/website/docs/config/dotenv.md index 089b02c9a17..f987d477701 100644 --- a/cecli/website/docs/config/dotenv.md +++ b/cecli/website/docs/config/dotenv.md @@ -26,7 +26,7 @@ If the files above exist, they will be loaded in that order. Files loaded last w Below is a sample `.env` file, which you can also -[download from GitHub](https://github.com/dwash96/cecli/blob/main/cecli/website/assets/sample.env). +[download from GitHub](https://github.com/cecli-dev/cecli/blob/main/cecli/website/assets/sample.env).