Skip to content
Merged
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
19 changes: 19 additions & 0 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,25 @@ 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
37 changes: 32 additions & 5 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ def total_cached_tokens(self, value):
message_tokens_sent = 0
message_tokens_received = 0
message_cached_tokens = 0
message_cost_deferred = None
add_cache_headers = False
cache_warming_thread = None
num_cache_warming_pings = 0
Expand Down Expand Up @@ -1497,6 +1498,10 @@ async def _run_linear(self, with_message=None, preproc=True):
self.show_announcements()
self.suppress_announcements_for_next_prompt = True

if self.message_cost_deferred and not self.io.spinner_active:
self.io.tool_output(self.message_cost_deferred)
self.message_cost_deferred = None

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

if self.message_cost_deferred and not self.io.spinner_active:
self.io.tool_output(self.message_cost_deferred)
self.message_cost_deferred = None

# Stop spinner before showing announcements or getting input
self.io.stop_spinner()
self.copy_context()
Expand Down Expand Up @@ -2512,7 +2521,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 self.io.spinner_active:
self.io.start_spinner(spinner_text, coder_uuid=getattr(self, "uuid", None))
else:
self.message_cost_deferred = spinner_text

if self.stream:
self.mdstream = True
else:
Expand Down Expand Up @@ -2635,6 +2648,10 @@ async def format_in_executor():

# Ensure any waiting spinner is stopped
self.io.start_spinner("Processing Answer...", coder_uuid=getattr(self, "uuid", None))

if not self.io.spinner_active:
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 +2997,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
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
15 changes: 14 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 @@ -499,6 +500,7 @@ def __init__(
fancy_input = False

# Spinner state
self.spinner_active = show_spinner
self.spinner_running = False
self.spinner_text = ""
self.last_spinner_text = ""
Expand All @@ -507,7 +509,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 @@ -569,7 +571,12 @@ def start_spinner(self, text, update_last_text=True, **kwargs):
"""Start the spinner."""
self.stop_spinner()

if not self.spinner_active:
return

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 All @@ -582,9 +589,15 @@ def start_spinner(self, text, update_last_text=True, **kwargs):
self.fallback_spinner.step()

def update_spinner(self, text):
if not self.spinner_active:
return

self.spinner_text = text

def update_spinner_suffix(self, text=None):
if not self.spinner_active:
return

if text:
self.spinner_suffix = f" • {text[:16].strip()}"
else:
Expand Down
15 changes: 15 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
5 changes: 3 additions & 2 deletions cecli/mcp/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ async def connect_server(self, name: str) -> bool:
# When io is None (e.g., during from_servers before IO is assigned),
# _log_warning and _log_error silently return — retries still happen
# but with no user-visible feedback. This is intentional.
max_retries = 3
max_retries = 3 if server.name != "unnamed-server" else 1
delay = 1.0
backoff = 2.0
max_delay = 30.0
Expand All @@ -185,11 +185,12 @@ async def connect_server(self, name: str) -> bool:
except asyncio.CancelledError:
raise
except Exception as e:
if attempt < max_retries:
if attempt < max_retries and server.name != "unnamed-server":
self._log_warning(
f"Connection attempt {attempt} failed for {name}, "
f"retrying in {delay}s... ({e})"
)

await asyncio.sleep(delay)
delay = min(delay * backoff, max_delay)
else:
Expand Down
42 changes: 42 additions & 0 deletions cecli/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,48 @@ 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
1 change: 1 addition & 0 deletions cecli/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ 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 model.system_prompt_prefix:
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
8 changes: 8 additions & 0 deletions cecli/website/docs/config/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,14 @@ Aliases:
- `--stream`
- `--no-stream`

### `--spinner`
Enable/disable the spinner while waiting for LLM responses (default: True)
Default: True
Environment variable: `CECLI_SPINNER`
Aliases:
- `--spinner`
- `--no-spinner`

### `--user-input-color VALUE`
Set the color for user input (default: #00cc00)
Default: #00cc00
Expand Down
Loading
Loading