diff --git a/cecli/__init__.py b/cecli/__init__.py index 8fc1c520701..21130385bd7 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.96.4.dev" +__version__ = "0.96.5.dev" safe_version = __version__ try: diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 86ba929465d..b02be54c101 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -724,6 +724,7 @@ async def reply_completed(self): ) = await self._process_tool_commands(content) if self.agent_finished: self.tool_usage_history = [] + self.reflected_message = None if self.files_edited_by_tools: _ = await self.auto_commit(self.files_edited_by_tools) return False diff --git a/cecli/coders/architect_coder.py b/cecli/coders/architect_coder.py index aa5789e1b17..8ce450b3259 100644 --- a/cecli/coders/architect_coder.py +++ b/cecli/coders/architect_coder.py @@ -61,6 +61,14 @@ async def reply_completed(self): if self.verbose: editor_coder.show_announcements() + postamble = """ + The above changes are proposed changes. + You must repeat SEARCH/REPLACE blocks in order to apply edits. + Shell commands must also be duplicated in order to run them. + """ + + content = f"Please implement all requested changes from:\n{content}\n{postamble}" + try: await editor_coder.generate(user_message=content, preproc=False) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 611d6a0c84e..330a22ce2c6 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -1584,6 +1584,7 @@ async def run_one(self, user_message, preproc): pass if not self.reflected_message: + await self.auto_save_session(force=True) break if self.num_reflections >= self.max_reflections: @@ -1603,6 +1604,8 @@ async def run_one(self, user_message, preproc): if self.enable_context_compaction: await self.compact_context_if_needed() + await self.auto_save_session(force=True) + async def check_and_open_urls(self, exc, friendly_msg=None): """Check exception for URLs, offer to open in a browser, with user-friendly error msgs.""" text = str(exc) @@ -3776,7 +3779,7 @@ def apply_edits(self, edits): def apply_edits_dry_run(self, edits): return edits - async def auto_save_session(self): + async def auto_save_session(self, force=False): """Automatically save the current session to {auto-save-session-name}.json.""" if not getattr(self.args, "auto_save", False): return @@ -3789,11 +3792,17 @@ async def auto_save_session(self): self._autosave_future = None if self._autosave_future and not self._autosave_future.done(): - return + if force: + try: + await self._autosave_future + except Exception: + pass + else: + return # Throttle autosave to run at most once every 15 seconds current_time = time.time() - if current_time - self._last_autosave_time >= 15.0: + if current_time - self._last_autosave_time >= 15.0 or force: try: self._last_autosave_time = current_time session_manager = SessionManager(self, self.io) diff --git a/cecli/io.py b/cecli/io.py index 4ee3ee21322..f7f86f14b26 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -148,7 +148,6 @@ def __init__( self.rel_fnames = rel_fnames self.encoding = encoding self.abs_read_only_fnames = abs_read_only_fnames or [] - self.post_filter_commands = ["/add"] fname_to_rel_fnames = defaultdict(list) for rel_fname in addable_rel_fnames: @@ -214,22 +213,48 @@ def tokenize(self): def get_command_completions(self, document, complete_event, text, words): if len(words) == 1 and not text[-1].isspace(): + # Handle command completion (e.g., typing "/ad" should complete to "/add") partial = words[0].lower() - candidates = [cmd for cmd in self.command_names if cmd.startswith(partial)] + # Strip leading '/' if present for comparison with command names + if partial.startswith("/"): + partial = partial[1:] + # Compare with command names without leading '/' + candidates = [cmd for cmd in self.command_names if cmd[1:].startswith(partial)] for candidate in sorted(candidates): + # Add back the leading '/' for the completion yield Completion(candidate, start_position=-len(words[-1])) return - if len(words) <= 1 or text[-1].isspace(): - return + # Handle command followed by space: trigger auto-completion with empty partial + if text[-1].isspace(): + # We have a command followed by space, trigger auto-completion with empty string + if len(words) == 1: + # Command with no arguments yet, just a trailing space + partial = "" + # We need to get the command name without the trailing space + # The command is words[0] but might have leading '/' + cmd_text = words[0] + else: + # Command with arguments and trailing space + partial = "" + cmd_text = text.rstrip() # Remove trailing space for matching + else: + # No trailing space + if len(words) <= 1: + return + partial = words[-1].lower() + cmd_text = text - cmd = words[0] - partial = words[-1].lower() + # Pass the text (without trailing space if present) to matching_commands + matches, matched_cmd, _ = self.commands.matching_commands(cmd_text.rstrip()) + if not matches: + return - matches, _, _ = self.commands.matching_commands(cmd) if len(matches) == 1: cmd = matches[0] - elif cmd not in matches: + elif matched_cmd in matches: + cmd = matched_cmd + else: return raw_completer = self.commands.get_raw_completions(cmd) @@ -242,11 +267,14 @@ def get_command_completions(self, document, complete_event, text, words): if candidates is None: return - if cmd in self.post_filter_commands: - candidates = [word for word in candidates if partial in word.lower()] + candidates = [word for word in candidates if partial in word.lower()] for candidate in sorted(candidates): - yield Completion(candidate, start_position=-len(words[-1])) + # Calculate start position based on partial, not words[-1] + # When partial is empty (trailing space), start_position should be 0 + # When partial is not empty, replace that many characters + start_position = -len(partial) if partial else 0 + yield Completion(candidate, start_position=start_position) def get_completions(self, document, complete_event): self.tokenize() @@ -256,8 +284,9 @@ def get_completions(self, document, complete_event): if not words: return - if text and text[-1].isspace(): - # don't keep completing after a space + if text and text[-1].isspace() and not text.startswith("/"): + # don't keep completing after a space for non-commands + # For commands, we want to allow completion with empty string partial return if text[0] == "/": diff --git a/cecli/main.py b/cecli/main.py index 4ab8833cb57..b55c816c270 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -1202,6 +1202,7 @@ def apply_model_overrides(model_name): return await graceful_exit(coder) except SwitchCoderSignal as switch: coder.ok_to_warm_cache = False + await coder.auto_save_session(force=True) if hasattr(switch, "placeholder") and switch.placeholder is not None: io.placeholder = switch.placeholder @@ -1229,6 +1230,7 @@ def apply_model_overrides(model_name): except SystemExit: sys.settrace(None) + await coder.auto_save_session(force=True) return await graceful_exit(coder) diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 2155e7cac9a..01dcea809bb 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -884,7 +884,9 @@ def _get_suggestions(self, text: str) -> list[str]: suggestions = [] commands = self.worker.coder.commands - if len(text) and text[-1] == " ": + # Only return early for non-commands ending with space + # For commands, we want to allow completion with empty string partial + if len(text) and text[-1] == " " and not text.startswith("/"): return if "@" in text: @@ -905,12 +907,21 @@ def _get_suggestions(self, text: str) -> list[str]: suggestions = all_commands else: suggestions = [c for c in all_commands if c.startswith(cmd_part)] - elif len(parts) > 1: + else: # Complete command argument + # This handles both: + # 1. len(parts) > 1: command with arguments + # 2. len(parts) == 1 and text.endswith(" "): command with trailing space cmd_name = cmd_part - end_lookup = text.rsplit(maxsplit=1) - arg_prefix = end_lookup[-1] + if text.endswith(" "): + # Command with trailing space, empty argument prefix + arg_prefix = "" + else: + # Get the last word as argument prefix + end_lookup = text.rsplit(maxsplit=1) + arg_prefix = end_lookup[-1] + arg_prefix_lower = arg_prefix.lower() # Check if this command needs path-based completion @@ -955,8 +966,14 @@ def _get_completed_text(self, current_text: str, completion: str) -> str: """Calculate the new text after applying completion.""" if current_text.startswith("/"): parts = current_text.rsplit(maxsplit=1) - if len(parts) == 1: - # Replace entire command + + # Check if we have a command with trailing space + # This is when we want to insert argument completions after the space + if len(parts) == 1 and current_text.endswith(" "): + # Command with trailing space, insert completion after space + return current_text + completion + elif len(parts) == 1: + # Replace entire command (command name completion) # Only add space if command takes arguments commands = self.worker.coder.commands try: diff --git a/cecli/tui/worker.py b/cecli/tui/worker.py index cfa2acc0a42..f42ec284d6f 100644 --- a/cecli/tui/worker.py +++ b/cecli/tui/worker.py @@ -95,6 +95,7 @@ async def _async_run(self): except SwitchCoderSignal as switch: # Handle chat mode switches (e.g., /chat-mode architect) try: + await self.coder.auto_save_session(force=True) kwargs = dict(io=self.coder.io, from_coder=self.coder) kwargs.update(switch.kwargs) if "show_announcements" in kwargs: