From 783d3817101a9d662238d5861ecd184ad50719de Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:47:28 +0800 Subject: [PATCH] feat(tui): replace footer hints with model telemetry --- nanobot/agent/runner.py | 46 ++++++++++++++++-- nanobot/providers/base.py | 6 +++ tests/agent/test_runner_hooks.py | 46 +++++++++++++++++- tui/src/app.test.ts | 21 +++++--- tui/src/app.ts | 33 ++++++------- tui/src/footer-hints.test.ts | 45 ++++++++++------- tui/src/footer-hints.ts | 83 +++++++++++++++++++++++--------- tui/src/protocol.ts | 8 +++ 8 files changed, 218 insertions(+), 70 deletions(-) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 7d03674b3..2310e7e53 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import inspect import os +import time from collections.abc import Awaitable, Callable, Iterable from copy import deepcopy from dataclasses import dataclass, field @@ -932,6 +933,27 @@ class AgentRunner: progress_state: dict[str, bool] | None = None active_hosted_tools: dict[str, dict[str, Any]] = {} + request_started_at = 0.0 + first_output_at: float | None = None + generation_started_at: float | None = None + generation_elapsed_s = 0.0 + + def _generation_delta(delta: str) -> None: + nonlocal first_output_at, generation_started_at + if not delta: + return + now = time.perf_counter() + if first_output_at is None: + first_output_at = now + if generation_started_at is None: + generation_started_at = now + + def _pause_generation() -> None: + nonlocal generation_elapsed_s, generation_started_at + if generation_started_at is None: + return + generation_elapsed_s += max(0.0, time.perf_counter() - generation_started_at) + generation_started_at = None async def _provider_tool_event(event: dict[str, Any]) -> None: if event.get("kind") != "hosted_tool": @@ -950,6 +972,7 @@ class AgentRunner: thinking_buf = "" async def _stream(delta: str) -> None: + _generation_delta(delta) if delta: context.streamed_content = True await hook.on_stream(context, delta) @@ -958,6 +981,7 @@ class AgentRunner: nonlocal thinking_buf if not delta: return + _generation_delta(delta) prev_clean = strip_reasoning_tags(thinking_buf) thinking_buf += delta new_clean = strip_reasoning_tags(thinking_buf) @@ -967,6 +991,7 @@ class AgentRunner: await hook.emit_reasoning(incremental) async def _stream_recover() -> None: + _pause_generation() await hook.on_stream_end(context, resuming=True) coro = spec.runtime.provider.chat_stream_with_retry( @@ -986,6 +1011,7 @@ class AgentRunner: nonlocal stream_buf if not delta: return + _generation_delta(delta) prev_clean = strip_think(stream_buf) stream_buf += delta new_clean = strip_think(stream_buf) @@ -1027,6 +1053,7 @@ class AgentRunner: if is_streaming_request and timeout_s is not None else timeout_s ) + request_started_at = time.perf_counter() try: response = ( await coro if outer_timeout_s is None @@ -1045,6 +1072,11 @@ class AgentRunner: finish_reason="error", error_kind="timeout", ) + _pause_generation() + if first_output_at is not None: + response.ttft_ms = max(0, round((first_output_at - request_started_at) * 1000)) + if generation_elapsed_s > 0: + response.generation_ms = max(1, round(generation_elapsed_s * 1000)) # chat_stream_with_retry may recover internally, so only fail unfinished # hosted calls after the provider returns its final error response. if response.finish_reason == "error": @@ -1288,10 +1320,18 @@ class AgentRunner: if total > 0: usage["total_tokens"] = total usage.setdefault("provider_tokens", total) - return usage - if response.finish_reason == "error": + elif response.finish_reason == "error": return {} - return self._estimate_response_usage(spec, messages, response) + else: + usage = self._estimate_response_usage(spec, messages, response) + completion = usage.get("completion_tokens", 0) + if response.generation_ms is not None and completion > 0: + usage["generation_ms"] = response.generation_ms + usage["measured_completion_tokens"] = completion + if response.ttft_ms is not None: + usage["ttft_ms"] = response.ttft_ms + usage["timed_requests"] = 1 + return usage def _estimate_response_usage( self, diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index b8680278a..4efd8ddb8 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -258,6 +258,12 @@ class LLMResponse: tool_calls: list[ToolCallRequest] = field(default_factory=list) finish_reason: str = "stop" usage: dict[str, int] = field(default_factory=dict) + # Locally measured streaming telemetry. ``generation_ms`` excludes time to + # first token and provider retry gaps; ``ttft_ms`` measures the first + # streamed reasoning/content delta from request start. They stay separate + # from provider usage because providers do not report these consistently. + generation_ms: int | None = None + ttft_ms: int | None = None retry_after: float | None = None # Provider supplied retry wait in seconds. reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc. thinking_blocks: list[dict[str, Any]] | None = None # Anthropic extended thinking diff --git a/tests/agent/test_runner_hooks.py b/tests/agent/test_runner_hooks.py index 508f663e0..84c2e1c00 100644 --- a/tests/agent/test_runner_hooks.py +++ b/tests/agent/test_runner_hooks.py @@ -3,7 +3,7 @@ cached-token propagation, and hook context.""" from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -143,6 +143,50 @@ async def test_runner_streaming_hook_receives_deltas_and_end_signal(): provider.chat_with_retry.assert_not_awaited() +@pytest.mark.asyncio +async def test_runner_measures_stream_generation_without_time_to_first_token(): + from nanobot.agent.hook import AgentHook + from nanobot.agent.runner import AgentRunner + + provider = MagicMock(spec=LLMProvider) + + async def chat_stream_with_retry(*, on_content_delta, **kwargs): + await on_content_delta("he") + await on_content_delta("llo") + return LLMResponse( + content="hello", + usage={"prompt_tokens": 100, "completion_tokens": 12}, + ) + + provider.chat_stream_with_retry = chat_stream_with_retry + provider.chat_with_retry = AsyncMock() + tools = MagicMock() + tools.get_definitions.return_value = [] + + class StreamingHook(AgentHook): + def wants_streaming(self) -> bool: + return True + + with patch( + "nanobot.agent.runner.time.perf_counter", + side_effect=[10.0, 10.2, 10.4, 10.8], + ): + result = await AgentRunner().run(make_run_spec( + provider, + initial_messages=[], + tools=tools, + model="test-model", + max_iterations=1, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + hook=StreamingHook(), + )) + + assert result.usage["generation_ms"] == 600 + assert result.usage["measured_completion_tokens"] == 12 + assert result.usage["ttft_ms"] == 200 + assert result.usage["timed_requests"] == 1 + + @pytest.mark.asyncio async def test_runner_length_recovery_streams_segments_once_and_returns_all_content(): from nanobot.agent.hook import AgentHook, AgentHookContext diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index fc7ae2026..6beb4b225 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -1335,7 +1335,7 @@ describe("NanobotTui layout", () => { expect(assistantMarker?.renderable.fg.toInts().slice(0, 3)).toEqual([161, 161, 170]) }) - test("keeps footer status and shortcuts visually separated", async () => { + test("uses the idle footer for model telemetry instead of permanent shortcuts", async () => { setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" }) const app = mount(setup) app.accept({ event: "attached", chat_id: "chat" }) @@ -1343,23 +1343,32 @@ describe("NanobotTui layout", () => { event: "turn_end", chat_id: "chat", latency_ms: 1700, - usage: { prompt_tokens: 1200, completion_tokens: 80, cached_tokens: 900 }, + usage: { + prompt_tokens: 1200, + completion_tokens: 80, + cached_tokens: 900, + generation_ms: 1600, + measured_completion_tokens: 80, + ttft_ms: 240, + timed_requests: 1, + }, context_window_tokens: 128_000, }) await setup.flush() const footer = setup.captureCharFrame().split("\n").find((line) => line.includes("Ready · 1.7s")) || "" expect(footer).toContain("Ready · 1.7s") + expect(footer).toContain("50 tok/s") + expect(footer).toContain("cache 75%") expect(footer).toContain("↑1.2k ↓80") - expect(footer).toContain("enter send") - expect(footer).not.toContain("1.7senter") + expect(footer).not.toContain("enter send") app.accept({ event: "reasoning_delta", chat_id: "chat", text: "hidden" }) await Bun.sleep(130) await setup.renderOnce() const activeFooter = setup.captureCharFrame().split("\n").find((line) => line.includes("Thinking")) || "" - expect(activeFooter).toContain("ctrl+c stop") - expect(activeFooter).not.toContain("enter send") + expect(activeFooter).not.toContain("ctrl+c stop") + expect(activeFooter).not.toContain("enter steer") app.accept({ event: "turn_end", chat_id: "chat" }) }) diff --git a/tui/src/app.ts b/tui/src/app.ts index eec5ea74e..60ce86d22 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -71,6 +71,7 @@ import { QueuePreview, type QueuePreviewTheme } from "./queue-preview" import { RuntimeControls } from "./runtime-controls" import { contextualFooterHints, + footerTelemetry, type FooterMode, type FooterHintTheme, } from "./footer-hints" @@ -320,22 +321,6 @@ function formatElapsed(milliseconds: number): string { return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s` } -function usageStatus(usage: TokenUsage | null): string { - if (!usage) return "" - const prompt = usage.prompt_tokens - const completion = usage.completion_tokens - const tokens = typeof prompt === "number" || typeof completion === "number" - ? `↑${formatTokenCount(prompt || 0)} ↓${formatTokenCount(completion || 0)}` - : typeof usage.total_tokens === "number" ? `${formatTokenCount(usage.total_tokens)} tok` : "" - const cached = typeof usage.cached_tokens === "number" && usage.cached_tokens > 0 - ? `${formatTokenCount(usage.cached_tokens)} cached` - : "" - const cost = typeof usage.cost_usd === "number" && usage.cost_usd > 0 - ? `$${usage.cost_usd < 0.01 ? usage.cost_usd.toFixed(4) : usage.cost_usd.toFixed(2)}` - : "" - return [tokens, cached, cost].filter(Boolean).join(" · ") -} - async function copyWithSystemClipboard(text: string): Promise { const commands = process.platform === "darwin" ? [["pbcopy"]] @@ -942,6 +927,9 @@ export class NanobotTui { } this.updateTitle() this.setActive(false) + // A synthetic/rehydrated turn may already be idle, in which case + // setActive(false) intentionally does not repaint the footer. + this.updateMeta() this.readyDetail = typeof event.latency_ms === "number" ? `${(event.latency_ms / 1000).toFixed(1)}s` : "" @@ -1120,9 +1108,7 @@ export class NanobotTui { ? "New output · Ctrl+End latest" : "History · Ctrl+End latest" } - const usage = usageStatus(this.lastUsage) - const suffix = [detail, usage].filter(Boolean).join(" · ") - if (suffix) return `Ready · ${suffix}` + if (detail) return `Ready · ${detail}` return this.historyHasMore ? "Ready · PageUp for earlier history" : "Ready" } @@ -1429,6 +1415,14 @@ export class NanobotTui { : this.contextPanel.visible ? "context" : this.transcriptNavigation.awayFromBottom ? "history" : "ready" + if (mode === "ready") { + this.meta.content = footerTelemetry( + this.lastUsage, + this.renderer.width, + footerHintTheme(this.palette), + ) + return + } this.meta.content = contextualFooterHints( mode, this.renderer.width, @@ -1955,6 +1949,7 @@ export class NanobotTui { this.lastUsage = context.lastUsage || this.lastUsage this.updateTitle() if (!this.activeTurn) this.status.content = this.readyStatus() + this.updateMeta() } catch { // Keep the last known estimate; it is intentionally informational. } diff --git a/tui/src/footer-hints.test.ts b/tui/src/footer-hints.test.ts index bcd3f77aa..17c95fc73 100644 --- a/tui/src/footer-hints.test.ts +++ b/tui/src/footer-hints.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { contextualFooterHints, footerHints } from "./footer-hints" +import { contextualFooterHints, footerHints, footerTelemetry } from "./footer-hints" const theme = { accent: "#EF8E30", @@ -21,30 +21,39 @@ describe("footerHints", () => { expect(result.chunks[3]?.fg?.toInts().slice(0, 3)).toEqual([248, 113, 113]) }) - test("adapts the active-turn vocabulary to available width", () => { - const wide = contextualFooterHints("active", 100, theme, "linux") - const compact = contextualFooterHints("active", 72, theme, "linux") + test("keeps passive composer modes free of permanent instructions", () => { + const ready = contextualFooterHints("ready", 100, theme, "linux") + const active = contextualFooterHints("active", 100, theme, "darwin") - expect(wide.chunks.map(({ text }) => text).join("")) - .toBe("enter steer · tab queue · alt+↑ edit · ctrl+c stop") - expect(compact.chunks.map(({ text }) => text).join("")) - .toBe("enter steer · tab queue · ctrl+c stop") + expect(ready.chunks).toHaveLength(0) + expect(active.chunks).toHaveLength(0) }) - test("uses the native Option symbol on macOS", () => { - const result = contextualFooterHints("active", 100, theme, "darwin") + test("shows measured throughput, cache ratio, token counts, and TTFT", () => { + const result = footerTelemetry({ + prompt_tokens: 1200, + completion_tokens: 80, + cached_tokens: 900, + generation_ms: 1600, + measured_completion_tokens: 80, + ttft_ms: 500, + timed_requests: 2, + }, 120, theme) expect(result.chunks.map(({ text }) => text).join("")) - .toBe("enter steer · tab queue · ⌥↑ edit · ctrl+c stop") + .toBe("50 tok/s · cache 75% · ↑1.2k ↓80 · TTFT 250ms") + expect(result.chunks[0]?.fg?.toInts().slice(0, 3)).toEqual([239, 142, 48]) }) - test("advertises Shift+Enter when the terminal can distinguish it", () => { - const enhanced = contextualFooterHints("ready", 80, theme, "darwin", true) - const legacy = contextualFooterHints("ready", 80, theme, "darwin", false) + test("degrades telemetry instead of guessing missing provider metrics", () => { + const compact = footerTelemetry({ + prompt_tokens: 1000, + completion_tokens: 20, + cached_tokens: 0, + }, 60, theme) + const unsupported = footerTelemetry({ prompt_tokens: 1000, completion_tokens: 20 }, 60, theme) - expect(enhanced.chunks.map(({ text }) => text).join("")) - .toBe("enter send · shift+enter newline · ctrl+c stop") - expect(legacy.chunks.map(({ text }) => text).join("")) - .toBe("enter send · ctrl+j newline · ctrl+c stop") + expect(compact.chunks.map(({ text }) => text).join("")).toBe("cache 0%") + expect(unsupported.chunks).toHaveLength(0) }) }) diff --git a/tui/src/footer-hints.ts b/tui/src/footer-hints.ts index 80fd758c1..3b2996331 100644 --- a/tui/src/footer-hints.ts +++ b/tui/src/footer-hints.ts @@ -1,6 +1,7 @@ import { RGBA, StyledText, TextAttributes, type TextChunk } from "@opentui/core" -import { optionArrowUp } from "./platform-keys" +import { formatTokenCount } from "./context-panel" +import type { TokenUsage } from "./protocol" export interface FooterHint { key: string @@ -30,10 +31,63 @@ export function contextualFooterHints( mode: FooterMode, width: number, theme: FooterHintTheme, - platform: string = process.platform, - shiftedEnter = false, + _platform: string = process.platform, + _shiftedEnter = false, ): StyledText { - return footerHints(hintsFor(mode, width, platform, shiftedEnter), theme) + return footerHints(hintsFor(mode, width), theme) +} + +/** Last-turn model telemetry. Passive chrome reports the system, not its manual. */ +export function footerTelemetry( + usage: TokenUsage | null, + width: number, + theme: FooterHintTheme, +): StyledText { + if (!usage) return new StyledText([]) + const parts: string[] = [] + const duration = usage.generation_ms + const measured = usage.measured_completion_tokens + if (typeof duration === "number" && duration > 0 && typeof measured === "number") { + const rate = measured * 1000 / duration + const value = rate < 10 ? rate.toFixed(1) : String(Math.round(rate)) + const estimated = (usage.estimated_tokens || 0) > 0 ? "~" : "" + parts.push(`${estimated}${value} tok/s`) + } + if ( + typeof usage.cached_tokens === "number" + && typeof usage.prompt_tokens === "number" + && usage.prompt_tokens > 0 + ) { + const hitRate = Math.min(100, Math.max(0, Math.round( + usage.cached_tokens * 100 / usage.prompt_tokens, + ))) + parts.push(`cache ${hitRate}%`) + } + if (width >= 72) { + const prompt = usage.prompt_tokens + const completion = usage.completion_tokens + if (typeof prompt === "number" || typeof completion === "number") { + parts.push(`↑${formatTokenCount(prompt || 0)} ↓${formatTokenCount(completion || 0)}`) + } + } + if (width >= 112 && typeof usage.ttft_ms === "number") { + const requests = Math.max(1, usage.timed_requests || 1) + const average = usage.ttft_ms / requests + parts.push(`TTFT ${average < 1000 ? `${Math.round(average)}ms` : `${(average / 1000).toFixed(1)}s`}`) + } + if (width >= 128 && typeof usage.cost_usd === "number" && usage.cost_usd > 0) { + parts.push(`$${usage.cost_usd < 0.01 ? usage.cost_usd.toFixed(4) : usage.cost_usd.toFixed(2)}`) + } + return footerMetrics(parts, theme) +} + +function footerMetrics(parts: readonly string[], theme: FooterHintTheme): StyledText { + const chunks: TextChunk[] = [] + parts.forEach((text, index) => { + if (index) chunks.push(chunk(" · ", theme.separator)) + chunks.push(chunk(text, index === 0 ? theme.accent : theme.muted, index === 0)) + }) + return new StyledText(chunks) } /** Give shortcuts visual hierarchy without turning the footer into a toolbar. */ @@ -51,8 +105,6 @@ export function footerHints(hints: readonly FooterHint[], theme: FooterHintTheme function hintsFor( mode: FooterMode, width: number, - platform: string, - shiftedEnter: boolean, ): FooterHint[] { if (mode === "runtime") return width >= 64 ? [hint("↑↓/click", "choose"), hint("enter", "apply"), hint("esc", "close")] @@ -60,9 +112,7 @@ function hintsFor( if (mode === "mention") return width >= 64 ? [hint("↑↓", "choose"), hint("tab/enter", "insert"), hint("esc", "close")] : [hint("enter", "insert"), hint("esc", "close")] - if (mode === "active") return width >= 96 - ? [hint("enter", "steer"), hint("tab", "queue"), hint(optionArrowUp(platform), "edit"), stopHint()] - : width >= 64 ? [hint("enter", "steer"), hint("tab", "queue"), stopHint()] : [] + if (mode === "active") return [] if (mode === "branch") return width >= 64 ? [hint("type", "filter"), hint("↑↓", "choose"), hint("enter", "branch"), hint("esc", "close")] : [hint("enter", "branch"), hint("esc", "close")] @@ -76,26 +126,13 @@ function hintsFor( if (mode === "history") return width >= 72 ? [hint("ctrl+end", "latest"), hint("pgup/pgdn", "scroll")] : width >= 48 ? [hint("ctrl+end", "latest")] : [] - const newline = shiftedEnter ? "shift+enter" : "ctrl+j" - if (width >= 112) return [ - hint("enter", "send"), - hint(newline, "newline"), - hint("pgup/pgdn", "scroll"), - hint("ctrl+o", "tools"), - stopHint(), - ] - if (width >= 72) return [hint("enter", "send"), hint(newline, "newline"), stopHint()] - return width >= 48 ? [hint("enter", "send"), hint(newline, "newline")] : [] + return [] } function hint(key: string, label: string): FooterHint { return { key, label } } -function stopHint(): FooterHint { - return { key: "ctrl+c", label: "stop", tone: "danger" } -} - function chunk(text: string, color: string, bold = false): TextChunk { return { __isChunk: true, diff --git a/tui/src/protocol.ts b/tui/src/protocol.ts index 32fd5bc99..8e76d602e 100644 --- a/tui/src/protocol.ts +++ b/tui/src/protocol.ts @@ -181,6 +181,10 @@ export interface TokenUsage { provider_tokens?: number estimated_tokens?: number cost_usd?: number + generation_ms?: number + measured_completion_tokens?: number + ttft_ms?: number + timed_requests?: number } export interface SessionContextSnapshot { @@ -325,6 +329,10 @@ function isTokenUsage(value: unknown): value is TokenUsage { "provider_tokens", "estimated_tokens", "cost_usd", + "generation_ms", + "measured_completion_tokens", + "ttft_ms", + "timed_requests", ].every((key) => optional(value[key], "number")) }