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.4.dev"
__version__ = "0.96.5.dev"
safe_version = __version__

try:
Expand Down
1 change: 1 addition & 0 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions cecli/coders/architect_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
15 changes: 12 additions & 3 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
55 changes: 42 additions & 13 deletions cecli/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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] == "/":
Expand Down
2 changes: 2 additions & 0 deletions cecli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down
29 changes: 23 additions & 6 deletions cecli/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions cecli/tui/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading