diff --git a/.github/workflows/check_pypi_version.yml b/.github/workflows/check_pypi_version.yml index 874e831f15c..9ea6af8c03f 100644 --- a/.github/workflows/check_pypi_version.yml +++ b/.github/workflows/check_pypi_version.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12", "3.11", "3.10"] + python-version: ["3.14", "3.13", "3.12", "3.11", "3.10"] steps: - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/ubuntu-tests.yml b/.github/workflows/ubuntu-tests.yml index 12455bc9f8b..a70004f4e6c 100644 --- a/.github/workflows/ubuntu-tests.yml +++ b/.github/workflows/ubuntu-tests.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12", "3.11", "3.10"] + python-version: ["3.14", "3.13", "3.12", "3.11", "3.10"] steps: - name: Check out repository diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml index 404f1d387ee..223c3e4ea89 100644 --- a/.github/workflows/windows-tests.yml +++ b/.github/workflows/windows-tests.yml @@ -25,7 +25,7 @@ jobs: runs-on: windows-latest strategy: matrix: - python-version: ["3.12", "3.11", "3.10"] + python-version: ["3.14", "3.13", "3.12", "3.11", "3.10"] steps: - name: Check out repository diff --git a/.github/workflows/windows_check_pypi_version.yml b/.github/workflows/windows_check_pypi_version.yml index ad867d4a6e9..e1d03210674 100644 --- a/.github/workflows/windows_check_pypi_version.yml +++ b/.github/workflows/windows_check_pypi_version.yml @@ -11,7 +11,7 @@ jobs: runs-on: windows-latest strategy: matrix: - python-version: ["3.12", "3.11", "3.10"] + python-version: ["3.14", "3.13", "3.12", "3.11", "3.10"] defaults: run: shell: pwsh # Use PowerShell for all run steps diff --git a/cecli/__init__.py b/cecli/__init__.py index bd15d32ae11..ac9835ec20c 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.96.0.dev" +__version__ = "0.96.1.dev" safe_version = __version__ try: diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index c48e80afd4d..10618f6c8ca 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -729,7 +729,6 @@ async def reply_completed(self): _ = await self.auto_commit(self.files_edited_by_tools) return False self.partial_response_content = processed_content.strip() - self._process_file_mentions(processed_content) has_search = "<<<<<<< SEARCH" in self.partial_response_content has_divider = "=======" in self.partial_response_content has_replace = ">>>>>>> REPLACE" in self.partial_response_content @@ -1463,17 +1462,6 @@ def _add_file_to_context(self, file_path, explicit=False): self.io.tool_error(f"Error adding file '{file_path}' for viewing: {str(e)}") return f"Error adding file for viewing: {str(e)}" - def _process_file_mentions(self, content): - """ - Process implicit file mentions in the content, adding files if they're not already in context. - - This handles the case where the LLM mentions file paths without using explicit tool commands. - """ - mentioned_files = set(self.get_file_mentions(content, ignore_current=False)) - current_files = set(self.get_inchat_relative_files()) - mentioned_files - current_files - pass - async def check_for_file_mentions(self, content): """ Override parent's method to use our own file processing logic. diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index 6c4bc79d1fc..ef7dff9dad5 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -13,6 +13,7 @@ from .code import CodeCommand from .command_prefix import CommandPrefixCommand from .commit import CommitCommand +from .compact import CompactCommand from .context import ContextCommand from .context_blocks import ContextBlocksCommand from .context_management import ContextManagementCommand @@ -20,8 +21,6 @@ from .copy_context import CopyContextCommand from .core import Commands, SwitchCoderSignal from .diff import DiffCommand - -# Import and register commands from .drop import DropCommand from .editor import EditCommand, EditorCommand from .editor_model import EditorModelCommand @@ -76,129 +75,131 @@ from .web import WebCommand # Register commands -CommandRegistry.register(DropCommand) +CommandRegistry.register(AddCommand) +CommandRegistry.register(AgentCommand) +CommandRegistry.register(ArchitectCommand) +CommandRegistry.register(AskCommand) CommandRegistry.register(ClearCommand) -CommandRegistry.register(LsCommand) -CommandRegistry.register(DiffCommand) -CommandRegistry.register(ResetCommand) +CommandRegistry.register(CodeCommand) +CommandRegistry.register(CommandPrefixCommand) +CommandRegistry.register(CommitCommand) +CommandRegistry.register(CompactCommand) +CommandRegistry.register(ContextBlocksCommand) +CommandRegistry.register(ContextCommand) +CommandRegistry.register(ContextManagementCommand) CommandRegistry.register(CopyCommand) -CommandRegistry.register(PasteCommand) -CommandRegistry.register(SettingsCommand) -CommandRegistry.register(ReportCommand) -CommandRegistry.register(TokensCommand) -CommandRegistry.register(UndoCommand) +CommandRegistry.register(CopyContextCommand) +CommandRegistry.register(DiffCommand) +CommandRegistry.register(DropCommand) +CommandRegistry.register(EditCommand) +CommandRegistry.register(EditorCommand) +CommandRegistry.register(EditorModelCommand) +CommandRegistry.register(ExitCommand) CommandRegistry.register(GitCommand) -CommandRegistry.register(RunCommand) CommandRegistry.register(HelpCommand) -CommandRegistry.register(CommitCommand) -CommandRegistry.register(ModelsCommand) -CommandRegistry.register(ExitCommand) -CommandRegistry.register(QuitCommand) -CommandRegistry.register(VoiceCommand) -CommandRegistry.register(MapCommand) -CommandRegistry.register(MapRefreshCommand) -CommandRegistry.register(MultilineModeCommand) -CommandRegistry.register(EditorCommand) -CommandRegistry.register(EditCommand) CommandRegistry.register(HistorySearchCommand) -CommandRegistry.register(ThinkTokensCommand) -CommandRegistry.register(LoadCommand) -CommandRegistry.register(SaveCommand) -CommandRegistry.register(ReasoningEffortCommand) -CommandRegistry.register(SaveSessionCommand) +CommandRegistry.register(LintCommand) CommandRegistry.register(ListSessionsCommand) +CommandRegistry.register(LoadCommand) +CommandRegistry.register(LoadMcpCommand) CommandRegistry.register(LoadSessionCommand) +CommandRegistry.register(LoadSkillCommand) +CommandRegistry.register(LsCommand) +CommandRegistry.register(MapCommand) +CommandRegistry.register(MapRefreshCommand) +CommandRegistry.register(ModelCommand) +CommandRegistry.register(ModelsCommand) +CommandRegistry.register(MultilineModeCommand) +CommandRegistry.register(PasteCommand) +CommandRegistry.register(QuitCommand) CommandRegistry.register(ReadOnlyCommand) CommandRegistry.register(ReadOnlyStubCommand) -CommandRegistry.register(AddCommand) -CommandRegistry.register(ModelCommand) -CommandRegistry.register(WeakModelCommand) -CommandRegistry.register(EditorModelCommand) -CommandRegistry.register(WebCommand) -CommandRegistry.register(LintCommand) -CommandRegistry.register(TestCommand) -CommandRegistry.register(ContextManagementCommand) -CommandRegistry.register(ContextBlocksCommand) -CommandRegistry.register(AskCommand) -CommandRegistry.register(CodeCommand) -CommandRegistry.register(ArchitectCommand) -CommandRegistry.register(ContextCommand) -CommandRegistry.register(AgentCommand) -CommandRegistry.register(CopyContextCommand) -CommandRegistry.register(CommandPrefixCommand) -CommandRegistry.register(LoadSkillCommand) +CommandRegistry.register(ReasoningEffortCommand) +CommandRegistry.register(RemoveMcpCommand) CommandRegistry.register(RemoveSkillCommand) +CommandRegistry.register(ReportCommand) +CommandRegistry.register(ResetCommand) +CommandRegistry.register(RunCommand) +CommandRegistry.register(SaveCommand) +CommandRegistry.register(SaveSessionCommand) +CommandRegistry.register(SettingsCommand) CommandRegistry.register(TerminalSetupCommand) -CommandRegistry.register(LoadMcpCommand) -CommandRegistry.register(RemoveMcpCommand) +CommandRegistry.register(TestCommand) +CommandRegistry.register(ThinkTokensCommand) +CommandRegistry.register(TokensCommand) +CommandRegistry.register(UndoCommand) +CommandRegistry.register(VoiceCommand) +CommandRegistry.register(WeakModelCommand) +CommandRegistry.register(WebCommand) __all__ = [ + "AddCommand", + "AgentCommand", + "ArchitectCommand", + "AskCommand", "BaseCommand", - "CommandRegistry", + "ClearCommand", + "CodeCommand", "CommandError", - "quote_filename", - "parse_quoted_filenames", - "glob_filtered_to_repo", - "validate_file_access", + "CommandPrefixCommand", + "CommandRegistry", + "Commands", + "CommitCommand", + "CompactCommand", + "ContextBlocksCommand", + "ContextCommand", + "ContextManagementCommand", + "CopyCommand", + "CopyContextCommand", + "DiffCommand", + "DropCommand", + "EditCommand", + "EditorCommand", + "EditorModelCommand", + "ExitCommand", + "expand_subdir", "format_command_result", "get_available_files", - "expand_subdir", - "DropCommand", - "ClearCommand", - "LsCommand", - "DiffCommand", - "ResetCommand", - "CopyCommand", - "PasteCommand", - "SettingsCommand", - "ReportCommand", - "TokensCommand", - "UndoCommand", "GitCommand", - "RunCommand", + "glob_filtered_to_repo", "HelpCommand", - "CommitCommand", - "ModelsCommand", - "ExitCommand", - "QuitCommand", - "VoiceCommand", - "MapCommand", - "MapRefreshCommand", - "MultilineModeCommand", - "EditorCommand", - "EditCommand", "HistorySearchCommand", - "ThinkTokensCommand", - "LoadCommand", - "SaveCommand", - "ReasoningEffortCommand", - "SaveSessionCommand", + "LintCommand", "ListSessionsCommand", + "LoadCommand", + "LoadMcpCommand", "LoadSessionCommand", + "LoadSkillCommand", + "LsCommand", + "MapCommand", + "MapRefreshCommand", + "ModelCommand", + "ModelsCommand", + "MultilineModeCommand", + "parse_quoted_filenames", + "PasteCommand", + "quote_filename", + "QuitCommand", "ReadOnlyCommand", "ReadOnlyStubCommand", - "AddCommand", - "ModelCommand", - "WeakModelCommand", - "EditorModelCommand", - "WebCommand", - "LintCommand", - "TestCommand", - "ContextManagementCommand", - "ContextBlocksCommand", - "AskCommand", - "CodeCommand", - "ArchitectCommand", - "ContextCommand", - "AgentCommand", - "CopyContextCommand", - "CommandPrefixCommand", - "LoadSkillCommand", + "ReasoningEffortCommand", + "RemoveMcpCommand", "RemoveSkillCommand", - "TerminalSetupCommand", + "ReportCommand", + "ResetCommand", + "RunCommand", + "SaveCommand", + "SaveSessionCommand", + "SettingsCommand", "SwitchCoderSignal", - "Commands", - "LoadMcpCommand", - "RemoveMcpCommand", + "TerminalSetupCommand", + "TestCommand", + "ThinkTokensCommand", + "TokensCommand", + "UndoCommand", + "validate_file_access", + "VoiceCommand", + "WeakModelCommand", + "WebCommand", ] diff --git a/cecli/commands/command_prefix.py b/cecli/commands/command_prefix.py index b9db62bb058..f27a387ead4 100644 --- a/cecli/commands/command_prefix.py +++ b/cecli/commands/command_prefix.py @@ -6,7 +6,7 @@ class CommandPrefixCommand(BaseCommand): NORM_NAME = "command-prefix" - DESCRIPTION = "Change Command Prefix For All Running Commands" + DESCRIPTION = "Change command prefix for all running commands" @classmethod async def execute(cls, io, coder, args, **kwargs): diff --git a/cecli/commands/compact.py b/cecli/commands/compact.py new file mode 100644 index 00000000000..510d9215fab --- /dev/null +++ b/cecli/commands/compact.py @@ -0,0 +1,14 @@ +from .utils.base_command import BaseCommand + + +class CompactCommand(BaseCommand): + NORM_NAME = "compact" + DESCRIPTION = "Force compaction of the chat history context" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + await coder.compact_context_if_needed(force=True) + + @classmethod + def get_help(cls) -> str: + return "Force compaction of the chat history context." diff --git a/cecli/commands/core.py b/cecli/commands/core.py index ca2554478b8..266c250dbcd 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -165,12 +165,12 @@ def get_raw_completions(self, cmd): raw_completer = getattr(self, f"completions_raw_{cmd}", None) return raw_completer - def get_completions(self, cmd): + def get_completions(self, cmd, args=""): assert cmd.startswith("/") cmd = cmd[1:] command_class = CommandRegistry.get_command(cmd) if command_class: - return command_class.get_completions(self.io, self.coder, "") + return command_class.get_completions(self.io, self.coder, args) return [] def get_commands(self): diff --git a/cecli/commands/paste.py b/cecli/commands/paste.py index 181f292d2d3..fd7fc4306b8 100644 --- a/cecli/commands/paste.py +++ b/cecli/commands/paste.py @@ -57,7 +57,11 @@ async def execute(cls, io, coder, args, **kwargs): # If not an image, try to get text text = pyperclip.paste() if text: - io.tool_output(text) + if coder.tui and coder.tui(): + coder.tui().set_input_value(text) + else: + coder.io.set_placeholder(text) + return format_command_result(io, "paste", "Pasted text from clipboard") io.tool_error("No image or text content found in clipboard.") diff --git a/cecli/commands/read_only.py b/cecli/commands/read_only.py index 9ec985e725c..5a01fd7f45b 100644 --- a/cecli/commands/read_only.py +++ b/cecli/commands/read_only.py @@ -215,9 +215,14 @@ def get_completions(cls, io, coder, args) -> List[str]: if "/" in args: # Has directory component dir_part, file_part = args.rsplit("/", 1) - search_dir = root / dir_part + if dir_part == "": + search_dir = Path("/") + path_prefix = "/" + else: + # Use os.path.expanduser for ~ support if needed, but Path handles it mostly + search_dir = (root / dir_part).resolve() + path_prefix = dir_part + "/" search_prefix = file_part.lower() - path_prefix = dir_part + "/" else: search_dir = root search_prefix = args.lower() @@ -228,8 +233,9 @@ def get_completions(cls, io, coder, args) -> List[str]: if search_dir.exists() and search_dir.is_dir(): for entry in search_dir.iterdir(): name = entry.name - if search_prefix and search_prefix not in name.lower(): + if search_prefix and not name.lower().startswith(search_prefix): continue + # Add trailing slash for directories if entry.is_dir(): completions.append(path_prefix + name + "/") @@ -238,6 +244,7 @@ def get_completions(cls, io, coder, args) -> List[str]: except (PermissionError, OSError): pass + # Also include files already in the chat that match add_completions = coder.commands.get_completions("/add") for c in add_completions: if args.lower() in str(c).lower() and str(c) not in completions: diff --git a/cecli/commands/read_only_stub.py b/cecli/commands/read_only_stub.py index d7ec727f096..0249d5ba9d5 100644 --- a/cecli/commands/read_only_stub.py +++ b/cecli/commands/read_only_stub.py @@ -206,7 +206,7 @@ def _add_read_only_directory( @classmethod def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for read-only command.""" + """Get completion options for read-only-stub command.""" from pathlib import Path root = Path(coder.root) if hasattr(coder, "root") else Path.cwd() @@ -215,9 +215,13 @@ def get_completions(cls, io, coder, args) -> List[str]: if "/" in args: # Has directory component dir_part, file_part = args.rsplit("/", 1) - search_dir = root / dir_part + if dir_part == "": + search_dir = Path("/") + path_prefix = "/" + else: + search_dir = (root / dir_part).resolve() + path_prefix = dir_part + "/" search_prefix = file_part.lower() - path_prefix = dir_part + "/" else: search_dir = root search_prefix = args.lower() @@ -228,8 +232,9 @@ def get_completions(cls, io, coder, args) -> List[str]: if search_dir.exists() and search_dir.is_dir(): for entry in search_dir.iterdir(): name = entry.name - if search_prefix and search_prefix not in name.lower(): + if search_prefix and not name.lower().startswith(search_prefix): continue + # Add trailing slash for directories if entry.is_dir(): completions.append(path_prefix + name + "/") @@ -238,6 +243,7 @@ def get_completions(cls, io, coder, args) -> List[str]: except (PermissionError, OSError): pass + # Also include files already in the chat that match add_completions = coder.commands.get_completions("/add") for c in add_completions: if args.lower() in str(c).lower() and str(c) not in completions: diff --git a/cecli/commands/terminal_data/linux.keytab b/cecli/commands/terminal_data/linux.keytab new file mode 100644 index 00000000000..614a959d7cc --- /dev/null +++ b/cecli/commands/terminal_data/linux.keytab @@ -0,0 +1,136 @@ +# [linux.keytab] Konsole Keyboard Table (Linux console keys) +# +# -------------------------------------------------------------- + +# NOT TESTED, MAY NEED SOME CLEANUPS +keyboard "Linux console" + +# -------------------------------------------------------------- +# +# This configuration table allows to customize the +# meaning of the keys. +# +# The syntax is that each entry has the form : +# +# "key" Keyname { ("+"|"-") Modename } ":" (String|Operation) +# +# Keynames are those defined in with the +# "Qt::Key_" removed. (We'd better insert the list here) +# +# Mode names are : +# +# - Shift +# - Alt +# - Control +# +# The VT100 emulation has two modes that can affect the +# sequences emitted by certain keys. These modes are +# under control of the client program. +# +# - Newline : effects Return and Enter key. +# - Application : effects Up and Down key. +# +# - Ansi : effects Up and Down key (This is for VT52, really). +# +# Operations are +# +# - scrollUpLine +# - scrollUpPage +# - scrollDownLine +# - scrollDownPage +# +# - emitSelection +# +# If the key is not found here, the text of the +# key event as provided by QT is emitted, possibly +# preceded by ESC if the Alt key is pressed. +# +# -------------------------------------------------------------- + +key Escape : "\E" +key Tab : "\t" + +# VT100 can add an extra \n after return. +# The NewLine mode is set by an escape sequence. + +key Return-NewLine : "\r" +key Return+NewLine : "\r\n" + +# Some desperately try to save the ^H. + +key Backspace : "\x7f" +key Delete : "\E[3~" + +# These codes are for the VT52 mode of VT100 +# The Ansi mode (i.e. VT100 mode) is set by +# an escape sequence + +key Up -Shift-Ansi : "\EA" +key Down -Shift-Ansi : "\EB" +key Right-Shift-Ansi : "\EC" +key Left -Shift-Ansi : "\ED" + +# VT100 emits a mode bit together +# with the arrow keys.The AppCuKeys +# mode is set by an escape sequence. + +key Up -Shift+Ansi+AppCuKeys : "\EOA" +key Down -Shift+Ansi+AppCuKeys : "\EOB" +key Right-Shift+Ansi+AppCuKeys : "\EOC" +key Left -Shift+Ansi+AppCuKeys : "\EOD" + +key Up -Shift+Ansi-AppCuKeys : "\E[A" +key Down -Shift+Ansi-AppCuKeys : "\E[B" +key Right-Shift+Ansi-AppCuKeys : "\E[C" +key Left -Shift+Ansi-AppCuKeys : "\E[D" + +# linux functions keys F1-F5 differ from xterm + +key F1 : "\E[[A" +key F2 : "\E[[B" +key F3 : "\E[[C" +key F4 : "\E[[D" +key F5 : "\E[[E" + +key F6 : "\E[17~" +key F7 : "\E[18~" +key F8 : "\E[19~" +key F9 : "\E[20~" +key F10 : "\E[21~" +key F11 : "\E[23~" +key F12 : "\E[24~" + +key Home : "\E[1~" +key End : "\E[4~" + +key PgUp -Shift : "\E[5~" +key PgDown -Shift : "\E[6~" +key Insert -Shift : "\E[2~" + +# Keypad-Enter. See comment on Return above. + +key Enter+NewLine : "\r\n" +key Enter-NewLine : "\r" + +key Space +Control : "\x00" + +# some of keys are used by konsole. + +key Up +Shift : scrollLineUp +key PgUp +Shift-Ctrl : scrollPageUp +key PgUp +Shift+Ctrl : scrollPromptUp +key Down +Shift : scrollLineDown +key PgDown +Shift-Ctrl : scrollPageDown +key PgDown +Shift+Ctrl : scrollPromptDown + + +#---------------------------------------------------------- + +# keypad characters as offered by Qt +# cannot be recognized as such. + +#---------------------------------------------------------- + +# Following other strings as emitted by konsole. + +key Return+Shift : "\n" diff --git a/cecli/commands/terminal_setup.py b/cecli/commands/terminal_setup.py index 0cc8ea787dd..5dca6666c13 100644 --- a/cecli/commands/terminal_setup.py +++ b/cecli/commands/terminal_setup.py @@ -57,12 +57,15 @@ def _get_config_paths(cls): paths = {} # Check for WSL specifically - is_wsl_env = "microsoft" in platform.uname().release.lower() + is_wsl_env = "microsoft" in platform.uname().release.lower() and os.environ.get( + "WSL_DISTRO_NAME" + ) if system == "Linux": # Standard Linux paths (applies to WSL instances of Kitty/Alacritty too) paths["alacritty"] = home / ".config" / "alacritty" / "alacritty.toml" paths["kitty"] = home / ".config" / "kitty" / "kitty.conf" + paths["konsole"] = home / ".local" / "share" / "konsole" / "linux.keytab" paths["vscode"] = home / ".config" / "Code" / "User" / "keybindings.json" if is_wsl_env: @@ -155,6 +158,9 @@ def _backup_file(cls, file_path, io): @classmethod def _update_alacritty(cls, path, io, dry_run=False): """Updates Alacritty TOML configuration with shift+enter binding.""" + if os.environ.get("TERM") != "alacritty": + return False + if not path.exists(): io.tool_output(f"Skipping Alacritty: File not found at {path}") return False @@ -281,6 +287,70 @@ def _update_kitty(cls, path, io, dry_run=False): io.tool_output("Updated Kitty config.") return True + @classmethod + def _update_konsole(cls, path, io, dry_run=False): + """Updates Konsole keytab configuration with shift+enter binding.""" + if not os.environ.get("KONSOLE_VERSION"): + return False + + default_keytab_path = Path(__file__).parent / "terminal_data" / "linux.keytab" + + if not path.exists(): + if dry_run: + io.tool_output(f"DRY-RUN: Would create Konsole keytab at {path}") + return True + + try: + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(default_keytab_path, path) + io.tool_output(f"Created Konsole keytab at {path}") + return True + except Exception as e: + io.tool_output(f"Error creating Konsole keytab: {e}") + return False + + try: + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + import re + + # Pattern to find Return+Shift rule + pattern = r"^\s*key\s+Return\s*\+\s*Shift\s*:\s*(.*)$" + match = re.search(pattern, content, re.MULTILINE) + + new_rule = 'key Return+Shift : "\\n"' + + if match: + current_val = match.group(1).strip() + if current_val == '"\\n"': + io.tool_output("Konsole already configured.") + return False + + if dry_run: + io.tool_output(f"DRY-RUN: Would update Konsole Return+Shift rule in {path}") + return True + + cls._backup_file(path, io) + new_content = re.sub(pattern, new_rule, content, flags=re.MULTILINE) + with open(path, "w", encoding="utf-8") as f: + f.write(new_content) + io.tool_output("Updated Konsole keytab rule.") + return True + else: + if dry_run: + io.tool_output(f"DRY-RUN: Would add Konsole Return+Shift rule to {path}") + return True + + cls._backup_file(path, io) + with open(path, "a", encoding="utf-8") as f: + f.write(f"\n{new_rule}\n") + io.tool_output("Added Konsole Return+Shift rule.") + return True + except Exception as e: + io.tool_output(f"Error updating Konsole keytab: {e}") + return False + @classmethod def _update_windows_terminal(cls, path, io, dry_run=False): """Parses JSON, adds action to 'actions' list and keybinding to 'keybindings' list.""" @@ -688,6 +758,10 @@ async def execute(cls, io, coder, args, **kwargs): if cls._update_kitty(paths["kitty"], io, dry_run=dry_run): updated = True + if "konsole" in paths: + if cls._update_konsole(paths["konsole"], io, dry_run=dry_run): + updated = True + if "windows_terminal" in paths: if cls._update_windows_terminal(paths["windows_terminal"], io, dry_run=dry_run): updated = True @@ -748,8 +822,8 @@ def get_help(cls) -> str: " /terminal-setup --dry-run # Show what would be changed without modifying files\n" ) help_text += ( - "\nNote: This command modifies terminal configuration files (Alacritty, Kitty, Windows" - " Terminal, VS Code)\n" + "\nNote: This command modifies terminal configuration files (Alacritty, Kitty, Konsole," + " Windows Terminal, VS Code)\n" ) help_text += ( "to add a key binding that sends a newline character when shift+enter is pressed.\n" diff --git a/cecli/commands/utils/registry.py b/cecli/commands/utils/registry.py index fd054c49c62..7df118076e3 100644 --- a/cecli/commands/utils/registry.py +++ b/cecli/commands/utils/registry.py @@ -29,6 +29,22 @@ async def execute(cls, name, io, coder, args, **kwargs): return await command_class.process_command(io, coder, args, **kwargs) + @classmethod + def get_command_description(cls, name: str) -> str: + """ + Get description for a specific command. + + Args: + name: Command name + + Returns: + Command description string or empty string if command not found + """ + command_class = cls.get_command(name) + if not command_class: + return "" + return command_class.DESCRIPTION or "" + @classmethod def get_command_help(cls, name: str = None) -> str: """ diff --git a/cecli/help.py b/cecli/help.py index 50466d07d4a..d87e6aa7ea2 100755 --- a/cecli/help.py +++ b/cecli/help.py @@ -15,7 +15,11 @@ async def install_help_extra(io): - pip_install_cmd = ["cecli[help]", "--extra-index-url", "https://download.pytorch.org/whl/cpu"] + pip_install_cmd = [ + "cecli-dev[help]", + "--extra-index-url", + "https://download.pytorch.org/whl/cpu", + ] res = await utils.check_pip_install_extra( io, "llama_index.embeddings.huggingface", diff --git a/cecli/helpers/conversation/base_message.py b/cecli/helpers/conversation/base_message.py index 3bb84fade81..79f0be2505e 100644 --- a/cecli/helpers/conversation/base_message.py +++ b/cecli/helpers/conversation/base_message.py @@ -1,4 +1,3 @@ -import json import time import uuid from dataclasses import dataclass, field @@ -82,7 +81,7 @@ def generate_id(self) -> str: if tool_calls: # For tool calls, include them in the hash transformed_tool_calls = self._transform_message(tool_calls) - tool_calls_str = json.dumps(transformed_tool_calls, sort_keys=True) + tool_calls_str = str(transformed_tool_calls) key_data = f"{role}:{content}:{tool_calls_str}" else: key_data = f"{role}:{content}" diff --git a/cecli/helpers/conversation/manager.py b/cecli/helpers/conversation/manager.py index 8d27dbe1d28..43c4713f4f3 100644 --- a/cecli/helpers/conversation/manager.py +++ b/cecli/helpers/conversation/manager.py @@ -30,6 +30,7 @@ class ConversationManager: # Caching for tagged message dict queries _tag_cache: Dict[str, List[Dict[str, Any]]] = {} + _ALL_MESSAGES_CACHE_KEY = "__all__" # Special key for caching all messages (tag=None) @classmethod def initialize(cls, coder) -> None: @@ -122,8 +123,9 @@ def add_message( existing_message.priority = priority existing_message.timestamp = timestamp existing_message.mark_for_delete = mark_for_delete - # Clear cache for this tag since message was updated + # Clear cache for this tag and all messages cache since message was updated cls._tag_cache.pop(tag.value, None) + cls._tag_cache.pop(cls._ALL_MESSAGES_CACHE_KEY, None) return existing_message else: # Return existing message without updating @@ -132,8 +134,9 @@ def add_message( # Add new message cls._messages.append(message) cls._message_index[message.message_id] = message - # Clear cache for this tag since new message was added + # Clear cache for this tag and all messages cache since new message was added cls._tag_cache.pop(tag.value, None) + cls._tag_cache.pop(cls._ALL_MESSAGES_CACHE_KEY, None) return message @classmethod @@ -172,18 +175,21 @@ def get_messages_dict( """ coder = cls.get_coder() - # Check cache for tagged queries (not for None tag which gets all messages) - if tag is not None and not reload: - if not isinstance(tag, MessageTag): - try: - tag = MessageTag(tag) - except ValueError: - raise ValueError(f"Invalid tag: {tag}") - tag_str = tag.value + # Check cache for all queries (including tag=None) + if not reload: + if tag is not None: + if not isinstance(tag, MessageTag): + try: + tag = MessageTag(tag) + except ValueError: + raise ValueError(f"Invalid tag: {tag}") + cache_key = tag.value + else: + cache_key = cls._ALL_MESSAGES_CACHE_KEY # Return cached result if available - if tag_str in cls._tag_cache: - return cls._tag_cache[tag_str] + if cache_key in cls._tag_cache: + return cls._tag_cache[cache_key] messages = cls.get_messages() @@ -199,15 +205,18 @@ def get_messages_dict( messages_dict = [msg.to_dict() for msg in messages] - # Cache the result for tagged queries + # Cache the result for all queries (including tag=None) if tag is not None: if not isinstance(tag, MessageTag): try: tag = MessageTag(tag) except ValueError: raise ValueError(f"Invalid tag: {tag}") - tag_str = tag.value - cls._tag_cache[tag_str] = messages_dict + cache_key = tag.value + else: + cache_key = cls._ALL_MESSAGES_CACHE_KEY + + cls._tag_cache[cache_key] = messages_dict # Debug: Compare with previous messages if debug is enabled # We need to compare the full unfiltered message stream, not just filtered views @@ -263,11 +272,11 @@ def clear_tag(cls, tag: str) -> None: for message in messages_to_remove: cls._messages.remove(message) del cls._message_index[message.message_id] - # Clear cache for this tag since message was removed - cls._tag_cache.pop(message.tag, None) - # Clear cache for this tag since messages were removed - cls._tag_cache.pop(tag_str, None) + # Clear cache for this tag and all messages cache since messages were removed + if messages_to_remove: + cls._tag_cache.pop(tag_str, None) + cls._tag_cache.pop(cls._ALL_MESSAGES_CACHE_KEY, None) @classmethod def remove_messages_by_hash_key_pattern(cls, pattern_checker) -> None: @@ -284,11 +293,18 @@ def remove_messages_by_hash_key_pattern(cls, pattern_checker) -> None: if message.hash_key and pattern_checker(message.hash_key): messages_to_remove.append(message) + # Remove messages and track affected tags + tags_to_clear = set() for message in messages_to_remove: cls._messages.remove(message) del cls._message_index[message.message_id] - # Clear cache for this tag since message was removed - cls._tag_cache.pop(message.tag, None) + tags_to_clear.add(message.tag) + + # Clear cache for affected tags and all messages cache if any messages were removed + if messages_to_remove: + for tag in tags_to_clear: + cls._tag_cache.pop(tag, None) + cls._tag_cache.pop(cls._ALL_MESSAGES_CACHE_KEY, None) @classmethod def remove_message_by_hash_key(cls, hash_key: Tuple[str, ...]) -> bool: @@ -305,8 +321,9 @@ def remove_message_by_hash_key(cls, hash_key: Tuple[str, ...]) -> bool: if message.hash_key == hash_key: cls._messages.remove(message) del cls._message_index[message.message_id] - # Clear cache for this tag since message was removed + # Clear cache for this tag and all messages cache since message was removed cls._tag_cache.pop(message.tag, None) + cls._tag_cache.pop(cls._ALL_MESSAGES_CACHE_KEY, None) return True return False @@ -334,12 +351,18 @@ def decrement_mark_for_delete(cls) -> None: if message.is_expired(): messages_to_remove.append(message) - # Remove expired messages + # Remove expired messages and clear cache for each tag + tags_to_clear = set() for message in messages_to_remove: cls._messages.remove(message) del cls._message_index[message.message_id] - # Clear cache for this tag since message was removed - cls._tag_cache.pop(message.tag, None) + tags_to_clear.add(message.tag) + + # Clear cache for affected tags and all messages cache if any messages were removed + if messages_to_remove: + for tag in tags_to_clear: + cls._tag_cache.pop(tag, None) + cls._tag_cache.pop(cls._ALL_MESSAGES_CACHE_KEY, None) @classmethod def get_coder(cls): diff --git a/cecli/helpers/requests.py b/cecli/helpers/requests.py index a03e33c0c71..4ee4866fd75 100644 --- a/cecli/helpers/requests.py +++ b/cecli/helpers/requests.py @@ -48,9 +48,18 @@ def thought_signature(model, messages): if "provider_specific_fields" not in call: call["provider_specific_fields"] = {} if "thought_signature" not in call["provider_specific_fields"]: - call["provider_specific_fields"][ - "thought_signature" - ] = "skip_thought_signature_validator" + if "thought_signatures" in call["provider_specific_fields"] and len( + call["provider_specific_fields"]["thought_signatures"] + ): + call["provider_specific_fields"]["thought_signature"] = call[ + "provider_specific_fields" + ]["thought_signatures"][0] + + call["provider_specific_fields"].pop("thought_signatures", None) + else: + call["provider_specific_fields"][ + "thought_signature" + ] = "skip_thought_signature_validator" if "function_call" in msg: call = msg["function_call"] diff --git a/cecli/io.py b/cecli/io.py index ab82dd9b328..ba76f04dcfb 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -236,16 +236,10 @@ def get_command_completions(self, document, complete_event, text, words): yield from raw_completer(document, complete_event) return - if cmd not in self.command_completions: - candidates = self.commands.get_completions(cmd) - self.command_completions[cmd] = candidates - else: - candidates = self.command_completions[cmd] + candidates = self.commands.get_completions(cmd, partial) if candidates is None: return - - candidates = [word for word in candidates if partial in word.lower()] for candidate in sorted(candidates): yield Completion(candidate, start_position=-len(words[-1])) diff --git a/cecli/mcp/utils.py b/cecli/mcp/utils.py index 5642a9b9aae..0bfc919f991 100644 --- a/cecli/mcp/utils.py +++ b/cecli/mcp/utils.py @@ -75,7 +75,7 @@ def _resolve_mcp_config_path(file_path, io, verbose=False): return None # If the path is absolute or already exists, use it as-is - path = Path(file_path) + path = Path(file_path).expanduser() if path.is_absolute() or path.exists(): return str(path.resolve()) diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 5ded512fe65..a0966404b5c 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -4,6 +4,7 @@ import json import queue +from textual import events from textual.app import App, ComposeResult # from textual.binding import Binding @@ -11,6 +12,7 @@ from textual.theme import Theme from cecli.editor import pipe_editor +from cecli.io import CommandCompletionException from .widgets import ( CompletionBar, @@ -45,6 +47,7 @@ def __init__(self, coder_worker, output_queue, input_queue, args): # Cache for code symbols (functions, classes, variables) self._symbols_cache = None self._symbols_files_hash = None + self._mouse_hold_timer = None self.tui_config = self._get_config() @@ -161,6 +164,9 @@ def _get_config(self): # Continue with empty config, will apply defaults below # Ensure config has a colors entry with nested structure matching BASE_THEME + if "banner" not in config: + config["banner"] = True + if "colors" not in config: config["colors"] = {} @@ -296,7 +302,13 @@ def on_mount(self): """Called when app starts.""" # Show startup banner output_container = self.query_one("#output", OutputContainer) - output_container.add_output(self.BANNER, dim=False) + if self.tui_config["banner"]: + output_container.add_output(self.BANNER, dim=False) + else: + output_container.add_output( + f"[bold {self.BANNER_COLORS[0]}] [/bold {self.BANNER_COLORS[0]}]", dim=False + ) + self.begin_capture_print(output_container, stdout=True, stderr=True) self.set_interval(0.05, self.check_output_queue) @@ -309,19 +321,89 @@ def on_mount(self): # Load git info in background to avoid blocking startup self.call_later(self._load_git_info) + def on_mouse_down(self, event: events.MouseDown) -> None: + """Handle mouse down events to start the selection hint timer.""" + if self._mouse_hold_timer: + self._mouse_hold_timer.stop() + self._mouse_hold_timer = self.set_timer(0.25, self._show_select_hint) + + def on_mouse_up(self, event: events.MouseUp) -> None: + """Handle mouse up events to clear the selection hint timer.""" + if self._mouse_hold_timer: + self._mouse_hold_timer.stop() + self._mouse_hold_timer = None + self.update_key_hints() + + def _show_select_hint(self) -> None: + """Show the shift+drag to select hint.""" + try: + hints = self.query_one(KeyHints) + hints.update_right("shift+drag to select") + except Exception: + pass + def update_key_hints(self, generating=False): """Update the key hints below the input area.""" + if self._mouse_hold_timer: + self._mouse_hold_timer.stop() + self._mouse_hold_timer = None try: hints = self.query_one(KeyHints) if generating: stop = self.app.get_keys_for("stop") - hints.update(f"{stop} to cancel") + hints.update_right(f"{stop} to cancel") else: submit = self.app.get_keys_for("submit") - hints.update(f"{submit} to submit") + hints.update_right(f"{submit} to submit") + except Exception: + pass + + def update_key_hints_left(self, text: str): + """Update the left sub-panel message.""" + try: + hints = self.query_one(KeyHints) + hints.update_left(text) except Exception: pass + def _update_key_hints_for_commands(self, text: str, is_completion: bool = False): + """ + Update key hints left area with command description. + + Handles both regular input text and completion suggestions. + + Args: + text: The text to analyze (input text or completion suggestion) + is_completion: Whether this is a completion suggestion (default: False) + """ + # Check if text starts with slash + if text.startswith("/"): + # Extract command name + # For completions, we just need to remove the leading slash + # For regular input, we need to extract the first word after slash + if is_completion: + # Completion suggestion like "/help" - just remove leading slash + cmd_name = text[1:].strip() + else: + # Regular input like "/help arg1 arg2" - extract first word + parts = text[1:].strip().split() + cmd_name = parts[0] if parts else "" + + # Get command description if we have a command name + if cmd_name: + try: + from cecli.commands.utils.registry import CommandRegistry + + description = CommandRegistry.get_command_description(cmd_name) + if description: + self.update_key_hints_left(f"{description}") + return + except Exception: + pass + + # If not a valid slash command, show default text + self.update_key_hints_left(KeyHints.DEFAULT_LEFT_TEXT) + def _load_git_info(self): """Load git branch and dirty count (deferred to avoid blocking startup).""" footer = self.query_one(MainFooter) @@ -476,6 +558,10 @@ def show_error(self, message): status_bar = self.query_one("#status-bar", StatusBar) status_bar.show_notification(f"Error: {message}", severity="error", timeout=10) + def on_input_area_text_changed(self, message: InputArea.TextChanged): + """Handle text changes in input area.""" + self._update_key_hints_for_commands(message.text, is_completion=False) + def on_input_area_submit(self, message: InputArea.Submit): """Handle input submission.""" user_input = message.value @@ -535,7 +621,13 @@ def action_clear_output(self): """Clear all output.""" output_container = self.query_one("#output", OutputContainer) output_container.clear_output() - output_container.add_output(self.BANNER, dim=False) + if self.tui_config["banner"]: + output_container.add_output(self.BANNER, dim=False) + else: + output_container.add_output( + f"[bold {self.BANNER_COLORS[0]}] [/bold {self.BANNER_COLORS[0]}]", dim=False + ) + self.worker.coder.show_announcements() def action_interrupt(self): @@ -852,7 +944,19 @@ def _get_completed_text(self, current_text: str, completion: str) -> str: # Replace entire command # Only add space if command takes arguments commands = self.worker.coder.commands - has_completions = commands.get_completions(completion) is not None + try: + cmd_completions = commands.get_completions(completion) + has_completions = cmd_completions is not None + except Exception as e: + # Check if it's a CommandCompletionException + if isinstance(e, CommandCompletionException): + # For CommandCompletionException, treat it as having completions + # so we add a space after the command + has_completions = True + else: + # For other exceptions, assume no completions + has_completions = False + if has_completions: return completion + " " else: @@ -896,6 +1000,11 @@ def on_input_area_completion_requested(self, message: InputArea.CompletionReques suggestions=suggestions, prefix=text, id="completion-bar" ) self.mount(completion_bar, before=input_area) + + # Update key hints with description for first suggestion + if suggestions: + first_suggestion = suggestions[0] + self._update_key_hints_for_commands(first_suggestion, is_completion=True) else: # No suggestions - dismiss if active input_area.completion_active = False @@ -914,6 +1023,8 @@ def on_input_area_completion_cycle(self, message: InputArea.CompletionCycle): base_text = input_area.completion_prefix new_text = self._get_completed_text(base_text, selected) input_area.set_completion_preview(new_text) + # Update key hints with command description for selected completion + self._update_key_hints_for_commands(selected, is_completion=True) except Exception: pass @@ -929,6 +1040,8 @@ def on_input_area_completion_cycle_previous(self, message: InputArea.CompletionC base_text = input_area.completion_prefix new_text = self._get_completed_text(base_text, selected) input_area.set_completion_preview(new_text) + # Update key hints with command description for selected completion + self._update_key_hints_for_commands(selected, is_completion=True) except Exception: pass @@ -939,6 +1052,9 @@ def on_input_area_completion_accept(self, message: InputArea.CompletionAccept): completion_bar.select_current() except Exception: pass + # Update key hints based on accepted completion + input_area = self.query_one("#input", InputArea) + self._update_key_hints_for_commands(input_area.text, is_completion=False) def on_input_area_completion_dismiss(self, message: InputArea.CompletionDismiss): """Handle Escape to dismiss completions.""" @@ -949,6 +1065,8 @@ def on_input_area_completion_dismiss(self, message: InputArea.CompletionDismiss) completion_bar.dismiss() except Exception: pass + # Update key hints back to normal based on current input + self._update_key_hints_for_commands(input_area.text, is_completion=False) def on_completion_bar_selected(self, message: CompletionBar.Selected): """Handle completion selection.""" diff --git a/cecli/tui/styles.tcss b/cecli/tui/styles.tcss index 4a0fa89f912..7800dbc633e 100644 --- a/cecli/tui/styles.tcss +++ b/cecli/tui/styles.tcss @@ -100,13 +100,24 @@ TextArea > .text-area--selection { /* Key hints below input */ #key-hints { + color: $secondary; height: 1; width: 100%; + padding: 0 2 0 2; + margin: 0 0 1 0; +} + +#key-hints > .key-hints-left { + color: $secondary; + width: auto; +} + +#key-hints > .key-hints-right { text-align: right; color: $secondary; - padding: 0 2 0 0; - margin: 0 0 1 0; + width: 1fr; } + /* Footer - same background as everything else */ #footer { height: 1; diff --git a/cecli/tui/widgets/input_area.py b/cecli/tui/widgets/input_area.py index b6846b1fc0d..0ae743b2811 100644 --- a/cecli/tui/widgets/input_area.py +++ b/cecli/tui/widgets/input_area.py @@ -42,6 +42,13 @@ class CompletionDismiss(Message): pass + class TextChanged(Message): + """Text in the input area has changed.""" + + def __init__(self, text: str): + self.text = text + super().__init__() + def __init__(self, history_file: str = None, **kwargs): """Initialize input area. @@ -59,12 +66,10 @@ def __init__(self, history_file: str = None, **kwargs): # Let's assume kwargs might handle it or we set it. # Actually, let's just set the default if it's empty. if not self.placeholder: - submit = self.app.get_keys_for("submit") + # submit = self.app.get_keys_for("submit") newline = self.app.get_keys_for("newline") - self.placeholder = ( - f"> Type your message... ({submit} to submit, {newline} for new line)" - ) + self.placeholder = f"> Type your message... ({newline} for new line)" self.files = [] self.commands = [] @@ -308,6 +313,9 @@ def on_text_area_changed(self, event) -> None: self._completion_prefix = self.text + # Post TextChanged message for parent to handle + self.post_message(self.TextChanged(self.text)) + if not self.disabled: val = self.text possible_path = False diff --git a/cecli/tui/widgets/key_hints.py b/cecli/tui/widgets/key_hints.py index 89674ee3df0..94d62863f91 100644 --- a/cecli/tui/widgets/key_hints.py +++ b/cecli/tui/widgets/key_hints.py @@ -1,16 +1,52 @@ +from textual.containers import Horizontal from textual.widgets import Static -class KeyHints(Static): - """Key hints widget.""" +class KeyHints(Horizontal): + """Key hints widget with left sub-panel and right hints.""" DEFAULT_CSS = """ KeyHints { - text-align: right; color: $secondary; - padding: 0 2 0 0; height: 1; width: 100%; + padding: 0 2 0 2; margin: 0 0 1 0; } + + KeyHints > .key-hints-left { + color: $secondary; + width: auto; + } + + KeyHints > .key-hints-right { + text-align: right; + color: $secondary; + width: 1fr; + } """ + + DEFAULT_LEFT_TEXT = "/commands • @path/to/file" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.left_panel = Static(self.DEFAULT_LEFT_TEXT, classes="key-hints-left") + self.right_panel = Static("", classes="key-hints-right") + + def compose(self): + yield self.left_panel + yield self.right_panel + + def update_left(self, text: str): + """Update the left sub-panel text, limiting to 96 chars with ellipses.""" + if len(text) > 96: + text = text[:93] + "..." + self.left_panel.update(text) + + def update_right(self, text: str): + """Update the right hints text.""" + self.right_panel.update(text) + + def update(self, text: str): + """Update the right hints text (backward compatibility).""" + self.update_right(text) diff --git a/cecli/versioncheck.py b/cecli/versioncheck.py index 3cf2ae4feba..05d13fe964f 100644 --- a/cecli/versioncheck.py +++ b/cecli/versioncheck.py @@ -21,7 +21,7 @@ async def install_from_main_branch(io): io, None, "Install the development version of cecli from the main branch?", - ["git+https://github.com/dwash96/aider-ce.git"], + ["git+https://github.com/dwash96/cecli.git"], self_update=True, ) @@ -40,7 +40,7 @@ async def install_upgrade(io, latest_version=None): io.tool_warning(text) return True success = await utils.check_pip_install_extra( - io, None, new_ver_text, ["cecli"], self_update=True + io, None, new_ver_text, ["cecli-dev"], self_update=True ) if success: io.tool_output("Re-run cecli to use new version.") diff --git a/tests/basic/test_io.py b/tests/basic/test_io.py index 4efd70c7a47..0af08d0ef19 100644 --- a/tests/basic/test_io.py +++ b/tests/basic/test_io.py @@ -90,7 +90,7 @@ def test_autocompleter_get_command_completions(self): " ".join(inp.strip().split()[1:]), ) commands.get_raw_completions.return_value = None - commands.get_completions.side_effect = lambda cmd: ( + commands.get_completions.side_effect = lambda cmd, partial: ( ["file1.txt", "file2.txt"] if cmd == "/add" else None )