From d6d3cdf45cabea30915e45c0aa01487caeeae585 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 15 Jun 2026 18:41:59 -0400 Subject: [PATCH 1/5] #576: Fix JSON parsing error message for agent-config --- cecli/coders/agent_coder.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 91ab4d7cdc6..e3fa3c992d1 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -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 = [] @@ -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) @@ -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( From 71e90de6d2a2a38a198fcf94446a63669dff5ba2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 15 Jun 2026 18:46:06 -0400 Subject: [PATCH 2/5] Bump Version --- cecli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/__init__.py b/cecli/__init__.py index 1f6990a2630..6ea9f35ef0a 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.100.6.dev" +__version__ = "0.100.8.dev" safe_version = __version__ try: From c24cb3a4fe00d1768820b4dbf065adf8e09e46b8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 15 Jun 2026 20:34:12 -0400 Subject: [PATCH 3/5] Add `/hot-reload` command to dynamically update configuration --- cecli/commands/__init__.py | 6 ++- cecli/commands/core.py | 19 +++++++++ cecli/commands/hot_reload.py | 39 +++++++++++++++++ cecli/main.py | 83 ++++++++++++++++++++++++++++++++---- cecli/tui/__init__.py | 16 ++++++- cecli/tui/worker.py | 8 +++- 6 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 cecli/commands/hot_reload.py diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index 05e352a66ea..ae4e83a84cd 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -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 @@ -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 @@ -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) @@ -197,6 +199,7 @@ "HelpCommand", "HistorySearchCommand", "HooksCommand", + "HotReloadCommand", "IncludeSkillCommand", "ReapAgentCommand", "SpawnAgentCommand", @@ -223,6 +226,7 @@ "ReadOnlyCommand", "ReadOnlyStubCommand", "ReasoningEffortCommand", + "ReloadProgramSignal", "RemoveHookCommand", "RemoveMcpCommand", "RemoveSkillCommand", diff --git a/cecli/commands/core.py b/cecli/commands/core.py index 5242b73397a..53aabd70e8c 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -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 diff --git a/cecli/commands/hot_reload.py b/cecli/commands/hot_reload.py new file mode 100644 index 00000000000..acc8f2e0521 --- /dev/null +++ b/cecli/commands/hot_reload.py @@ -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 diff --git a/cecli/main.py b/cecli/main.py index 85893163897..65913f886be 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -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 @@ -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:] @@ -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, @@ -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: @@ -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): diff --git a/cecli/tui/__init__.py b/cecli/tui/__init__.py index f2854c2602d..d0723373302 100644 --- a/cecli/tui/__init__.py +++ b/cecli/tui/__init__.py @@ -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 @@ -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) @@ -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 diff --git a/cecli/tui/worker.py b/cecli/tui/worker.py index 9da5439dcbf..a233df24243 100644 --- a/cecli/tui/worker.py +++ b/cecli/tui/worker.py @@ -7,7 +7,7 @@ from typing import Optional from cecli.coders import Coder -from cecli.commands import SwitchCoderSignal +from cecli.commands import ReloadProgramSignal, SwitchCoderSignal from cecli.helpers.conversation import ConversationService, MessageTag logger = logging.getLogger(__name__) @@ -97,6 +97,12 @@ async def _async_run(self): break except KeyboardInterrupt: continue + except ReloadProgramSignal: + # Store the signal and tell the TUI to exit so the + # full program reload can propagate to main() + self._reload_signal = True + self.output_queue.put({"type": "exit"}) + break except SwitchCoderSignal as switch: await self._handle_switch_coder_signal(switch) # Continue the loop with the new coder From b3991539c47b62ef375c1b5111999b9c086137de Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 15 Jun 2026 20:57:24 -0400 Subject: [PATCH 4/5] Fix adjacent replace operations in applying hashline/hashpos operations --- cecli/helpers/hashline.py | 45 +++++- tests/basic/test_hashline_closure.py | 221 +++++++++++++++++++++++++++ 2 files changed, 262 insertions(+), 4 deletions(-) diff --git a/cecli/helpers/hashline.py b/cecli/helpers/hashline.py index 197930a9e25..4d985578b20 100644 --- a/cecli/helpers/hashline.py +++ b/cecli/helpers/hashline.py @@ -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"]) diff --git a/tests/basic/test_hashline_closure.py b/tests/basic/test_hashline_closure.py index 174a24fb248..ca2587dc138 100644 --- a/tests/basic/test_hashline_closure.py +++ b/tests/basic/test_hashline_closure.py @@ -3,6 +3,7 @@ from cecli.helpers.hashline import ( _apply_closure_safeguard, _fix_duplicate_content_boundaries, + _merge_replace_operations, _would_create_duplicate_content, ) @@ -421,3 +422,223 @@ def test_closure_safeguard_heals_broken_dict(): assert ( not tree.root_node.has_error ), f"Healed source still has tree-sitter errors: {new_source!r}" + + +# ============================================================================= +# Tests for _merge_replace_operations +# ============================================================================= + + +def _make_merge_op(operation, text, start_idx, end_idx, index=0): + """Helper to create a resolved operation dict for merge tests.""" + return { + "index": index, + "start_idx": start_idx, + "end_idx": end_idx, + "op": { + "operation": operation, + "text": text, + }, + } + + +def test_merge_non_adjacent_ops(): + """Non-adjacent operations with a gap should NOT be merged.""" + ops = [ + _make_merge_op("replace", "first block", 0, 1, index=0), + _make_merge_op("replace", "second block", 3, 4, index=1), + ] + result = _merge_replace_operations(ops) + # Should remain separate (2 items) + assert len(result) == 2 + assert result[0]["op"]["text"] == "first block" + assert result[1]["op"]["text"] == "second block" + + +def test_merge_overlapping_with_text_overlap(): + """Overlapping ops with overlapping text should merge and deduplicate.""" + ops = [ + _make_merge_op("replace", "a\nb\nc", 0, 2, index=0), + _make_merge_op("replace", "c\nd\ne", 2, 4, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + # The overlapping line "c" should appear only once + assert result[0]["op"]["text"] == "a\nb\nc\nd\ne" + # Range should cover both + assert result[0]["start_idx"] == 0 + assert result[0]["end_idx"] == 4 + + +def test_merge_adjacent_no_overlap_with_trailing_newline(): + """ + Adjacent ops where prev_text ends with \\n. + This should merge correctly without needing an extra newline. + """ + ops = [ + _make_merge_op("replace", "def foo():\n pass\n", 0, 1, index=0), + _make_merge_op("replace", "def bar():\n pass", 2, 3, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + assert "def foo():" in merged_text + assert "def bar():" in merged_text + # The trailing \n on prev_text provides the necessary separator + assert " pass\ndef bar():" in merged_text or "pass\ndef bar():" in merged_text + + +def test_merge_adjacent_no_overlap_leading_newline_in_curr(): + """ + Adjacent ops where curr_text starts with \\n. + This should merge correctly without needing an extra newline. + """ + ops = [ + _make_merge_op("replace", "def foo():\n pass", 0, 1, index=0), + _make_merge_op("replace", "\ndef bar():\n pass", 2, 3, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + assert "def foo():" in merged_text + assert "def bar():" in merged_text + + +def test_merge_adjacent_no_overlap_missing_newline_separator(): + """ + ADJACENT ops where NEITHER text provides the newline separator. + This is the bug case: " pass" + "def bar():" should have a newline between them. + Expected: "def foo():\n pass\ndef bar():\n pass" + """ + ops = [ + _make_merge_op("replace", "def foo():\n pass", 0, 1, index=0), + _make_merge_op("replace", "def bar():\n pass", 2, 3, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + # Expect content to be on separate lines + expected = "def foo():\n pass\ndef bar():\n pass" + assert merged_text == expected, ( + f"Expected lines to be separated by newline.\n" + f" Expected: {expected!r}\n" + f" Got: {merged_text!r}\n" + f" Problem: 'pass' and 'def bar()' are smushed together" + ) + + +def test_merge_adjacent_single_line_texts_no_newline_separator(): + """ + Adjacent ops with single-line texts and no newline separator. + If one op replaces line 1 with 'line_a' and another replaces line 2 with 'line_b', + the merged result should be 'line_a\nline_b'. + """ + ops = [ + _make_merge_op("replace", "line_a", 0, 0, index=0), + _make_merge_op("replace", "line_b", 1, 1, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + expected = "line_a\nline_b" + assert merged_text == expected, ( + f"Expected single-line texts to be separated by newline.\n" + f" Expected: {expected!r}\n" + f" Got: {merged_text!r}\n" + ) + + +def test_merge_adjacent_multi_line_without_separator(): + """ + Adjacent ops, each with multi-line text, where neither provides the + newline separator between the two blocks. + """ + ops = [ + _make_merge_op("replace", "line1\nline2", 0, 1, index=0), + _make_merge_op("replace", "line3\nline4", 2, 3, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + expected = "line1\nline2\nline3\nline4" + assert merged_text == expected, ( + f"Expected all 4 lines to be on separate lines.\n" + f" Expected: {expected!r}\n" + f" Got: {merged_text!r}\n" + f" Problem: 'line2' and 'line3' are likely on the same line" + ) + + +def test_merge_adjacent_complete_lines_with_separator(): + """ + Adjacent ops where both texts end with \\n (complete lines). + This should already work correctly. + """ + ops = [ + _make_merge_op("replace", "line1\nline2\n", 0, 1, index=0), + _make_merge_op("replace", "line3\nline4\n", 2, 3, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + expected = "line1\nline2\nline3\nline4\n" + assert merged_text == expected + + +def test_merge_three_adjacent_ops_no_separator(): + """ + Three adjacent ops where none provides the newline separator between blocks. + """ + ops = [ + _make_merge_op("replace", "block1", 0, 0, index=0), + _make_merge_op("replace", "block2", 1, 1, index=1), + _make_merge_op("replace", "block3", 2, 2, index=2), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + expected = "block1\nblock2\nblock3" + assert merged_text == expected, ( + f"Expected 3 blocks on separate lines.\n" + f" Expected: {expected!r}\n" + f" Got: {merged_text!r}" + ) + + +def test_merge_overlapping_no_text_overlap(): + """ + Overlapping ops (same or overlapping line ranges) but with no + overlapping text. This shouldn't normally happen, but the function + should handle it gracefully. + """ + ops = [ + _make_merge_op("replace", "aaa\nbbb", 0, 1, index=0), + _make_merge_op("replace", "ccc\nddd", 1, 2, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 1 + merged_text = result[0]["op"]["text"] + # No overlap in text, so they get concatenated + # Since they overlap on line 1, we need a newline + assert "aaa\nbbb" in merged_text + assert "ccc\nddd" in merged_text + + +def test_merge_different_op_types_not_merged(): + """ + Adjacent ops of different types (replace vs insert) should NOT be merged. + """ + ops = [ + _make_merge_op("replace", "replacement", 0, 0, index=0), + _make_merge_op("insert", "insertion", 1, 1, index=1), + ] + result = _merge_replace_operations(ops) + assert len(result) == 2 + + +def test_merge_single_op_returns_as_is(): + """A single operation should be returned unchanged.""" + ops = [_make_merge_op("replace", "text", 0, 0, index=0)] + result = _merge_replace_operations(ops) + assert len(result) == 1 + assert result[0]["op"]["text"] == "text" From 2b2d0c1bd125684640374c3a39fd773c335494d2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 15 Jun 2026 21:26:33 -0400 Subject: [PATCH 5/5] Make sure text area is scrollable to see last line when completions are active --- cecli/tui/widgets/completion_bar.py | 1 + cecli/tui/widgets/input_area.py | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/cecli/tui/widgets/completion_bar.py b/cecli/tui/widgets/completion_bar.py index 187d2a16917..24ea86e11d8 100644 --- a/cecli/tui/widgets/completion_bar.py +++ b/cecli/tui/widgets/completion_bar.py @@ -16,6 +16,7 @@ class CompletionBar(Widget, can_focus=False): DEFAULT_CSS = """ CompletionBar { + dock: top; height: 1; background: $surface; margin: 0 0; diff --git a/cecli/tui/widgets/input_area.py b/cecli/tui/widgets/input_area.py index 4d59e66246d..7a3ee54cc05 100644 --- a/cecli/tui/widgets/input_area.py +++ b/cecli/tui/widgets/input_area.py @@ -73,7 +73,7 @@ def __init__(self, history_file: str = None, **kwargs): self.files = [] self.commands = [] - self.completion_active = False + self._completion_active = False self._cycling = False self._completion_prefix = "" @@ -125,6 +125,20 @@ def cursor_position(self, pos: int): col = len(lines[row]) self.cursor_location = (row, col) + @property + def completion_active(self) -> bool: + """Whether completion suggestions are currently active.""" + return self._completion_active + + @completion_active.setter + def completion_active(self, value: bool) -> None: + """Set completion active state and update CSS class.""" + self._completion_active = value + if value: + self.add_class("completion-active") + else: + self.remove_class("completion-active") + def _ensure_history_loaded(self) -> list[str]: """Lazily load history on first access. @@ -236,7 +250,7 @@ def on_key(self, event) -> None: self.completion_active = False self.post_message(self.CompletionDismiss()) - if self.app.is_key_for("cancel", event.key): + if self.app.is_key_for("cancel", event.key) and not self.selected_text: event.stop() event.prevent_default() if self.text.strip():