Skip to content

Commit 855931c

Browse files
authored
Merge pull request #531 from cecli-dev/v0.100.1
V0.100.1
2 parents 8b6086f + da52a77 commit 855931c

8 files changed

Lines changed: 161 additions & 15 deletions

File tree

cecli/coders/base_coder.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,13 +1693,27 @@ async def preproc_user_input(self, inp):
16931693
inp = inp.strip()
16941694

16951695
if self.commands.is_command(inp):
1696+
run_kwargs = {}
16961697
if inp[0] in "!":
1697-
inp = f"/run {inp[1:]}"
1698+
# Count and strip all leading exclamation marks
1699+
# "!command" -> normal execution
1700+
# "!!command" -> suppress adding output to chat
1701+
# "!!!command" -> background/obstructive mode (TUI suspended)
1702+
num_marks = 0
1703+
while num_marks < len(inp) and inp[num_marks] == "!":
1704+
num_marks += 1
1705+
command_text = inp[num_marks:]
1706+
inp = f"/run {command_text}"
1707+
if num_marks >= 3:
1708+
run_kwargs["background"] = True
1709+
run_kwargs["suppress_add"] = True
1710+
elif num_marks == 2:
1711+
run_kwargs["suppress_add"] = True
16981712

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

1702-
return await self.commands.run(inp, coder=self)
1716+
return await self.commands.run(inp, coder=self, **run_kwargs)
17031717

17041718
await self.check_for_file_mentions(inp)
17051719
inp = await self.check_for_urls(inp)

cecli/commands/add.py

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,9 @@ async def execute(cls, io, coder, args, **kwargs):
8484
for matched_file in sorted(all_matched_files):
8585
abs_file_path = coder.abs_root_path(matched_file)
8686

87-
if (
88-
coder.repo
89-
and coder.repo.git_ignored_file(matched_file)
90-
and not coder.add_gitignore_files
91-
):
92-
io.tool_error(f"Can't add {matched_file} which is in gitignore")
87+
blocked = cls._add_blocked_message(coder, matched_file)
88+
if blocked:
89+
io.tool_error(blocked)
9390
continue
9491

9592
if abs_file_path in coder.abs_fnames:
@@ -240,3 +237,79 @@ def get_help(cls) -> str:
240237
help_text += "Files can be moved from read-only to editable status.\n"
241238
help_text += "Image files can be added if the model supports vision.\n"
242239
return help_text
240+
241+
@staticmethod
242+
def _display_add_path(matched_file: str) -> str:
243+
try:
244+
label = os.path.relpath(matched_file)
245+
except ValueError:
246+
label = matched_file
247+
if len(label) > 64:
248+
label = f".../{os.path.basename(label)}"
249+
return label
250+
251+
@staticmethod
252+
def _git_repo_for_add_check(coder, matched_file: str):
253+
"""Resolve the GitRepo used for ignore checks (RepoSet-aware)."""
254+
repo = coder.repo
255+
if not repo:
256+
return None, matched_file
257+
if hasattr(repo, "repo_for_rel_path"):
258+
git_repo = repo.repo_for_rel_path(matched_file)
259+
try:
260+
rel = repo.path_relative_to_repo(matched_file, git_repo)
261+
except (ValueError, OSError):
262+
rel = matched_file
263+
return git_repo, rel
264+
return repo, matched_file
265+
266+
@classmethod
267+
def _add_blocked_message(cls, coder, matched_file: str) -> str | None:
268+
"""
269+
Explain why /add refused a path. Distinguishes .gitignore vs .cecli.ignore when possible.
270+
"""
271+
if not coder.repo or coder.add_gitignore_files:
272+
return None
273+
if not coder.repo.git_ignored_file(matched_file):
274+
return None
275+
276+
display = cls._display_add_path(matched_file)
277+
git_repo, rel = cls._git_repo_for_add_check(coder, matched_file)
278+
if not git_repo:
279+
return (
280+
f"Can't add {display}: excluded by ignore rules for this session. "
281+
"Confirm Settings \u2192 project folder is the git root for this file."
282+
)
283+
284+
gitignore_hit = False
285+
cecli_hit = False
286+
if hasattr(git_repo, "_is_gitignored_by_pathspec"):
287+
try:
288+
gitignore_hit = bool(git_repo._is_gitignored_by_pathspec(rel))
289+
except (ValueError, OSError):
290+
gitignore_hit = False
291+
292+
ignore_file = getattr(git_repo, "cecli_ignore_file", None) or getattr(
293+
git_repo, "aider_ignore_file", None
294+
)
295+
if ignore_file and Path(ignore_file).is_file() and hasattr(git_repo, "ignored_file_raw"):
296+
try:
297+
cecli_hit = bool(git_repo.ignored_file_raw(rel))
298+
except (ValueError, OSError):
299+
cecli_hit = False
300+
301+
if cecli_hit and not gitignore_hit:
302+
return (
303+
f"Can't add {display}: blocked by {Path(ignore_file).name} "
304+
"(cecli ignore rules), not .gitignore."
305+
)
306+
if gitignore_hit:
307+
return (
308+
f"Can't add {display}: matched .gitignore under the session workspace. "
309+
"If this is normal tracked source, check the project folder in Settings."
310+
)
311+
return (
312+
f"Can't add {display}: excluded by ignore rules for this session "
313+
"(.gitignore and/or cecli ignore). "
314+
"Confirm the project folder is the git root that contains this file."
315+
)

cecli/commands/core.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,13 @@ def matching_commands(self, inp):
232232
matching_commands = [cmd for cmd in all_commands if cmd.startswith(first_word)]
233233
return matching_commands, first_word, rest_inp
234234

235-
async def run(self, inp, coder=None):
235+
async def run(self, inp, coder=None, **kwargs):
236+
if inp.startswith("!!!"):
237+
return await self.execute(
238+
"run", inp[3:], coder=coder, background=True, suppress_add=True
239+
)
240+
if inp.startswith("!!"):
241+
return await self.execute("run", inp[2:], coder=coder, suppress_add=True)
236242
if inp.startswith("!"):
237243
return await self.execute("run", inp[1:], coder=coder)
238244
res = self.matching_commands(inp)
@@ -241,10 +247,10 @@ async def run(self, inp, coder=None):
241247
matching_commands, first_word, rest_inp = res
242248
if len(matching_commands) == 1:
243249
command = matching_commands[0][1:]
244-
return await self.execute(command, rest_inp, coder=coder)
250+
return await self.execute(command, rest_inp, coder=coder, **kwargs)
245251
elif first_word in matching_commands:
246252
command = first_word[1:]
247-
return await self.execute(command, rest_inp, coder=coder)
253+
return await self.execute(command, rest_inp, coder=coder, **kwargs)
248254
elif len(matching_commands) > 1:
249255
self.io.tool_error(f"Ambiguous command: {', '.join(matching_commands)}")
250256
else:

cecli/commands/run.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,14 @@ class RunCommand(BaseCommand):
1414
@classmethod
1515
async def execute(cls, io, coder, args, **kwargs):
1616
"""Execute the run command with given parameters."""
17+
suppress_add = kwargs.get("suppress_add", False)
18+
background = kwargs.get("background", False)
1719
add_on_nonzero_exit = kwargs.get("add_on_nonzero_exit", False)
1820

21+
# Background mode: suspend the TUI and run interactively
22+
if background:
23+
return await cls._execute_background(io, coder, args)
24+
1925
should_print = True
2026

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

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

92+
@classmethod
93+
async def _execute_background(cls, io, coder, args):
94+
"""
95+
Execute a command in background/obstructive mode with the TUI suspended.
96+
97+
This allows running interactive commands (e.g., sudo) that require
98+
direct terminal access for user input. The TUI is suspended while
99+
the command runs and is resumed upon completion.
100+
"""
101+
import subprocess
102+
103+
def _run_sync():
104+
"""Run the command synchronously with direct terminal access."""
105+
subprocess.run(
106+
args,
107+
shell=True,
108+
cwd=coder.root,
109+
)
110+
111+
if coder.tui and coder.tui():
112+
# Suspend the TUI and run the command with direct terminal access
113+
coder.tui().run_obstructive(_run_sync)
114+
else:
115+
# Not in TUI mode, run directly
116+
_run_sync()
117+
118+
io.tool_output(f"Background command completed: {args}")
119+
return format_command_result(io, "run", "Command executed in background mode")
120+
83121
@classmethod
84122
def get_completions(cls, io, coder, args) -> List[str]:
85123
"""Get completion options for run command."""

cecli/models.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,9 +1206,12 @@ async def send_completion(
12061206
kwargs["max_tokens"] = max_tokens
12071207
if "max_tokens" in kwargs and kwargs["max_tokens"]:
12081208
kwargs["max_completion_tokens"] = kwargs.pop("max_tokens")
1209-
if self.is_ollama() and "num_ctx" not in kwargs:
1210-
num_ctx = int(self.token_count(messages) * 1.25) + 8192
1211-
kwargs["num_ctx"] = num_ctx
1209+
if self.is_ollama():
1210+
# Ollama defaults to ~5m unload unless every request sets keep_alive (see Ollama API docs).
1211+
kwargs.setdefault("keep_alive", -1)
1212+
if "num_ctx" not in kwargs:
1213+
num_ctx = int(self.token_count(messages) * 1.25) + 8192
1214+
kwargs["num_ctx"] = num_ctx
12121215
key = json.dumps(kwargs, sort_keys=True).encode()
12131216
hash_object = hashlib.sha1(key)
12141217
if "timeout" not in kwargs:

cecli/tui/worker.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ def _cleanup_loop(self):
7979
)
8080
except RuntimeError:
8181
pass # Loop already stopped
82+
except KeyboardInterrupt:
83+
pass # Loop already stopped
8284

8385
self.loop.close()
8486
except Exception:
@@ -186,6 +188,11 @@ def stop(self):
186188
# We'll just pass to allow the thread to exit gracefully
187189
# without a scary traceback.
188190
pass
191+
except KeyboardInterrupt:
192+
# An interrupt was not caught within the async run loop.
193+
# We'll just pass to allow the thread to exit gracefully
194+
# without a scary traceback.
195+
pass
189196
self.interrupt()
190197

191198
# Wait for thread to finish

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ include-package-data = true
4141
[tool.setuptools.packages.find]
4242
include = ["cecli*"]
4343

44+
[tool.uv]
45+
exclude-newer = "2 days"
46+
4447
[build-system]
4548
requires = ["setuptools>=68", "setuptools_scm[toml]>=8"]
4649
build-backend = "setuptools.build_meta"

tests/basic/test_models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ async def test_ollama_num_ctx_set_when_missing(self, mock_token_count, mock_comp
381381
temperature=0,
382382
num_ctx=expected_ctx,
383383
timeout=600,
384+
keep_alive=-1,
384385
drop_params=True,
385386
headers={"Connection": "close", "User-Agent": ANY},
386387
cache_control_injection_points=ANY,
@@ -426,6 +427,7 @@ async def test_ollama_uses_existing_num_ctx(self, mock_completion):
426427
temperature=0,
427428
num_ctx=4096,
428429
timeout=600,
430+
keep_alive=-1,
429431
drop_params=True,
430432
headers={"Connection": "close", "User-Agent": ANY},
431433
cache_control_injection_points=ANY,

0 commit comments

Comments
 (0)