diff --git a/cecli/__init__.py b/cecli/__init__.py
index 61710003ccb..85f102b2b96 100644
--- a/cecli/__init__.py
+++ b/cecli/__init__.py
@@ -1,6 +1,6 @@
from packaging import version
-__version__ = "0.99.7.dev"
+__version__ = "0.99.9.dev"
safe_version = __version__
try:
diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py
index 30f52a193fb..d261e772222 100644
--- a/cecli/coders/agent_coder.py
+++ b/cecli/coders/agent_coder.py
@@ -618,9 +618,7 @@ def get_context_summary(self):
total_file_tokens += tokens
editable_tokens += tokens
size_indicator = (
- "🔴 Large"
- if tokens > 5000
- else "🟡 Medium" if tokens > 1000 else "🟢 Small"
+ "Large" if tokens > 5000 else "Medium" if tokens > 1000 else "Small"
)
editable_files.append(
f"- {rel_fname}: {tokens:,} tokens ({size_indicator})"
@@ -642,9 +640,7 @@ def get_context_summary(self):
total_file_tokens += tokens
readonly_tokens += tokens
size_indicator = (
- "🔴 Large"
- if tokens > 5000
- else "🟡 Medium" if tokens > 1000 else "🟢 Small"
+ "Large" if tokens > 5000 else "Medium" if tokens > 1000 else "Small"
)
readonly_files.append(
f"- {rel_fname}: {tokens:,} tokens ({size_indicator})"
diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py
index 0b7f847d436..8c366dcbc2a 100755
--- a/cecli/coders/base_coder.py
+++ b/cecli/coders/base_coder.py
@@ -2178,6 +2178,8 @@ async def send_message(self, inp):
# Notify IO that LLM processing is starting
self.io.llm_started()
+ ConversationService.get_manager(self).flush_queue()
+
if inp:
# Make sure current coder actually has control of conversation system
ConversationService.get_chunks(self).initialize_conversation_system()
@@ -4003,7 +4005,10 @@ def apply_edits_dry_run(self, edits):
return edits
def local_agent_folder(self, path):
- os.makedirs(f".cecli/agents/{GLOBAL_DATE}/{self.uuid}", exist_ok=True)
+ os.makedirs(
+ self.abs_root_path(f".cecli/agents/{GLOBAL_DATE}/{self.uuid}"),
+ exist_ok=True,
+ )
stripped = path.lstrip("/")
return f".cecli/agents/{GLOBAL_DATE}/{self.uuid}/{stripped}"
diff --git a/cecli/commands/load_session.py b/cecli/commands/load_session.py
index 1d5676d97e9..f3c38396a8b 100644
--- a/cecli/commands/load_session.py
+++ b/cecli/commands/load_session.py
@@ -18,7 +18,7 @@ async def execute(cls, io, coder, args, **kwargs):
from cecli import sessions
session_manager = sessions.SessionManager(coder, io)
- session_manager.load_session(args.strip())
+ await session_manager.load_session(args.strip())
return format_command_result(io, "load-session", f"Loaded session: {args.strip()}")
diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py
index aba4f584670..00ee834e004 100644
--- a/cecli/helpers/conversation/integration.py
+++ b/cecli/helpers/conversation/integration.py
@@ -446,7 +446,7 @@ def add_repo_map_messages(self) -> List[Dict[str, Any]]:
dict(role="user", content=repo_content),
dict(
role="assistant",
- content="Ok, I won't try and edit those files without asking first.",
+ content="Thank you, these files will help with navigating the codebase.",
),
]
@@ -781,14 +781,14 @@ def add_file_context_messages(self, promote_messages=True) -> None:
}
# Add to conversation manager
- ConversationService.get_manager(coder).add_message(
+ ConversationService.get_manager(coder).queue_message(
message_dict=user_msg,
tag=MessageTag.FILE_CONTEXTS,
hash_key=("file_context_user", file_path),
force=True,
)
- ConversationService.get_manager(coder).add_message(
+ ConversationService.get_manager(coder).queue_message(
message_dict=assistant_msg,
tag=MessageTag.FILE_CONTEXTS,
hash_key=("file_context_assistant", file_path),
diff --git a/cecli/helpers/conversation/manager.py b/cecli/helpers/conversation/manager.py
index 5561979189b..93c66e8164d 100644
--- a/cecli/helpers/conversation/manager.py
+++ b/cecli/helpers/conversation/manager.py
@@ -25,6 +25,7 @@ def __init__(self, coder):
self._tag_cache: Dict[str, List[Dict[str, Any]]] = {}
self._ALL_MESSAGES_CACHE_KEY = "__all__"
self.DEFAULT_TAG_PROMOTION_VALUE: int = 999
+ self._queue: List[Dict[str, Any]] = []
@classmethod
def get_instance(cls, coder) -> "ConversationManager":
@@ -193,6 +194,33 @@ def add_message(
self._tag_cache.pop(self._ALL_MESSAGES_CACHE_KEY, None)
return message
+ def queue_message(self, **kwargs) -> None:
+ """
+ Queue an add_message() call for later insertion.
+
+ Accepts the same keyword arguments as add_message()
+ and stores them in an internal queue to be flushed
+ later via flush_queue().
+ """
+ self._queue.append(kwargs)
+
+ def flush_queue(self) -> List[Any]:
+ """
+ Flush all queued add_message() calls.
+
+ Calls add_message() for each set of kwargs in the
+ internal queue, then clears the queue.
+
+ Returns:
+ List of BaseMessage instances returned by add_message()
+ """
+ results = []
+ while self._queue:
+ kwargs = self._queue.pop(0)
+ result = self.add_message(**kwargs)
+ results.append(result)
+ return results
+
def base_sort(self, messages: List[BaseMessage]) -> List[BaseMessage]:
"""
Sorts messages by effective priority (promotion if mark_for_demotion has not elapsed yet), then timestamp.
diff --git a/cecli/main.py b/cecli/main.py
index bf8b89fa99d..bfebaffc6d1 100644
--- a/cecli/main.py
+++ b/cecli/main.py
@@ -1198,7 +1198,7 @@ def get_io(pretty):
from cecli.sessions import SessionManager
session_manager = SessionManager(coder, io)
- session_manager.load_session(
+ await session_manager.load_session(
args.auto_save_session_name if args.auto_save_session_name else "auto-save"
)
except Exception:
diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml
index dabba8ec53b..2c716a8889b 100644
--- a/cecli/prompts/agent.yml
+++ b/cecli/prompts/agent.yml
@@ -14,6 +14,10 @@ files_no_full_files_with_repo_map: |
I have a repository map. I will use it to target my navigation and add relevant files to the context.
+repo_content_prefix: |
+ Here are summaries of some files present in my git repository.
+ These files should be helpful for navigating the codebase.
+
main_system: |
## Core Directives
@@ -34,7 +38,7 @@ main_system: |
uXdn::def example_method():
WAR5:: return "example"
vwkS::
-
+
## Core Workflow
diff --git a/cecli/sessions.py b/cecli/sessions.py
index 5d8447d5213..c1e9fbdc5f3 100644
--- a/cecli/sessions.py
+++ b/cecli/sessions.py
@@ -88,7 +88,7 @@ def list_sessions(self) -> List[Dict]:
return sessions
- def load_session(self, session_identifier: str) -> bool:
+ async def load_session(self, session_identifier: str) -> bool:
"""Load a saved session by name or file path."""
if not session_identifier:
self.io.tool_error("Please provide a session name or file path.")
@@ -112,7 +112,17 @@ def load_session(self, session_identifier: str) -> bool:
return False
# Apply session data
- return self._apply_session_data(session_data, session_file)
+ applied = await self._apply_session_data(session_data, session_file)
+ if applied:
+ from cecli.commands import SwitchCoderSignal
+
+ raise SwitchCoderSignal(
+ edit_format=self.coder.edit_format,
+ from_coder=self.coder,
+ summarize_from_coder=False,
+ show_announcements=True,
+ )
+ return applied
def _build_session_data(self, session_name) -> Dict:
"""Build session data dictionary from current coder state."""
@@ -140,6 +150,39 @@ def _build_session_data(self, session_name) -> Dict:
self.io.tool_warning(f"Could not read todo list file: {e}")
# Get CUR and DONE messages from ConversationManager
+ connected_mcps = []
+ if hasattr(self.coder, "mcp_manager") and self.coder.mcp_manager:
+ connected_mcps = [server.name for server in self.coder.mcp_manager.connected_servers]
+
+ # Get CUR and DONE messages from ConversationManager
+ connected_mcps = []
+ if hasattr(self.coder, "mcp_manager") and self.coder.mcp_manager:
+ connected_mcps = [server.name for server in self.coder.mcp_manager.connected_servers]
+
+ skills_data = None
+ if hasattr(self.coder, "skills_manager") and self.coder.skills_manager:
+ skills_data = {
+ "skills_paths": [str(p) for p in self.coder.skills_manager.directory_paths],
+ "skills_includelist": (
+ list(self.coder.skills_manager.include_list)
+ if self.coder.skills_manager.include_list is not None
+ else []
+ ),
+ "skills_excludelist": (
+ list(self.coder.skills_manager.exclude_list)
+ if self.coder.skills_manager.exclude_list is not None
+ else []
+ ),
+ }
+
+ agent_config_data = None
+ if hasattr(self.coder, "agent_config"):
+ agent_config_data = {
+ "tools_paths": self.coder.agent_config.get("tools_paths", []),
+ "tools_includelist": self.coder.agent_config.get("tools_includelist", []),
+ "tools_excludelist": self.coder.agent_config.get("tools_excludelist", []),
+ }
+
return {
"version": 1,
"session_name": session_name,
@@ -168,6 +211,9 @@ def _build_session_data(self, session_name) -> Dict:
"auto_test": self.coder.auto_test,
},
"todo_list": todo_content,
+ "mcps": connected_mcps,
+ "skills": skills_data,
+ "tools": agent_config_data,
}
def _find_session_file(self, session_identifier: str) -> Optional[Path]:
@@ -194,7 +240,7 @@ def _find_session_file(self, session_identifier: str) -> Optional[Path]:
self.io.tool_output("Use /list-sessions to see available sessions.")
return None
- def _apply_session_data(self, session_data: Dict, session_file: Path) -> bool:
+ async def _apply_session_data(self, session_data: Dict, session_file: Path) -> bool:
"""Apply session data to current coder state."""
try:
# Clear current state
@@ -303,6 +349,40 @@ def _apply_session_data(self, session_data: Dict, session_file: Path) -> bool:
)
self.io.tool_output(f"Loaded {num_messages} messages and {num_files} files")
+ # Load MCPs
+ saved_mcps = session_data.get("mcps", [])
+ if hasattr(self.coder, "mcp_manager") and self.coder.mcp_manager:
+ current_mcps = {server.name for server in self.coder.mcp_manager.connected_servers}
+ saved_mcps_set = set(saved_mcps)
+
+ to_disconnect = current_mcps - saved_mcps_set
+ for mcp_name in to_disconnect:
+ await self.coder.mcp_manager.disconnect_server(mcp_name)
+
+ to_connect = saved_mcps_set - current_mcps
+ for mcp_name in to_connect:
+ await self.coder.mcp_manager.connect_server(mcp_name)
+
+ # Load skills
+ skills_data = session_data.get("skills")
+ if skills_data and hasattr(self.coder, "skills_manager") and self.coder.skills_manager:
+ self.coder.skills_manager.directory_paths = skills_data.get("skills_paths", [])
+ self.coder.skills_manager.include_list = set(
+ skills_data.get("skills_includelist", [])
+ )
+ self.coder.skills_manager.exclude_list = set(
+ skills_data.get("skills_excludelist", [])
+ )
+
+ # Load tools config
+ agent_config_data = session_data.get("tools")
+ if agent_config_data and hasattr(self.coder, "agent_config"):
+ self.coder.agent_config.update(agent_config_data)
+ from cecli.tools.utils.registry import ToolRegistry
+
+ ToolRegistry.build_registry(agent_config=self.coder.agent_config)
+ self.coder.loaded_custom_tools = ToolRegistry.loaded_custom_tools
+
return True
except Exception as e:
diff --git a/cecli/tools/edit_text.py b/cecli/tools/edit_text.py
index f03fc6d96df..5b4d64f7c3c 100644
--- a/cecli/tools/edit_text.py
+++ b/cecli/tools/edit_text.py
@@ -56,9 +56,9 @@ class Tool(BaseTool):
"type": "string",
"enum": ["replace", "delete", "insert"],
"description": (
- "The type of operation: 'replace' (replace range with text), "
- "'delete' (remove range), or 'insert' (insert text after start_line). "
- "Defaults to 'replace'."
+ "The type of operation: 'replace' (replace range with"
+ " text), 'delete' (remove range), or 'insert' (insert text"
+ " after start_line). Defaults to 'replace'."
),
},
"text": {
@@ -78,8 +78,8 @@ class Tool(BaseTool):
"end_line": {
"type": "string",
"description": (
- 'Hashline format for end line: "{4 char hash}" (without the '
- "braces)"
+ 'Hashline format for end line: "{4 char hash}" (without the'
+ " braces)"
),
},
},
@@ -179,8 +179,8 @@ def execute(
if operation in ("replace", "delete"):
if edit_start_line is None:
raise ToolError(
- f"Edit {edit_index + 1}: 'start_line' parameter is required "
- f"for '{operation}' operation"
+ f"Edit {edit_index + 1}: 'start_line' parameter is required"
+ f" for '{operation}' operation"
)
if edit_end_line is None:
raise ToolError(
@@ -190,8 +190,8 @@ def execute(
if operation == "insert":
if edit_start_line is None:
raise ToolError(
- f"Edit {edit_index + 1}: 'start_line' parameter is required "
- "for 'insert' operation"
+ f"Edit {edit_index + 1}: 'start_line' parameter is required"
+ " for 'insert' operation"
)
# For insert, end_line defaults to start_line
edit_end_line = edit_end_line or edit_start_line
diff --git a/cecli/tools/get_lines.py b/cecli/tools/get_lines.py
index a7f39982032..138428b4da0 100644
--- a/cecli/tools/get_lines.py
+++ b/cecli/tools/get_lines.py
@@ -21,12 +21,14 @@ class Tool(BaseTool):
"description": (
"Get hashline prefixes of context between start and end patterns in multiple files."
" Accepts an array of show objects, each with file_path, start_text,"
- " end_text, and optional padding. Special markers '@000' and '000@' can be"
- " used for start_text and end_text to represent the first and last lines of"
- " the file respectively. Never use hashlines as the start_text and end_text"
- " values. These values must be lines from the content of the file."
+ " end_text, and optional padding."
+ " These values must be lines from the content of the file."
" They can contain up to 3 lines but newlines should generally be avoided."
- " Avoid using generic keywords."
+ " Avoid using generic keywords and symbols. Special markers '@000' and '000@' can be"
+ " used for start_text and end_text to represent the first and last lines of"
+ " the file respectively. Avoid using the special markers on files with contents."
+ " They are intended to be used with empty files."
+ " Never use hashlines as the start_text and end_text values."
" Do not use the same pattern for the start_text and end_text."
" It is usually best to use function names and other block identifiers as "
" start_texts and end_texts."
@@ -279,7 +281,7 @@ def execute(cls, coder, show, **kwargs):
coder.io.tool_output("File contents already up to date")
return (
"File contents already up to date."
- "Do not call GetLines again with these parameters until you edit the file."
+ " Do not call GetLines again with these parameters until you edit the file."
)
else:
coder.io.tool_output(f"✅ Successfully retrieved context for {len(show)} file(s)")
diff --git a/cecli/tools/utils/helpers.py b/cecli/tools/utils/helpers.py
index 45e123d91f5..d3c219383bb 100644
--- a/cecli/tools/utils/helpers.py
+++ b/cecli/tools/utils/helpers.py
@@ -55,11 +55,11 @@ def validate_file_for_edit(coder, file_path):
raise ToolError(
f"File '{file_path}' is read-only. Make editable with `ContextManager` first."
)
- else:
- # File exists but is not in context at all
- raise ToolError(
- f"File '{file_path}' not in context. Make editable with `ContextManager` first."
- )
+ # else:
+ # # File exists but is not in context at all
+ # raise ToolError(
+ # f"File '{file_path}' not in context. Make editable with `ContextManager` first."
+ # )
# Reread content immediately before potential modification
content = coder.io.read_text(abs_path)
diff --git a/cecli/tui/app.py b/cecli/tui/app.py
index 464726a61c1..fc87bd7211b 100644
--- a/cecli/tui/app.py
+++ b/cecli/tui/app.py
@@ -2,6 +2,7 @@
import concurrent.futures
import json
+import platform
import queue
import time
from functools import lru_cache
@@ -359,6 +360,11 @@ def on_mouse_up(self, event: events.MouseUp) -> None:
self._mouse_hold_timer = None
self.update_key_hints(generating=self._currently_generating)
+ def on_mouse_move(self, event: events.MouseMove) -> None:
+ """Handle mouse move events to prevent strange characters on Windows."""
+ if platform.system() == "Windows":
+ event.stop()
+
def _show_select_hint(self) -> None:
"""Show the shift+drag to select hint."""
try:
diff --git a/tests/basic/test_sessions.py b/tests/basic/test_sessions.py
index adb5a01a907..aa26f4f5a26 100644
--- a/tests/basic/test_sessions.py
+++ b/tests/basic/test_sessions.py
@@ -7,7 +7,7 @@
from unittest import TestCase, mock
from cecli.coders import Coder
-from cecli.commands import Commands
+from cecli.commands import Commands, SwitchCoderSignal
from cecli.helpers.file_searcher import handle_core_files
from cecli.io import InputOutput
from cecli.models import Model
@@ -196,3 +196,114 @@ async def test_preserve_todo_list_deprecated(self):
self.assertTrue(
any("deprecated" in call[0][0] for call in mock_tool_warning.call_args_list)
)
+
+ async def test_cmd_save_load_session_agent_config(self):
+ """Test session save/load for agent-specific configs (mcp, skills, tools)."""
+ with GitTemporaryDirectory():
+ # Mock args for AgentCoder
+ mock_args = mock.MagicMock()
+ mock_args.agent_config = json.dumps(
+ {
+ "tools_paths": ["/test/tools/path"],
+ "tools_includelist": ["included_tool"],
+ "tools_excludelist": ["excluded_tool"],
+ }
+ )
+ # This is needed for the skills manager to be created
+ mock_args.skills_paths = ["/test/skills/path"]
+ mock_args.mcp_servers = json.dumps([{"name": "mock_mcp"}])
+ mock_args.mcp_servers_files = []
+ mock_args.verbose = False
+ mock_args.debug = False
+ mock_args.tui = False
+ mock_args.auto_save_session_name = "auto-save"
+ mock_args.auto_save = False
+ mock_args.auto_load = False
+ mock_args.yes_always_commands = True
+ mock_args.command_prefix = None
+ mock_args.file_diffs = True
+ mock_args.max_reflections = 3
+ mock_args.model = "gpt-3.5-turbo"
+ mock_args.weak_model = None
+ mock_args.editor_model = None
+ mock_args.agent_model = None
+ mock_args.editor_edit_format = None
+ mock_args.retries = None
+ mock_args.reasoning_effort = None
+ mock_args.thinking_tokens = None
+ mock_args.check_model_accepts_settings = True
+ mock_args.copy_paste = False
+ mock_args.hooks = None
+
+ io = InputOutput(pretty=False, fancy_input=False, yes=True)
+
+ # === SAVE SESSION ===
+ coder_to_save = await Coder.create(
+ self.GPT35, "agent", io, args=mock_args, repo=mock.MagicMock()
+ )
+ commands_to_save = Commands(io, coder_to_save, args=mock_args)
+
+ # Configure state to be saved
+ await coder_to_save.mcp_manager.connect_server("mock_mcp")
+ coder_to_save.skills_manager.include_list = {"included_skill"}
+ coder_to_save.skills_manager.exclude_list = {"excluded_skill"}
+ coder_to_save.skills_manager.directory_paths = ["/test/skills/path/saved"]
+
+ session_name = "agent_session"
+ await commands_to_save.execute("save-session", session_name)
+
+ session_file = Path(handle_core_files(".cecli")) / "sessions" / f"{session_name}.json"
+ self.assertTrue(session_file.exists())
+
+ with open(session_file, "r", encoding="utf-8") as f:
+ saved_data = json.load(f)
+
+ # Assert saved data is correct
+ self.assertEqual(saved_data["mcps"], ["mock_mcp"])
+ self.assertEqual(saved_data["skills"]["skills_paths"], ["/test/skills/path/saved"])
+ self.assertEqual(saved_data["skills"]["skills_includelist"], ["included_skill"])
+ self.assertEqual(saved_data["skills"]["skills_excludelist"], ["excluded_skill"])
+ self.assertEqual(saved_data["tools"]["tools_paths"], ["/test/tools/path"])
+ self.assertEqual(saved_data["tools"]["tools_includelist"], ["included_tool"])
+ self.assertEqual(saved_data["tools"]["tools_excludelist"], ["excluded_tool"])
+
+ # === LOAD SESSION ===
+ # Create a new coder to load into, ensuring it's a clean slate
+ coder_to_load_initial = await Coder.create(
+ self.GPT35, "agent", io, args=mock_args, repo=mock.MagicMock()
+ )
+ commands_to_load = Commands(io, coder_to_load_initial, args=mock_args)
+
+ # Mock ToolRegistry.build_registry to check if it's called
+ with mock.patch(
+ "cecli.tools.utils.registry.ToolRegistry.build_registry"
+ ) as mock_build_registry:
+ coder_after_load = None
+ try:
+ await commands_to_load.execute("load-session", session_name)
+ except SwitchCoderSignal as e:
+ # The SwitchCoderSignal is expected, we need to get the new coder from it
+ coder_after_load = await Coder.create(**e.kwargs)
+
+ self.assertIsNotNone(coder_after_load)
+
+ # Assert loaded state is correct in the new coder instance
+ connected_mcps = {s.name for s in coder_after_load.mcp_manager.connected_servers}
+ self.assertIn("mock_mcp", connected_mcps)
+
+ self.assertEqual(
+ coder_after_load.skills_manager.directory_paths, ["/test/skills/path/saved"]
+ )
+ self.assertEqual(coder_after_load.skills_manager.include_list, {"included_skill"})
+ self.assertEqual(coder_after_load.skills_manager.exclude_list, {"excluded_skill"})
+
+ self.assertEqual(coder_after_load.agent_config["tools_paths"], ["/test/tools/path"])
+ self.assertEqual(
+ coder_after_load.agent_config["tools_includelist"], ["included_tool"]
+ )
+ self.assertEqual(
+ coder_after_load.agent_config["tools_excludelist"], ["excluded_tool"]
+ )
+
+ # Assert that the tool registry was rebuilt
+ mock_build_registry.assert_called_with(agent_config=coder_after_load.agent_config)
diff --git a/tests/tui/test_app.py b/tests/tui/test_app.py
new file mode 100644
index 00000000000..e6244d87cf2
--- /dev/null
+++ b/tests/tui/test_app.py
@@ -0,0 +1,37 @@
+from unittest.mock import MagicMock, patch
+
+import pytest
+from textual import events
+
+# Assuming TUI is in cecli.tui.app
+from cecli.tui.app import TUI
+
+
+@pytest.fixture
+def tui_instance(monkeypatch):
+ """A pytest fixture to create a mocked TUI instance."""
+ monkeypatch.setattr("cecli.tui.app.TUI.__init__", lambda *args, **kwargs: None)
+ tui = TUI(coder_worker=None, output_queue=None, input_queue=None, args=None)
+ tui._mouse_hold_timer = None
+ tui._currently_generating = False
+ return tui
+
+
+def test_on_mouse_move_windows(tui_instance):
+ """
+ Test that on_mouse_move stops the event on Windows.
+ """
+ with patch("platform.system", return_value="Windows"):
+ mock_event = MagicMock(spec=events.MouseMove)
+ tui_instance.on_mouse_move(mock_event)
+ mock_event.stop.assert_called_once()
+
+
+def test_on_mouse_move_linux(tui_instance):
+ """
+ Test that on_mouse_move does not stop the event on Linux.
+ """
+ with patch("platform.system", return_value="Linux"):
+ mock_event = MagicMock(spec=events.MouseMove)
+ tui_instance.on_mouse_move(mock_event)
+ mock_event.stop.assert_not_called()