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
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.100.6.dev"
__version__ = "0.100.8.dev"
safe_version = __version__

try:
Expand Down
8 changes: 7 additions & 1 deletion cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def __init__(self, *args, **kwargs):
if kwargs.get("uuid", None):
self.uuid = kwargs.get("uuid")

self.start_up_errors = []
self.recently_removed = {}
self.tool_usage_history = []
self.loaded_custom_tools = []
Expand Down Expand Up @@ -123,6 +124,11 @@ def post_init(self):
map(str.lower, self.agent_config.get("servers_excludelist", []))
)

for err in self.start_up_errors:
self.io.tool_warning(err)

self.start_up_errors = []

def _setup_agent(self):
os.makedirs(".cecli/temp", exist_ok=True)

Expand All @@ -143,7 +149,7 @@ def _get_agent_config(self):
try:
config = json.loads(self.args.agent_config)
except (json.JSONDecodeError, TypeError) as e:
self.io.tool_warning(f"Failed to parse agent-config JSON: {e}")
self.start_up_errors.append(f"Failed to parse agent-config JSON: {e}")
return {}

config["large_file_token_threshold"] = nested.getter(
Expand Down
6 changes: 5 additions & 1 deletion cecli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from .context_management import ContextManagementCommand
from .copy import CopyCommand
from .copy_context import CopyContextCommand
from .core import Commands, SwitchCoderSignal
from .core import Commands, ReloadProgramSignal, SwitchCoderSignal
from .diff import DiffCommand
from .drop import DropCommand
from .editor import EditCommand, EditorCommand
Expand All @@ -32,6 +32,7 @@
from .help import HelpCommand
from .history_search import HistorySearchCommand
from .hooks import HooksCommand
from .hot_reload import HotReloadCommand
from .include_skill import IncludeSkillCommand
from .lint import LintCommand
from .list_sessions import ListSessionsCommand
Expand Down Expand Up @@ -116,6 +117,7 @@
CommandRegistry.register(HelpCommand)
CommandRegistry.register(HistorySearchCommand)
CommandRegistry.register(HooksCommand)
CommandRegistry.register(HotReloadCommand)
CommandRegistry.register(ReapAgentCommand)
CommandRegistry.register(SpawnAgentCommand)
CommandRegistry.register(SwitchAgentCommand)
Expand Down Expand Up @@ -197,6 +199,7 @@
"HelpCommand",
"HistorySearchCommand",
"HooksCommand",
"HotReloadCommand",
"IncludeSkillCommand",
"ReapAgentCommand",
"SpawnAgentCommand",
Expand All @@ -223,6 +226,7 @@
"ReadOnlyCommand",
"ReadOnlyStubCommand",
"ReasoningEffortCommand",
"ReloadProgramSignal",
"RemoveHookCommand",
"RemoveMcpCommand",
"RemoveSkillCommand",
Expand Down
19 changes: 19 additions & 0 deletions cecli/commands/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@ def __init__(self, placeholder=None, **kwargs):
super().__init__()


class ReloadProgramSignal(BaseException):
"""
Signal to reload the entire program configuration.

This is NOT an error - it's a control flow signal used to trigger
a full program reload, re-parsing config files and re-initializing
all components. Useful for hot-reloading when configuration files
change.

Note: Inherits from BaseException (like KeyboardInterrupt and SystemExit)
to avoid being caught by generic `except Exception` handlers.
"""

def __init__(self, message="Reloading program configuration...", **kwargs):
self.kwargs = kwargs
self.message = message
super().__init__(self.message)


class Commands:
scraper = None

Expand Down
39 changes: 39 additions & 0 deletions cecli/commands/hot_reload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from typing import List

from cecli.commands.core import ReloadProgramSignal
from cecli.commands.utils.base_command import BaseCommand


class HotReloadCommand(BaseCommand):
NORM_NAME = "hot-reload"
DESCRIPTION = "Hot-reload all configuration and restart the program"
show_completion_notification = False

@classmethod
async def execute(cls, io, coder, args, **kwargs):
"""Raise ReloadProgramSignal to trigger a full program hot-reload.

Passes the current coder as from_coder so the new coder
preserves its UUID, edit_format, and other state across
the reload cycle.
"""
io.tool_output("Hot-reloading program configuration...")
raise ReloadProgramSignal(
"User requested configuration reload",
from_coder=coder,
)

@classmethod
def get_completions(cls, io, coder, args) -> List[str]:
"""Get completion options for hot-reload command."""
return []

@classmethod
def get_help(cls) -> str:
"""Get help text for the hot-reload command."""
help_text = super().get_help()
help_text += "\nUsage:\n"
help_text += " /hot-reload # Hot-reload all configuration files and restart\n"
help_text += "\nThis will re-read config files, reinitialize the connection,"
help_text += " and restart the chat session with the updated configuration."
return help_text
45 changes: 41 additions & 4 deletions cecli/helpers/hashline.py
Original file line number Diff line number Diff line change
Expand Up @@ -1056,17 +1056,54 @@ def _merge_replace_operations(resolved_ops):
curr_lines = curr_text.splitlines(keepends=True)

# Find longest overlap between suffix of prev and prefix of current
# Normalize trailing newlines for comparison so that
# e.g. ["c"] matches ["c\n"] when the last line of prev
# doesn't have a trailing newline but the first line of curr does.
max_check = min(len(prev_lines), len(curr_lines))
overlap_len = 0
for i in range(1, max_check + 1):
if prev_lines[-i:] == curr_lines[:i]:
prev_suffix = [line.rstrip("\n") for line in prev_lines[-i:]]
curr_prefix = [line.rstrip("\n") for line in curr_lines[:i]]
if prev_suffix == curr_prefix:
overlap_len = i

if overlap_len > 0:
new_text = "".join(prev_lines) + "".join(curr_lines[overlap_len:])
# Build merged result:
# Take all of prev's lines, then curr's remaining lines.
# Ensure the last line of prev and first line of curr
# are properly separated by a newline.
result_lines = list(prev_lines)
remaining = list(curr_lines[overlap_len:])
if remaining:
# Ensure proper newline separation between the overlapping
# content and the remaining lines from curr.
# If the last overlapping line in prev doesn't end with \n
# and the first remaining line doesn't start with \n,
# add a newline to keep them on separate lines.
if (
result_lines
and not result_lines[-1].endswith("\n")
and not remaining[0].startswith("\n")
):
result_lines[-1] = result_lines[-1] + "\n"
result_lines.extend(remaining)
new_text = "".join(result_lines)
else:
# No overlap, just concatenate
new_text = prev_text + curr_text
# No overlap, concatenate with newline separator if needed
# Adjacent operations that replace consecutive line ranges
# must keep their content on separate lines.
is_adjacent = prev["end_idx"] + 1 == current["start_idx"]
needs_newline = (
is_adjacent
and prev_text
and curr_text
and not prev_text.endswith("\n")
and not curr_text.startswith("\n")
)
if needs_newline:
new_text = prev_text + "\n" + curr_text
else:
new_text = prev_text + curr_text

# Update prev
prev["end_idx"] = max(prev["end_idx"], current["end_idx"])
Expand Down
83 changes: 74 additions & 9 deletions cecli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
from cecli.args import get_parser
from cecli.coders import AgentCoder, Coder
from cecli.coders.base_coder import UnknownEditFormat
from cecli.commands import Commands, SwitchCoderSignal
from cecli.commands import Commands, ReloadProgramSignal, SwitchCoderSignal
from cecli.deprecated_args import handle_deprecated_model_args
from cecli.format_settings import format_settings, scrub_sensitive_info
from cecli.helpers.conversation import ConversationService, MessageTag
Expand Down Expand Up @@ -471,16 +471,54 @@ def custom_tracer(frame, event, arg):


def main(argv=None, input=None, output=None, force_git_root=None, return_coder=False):
if sys.platform == "win32":
if sys.version_info >= (3, 12) and hasattr(asyncio, "SelectorEventLoop"):
# Tracks the coder instance from a ReloadProgramSignal so the new
# main_async() can pass it as from_coder to Coder.create(), preserving
# UUID, edit_format, and other state across the reload cycle.
reload_from_coder = None

while True:
try:
if sys.platform == "win32":
if sys.version_info >= (3, 12) and hasattr(asyncio, "SelectorEventLoop"):
return asyncio.run(
main_async(
argv,
input,
output,
force_git_root,
return_coder,
from_coder=reload_from_coder,
),
loop_factory=asyncio.SelectorEventLoop,
)
return asyncio.run(
main_async(argv, input, output, force_git_root, return_coder),
loop_factory=asyncio.SelectorEventLoop,
main_async(
argv,
input,
output,
force_git_root,
return_coder,
from_coder=reload_from_coder,
)
)
return asyncio.run(main_async(argv, input, output, force_git_root, return_coder))
except ReloadProgramSignal as sig:
reload_from_coder = sig.kwargs.get("from_coder")
# Clear hook registries to prevent 'already exists' warnings on reload.
# The old HookManager and HookRegistry instances are cached by UUID and
# would be reused by the new coder, causing hook registration failures.
if reload_from_coder:
HookService.destroy_instances(reload_from_coder.uuid)
continue


async def main_async(argv=None, input=None, output=None, force_git_root=None, return_coder=False):
async def main_async(
argv=None,
input=None,
output=None,
force_git_root=None,
return_coder=False,
from_coder=None,
):
report_uncaught_exceptions()
if argv is None:
argv = sys.argv[1:]
Expand Down Expand Up @@ -1016,7 +1054,12 @@ def get_io(pretty):
)
mcp_manager = await McpServerManager.from_servers(mcp_servers, io, args.verbose)

if from_coder:
from_coder.tui = None
from_coder.io = None

coder = await Coder.create(
from_coder=from_coder,
main_model=main_model,
edit_format=args.edit_format,
io=io,
Expand Down Expand Up @@ -1233,8 +1276,23 @@ def get_io(pretty):
from cecli.tui import launch_tui

del pre_init_io
print("Starting cecli TUI...", flush=True)
return_code = await launch_tui(coder, output_queue, input_queue, args)
try:
return_code = await launch_tui(coder, output_queue, input_queue, args)
except ReloadProgramSignal:
# Clean up before full program reload (mirrors while True loop below)
sys.settrace(None)
await coder.auto_save_session(force=True)
if coder.mcp_manager and coder.mcp_manager.is_connected:
await coder.mcp_manager.disconnect_all()

# Clean up stale TUI per-coder queues from previous sessions
# to prevent stale queue entries from accumulating across
# reload cycles.
from cecli.tui.io import TextualInputOutput as _TuiIO

_TuiIO._per_coder_queues.clear()

raise
return await graceful_exit(coder, return_code)
while True:
try:
Expand Down Expand Up @@ -1275,6 +1333,13 @@ def get_io(pretty):
sys.settrace(None)
await coder.auto_save_session(force=True)
return await graceful_exit(coder)
except ReloadProgramSignal:
# Clean up before full program reload
sys.settrace(None)
await coder.auto_save_session(force=True)
if coder.mcp_manager and coder.mcp_manager.is_connected:
await coder.mcp_manager.disconnect_all()
raise


def is_first_run_of_new_version(io, verbose=False):
Expand Down
16 changes: 14 additions & 2 deletions cecli/tui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import queue
import weakref

from cecli.commands import ReloadProgramSignal

from .app import TUI
from .io import TextualInputOutput
from .worker import CoderWorker
Expand Down Expand Up @@ -71,6 +73,8 @@ async def launch_tui(coder, output_queue, input_queue, args):
Returns:
Exit code from TUI
"""
worker = None
return_code = 0
try:
worker = CoderWorker(coder, output_queue, input_queue)
app = TUI(worker, output_queue, input_queue, args)
Expand All @@ -79,8 +83,16 @@ async def launch_tui(coder, output_queue, input_queue, args):
coder.tui = weakref.ref(app)

return_code = await app.run_async()

return return_code if return_code else 0
return_code = return_code if return_code else 0
finally:
if worker:
worker.stop()

# After clean shutdown, check if a reload was signaled
# by the worker thread (ReloadProgramSignal caught in _async_run)
if worker and getattr(worker, "_reload_signal", False):
raise ReloadProgramSignal(
"Reloading program configuration after TUI exit", from_coder=worker.coder
)

return return_code
1 change: 1 addition & 0 deletions cecli/tui/widgets/completion_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class CompletionBar(Widget, can_focus=False):

DEFAULT_CSS = """
CompletionBar {
dock: top;
height: 1;
background: $surface;
margin: 0 0;
Expand Down
Loading
Loading