Skip to content
Open
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
5 changes: 4 additions & 1 deletion cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -4877,7 +4877,10 @@ async def allowed_to_edit(self, path):
return

if not Path(full_path).exists():
rel_path = os.path.relpath(full_path)
try:
rel_path = os.path.relpath(full_path)
except ValueError:
rel_path = full_path
if not await self.io.confirm_ask(f"Create new file? ({rel_path})", subject=path):
self.io.tool_output(f"Skipping edits to {path}")
return
Expand Down
5 changes: 4 additions & 1 deletion cecli/commands/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ async def execute(cls, io, coder, args, **kwargs):
io.tool_output(f"You can add to git with: /git add {fname}")
continue

confirm_fname = os.path.relpath(fname)
try:
confirm_fname = os.path.relpath(fname)
except ValueError:
confirm_fname = str(fname)
if len(confirm_fname) > 64:
confirm_fname = f".../{os.path.basename(confirm_fname)}"

Expand Down
502 changes: 502 additions & 0 deletions cecli/sessions.py

Large diffs are not rendered by default.

16 changes: 13 additions & 3 deletions cecli/tools/grep.py
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,11 @@ def execute(
if os.path.isabs(raw_path)
else os.path.normpath(os.path.join(repo.root, raw_path))
)
rel_files.append((os.path.relpath(abs_path, repo.root), file_count))
try:
rel_path = os.path.relpath(abs_path, repo.root)
except ValueError:
rel_path = abs_path
rel_files.append((rel_path, file_count))
rel_files.sort(key=lambda item: (-item[1], item[0]))

shown_files = rel_files[:MAX_FILES]
Expand Down Expand Up @@ -883,7 +887,10 @@ def execute(
pf["count_from_pass"] = counts[raw_path]
else:
# Try with repo root prefix stripped
rel = os.path.relpath(raw_path, repo.root)
try:
rel = os.path.relpath(raw_path, repo.root)
except ValueError:
rel = raw_path
pf["count_from_pass"] = counts.get(rel, pf["match_count"])
else:
for pf in parsed_files:
Expand All @@ -897,7 +904,10 @@ def execute(

rendered = []
for pf in parsed_files[:MAX_FILES]:
rel_path = os.path.relpath(pf["path"], repo.root)
try:
rel_path = os.path.relpath(pf["path"], repo.root)
except ValueError:
rel_path = pf["path"]
count = pf.get("count_from_pass", 0)
total_matches += count

Expand Down
10 changes: 8 additions & 2 deletions cecli/tools/ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,21 @@ def execute(cls, coder, path=None, **kwargs):
with os.scandir(abs_path) as entries:
for entry in entries:
if not entry.name.startswith("."):
rel_path = os.path.relpath(entry.path, coder.root)
try:
rel_path = os.path.relpath(entry.path, coder.root)
except ValueError:
rel_path = entry.path
contents.append(rel_path)
except OSError as e:
coder.io.tool_error(f"Error listing directory '{dir_path}': {e}")
response.append_result(f"Error: {e}")
return response
elif os.path.isfile(abs_path):
# It's a file, just return its relative path
contents.append(os.path.relpath(abs_path, coder.root))
try:
contents.append(os.path.relpath(abs_path, coder.root))
except ValueError:
contents.append(abs_path)

if contents:
coder.io.tool_output(
Expand Down
21 changes: 19 additions & 2 deletions cecli/tui/widgets/completion_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,19 @@ def current_selection(self) -> str | None:
return self.suggestions[self.selected_index]
return None

@staticmethod
def _safe_relpath(path: str) -> str:
"""Return ``os.path.relpath(path)``, falling back to ``path`` on cross-drive.

On Windows, ``os.path.relpath`` raises ``ValueError`` when *path* and the
implicit start (the CWD) are on different drives. Mirror the guarded
``get_rel_fname`` helpers and keep the absolute path in that case.
"""
try:
return os.path.relpath(path)
except ValueError:
return path

def _compute_display_names(self) -> None:
"""Compute common directory prefix and short display names."""
if not self.suggestions:
Expand All @@ -130,7 +143,7 @@ def _compute_display_names(self) -> None:
if is_absolute:
candidates = self.suggestions
else:
candidates = [os.path.relpath(s) for s in self.suggestions]
candidates = [self._safe_relpath(s) for s in self.suggestions]

# Find common directory prefix
dirs = [os.path.dirname(s) for s in candidates]
Expand All @@ -140,7 +153,11 @@ def _compute_display_names(self) -> None:
self._display_names = [os.path.basename(s) for s in candidates]
else:
# Find longest common path prefix
common = os.path.commonpath(candidates) if candidates else ""
try:
common = os.path.commonpath(candidates) if candidates else ""
except ValueError:
# Mixed drives (Windows): no common prefix to collapse.
common = ""
if common and os.sep in common:
# Use the directory part of common prefix
self._common_prefix = common.rsplit(os.sep, 1)[0] + os.sep
Expand Down
14 changes: 7 additions & 7 deletions cecli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,14 +402,14 @@ def find_common_root(abs_fnames):
return safe_abs_path(os.path.dirname(list(abs_fnames)[0]))
elif abs_fnames:
return safe_abs_path(os.path.commonpath(list(abs_fnames)))
except OSError:
except (OSError, ValueError):
# ValueError: cross-drive commonpath on Windows (e.g. C: vs E:).
pass

try:
return safe_abs_path(os.getcwd())
except FileNotFoundError:
# Fallback if cwd is deleted
return "."
# Restore the original safe fallback: callers assign this straight to
# Coder.root, and Path(False) (or the implicit None for empty input)
# would TypeError on the next Path(root) call. "" resolves as the CWD,
# matching the pre-existing behavior.
return ""


def format_tokens(count):
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ uvicorn[standard]==0.38.0
# -c requirements/common-constraints.txt
# chromadb
# mcp
uvloop==0.22.1
uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
# via
# -c requirements/common-constraints.txt
# uvicorn
Expand Down Expand Up @@ -642,6 +642,6 @@ zipp==3.23.0
# via
# -c requirements/common-constraints.txt
# importlib-metadata

tree-sitter==0.23.2; python_version < "3.10"
tree-sitter>=0.25.1; python_version >= "3.10"
2 changes: 1 addition & 1 deletion requirements/common-constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ uvicorn[standard]==0.38.0
# via
# chromadb
# mcp
uvloop==0.22.1
uvloop==0.22.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'
# via uvicorn
virtualenv==20.35.4
# via pre-commit
Expand Down
65 changes: 65 additions & 0 deletions tests/basic/test_retry_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from unittest.mock import AsyncMock, call, patch

import pytest

from cecli.llm import litellm
from cecli.models import Model, _parse_retry_config


def test_parse_retry_config_string():
config_str = '{"retry_timeout": 15, "retry-on-empty": true}'
result = _parse_retry_config(config_str)
assert result["retry_timeout"] == 15.0
assert result["retry_on_empty"] is True
# defaults
assert result["retry_backoff_factor"] == 1.5
assert result["retry_on_unavailable"] is True


def test_parse_retry_config_dict():
config_dict = {
"retry_timeout": 10.0,
"retry_backoff_factor": 2.0,
"retry-on-unavailable": False,
}
result = _parse_retry_config(config_dict)
assert result["retry_timeout"] == 10.0
assert result["retry_backoff_factor"] == 2.0
assert result["retry_on_unavailable"] is False
assert result["retry_on_empty"] is False


@pytest.mark.asyncio
async def test_simple_send_with_retries_honors_timeout():
# Setup model with a short retry timeout limit
model = Model("gpt-4o", retries={"retry_timeout": 0.5, "retry_backoff_factor": 2.0})

# retry_delay starts at 0.125 and is multiplied by the backoff factor
# BEFORE each retry sleep; retry_timeout caps the per-retry delay:
# attempt 1 fails -> 0.125 * 2.0 = 0.25 (<= 0.5, sleep and retry)
# attempt 2 fails -> 0.25 * 2.0 = 0.50 (<= 0.5, sleep and retry)
# attempt 3 fails -> 0.50 * 2.0 = 1.00 (> 0.5, give up)
err = litellm.APIConnectionError(
message="Simulated connection error", llm_provider="openai", model="gpt-4o", request=None
)

mock_send = AsyncMock(side_effect=err)

with (
patch.object(model, "send_completion", mock_send),
patch("time.sleep") as mock_sleep,
patch("builtins.print"),
): # Mute prints in test output

content, response = await model.simple_send_with_retries(messages=[])

# It should exit yielding None, None because it exhausted retries.
assert content is None
assert response is None

# The backoff factor is applied before each sleep, so the sleeps are
# 0.25 then 0.50; the third failure would need 1.0 > 0.5, so it stops.

assert mock_send.call_count == 3
assert mock_sleep.call_count == 2
assert mock_sleep.call_args_list == [call(0.25), call(0.5)]
72 changes: 72 additions & 0 deletions tests/tui/test_completion_bar.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import os
import sys
from unittest import mock

import pytest

from cecli.tui.widgets.completion_bar import CompletionBar

IS_WINDOWS = sys.platform == "win32"

# Capture the real relpath before any test patches os.path.relpath, so fake
# implementations can delegate to it without recursing into the mock.
original_relpath = os.path.relpath


@pytest.mark.skipif(IS_WINDOWS, reason="POSIX-only path separators")
def test_absolute_path_suggestions_stay_absolute():
Expand Down Expand Up @@ -63,3 +69,69 @@ def test_windows_relative_path_suggestions_kept():

assert bar.suggestions == suggestions
assert bar._display_names == suggestions


def test_relpath_cross_drive_falls_back():
"""os.path.relpath() raising ValueError (Windows cross-drive) must not crash.

Simulates the reported crash: C:-relative suggestions mixed with an
absolute path on another drive (E:), i.e.
"ValueError: path is on mount 'E:', start on mount 'C:'".
"""

def fake_relpath(path, start=None):
if "E:\\" in path:
raise ValueError("path is on mount 'E:', start on mount 'C:'")
return original_relpath(path)

with mock.patch("os.path.relpath", side_effect=fake_relpath):
# Must not raise; the cross-drive suggestion is displayed as-is.
bar = CompletionBar(
suggestions=[
"../.cecli/rules.md",
"E:\\My_Mods\\data\\file1.txt",
],
prefix="/drop ",
)
bar._compute_display_names()

assert "E:\\My_Mods\\data\\file1.txt" in bar._display_names


def test_commonpath_mixed_drives_falls_back():
"""os.path.commonpath() raising ValueError (mixed drives) must not crash.

The same Windows limitation hits the common prefix step one line after
relpath; the bar must fall back to showing candidates as-is.
"""
with (
mock.patch("os.path.commonpath", side_effect=ValueError("Can't mix paths")),
mock.patch("os.path.relpath", side_effect=lambda path, start=None: path),
):
# Must not raise at the commonpath step either.
bar = CompletionBar(
suggestions=[
"E:\\My_Mods\\data\\file1.txt",
"../other/file2.txt",
],
prefix="/drop ",
)
bar._compute_display_names()

assert "E:\\My_Mods\\data\\file1.txt" in bar._display_names


def test_same_directory_suggestions_compress():
"""Suggestions in one directory still collapse to a shared prefix + basenames."""
sep = os.sep
bar = CompletionBar(
suggestions=[
"src" + sep + "one.py",
"src" + sep + "two.py",
],
prefix="/add ",
)
bar._compute_display_names()

assert bar._common_prefix == "src" + sep
assert bar._display_names == ["one.py", "two.py"]
Loading