Skip to content

Commit 58ba9dc

Browse files
authored
Merge pull request #615 from cecli-dev/v0.100.14-merge-no-spinner
V0.100.14 merge no spinner
2 parents 17f5694 + ac740b3 commit 58ba9dc

18 files changed

Lines changed: 401 additions & 13 deletions

cecli/args.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,12 @@ def get_parser(default_config_files, git_root):
598598
default=True,
599599
help="Enable/disable streaming responses (default: True)",
600600
)
601+
group.add_argument(
602+
"--spinner",
603+
action=argparse.BooleanOptionalAction,
604+
default=True,
605+
help="Enable/disable the spinner while waiting for LLM responses (default: True)",
606+
)
601607
group.add_argument(
602608
"--user-input-color",
603609
default="#00cc00",

cecli/coders/agent_coder.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,25 @@ async def _exec_async():
305305
call_result = await litellm.experimental_mcp_client.call_openai_tool(
306306
session=session, openai_tool=tool_call_dict
307307
)
308+
except Exception as e:
309+
if server.is_session_expired_error(e):
310+
try:
311+
session = await server.reconnect()
312+
call_result = await litellm.experimental_mcp_client.call_openai_tool(
313+
session=session, openai_tool=tool_call_dict
314+
)
315+
except Exception as retry_exc:
316+
self.io.tool_warning(
317+
f"Executing {tool_name} on {server.name} failed after reconnect:\n"
318+
f"Error: {retry_exc}"
319+
)
320+
return f"Error executing tool call {tool_name}: {retry_exc}"
321+
else:
322+
self.io.tool_warning(
323+
f"Executing {tool_name} on {server.name} failed:\nError: {e}"
324+
)
325+
return f"Error executing tool call {tool_name}: {e}"
326+
try:
308327
content_parts = []
309328
if call_result.content:
310329
for item in call_result.content:

cecli/coders/base_coder.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ def total_cached_tokens(self, value):
221221
message_tokens_sent = 0
222222
message_tokens_received = 0
223223
message_cached_tokens = 0
224+
message_cost_deferred = None
224225
add_cache_headers = False
225226
cache_warming_thread = None
226227
num_cache_warming_pings = 0
@@ -1497,6 +1498,10 @@ async def _run_linear(self, with_message=None, preproc=True):
14971498
self.show_announcements()
14981499
self.suppress_announcements_for_next_prompt = True
14991500

1501+
if self.message_cost_deferred and not self.io.spinner_active:
1502+
self.io.tool_output(self.message_cost_deferred)
1503+
self.message_cost_deferred = None
1504+
15001505
await self.io.recreate_input()
15011506
await self.io.input_task
15021507
user_message = self.io.input_task.result()
@@ -1645,6 +1650,10 @@ async def input_task(self, preproc):
16451650
self.show_announcements()
16461651
self.suppress_announcements_for_next_prompt = True
16471652

1653+
if self.message_cost_deferred and not self.io.spinner_active:
1654+
self.io.tool_output(self.message_cost_deferred)
1655+
self.message_cost_deferred = None
1656+
16481657
# Stop spinner before showing announcements or getting input
16491658
self.io.stop_spinner()
16501659
self.copy_context()
@@ -2512,7 +2521,11 @@ async def format_in_executor():
25122521
if not self.tui:
25132522
spinner_text += f" • ${self.format_cost(self.total_cost)} session"
25142523

2515-
self.io.start_spinner(spinner_text, coder_uuid=getattr(self, "uuid", None))
2524+
if self.io.spinner_active:
2525+
self.io.start_spinner(spinner_text, coder_uuid=getattr(self, "uuid", None))
2526+
else:
2527+
self.message_cost_deferred = spinner_text
2528+
25162529
if self.stream:
25172530
self.mdstream = True
25182531
else:
@@ -2635,6 +2648,10 @@ async def format_in_executor():
26352648

26362649
# Ensure any waiting spinner is stopped
26372650
self.io.start_spinner("Processing Answer...", coder_uuid=getattr(self, "uuid", None))
2651+
2652+
if not self.io.spinner_active:
2653+
self.partial_response_content = self.get_multi_response_content_in_progress(True)
2654+
26382655
self.remove_reasoning_content()
26392656
self.multi_response_content = ""
26402657

@@ -2980,12 +2997,22 @@ async def _execute_mcp_tools(self, server, tool_calls):
29802997
continue
29812998

29822999
async def do_tool_call():
3000+
nonlocal session
29833001
from litellm import experimental_mcp_client
29843002

2985-
return await experimental_mcp_client.call_openai_tool(
2986-
session=session,
2987-
openai_tool=new_tool_call,
2988-
)
3003+
try:
3004+
return await experimental_mcp_client.call_openai_tool(
3005+
session=session,
3006+
openai_tool=new_tool_call,
3007+
)
3008+
except Exception as e:
3009+
if server.is_session_expired_error(e):
3010+
session = await server.reconnect()
3011+
return await experimental_mcp_client.call_openai_tool(
3012+
session=session,
3013+
openai_tool=new_tool_call,
3014+
)
3015+
raise
29893016

29903017
call_result, interrupted = await coroutines.interruptible(
29913018
do_tool_call(), self.interrupt_event

cecli/interruptible_input.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ def __init__(self):
1717
raise RuntimeError("InterruptibleInput is Unix-only (requires selectable stdin).")
1818

1919
self._cancel = threading.Event()
20-
self._sel = selectors.DefaultSelector()
20+
21+
# The default selector (Kqueue on macOS, Epoll on Linux) cannot
22+
# handle pipe-based stdin (e.g. when running inside Emacs comint-mode).
23+
# Fall back to SelectSelector which works with any fd that supports select().
24+
if not sys.stdin.isatty():
25+
self._sel = selectors.SelectSelector()
26+
else:
27+
self._sel = selectors.DefaultSelector()
2128

2229
# self-pipe to wake up select() from interrupt()
2330
self._r, self._w = os.pipe()

cecli/io.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ def __init__(
369369
notifications_command=None,
370370
notification_bell=False,
371371
verbose=False,
372+
show_spinner=True,
372373
):
373374
self.console = Console()
374375
self.pretty = pretty
@@ -499,6 +500,7 @@ def __init__(
499500
fancy_input = False
500501

501502
# Spinner state
503+
self.spinner_active = show_spinner
502504
self.spinner_running = False
503505
self.spinner_text = ""
504506
self.last_spinner_text = ""
@@ -507,7 +509,7 @@ def __init__(
507509
self.spinner_last_frame_index = 0
508510
self.unicode_palette = "░█"
509511
self.fallback_spinner = None
510-
self.fallback_spinner_enabled = True
512+
self.fallback_spinner_enabled = show_spinner
511513

512514
self.interruptible_input = None
513515

@@ -569,7 +571,12 @@ def start_spinner(self, text, update_last_text=True, **kwargs):
569571
"""Start the spinner."""
570572
self.stop_spinner()
571573

574+
if not self.spinner_active:
575+
return
576+
572577
if self.prompt_session:
578+
if not self.fallback_spinner_enabled:
579+
return
573580
self.spinner_running = True
574581
self.spinner_text = text
575582
self.spinner_frame_index = self.spinner_last_frame_index
@@ -582,9 +589,15 @@ def start_spinner(self, text, update_last_text=True, **kwargs):
582589
self.fallback_spinner.step()
583590

584591
def update_spinner(self, text):
592+
if not self.spinner_active:
593+
return
594+
585595
self.spinner_text = text
586596

587597
def update_spinner_suffix(self, text=None):
598+
if not self.spinner_active:
599+
return
600+
588601
if text:
589602
self.spinner_suffix = f" • {text[:16].strip()}"
590603
else:

cecli/main.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@
4040
if sys.platform == "win32":
4141
if hasattr(asyncio, "set_event_loop_policy"):
4242
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
43+
elif sys.platform == "darwin":
44+
# The default KqueueSelector cannot handle pipe-based stdin
45+
# (e.g. when running inside Emacs comint-mode). Fall back to
46+
# SelectSelector which works with any file descriptor that supports select().
47+
import selectors
48+
49+
if not sys.stdin.isatty():
50+
_original_event_loop_policy = asyncio.DefaultEventLoopPolicy
51+
52+
class _SelectSelectorPolicy(asyncio.DefaultEventLoopPolicy):
53+
def new_event_loop(self):
54+
return asyncio.SelectorEventLoop(selectors.SelectSelector())
55+
56+
asyncio.set_event_loop_policy(_SelectSelectorPolicy())
4357
from prompt_toolkit.enums import EditingMode
4458

4559
from .dump import dump # noqa
@@ -708,6 +722,7 @@ def get_io(pretty):
708722
notifications_command=args.notifications_command,
709723
notification_bell=args.notification_bell,
710724
verbose=args.verbose,
725+
show_spinner=args.spinner,
711726
)
712727

713728
validate_tui_args(args)

cecli/mcp/manager.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ async def connect_server(self, name: str) -> bool:
167167
# When io is None (e.g., during from_servers before IO is assigned),
168168
# _log_warning and _log_error silently return — retries still happen
169169
# but with no user-visible feedback. This is intentional.
170-
max_retries = 3
170+
max_retries = 3 if server.name != "unnamed-server" else 1
171171
delay = 1.0
172172
backoff = 2.0
173173
max_delay = 30.0
@@ -185,11 +185,12 @@ async def connect_server(self, name: str) -> bool:
185185
except asyncio.CancelledError:
186186
raise
187187
except Exception as e:
188-
if attempt < max_retries:
188+
if attempt < max_retries and server.name != "unnamed-server":
189189
self._log_warning(
190190
f"Connection attempt {attempt} failed for {name}, "
191191
f"retrying in {delay}s... ({e})"
192192
)
193+
193194
await asyncio.sleep(delay)
194195
delay = min(delay * backoff, max_delay)
195196
else:

cecli/mcp/server.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,48 @@ async def disconnect(self):
130130
finally:
131131
self.session = None
132132

133+
async def reconnect(self):
134+
"""Disconnect and reconnect, establishing a fresh session.
135+
136+
Used when the server has invalidated the current session (e.g., after
137+
a server restart), as indicated by an HTTP 404 response per the MCP
138+
protocol specification.
139+
140+
Returns:
141+
ClientSession: The new active session
142+
"""
143+
if self.io:
144+
self.io.tool_warning(f"MCP session expired for {self.name}, reconnecting...")
145+
await self.disconnect()
146+
self.exit_stack = AsyncExitStack()
147+
return await self.connect()
148+
149+
@staticmethod
150+
def is_session_expired_error(exc):
151+
"""Check if an exception indicates an expired MCP session (HTTP 404).
152+
153+
Per the MCP specification, when a server terminates a session it
154+
responds with HTTP 404 Not Found. The client MUST then start a new
155+
session by sending a new InitializeRequest.
156+
157+
Args:
158+
exc: The exception to check
159+
160+
Returns:
161+
bool: True if the error indicates a 404 session expiry
162+
"""
163+
import httpx
164+
165+
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 404:
166+
return True
167+
168+
# Some transports wrap the status in the exception message
169+
exc_str = str(exc).lower()
170+
if "404" in exc_str and ("session" in exc_str or "not found" in exc_str):
171+
return True
172+
173+
return False
174+
133175

134176
class HttpBasedMcpServer(McpServer):
135177
"""Base class for HTTP-based MCP servers (HTTP streaming and SSE)."""

cecli/repo.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,7 @@ async def get_commit_message(self, diffs, context, user_language=None):
486486
commit_message = None
487487
for model in self.models:
488488
spinner_text = f"Generating commit message with {model.name}\n"
489+
489490
self.io.start_spinner(spinner_text, update_last_text=False)
490491

491492
if model.system_prompt_prefix:

cecli/website/docs/config/conf.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,9 @@ cog.outl("```")
236236
## Enable/disable streaming responses (default: True)
237237
#stream: true
238238
239+
## Enable/disable the spinner while waiting for LLM responses (default: True)
240+
#spinner: true
241+
239242
## Set the color for user input (default: #00cc00)
240243
#user-input-color: "#00cc00"
241244

0 commit comments

Comments
 (0)