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
18 changes: 16 additions & 2 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1693,13 +1693,27 @@ async def preproc_user_input(self, inp):
inp = inp.strip()

if self.commands.is_command(inp):
run_kwargs = {}
if inp[0] in "!":
inp = f"/run {inp[1:]}"
# Count and strip all leading exclamation marks
# "!command" -> normal execution
# "!!command" -> suppress adding output to chat
# "!!!command" -> background/obstructive mode (TUI suspended)
num_marks = 0
while num_marks < len(inp) and inp[num_marks] == "!":
num_marks += 1
command_text = inp[num_marks:]
inp = f"/run {command_text}"
if num_marks >= 3:
run_kwargs["background"] = True
run_kwargs["suppress_add"] = True
elif num_marks == 2:
run_kwargs["suppress_add"] = True

if self.commands.is_run_command(inp):
self.commands.cmd_running_event.clear() # Command is running

return await self.commands.run(inp, coder=self)
return await self.commands.run(inp, coder=self, **run_kwargs)

await self.check_for_file_mentions(inp)
inp = await self.check_for_urls(inp)
Expand Down
85 changes: 79 additions & 6 deletions cecli/commands/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,9 @@ async def execute(cls, io, coder, args, **kwargs):
for matched_file in sorted(all_matched_files):
abs_file_path = coder.abs_root_path(matched_file)

if (
coder.repo
and coder.repo.git_ignored_file(matched_file)
and not coder.add_gitignore_files
):
io.tool_error(f"Can't add {matched_file} which is in gitignore")
blocked = cls._add_blocked_message(coder, matched_file)
if blocked:
io.tool_error(blocked)
continue

if abs_file_path in coder.abs_fnames:
Expand Down Expand Up @@ -240,3 +237,79 @@ def get_help(cls) -> str:
help_text += "Files can be moved from read-only to editable status.\n"
help_text += "Image files can be added if the model supports vision.\n"
return help_text

@staticmethod
def _display_add_path(matched_file: str) -> str:
try:
label = os.path.relpath(matched_file)
except ValueError:
label = matched_file
if len(label) > 64:
label = f".../{os.path.basename(label)}"
return label

@staticmethod
def _git_repo_for_add_check(coder, matched_file: str):
"""Resolve the GitRepo used for ignore checks (RepoSet-aware)."""
repo = coder.repo
if not repo:
return None, matched_file
if hasattr(repo, "repo_for_rel_path"):
git_repo = repo.repo_for_rel_path(matched_file)
try:
rel = repo.path_relative_to_repo(matched_file, git_repo)
except (ValueError, OSError):
rel = matched_file
return git_repo, rel
return repo, matched_file

@classmethod
def _add_blocked_message(cls, coder, matched_file: str) -> str | None:
"""
Explain why /add refused a path. Distinguishes .gitignore vs .cecli.ignore when possible.
"""
if not coder.repo or coder.add_gitignore_files:
return None
if not coder.repo.git_ignored_file(matched_file):
return None

display = cls._display_add_path(matched_file)
git_repo, rel = cls._git_repo_for_add_check(coder, matched_file)
if not git_repo:
return (
f"Can't add {display}: excluded by ignore rules for this session. "
"Confirm Settings \u2192 project folder is the git root for this file."
)

gitignore_hit = False
cecli_hit = False
if hasattr(git_repo, "_is_gitignored_by_pathspec"):
try:
gitignore_hit = bool(git_repo._is_gitignored_by_pathspec(rel))
except (ValueError, OSError):
gitignore_hit = False

ignore_file = getattr(git_repo, "cecli_ignore_file", None) or getattr(
git_repo, "aider_ignore_file", None
)
if ignore_file and Path(ignore_file).is_file() and hasattr(git_repo, "ignored_file_raw"):
try:
cecli_hit = bool(git_repo.ignored_file_raw(rel))
except (ValueError, OSError):
cecli_hit = False

if cecli_hit and not gitignore_hit:
return (
f"Can't add {display}: blocked by {Path(ignore_file).name} "
"(cecli ignore rules), not .gitignore."
)
if gitignore_hit:
return (
f"Can't add {display}: matched .gitignore under the session workspace. "
"If this is normal tracked source, check the project folder in Settings."
)
return (
f"Can't add {display}: excluded by ignore rules for this session "
"(.gitignore and/or cecli ignore). "
"Confirm the project folder is the git root that contains this file."
)
12 changes: 9 additions & 3 deletions cecli/commands/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,13 @@ def matching_commands(self, inp):
matching_commands = [cmd for cmd in all_commands if cmd.startswith(first_word)]
return matching_commands, first_word, rest_inp

async def run(self, inp, coder=None):
async def run(self, inp, coder=None, **kwargs):
if inp.startswith("!!!"):
return await self.execute(
"run", inp[3:], coder=coder, background=True, suppress_add=True
)
if inp.startswith("!!"):
return await self.execute("run", inp[2:], coder=coder, suppress_add=True)
if inp.startswith("!"):
return await self.execute("run", inp[1:], coder=coder)
res = self.matching_commands(inp)
Expand All @@ -241,10 +247,10 @@ async def run(self, inp, coder=None):
matching_commands, first_word, rest_inp = res
if len(matching_commands) == 1:
command = matching_commands[0][1:]
return await self.execute(command, rest_inp, coder=coder)
return await self.execute(command, rest_inp, coder=coder, **kwargs)
elif first_word in matching_commands:
command = first_word[1:]
return await self.execute(command, rest_inp, coder=coder)
return await self.execute(command, rest_inp, coder=coder, **kwargs)
elif len(matching_commands) > 1:
self.io.tool_error(f"Ambiguous command: {', '.join(matching_commands)}")
else:
Expand Down
40 changes: 39 additions & 1 deletion cecli/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ class RunCommand(BaseCommand):
@classmethod
async def execute(cls, io, coder, args, **kwargs):
"""Execute the run command with given parameters."""
suppress_add = kwargs.get("suppress_add", False)
background = kwargs.get("background", False)
add_on_nonzero_exit = kwargs.get("add_on_nonzero_exit", False)

# Background mode: suspend the TUI and run interactively
if background:
return await cls._execute_background(io, coder, args)

should_print = True

if coder.args.tui:
Expand Down Expand Up @@ -44,7 +50,10 @@ async def execute(cls, io, coder, args, **kwargs):
token_count = coder.main_model.token_count(combined_output)
k_tokens = token_count / 1000

if add_on_nonzero_exit:
# When suppress_add is True, skip the confirmation and never add
if suppress_add:
add = False
elif add_on_nonzero_exit:
add = exit_status != 0
else:
add = await io.confirm_ask(f"Add {k_tokens:.1f}k tokens of command output to the chat?")
Expand Down Expand Up @@ -80,6 +89,35 @@ async def execute(cls, io, coder, args, **kwargs):
# Return None if output wasn't added or command succeeded
return format_command_result(io, "run", "Command executed successfully")

@classmethod
async def _execute_background(cls, io, coder, args):
"""
Execute a command in background/obstructive mode with the TUI suspended.

This allows running interactive commands (e.g., sudo) that require
direct terminal access for user input. The TUI is suspended while
the command runs and is resumed upon completion.
"""
import subprocess

def _run_sync():
"""Run the command synchronously with direct terminal access."""
subprocess.run(
args,
shell=True,
cwd=coder.root,
)

if coder.tui and coder.tui():
# Suspend the TUI and run the command with direct terminal access
coder.tui().run_obstructive(_run_sync)
else:
# Not in TUI mode, run directly
_run_sync()

io.tool_output(f"Background command completed: {args}")
return format_command_result(io, "run", "Command executed in background mode")

@classmethod
def get_completions(cls, io, coder, args) -> List[str]:
"""Get completion options for run command."""
Expand Down
9 changes: 6 additions & 3 deletions cecli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1206,9 +1206,12 @@ async def send_completion(
kwargs["max_tokens"] = max_tokens
if "max_tokens" in kwargs and kwargs["max_tokens"]:
kwargs["max_completion_tokens"] = kwargs.pop("max_tokens")
if self.is_ollama() and "num_ctx" not in kwargs:
num_ctx = int(self.token_count(messages) * 1.25) + 8192
kwargs["num_ctx"] = num_ctx
if self.is_ollama():
# Ollama defaults to ~5m unload unless every request sets keep_alive (see Ollama API docs).
kwargs.setdefault("keep_alive", -1)
if "num_ctx" not in kwargs:
num_ctx = int(self.token_count(messages) * 1.25) + 8192
kwargs["num_ctx"] = num_ctx
key = json.dumps(kwargs, sort_keys=True).encode()
hash_object = hashlib.sha1(key)
if "timeout" not in kwargs:
Expand Down
7 changes: 7 additions & 0 deletions cecli/tui/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ def _cleanup_loop(self):
)
except RuntimeError:
pass # Loop already stopped
except KeyboardInterrupt:
pass # Loop already stopped

self.loop.close()
except Exception:
Expand Down Expand Up @@ -186,6 +188,11 @@ def stop(self):
# We'll just pass to allow the thread to exit gracefully
# without a scary traceback.
pass
except KeyboardInterrupt:
# An interrupt was not caught within the async run loop.
# We'll just pass to allow the thread to exit gracefully
# without a scary traceback.
pass
self.interrupt()

# Wait for thread to finish
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ include-package-data = true
[tool.setuptools.packages.find]
include = ["cecli*"]

[tool.uv]
exclude-newer = "2 days"

[build-system]
requires = ["setuptools>=68", "setuptools_scm[toml]>=8"]
build-backend = "setuptools.build_meta"
Expand Down
2 changes: 2 additions & 0 deletions tests/basic/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ async def test_ollama_num_ctx_set_when_missing(self, mock_token_count, mock_comp
temperature=0,
num_ctx=expected_ctx,
timeout=600,
keep_alive=-1,
drop_params=True,
headers={"Connection": "close", "User-Agent": ANY},
cache_control_injection_points=ANY,
Expand Down Expand Up @@ -426,6 +427,7 @@ async def test_ollama_uses_existing_num_ctx(self, mock_completion):
temperature=0,
num_ctx=4096,
timeout=600,
keep_alive=-1,
drop_params=True,
headers={"Connection": "close", "User-Agent": ANY},
cache_control_injection_points=ANY,
Expand Down
Loading