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
8 changes: 3 additions & 5 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1597,7 +1597,7 @@ async def output_task(self, preproc):
self.io.output_task = asyncio.create_task(self.generate(user_message, preproc))

# Start spinner for output task
self.io.start_spinner("Processing...")
self.io.start_spinner("Processing...", coder_uuid=getattr(self, "uuid", None))
await self.io.recreate_input()

# Monitor output task
Expand Down Expand Up @@ -2365,7 +2365,7 @@ 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)
self.io.start_spinner(spinner_text, coder_uuid=getattr(self, "uuid", None))
if self.stream:
self.mdstream = True
else:
Expand Down Expand Up @@ -2452,9 +2452,7 @@ async def format_in_executor():
self.mdstream = None

# Ensure any waiting spinner is stopped
self.io.start_spinner("Processing Answer...")

self.partial_response_content = self.get_multi_response_content_in_progress(True)
self.io.start_spinner("Processing Answer...", coder_uuid=getattr(self, "uuid", None))
self.remove_reasoning_content()
self.multi_response_content = ""

Expand Down
2 changes: 1 addition & 1 deletion cecli/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ def _spinner_supports_unicode(self) -> bool:
except Exception:
return False

def start_spinner(self, text, update_last_text=True):
def start_spinner(self, text, update_last_text=True, **kwargs):
"""Start the spinner."""
self.stop_spinner()

Expand Down
88 changes: 72 additions & 16 deletions cecli/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,9 +502,10 @@ def check_output_queue(self):
pass

def handle_output_message(self, msg):
"""Route output messages to appropriate handlers."""
msg_type = msg["type"]

# Resolve agent_name from coder_uuid for agent-specific status messages
agent_name = self._resolve_agent_name(msg.get("coder_uuid"))
if msg_type == "output":
container = self._get_output_container(msg)
container.add_output(msg["text"], msg.get("task_id"))
Expand Down Expand Up @@ -532,15 +533,15 @@ def handle_output_message(self, msg):
container = self._get_output_container(msg)
container.start_task(msg["task_id"], msg["title"], msg.get("task_type"))
elif msg_type == "confirmation":
self.show_confirmation(msg)
self.show_confirmation(msg, agent_name=agent_name)
elif msg_type == "spinner":
self.update_spinner(msg)
self.update_spinner(msg, agent_name=agent_name)
elif msg_type == "ready_for_input":
self.enable_input(msg)
footer = self.query_one(MainFooter)
footer.stop_spinner()
elif msg_type == "error":
self.show_error(msg["message"])
self.show_error(msg["message"], agent_name=agent_name)
elif msg_type == "cost_update":
footer = self.query_one(MainFooter)
footer.update_cost(msg.get("cost", 0))
Expand All @@ -563,6 +564,53 @@ def handle_output_message(self, msg):
else:
self._switch_to_container(target_uuid)

def _resolve_agent_name(self, coder_uuid: str | None) -> str | None:
"""Resolve an agent display name from a coder_uuid.

Returns the sub-agent's name if the coder_uuid belongs to a known
sub-agent. For the primary agent, returns "primary" if sub-agents
exist, otherwise None.

If multiple sub-agents share the same name, disambiguates by
appending the first 3 characters of the UUID in parentheses.
"""
if not coder_uuid:
return None
try:
if not self.worker or not self.worker.coder:
return None # Cannot resolve without a coder
from cecli.helpers.agents.service import AgentService

agent_service = AgentService.get_instance(self.worker.coder)
if not agent_service:
return None
primary_uuid = str(agent_service.coder.uuid)
if coder_uuid == primary_uuid:
if agent_service.sub_agents:
return "primary"
return None # Primary agent gets no prefix
if not agent_service.sub_agents:
return None
for info in agent_service.sub_agents.values():
if not info or not info.coder:
continue
if str(info.coder.uuid) == coder_uuid:
# Check for duplicate names among sub-agents
name_count = sum(
1
for i in agent_service.sub_agents.values()
if i and hasattr(i, "name") and i.name == info.name
)
if name_count > 1:
# Disambiguate with first 3 UUID characters
short_uuid = str(info.coder.uuid)[:3]
return f"{info.name} ({short_uuid})"
return info.name
except (AttributeError, ImportError, KeyError):
# Agent service not available or coder not yet initialized
pass
return None

def add_output(self, text, task_id=None):
"""Add output to the output container."""
output_container = self.query_one("#output", OutputContainer)
Expand Down Expand Up @@ -601,7 +649,7 @@ def start_task(self, task_id, title, task_type="general"):
output_container = self.query_one("#output", OutputContainer)
output_container.start_task(task_id, title, task_type)

def show_confirmation(self, msg):
def show_confirmation(self, msg, agent_name: str | None = None):
"""Show inline confirmation bar."""
# Disable input while confirm bar is active
input_area = self.query_one("#input", InputArea)
Expand All @@ -623,6 +671,7 @@ def show_confirmation(self, msg):
allow_never=allow_never,
default=options.get("default", "y"),
explicit_yes_required=options.get("explicit_yes_required", False),
agent_name=agent_name,
)

def enable_input(self, msg, coder=None):
Expand Down Expand Up @@ -657,24 +706,25 @@ def enable_input(self, msg, coder=None):

input_area.focus()

def update_spinner(self, msg):
def update_spinner(self, msg, agent_name: str | None = None):
"""Update spinner in footer."""
footer = self.query_one(MainFooter)
action = msg.get("action", "start")

if action == "start":
footer.start_spinner(msg.get("text", ""))
footer.start_spinner(msg.get("text", ""), agent_name=agent_name or "")
elif action == "update":
footer.spinner_text = msg.get("text", "")
elif action == "update_suffix":
footer.spinner_suffix = msg.get("text", "")
elif action == "stop":
footer.stop_spinner()

def show_error(self, message):
"""Show error notification."""
status_bar = self.query_one("#status-bar", StatusBar)
status_bar.show_notification(f"Error: {message}", severity="error", timeout=10)
def show_error(self, message, agent_name: str | None = None):
"""Show an error message in the status bar."""
self.status_bar.show_notification(
message, severity="error", timeout=5, agent_name=agent_name
)

def on_resize(self) -> None:
file_list = self.query_one("#file-list", FileList)
Expand Down Expand Up @@ -781,15 +831,21 @@ def on_input_area_submit(self, message: InputArea.Submit):

# Update footer to show processing
footer = self.query_one(MainFooter)
footer.start_spinner("Processing...")

coder = self.worker.coder

if coder:
coder.io.start_spinner("Processing...")

# Determine which coder is in the foreground for input routing
foreground_coder = AgentService.get_instance(coder).foreground_coder
coder_uuid = (
str(foreground_coder.uuid)
if foreground_coder and hasattr(foreground_coder, "uuid")
else None
)
agent_name = self._resolve_agent_name(coder_uuid)

footer.start_spinner("Processing...", agent_name=agent_name or "")

if coder:
coder.io.start_spinner("Processing...", coder_uuid=coder_uuid)

if coder and is_active(getattr(coder.io, "output_task", None)):
from cecli.helpers.conversation import ConversationService, MessageTag
Expand Down
28 changes: 18 additions & 10 deletions cecli/tui/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,13 +328,15 @@ def _reroute_output(self, text, msg_type, **kwargs):

return False

def start_spinner(self, text, update_last_text=True):
def start_spinner(self, text, update_last_text=True, **kwargs):
"""Override start_spinner to send spinner state to TUI.

Args:
text: Spinner text
update_last_text: Whether to update last_spinner_text
coder_uuid: Optional uuid string to include in the message
"""
coder_uuid = kwargs.get("coder_uuid", None)
# Call parent to maintain state
super().start_spinner(text, update_last_text)

Expand All @@ -344,23 +346,27 @@ def start_spinner(self, text, update_last_text=True):
"type": "spinner",
"action": "start",
"text": text,
"coder_uuid": coder_uuid,
}
)

self.output_queue.put(
{
"type": "spinner",
"coder_uuid": coder_uuid,
"action": "update_suffix",
"text": "",
}
)

def update_spinner(self, text):
def update_spinner(self, text, **kwargs):
"""Override update_spinner to send updates to TUI.

Args:
text: New spinner text
coder_uuid: Optional uuid string to include in the message
"""
coder_uuid = kwargs.get("coder_uuid", None)
# Call parent
super().update_spinner(text)

Expand All @@ -370,15 +376,18 @@ def update_spinner(self, text):
"type": "spinner",
"action": "update",
"text": text,
"coder_uuid": coder_uuid,
}
)

def update_spinner_suffix(self, text=None):
def update_spinner_suffix(self, text=None, **kwargs):
"""Override update_spinner_suffix to send updates to TUI.

Args:
text: New spinner suffix text
coder_uuid: Optional uuid string to include in the message
"""
coder_uuid = kwargs.get("coder_uuid", None)
# Call parent
super().update_spinner_suffix(text)

Expand All @@ -388,21 +397,18 @@ def update_spinner_suffix(self, text=None):
"type": "spinner",
"action": "update_suffix",
"text": text,
"coder_uuid": coder_uuid,
}
)

def stop_spinner(self):
def stop_spinner(self, **kwargs):
"""Override stop_spinner to send stop state to TUI."""
coder_uuid = kwargs.get("coder_uuid", None)
# Call parent
super().stop_spinner()

# Send to TUI
self.output_queue.put(
{
"type": "spinner",
"action": "stop",
}
)
self.output_queue.put({"type": "spinner", "action": "stop", "coder_uuid": coder_uuid})

def interrupt_input(self):
self.interrupted = True
Expand Down Expand Up @@ -518,6 +524,7 @@ async def confirm_ask(
allow_never=False,
allow_tweak=False,
acknowledge=False,
coder_uuid=None,
):
"""Override confirm_ask to show modal instead of inline prompt.

Expand Down Expand Up @@ -594,6 +601,7 @@ async def confirm_ask(
"acknowledge": acknowledge,
"valid_responses": valid_responses,
},
"coder_uuid": coder_uuid,
}
)

Expand Down
11 changes: 8 additions & 3 deletions cecli/tui/widgets/footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class MainFooter(Static):

# Left side info
coder_mode = reactive("code")
agent_name = reactive("")
model_name = reactive("")

# Right side info
Expand Down Expand Up @@ -46,6 +47,7 @@ def __init__(
self.project_name = project_name
self.git_branch = git_branch
self.coder_mode = coder_mode
self.agent_name = ""
self._spinner_interval = None

def on_mount(self):
Expand Down Expand Up @@ -77,7 +79,7 @@ def _get_display_model(self) -> str:
else:
name = coder.get_active_model().name
except Exception:
name = self.app.worker.coder.get_active_model().name
name = self.model_name

# Strip common prefixes like "openrouter/x-ai/"
if len(name) > 40:
Expand All @@ -98,9 +100,10 @@ def render(self) -> Text:
if self.spinner_visible:
spinner_char = self._spinner_chars[self._spinner_frame]
left.append(f"{spinner_char} ")
if self.agent_name:
left.append(f"({self.agent_name}) ")
if self.spinner_text:
left.append(self.spinner_text)

# When a sub-agent is generating, show its model alongside the spinner
# if self._has_running_sub_agent():
# model_display = self._get_display_model()
Expand Down Expand Up @@ -176,9 +179,10 @@ def update_mode(self, mode: str):
self.coder_mode = mode
self.refresh()

def start_spinner(self, text: str = ""):
def start_spinner(self, text: str = "", agent_name: str = ""):
"""Show spinner with optional text."""
self.spinner_text = text
self.agent_name = agent_name
self.spinner_visible = True
self.refresh()

Expand All @@ -204,6 +208,7 @@ def stop_spinner(self):

self.spinner_visible = False
self.spinner_text = ""
self.agent_name = ""
self.refresh()

def _has_running_sub_agent(self) -> bool:
Expand Down
Loading
Loading