diff --git a/cecli/helpers/profiler.py b/cecli/helpers/profiler.py index 7f332612fc5..51de37949d8 100644 --- a/cecli/helpers/profiler.py +++ b/cecli/helpers/profiler.py @@ -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.""" @@ -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 @@ -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 @@ -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). diff --git a/cecli/tui/app.py b/cecli/tui/app.py index f685c9d46d0..96ded5e04d7 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -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")) @@ -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.""" diff --git a/cecli/tui/widgets/footer.py b/cecli/tui/widgets/footer.py index 1ae2c233049..6beac6ddd9d 100644 --- a/cecli/tui/widgets/footer.py +++ b/cecli/tui/widgets/footer.py @@ -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, @@ -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. @@ -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(" • ") @@ -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: diff --git a/cecli/tui/widgets/output.py b/cecli/tui/widgets/output.py index e40617f008c..7b6211ee701 100644 --- a/cecli/tui/widgets/output.py +++ b/cecli/tui/widgets/output.py @@ -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."""