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
33 changes: 29 additions & 4 deletions cecli/helpers/profiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def __init__(self, enable_printing: bool = False):
self._input_tokens: int = 0
self._output_tokens: int = 0
self._has_seen_first_token: bool = False
self._token_ticker: int = 0

def start(self) -> None:
"""Start timing an LLM request."""
Expand All @@ -40,15 +41,18 @@ def start(self) -> None:
self._has_seen_first_token = False
self._input_tokens = 0
self._output_tokens = 0
self._token_ticker = 0

def on_token(self) -> None:
"""
Record that a token was received.
Auto-detects if this is the first token.
"""
if not self._enabled or not self._start_time:
if not self._start_time:
return

self._token_ticker += 1

if not self._has_seen_first_token:
self._first_token_time = time.time()
self._has_seen_first_token = True
Expand All @@ -61,9 +65,6 @@ def set_token_counts(self, input_tokens: int, output_tokens: int) -> None:
input_tokens: Number of input/prompt tokens
output_tokens: Number of output/generated tokens
"""
if not self._enabled:
return

self._input_tokens = input_tokens
self._output_tokens = output_tokens

Expand All @@ -75,6 +76,30 @@ def on_error(self) -> None:
if self._end_time is None:
self._end_time = time.time()


def get_generation_speed(self) -> Optional[float]:
"""Live output generation speed in tokens/sec."""
if not self._start_time:
return None
end = self._end_time or time.time()
output_tokens = self._output_tokens if self._output_tokens > 0 else self._token_ticker
if output_tokens <= 0:
return None
gen_start = self._first_token_time or self._start_time
gen_time = end - gen_start
if gen_time <= 0:
return None
return output_tokens / gen_time

def get_prompt_speed(self) -> Optional[float]:
"""Live prompt-processing speed in prompt tokens/sec."""
if not self._start_time or self._first_token_time is None:
return None
ttft = self._first_token_time - self._start_time
if ttft <= 0 or self._input_tokens <= 0:
return None
return self._input_tokens / ttft

def get_report(self) -> Optional[str]:
"""
Get the formatted speed report (only if enabled).
Expand Down
8 changes: 4 additions & 4 deletions cecli/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,9 +652,9 @@ def handle_output_message(self, msg):
container = self._get_output_container(msg)
self.run_worker(self._stream_chunk(container, msg["text"]))
elif msg_type == "end_response":
# End the current LLM response
# End the current LLM response and append its average generation speed
container = self._get_output_container(msg)
self.run_worker(self._end_response(container))
self.run_worker(self._end_response(container, msg.get("coder_uuid")))
elif msg_type == "start_task":
container = self._get_output_container(msg)
container.start_task(msg["task_id"], msg["title"], msg.get("task_type"))
Expand Down Expand Up @@ -759,11 +759,11 @@ async def _stream_chunk(self, container, text: str):
container = self.query_one("#output", OutputContainer)
await container.stream_chunk(text)

async def _end_response(self, container=None):
async def _end_response(self, container=None, coder_uuid=None):
"""End the current LLM response (async helper)."""
if container is None:
container = self.query_one("#output", OutputContainer)
await container.end_response()
await container.end_response(coder_uuid)

def add_user_message(self, text: str):
"""Add a user message to output, routing to the active container."""
Expand Down
22 changes: 21 additions & 1 deletion cecli/tui/widgets/footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class MainFooter(Static):
spinner_visible = reactive(False)
_spinner_frame = 0
_spinner_chars = "⠏⠛⠹⠼⠶⠧"
# Live generation speed readout (tokens/sec), refreshed while streaming.
gen_speed = reactive(0.0)

def __init__(
self,
Expand Down Expand Up @@ -56,11 +58,25 @@ def on_mount(self):
)

def _animate_spinner(self):
"""Animate the spinner character."""
"""Animate the spinner character and update live generation speed."""
if self.spinner_visible:
self._spinner_frame = (self._spinner_frame + 1) % len(self._spinner_chars)
self._update_gen_speed()
self.refresh()

def _update_gen_speed(self) -> None:
"""Update live generation speed from the active coder's token profiler."""
try:
coder = self.app.worker.coder
if hasattr(coder, 'token_profiler'):
speed = coder.token_profiler.get_generation_speed()
if speed is not None:
self.gen_speed = speed
return
except Exception:
pass
self.gen_speed = 0.0

def _get_display_model(self) -> str:
"""Get shortened model name for display.

Expand Down Expand Up @@ -115,6 +131,9 @@ def render(self) -> Text:
if self.spinner_suffix:
left.append(" • ")
left.append(self.spinner_suffix)
if self.gen_speed > 0:
left.append(" • ")
left.append(f"{self.gen_speed:.1f} t/s")
else:
left.append("cecli")
left.append(" • ")
Expand Down Expand Up @@ -217,6 +236,7 @@ def stop_spinner(self):
self._spinner_interval.pause()
self.spinner_text = ""
self.agent_name = ""
self.gen_speed = 0.0
self.refresh()

def _has_running_sub_agent(self) -> bool:
Expand Down
53 changes: 52 additions & 1 deletion cecli/tui/widgets/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,60 @@ async def stream_chunk(self, text: str):
if wrapped.strip():
self.output(escape(wrapped), render_markdown=True)

async def end_response(self):
async def end_response(self, coder_uuid=None):
"""End the current LLM response."""
await self._stop_stream()
# Append the average generation speed for this response segment
self._append_generation_speed(coder_uuid)

def _append_generation_speed(self, coder_uuid=None):
"""Append a dim-styled line showing the average generation speed (t/s)

for the just-completed LLM response segment. Reads the underlying
coder's token_profiler via coder_uuid (falling back to the primary
coder). The styling matches system messages (dim + left indent), the
same font color used by the Models/Settings/Environment block.
"""
if not hasattr(self, "app") or self.app is None:
return
try:
coder = self._resolve_coder(coder_uuid)
if coder is None or not hasattr(coder, "token_profiler"):
return
speed = coder.token_profiler.get_generation_speed()
if speed is None or speed <= 0:
return
text = f"• {speed:.0f} t/s"
# Same styling as system messages: dim + left padding of 2
self.output(Padding(f"[dim]{escape(text)}[/dim]", (0, 0, 0, 2)))
except Exception:
pass

def _resolve_coder(self, coder_uuid):
"""Resolve a coder object from a coder_uuid.

Falls back to the primary worker coder when coder_uuid is None or the
uuid belongs to the primary agent. Looks up sub-agents via
AgentService when available.
"""
coder = getattr(getattr(self, "app", None), "worker", None)
coder = getattr(coder, "coder", None)
if coder is None or coder_uuid is None:
return coder
primary_uuid = str(getattr(coder, "uuid", ""))
if str(coder_uuid) == primary_uuid:
return coder
try:
from cecli.helpers.agents.service import AgentService

service = AgentService.get_instance(coder)
for info in service.sub_agents.values():
sub = info.coder if info is not None else None
if sub is not None and str(sub.uuid) == str(coder_uuid):
return sub
except Exception:
pass
return coder

async def _stop_stream(self):
"""Stop the current markdown stream."""
Expand Down
Loading