Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cecli/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,12 @@ def get_parser(default_config_files, git_root):
default=True,
help="Enable/disable streaming responses (default: True)",
)
group.add_argument(
"--spinner",
action=argparse.BooleanOptionalAction,
default=True,
help="Enable/disable the spinner while waiting for LLM responses (default: True)",
)
group.add_argument(
"--user-input-color",
default="#00cc00",
Expand Down
17 changes: 17 additions & 0 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,23 @@ async def _exec_async():
call_result = await litellm.experimental_mcp_client.call_openai_tool(
session=session, openai_tool=tool_call_dict
)
except Exception as e:
if server.is_session_expired_error(e):
try:
session = await server.reconnect()
call_result = await litellm.experimental_mcp_client.call_openai_tool(
session=session, openai_tool=tool_call_dict
)
except Exception as retry_exc:
self.io.tool_warning(
f"Executing {tool_name} on {server.name} failed after reconnect:\n"
f"Error: {retry_exc}"
)
return f"Error executing tool call {tool_name}: {retry_exc}"
else:
self.io.tool_warning(f"Executing {tool_name} on {server.name} failed:\nError: {e}")
return f"Error executing tool call {tool_name}: {e}"
try:
content_parts = []
if call_result.content:
for item in call_result.content:
Expand Down
77 changes: 56 additions & 21 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ def total_cached_tokens(self, value):
yield_stream = False
temperature = None
auto_lint = True
_deferred_cost_text = None
auto_test = False
test_cmd = None
lint_outcome = None
Expand Down Expand Up @@ -575,6 +576,7 @@ def __init__(
fnames,
None,
models=main_model.commit_message_models(),
show_spinner=nested.getter(self.args, "spinner", True),
)
except FileNotFoundError:
pass
Expand Down Expand Up @@ -1269,7 +1271,8 @@ def get_repo_map(self, force_refresh=False):
if not self.repo_map or not self.repo:
return

self.io.update_spinner("Updating repo map")
if nested.getter(self.args, "spinner", True):
self.io.update_spinner("Updating repo map")

cur_msg_text = self.get_cur_message_text()
try:
Expand Down Expand Up @@ -1371,7 +1374,8 @@ def _include_in_map(abs_path):
combined_dict = repo_result.get("combined_dict", {})
new_dict = repo_result.get("new_dict", {})

self.io.update_spinner(self.io.last_spinner_text)
if nested.getter(self.args, "spinner", True):
self.io.update_spinner(self.io.last_spinner_text)

# Build the return dict for backward compatibility
if combined_dict or new_dict:
Expand Down Expand Up @@ -1497,6 +1501,10 @@ async def _run_linear(self, with_message=None, preproc=True):
self.show_announcements()
self.suppress_announcements_for_next_prompt = True

if self._deferred_cost_text:
self.io.tool_output(self._deferred_cost_text)
self._deferred_cost_text = None

await self.io.recreate_input()
await self.io.input_task
user_message = self.io.input_task.result()
Expand Down Expand Up @@ -1645,6 +1653,10 @@ async def input_task(self, preproc):
self.show_announcements()
self.suppress_announcements_for_next_prompt = True

if self._deferred_cost_text:
self.io.tool_output(self._deferred_cost_text)
self._deferred_cost_text = None

# Stop spinner before showing announcements or getting input
self.io.stop_spinner()
self.copy_context()
Expand Down Expand Up @@ -2034,7 +2046,8 @@ async def compact_context_if_needed(self, force=False, message=""):
else:
self.io.tool_output("Compacting chat history to make room for new messages...")

self.io.update_spinner("Compacting...")
if nested.getter(self.args, "spinner", True):
self.io.update_spinner("Compacting...")

try:
compaction_prompt = self.gpt_prompts.compaction_prompt
Expand Down Expand Up @@ -2124,7 +2137,8 @@ async def summarize_and_update(messages, tag):
await summarize_and_update(cur_messages, MessageTag.CUR)

self.io.tool_output("...chat history compacted.")
self.io.update_spinner(self.io.last_spinner_text)
if nested.getter(self.args, "spinner", True):
self.io.update_spinner(self.io.last_spinner_text)

manager.clear_tag(MessageTag.DIFFS)
manager.clear_tag(MessageTag.FILE_CONTEXTS)
Expand Down Expand Up @@ -2512,7 +2526,11 @@ async def format_in_executor():
if not self.tui:
spinner_text += f" • ${self.format_cost(self.total_cost)} session"

self.io.start_spinner(spinner_text, coder_uuid=getattr(self, "uuid", None))
if nested.getter(self.args, "spinner", True):
self.io.start_spinner(spinner_text, coder_uuid=getattr(self, "uuid", None))
else:
self._deferred_cost_text = spinner_text

if self.stream:
self.mdstream = True
else:
Expand Down Expand Up @@ -2634,7 +2652,11 @@ async def format_in_executor():
self.mdstream = None

# Ensure any waiting spinner is stopped
self.io.start_spinner("Processing Answer...", coder_uuid=getattr(self, "uuid", None))
if nested.getter(self.args, "spinner", True):
self.io.start_spinner("Processing Answer...", coder_uuid=getattr(self, "uuid", None))

self.partial_response_content = self.get_multi_response_content_in_progress(True)

self.remove_reasoning_content()
self.multi_response_content = ""

Expand Down Expand Up @@ -2980,12 +3002,22 @@ async def _execute_mcp_tools(self, server, tool_calls):
continue

async def do_tool_call():
nonlocal session
from litellm import experimental_mcp_client

return await experimental_mcp_client.call_openai_tool(
session=session,
openai_tool=new_tool_call,
)
try:
return await experimental_mcp_client.call_openai_tool(
session=session,
openai_tool=new_tool_call,
)
except Exception as e:
if server.is_session_expired_error(e):
session = await server.reconnect()
return await experimental_mcp_client.call_openai_tool(
session=session,
openai_tool=new_tool_call,
)
raise

call_result, interrupted = await coroutines.interruptible(
do_tool_call(), self.interrupt_event
Expand Down Expand Up @@ -3627,17 +3659,18 @@ async def show_send_output_stream(self, completion):
for tool_call_chunk in chunk.choices[0].delta.tool_calls:
self.tool_reflection = True

if tool_call_chunk.type:
self.io.update_spinner_suffix(tool_call_chunk.type)
if nested.getter(self.args, "spinner", True):
if tool_call_chunk.type:
self.io.update_spinner_suffix(tool_call_chunk.type)

if tool_call_chunk.function:
if tool_call_chunk.function.name:
self.io.update_spinner_suffix(tool_call_chunk.function.name)
if tool_call_chunk.function:
if tool_call_chunk.function.name:
self.io.update_spinner_suffix(tool_call_chunk.function.name)

if tool_call_chunk.function.arguments:
self.io.update_spinner_suffix(
tool_call_chunk.function.arguments
)
if tool_call_chunk.function.arguments:
self.io.update_spinner_suffix(
tool_call_chunk.function.arguments
)

except (AttributeError, IndexError):
# Handle cases where the response structure doesn't match expectations
Expand All @@ -3649,7 +3682,8 @@ async def show_send_output_stream(self, completion):
if func:
for k, v in func.items():
self.tool_reflection = True
self.io.update_spinner_suffix(v)
if nested.getter(self.args, "spinner", True):
self.io.update_spinner_suffix(v)

received_content = True
self.token_profiler.on_token()
Expand All @@ -3676,7 +3710,8 @@ async def show_send_output_stream(self, completion):
text += content
received_content = True
self.token_profiler.on_token()
self.io.update_spinner_suffix(content)
if nested.getter(self.args, "spinner", True):
self.io.update_spinner_suffix(content)
except AttributeError:
pass

Expand Down
9 changes: 8 additions & 1 deletion cecli/interruptible_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@ def __init__(self):
raise RuntimeError("InterruptibleInput is Unix-only (requires selectable stdin).")

self._cancel = threading.Event()
self._sel = selectors.DefaultSelector()

# The default selector (Kqueue on macOS, Epoll on Linux) cannot
# handle pipe-based stdin (e.g. when running inside Emacs comint-mode).
# Fall back to SelectSelector which works with any fd that supports select().
if not sys.stdin.isatty():
self._sel = selectors.SelectSelector()
else:
self._sel = selectors.DefaultSelector()

# self-pipe to wake up select() from interrupt()
self._r, self._w = os.pipe()
Expand Down
5 changes: 4 additions & 1 deletion cecli/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ def __init__(
notifications_command=None,
notification_bell=False,
verbose=False,
show_spinner=True,
):
self.console = Console()
self.pretty = pretty
Expand Down Expand Up @@ -507,7 +508,7 @@ def __init__(
self.spinner_last_frame_index = 0
self.unicode_palette = "░█"
self.fallback_spinner = None
self.fallback_spinner_enabled = True
self.fallback_spinner_enabled = show_spinner

self.interruptible_input = None

Expand Down Expand Up @@ -570,6 +571,8 @@ def start_spinner(self, text, update_last_text=True, **kwargs):
self.stop_spinner()

if self.prompt_session:
if not self.fallback_spinner_enabled:
return
self.spinner_running = True
self.spinner_text = text
self.spinner_frame_index = self.spinner_last_frame_index
Expand Down
16 changes: 16 additions & 0 deletions cecli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@
if sys.platform == "win32":
if hasattr(asyncio, "set_event_loop_policy"):
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
elif sys.platform == "darwin":
# The default KqueueSelector cannot handle pipe-based stdin
# (e.g. when running inside Emacs comint-mode). Fall back to
# SelectSelector which works with any file descriptor that supports select().
import selectors

if not sys.stdin.isatty():
_original_event_loop_policy = asyncio.DefaultEventLoopPolicy

class _SelectSelectorPolicy(asyncio.DefaultEventLoopPolicy):
def new_event_loop(self):
return asyncio.SelectorEventLoop(selectors.SelectSelector())

asyncio.set_event_loop_policy(_SelectSelectorPolicy())
from prompt_toolkit.enums import EditingMode

from .dump import dump # noqa
Expand Down Expand Up @@ -708,6 +722,7 @@ def get_io(pretty):
notifications_command=args.notifications_command,
notification_bell=args.notification_bell,
verbose=args.verbose,
show_spinner=args.spinner,
)

validate_tui_args(args)
Expand Down Expand Up @@ -1042,6 +1057,7 @@ def get_io(pretty):
subtree_only=args.subtree_only,
git_commit_verify=args.git_commit_verify,
attribute_co_authored_by=args.attribute_co_authored_by,
show_spinner=args.spinner,
)
except FileNotFoundError:
pass
Expand Down
43 changes: 43 additions & 0 deletions cecli/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,49 @@ async def disconnect(self):
finally:
self.session = None

async def reconnect(self):
"""Disconnect and reconnect, establishing a fresh session.

Used when the server has invalidated the current session (e.g., after
a server restart), as indicated by an HTTP 404 response per the MCP
protocol specification.

Returns:
ClientSession: The new active session
"""
if self.io:
self.io.tool_warning(
f"MCP session expired for {self.name}, reconnecting..."
)
await self.disconnect()
self.exit_stack = AsyncExitStack()
return await self.connect()

@staticmethod
def is_session_expired_error(exc):
"""Check if an exception indicates an expired MCP session (HTTP 404).

Per the MCP specification, when a server terminates a session it
responds with HTTP 404 Not Found. The client MUST then start a new
session by sending a new InitializeRequest.

Args:
exc: The exception to check

Returns:
bool: True if the error indicates a 404 session expiry
"""
import httpx

if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 404:
return True

# Some transports wrap the status in the exception message
exc_str = str(exc).lower()
if "404" in exc_str and ("session" in exc_str or "not found" in exc_str):
return True

return False

class HttpBasedMcpServer(McpServer):
"""Base class for HTTP-based MCP servers (HTTP streaming and SSE)."""
Expand Down
8 changes: 6 additions & 2 deletions cecli/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,11 @@ def __init__(
subtree_only=False,
git_commit_verify=True,
attribute_co_authored_by=False, # Added parameter
show_spinner=True,
):
self.io = io
self.models = models
self.show_spinner = show_spinner

self.normalized_path = {}
# Single-entry file cache: (commit_sha, interned_set_of_paths)
Expand Down Expand Up @@ -486,7 +488,8 @@ async def get_commit_message(self, diffs, context, user_language=None):
commit_message = None
for model in self.models:
spinner_text = f"Generating commit message with {model.name}\n"
self.io.start_spinner(spinner_text, update_last_text=False)
if self.show_spinner:
self.io.start_spinner(spinner_text, update_last_text=False)

if model.system_prompt_prefix:
current_system_content = model.system_prompt_prefix + "\n" + system_content
Expand Down Expand Up @@ -523,7 +526,8 @@ async def get_commit_message(self, diffs, context, user_language=None):
if commit_message and commit_message[0] == '"' and commit_message[-1] == '"':
commit_message = commit_message[1:-1].strip()

self.io.start_spinner(self.io.last_spinner_text, update_last_text=False)
if self.show_spinner:
self.io.start_spinner(self.io.last_spinner_text, update_last_text=False)
return commit_message

def get_diffs(self, fnames=None):
Expand Down
3 changes: 3 additions & 0 deletions cecli/website/docs/config/conf.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@ cog.outl("```")
## Enable/disable streaming responses (default: True)
#stream: true

## Enable/disable the spinner while waiting for LLM responses (default: True)
#spinner: true

## Set the color for user input (default: #00cc00)
#user-input-color: "#00cc00"

Expand Down
Loading
Loading