mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
feat(tui): replace footer hints with model telemetry
This commit is contained in:
+43
-3
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from collections.abc import Awaitable, Callable, Iterable
|
from collections.abc import Awaitable, Callable, Iterable
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -932,6 +933,27 @@ class AgentRunner:
|
|||||||
|
|
||||||
progress_state: dict[str, bool] | None = None
|
progress_state: dict[str, bool] | None = None
|
||||||
active_hosted_tools: dict[str, dict[str, Any]] = {}
|
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:
|
async def _provider_tool_event(event: dict[str, Any]) -> None:
|
||||||
if event.get("kind") != "hosted_tool":
|
if event.get("kind") != "hosted_tool":
|
||||||
@@ -950,6 +972,7 @@ class AgentRunner:
|
|||||||
thinking_buf = ""
|
thinking_buf = ""
|
||||||
|
|
||||||
async def _stream(delta: str) -> None:
|
async def _stream(delta: str) -> None:
|
||||||
|
_generation_delta(delta)
|
||||||
if delta:
|
if delta:
|
||||||
context.streamed_content = True
|
context.streamed_content = True
|
||||||
await hook.on_stream(context, delta)
|
await hook.on_stream(context, delta)
|
||||||
@@ -958,6 +981,7 @@ class AgentRunner:
|
|||||||
nonlocal thinking_buf
|
nonlocal thinking_buf
|
||||||
if not delta:
|
if not delta:
|
||||||
return
|
return
|
||||||
|
_generation_delta(delta)
|
||||||
prev_clean = strip_reasoning_tags(thinking_buf)
|
prev_clean = strip_reasoning_tags(thinking_buf)
|
||||||
thinking_buf += delta
|
thinking_buf += delta
|
||||||
new_clean = strip_reasoning_tags(thinking_buf)
|
new_clean = strip_reasoning_tags(thinking_buf)
|
||||||
@@ -967,6 +991,7 @@ class AgentRunner:
|
|||||||
await hook.emit_reasoning(incremental)
|
await hook.emit_reasoning(incremental)
|
||||||
|
|
||||||
async def _stream_recover() -> None:
|
async def _stream_recover() -> None:
|
||||||
|
_pause_generation()
|
||||||
await hook.on_stream_end(context, resuming=True)
|
await hook.on_stream_end(context, resuming=True)
|
||||||
|
|
||||||
coro = spec.runtime.provider.chat_stream_with_retry(
|
coro = spec.runtime.provider.chat_stream_with_retry(
|
||||||
@@ -986,6 +1011,7 @@ class AgentRunner:
|
|||||||
nonlocal stream_buf
|
nonlocal stream_buf
|
||||||
if not delta:
|
if not delta:
|
||||||
return
|
return
|
||||||
|
_generation_delta(delta)
|
||||||
prev_clean = strip_think(stream_buf)
|
prev_clean = strip_think(stream_buf)
|
||||||
stream_buf += delta
|
stream_buf += delta
|
||||||
new_clean = strip_think(stream_buf)
|
new_clean = strip_think(stream_buf)
|
||||||
@@ -1027,6 +1053,7 @@ class AgentRunner:
|
|||||||
if is_streaming_request and timeout_s is not None
|
if is_streaming_request and timeout_s is not None
|
||||||
else timeout_s
|
else timeout_s
|
||||||
)
|
)
|
||||||
|
request_started_at = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
response = (
|
response = (
|
||||||
await coro if outer_timeout_s is None
|
await coro if outer_timeout_s is None
|
||||||
@@ -1045,6 +1072,11 @@ class AgentRunner:
|
|||||||
finish_reason="error",
|
finish_reason="error",
|
||||||
error_kind="timeout",
|
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
|
# chat_stream_with_retry may recover internally, so only fail unfinished
|
||||||
# hosted calls after the provider returns its final error response.
|
# hosted calls after the provider returns its final error response.
|
||||||
if response.finish_reason == "error":
|
if response.finish_reason == "error":
|
||||||
@@ -1288,10 +1320,18 @@ class AgentRunner:
|
|||||||
if total > 0:
|
if total > 0:
|
||||||
usage["total_tokens"] = total
|
usage["total_tokens"] = total
|
||||||
usage.setdefault("provider_tokens", total)
|
usage.setdefault("provider_tokens", total)
|
||||||
return usage
|
elif response.finish_reason == "error":
|
||||||
if response.finish_reason == "error":
|
|
||||||
return {}
|
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(
|
def _estimate_response_usage(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -258,6 +258,12 @@ class LLMResponse:
|
|||||||
tool_calls: list[ToolCallRequest] = field(default_factory=list)
|
tool_calls: list[ToolCallRequest] = field(default_factory=list)
|
||||||
finish_reason: str = "stop"
|
finish_reason: str = "stop"
|
||||||
usage: dict[str, int] = field(default_factory=dict)
|
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.
|
retry_after: float | None = None # Provider supplied retry wait in seconds.
|
||||||
reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc.
|
reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc.
|
||||||
thinking_blocks: list[dict[str, Any]] | None = None # Anthropic extended thinking
|
thinking_blocks: list[dict[str, Any]] | None = None # Anthropic extended thinking
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ cached-token propagation, and hook context."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -143,6 +143,50 @@ async def test_runner_streaming_hook_receives_deltas_and_end_signal():
|
|||||||
provider.chat_with_retry.assert_not_awaited()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_runner_length_recovery_streams_segments_once_and_returns_all_content():
|
async def test_runner_length_recovery_streams_segments_once_and_returns_all_content():
|
||||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||||
|
|||||||
+15
-6
@@ -1335,7 +1335,7 @@ describe("NanobotTui layout", () => {
|
|||||||
expect(assistantMarker?.renderable.fg.toInts().slice(0, 3)).toEqual([161, 161, 170])
|
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" })
|
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
|
||||||
const app = mount(setup)
|
const app = mount(setup)
|
||||||
app.accept({ event: "attached", chat_id: "chat" })
|
app.accept({ event: "attached", chat_id: "chat" })
|
||||||
@@ -1343,23 +1343,32 @@ describe("NanobotTui layout", () => {
|
|||||||
event: "turn_end",
|
event: "turn_end",
|
||||||
chat_id: "chat",
|
chat_id: "chat",
|
||||||
latency_ms: 1700,
|
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,
|
context_window_tokens: 128_000,
|
||||||
})
|
})
|
||||||
await setup.flush()
|
await setup.flush()
|
||||||
|
|
||||||
const footer = setup.captureCharFrame().split("\n").find((line) => line.includes("Ready · 1.7s")) || ""
|
const footer = setup.captureCharFrame().split("\n").find((line) => line.includes("Ready · 1.7s")) || ""
|
||||||
expect(footer).toContain("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("↑1.2k ↓80")
|
||||||
expect(footer).toContain("enter send")
|
expect(footer).not.toContain("enter send")
|
||||||
expect(footer).not.toContain("1.7senter")
|
|
||||||
|
|
||||||
app.accept({ event: "reasoning_delta", chat_id: "chat", text: "hidden" })
|
app.accept({ event: "reasoning_delta", chat_id: "chat", text: "hidden" })
|
||||||
await Bun.sleep(130)
|
await Bun.sleep(130)
|
||||||
await setup.renderOnce()
|
await setup.renderOnce()
|
||||||
const activeFooter = setup.captureCharFrame().split("\n").find((line) => line.includes("Thinking")) || ""
|
const activeFooter = setup.captureCharFrame().split("\n").find((line) => line.includes("Thinking")) || ""
|
||||||
expect(activeFooter).toContain("ctrl+c stop")
|
expect(activeFooter).not.toContain("ctrl+c stop")
|
||||||
expect(activeFooter).not.toContain("enter send")
|
expect(activeFooter).not.toContain("enter steer")
|
||||||
app.accept({ event: "turn_end", chat_id: "chat" })
|
app.accept({ event: "turn_end", chat_id: "chat" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+14
-19
@@ -71,6 +71,7 @@ import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
|
|||||||
import { RuntimeControls } from "./runtime-controls"
|
import { RuntimeControls } from "./runtime-controls"
|
||||||
import {
|
import {
|
||||||
contextualFooterHints,
|
contextualFooterHints,
|
||||||
|
footerTelemetry,
|
||||||
type FooterMode,
|
type FooterMode,
|
||||||
type FooterHintTheme,
|
type FooterHintTheme,
|
||||||
} from "./footer-hints"
|
} 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`
|
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<void> {
|
async function copyWithSystemClipboard(text: string): Promise<void> {
|
||||||
const commands = process.platform === "darwin"
|
const commands = process.platform === "darwin"
|
||||||
? [["pbcopy"]]
|
? [["pbcopy"]]
|
||||||
@@ -942,6 +927,9 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
this.updateTitle()
|
this.updateTitle()
|
||||||
this.setActive(false)
|
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"
|
this.readyDetail = typeof event.latency_ms === "number"
|
||||||
? `${(event.latency_ms / 1000).toFixed(1)}s`
|
? `${(event.latency_ms / 1000).toFixed(1)}s`
|
||||||
: ""
|
: ""
|
||||||
@@ -1120,9 +1108,7 @@ export class NanobotTui {
|
|||||||
? "New output · Ctrl+End latest"
|
? "New output · Ctrl+End latest"
|
||||||
: "History · Ctrl+End latest"
|
: "History · Ctrl+End latest"
|
||||||
}
|
}
|
||||||
const usage = usageStatus(this.lastUsage)
|
if (detail) return `Ready · ${detail}`
|
||||||
const suffix = [detail, usage].filter(Boolean).join(" · ")
|
|
||||||
if (suffix) return `Ready · ${suffix}`
|
|
||||||
return this.historyHasMore ? "Ready · PageUp for earlier history" : "Ready"
|
return this.historyHasMore ? "Ready · PageUp for earlier history" : "Ready"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1429,6 +1415,14 @@ export class NanobotTui {
|
|||||||
: this.contextPanel.visible ? "context"
|
: this.contextPanel.visible ? "context"
|
||||||
: this.transcriptNavigation.awayFromBottom ? "history"
|
: this.transcriptNavigation.awayFromBottom ? "history"
|
||||||
: "ready"
|
: "ready"
|
||||||
|
if (mode === "ready") {
|
||||||
|
this.meta.content = footerTelemetry(
|
||||||
|
this.lastUsage,
|
||||||
|
this.renderer.width,
|
||||||
|
footerHintTheme(this.palette),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
this.meta.content = contextualFooterHints(
|
this.meta.content = contextualFooterHints(
|
||||||
mode,
|
mode,
|
||||||
this.renderer.width,
|
this.renderer.width,
|
||||||
@@ -1955,6 +1949,7 @@ export class NanobotTui {
|
|||||||
this.lastUsage = context.lastUsage || this.lastUsage
|
this.lastUsage = context.lastUsage || this.lastUsage
|
||||||
this.updateTitle()
|
this.updateTitle()
|
||||||
if (!this.activeTurn) this.status.content = this.readyStatus()
|
if (!this.activeTurn) this.status.content = this.readyStatus()
|
||||||
|
this.updateMeta()
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the last known estimate; it is intentionally informational.
|
// Keep the last known estimate; it is intentionally informational.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import { contextualFooterHints, footerHints } from "./footer-hints"
|
import { contextualFooterHints, footerHints, footerTelemetry } from "./footer-hints"
|
||||||
|
|
||||||
const theme = {
|
const theme = {
|
||||||
accent: "#EF8E30",
|
accent: "#EF8E30",
|
||||||
@@ -21,30 +21,39 @@ describe("footerHints", () => {
|
|||||||
expect(result.chunks[3]?.fg?.toInts().slice(0, 3)).toEqual([248, 113, 113])
|
expect(result.chunks[3]?.fg?.toInts().slice(0, 3)).toEqual([248, 113, 113])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("adapts the active-turn vocabulary to available width", () => {
|
test("keeps passive composer modes free of permanent instructions", () => {
|
||||||
const wide = contextualFooterHints("active", 100, theme, "linux")
|
const ready = contextualFooterHints("ready", 100, theme, "linux")
|
||||||
const compact = contextualFooterHints("active", 72, theme, "linux")
|
const active = contextualFooterHints("active", 100, theme, "darwin")
|
||||||
|
|
||||||
expect(wide.chunks.map(({ text }) => text).join(""))
|
expect(ready.chunks).toHaveLength(0)
|
||||||
.toBe("enter steer · tab queue · alt+↑ edit · ctrl+c stop")
|
expect(active.chunks).toHaveLength(0)
|
||||||
expect(compact.chunks.map(({ text }) => text).join(""))
|
|
||||||
.toBe("enter steer · tab queue · ctrl+c stop")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses the native Option symbol on macOS", () => {
|
test("shows measured throughput, cache ratio, token counts, and TTFT", () => {
|
||||||
const result = contextualFooterHints("active", 100, theme, "darwin")
|
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(""))
|
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", () => {
|
test("degrades telemetry instead of guessing missing provider metrics", () => {
|
||||||
const enhanced = contextualFooterHints("ready", 80, theme, "darwin", true)
|
const compact = footerTelemetry({
|
||||||
const legacy = contextualFooterHints("ready", 80, theme, "darwin", false)
|
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(""))
|
expect(compact.chunks.map(({ text }) => text).join("")).toBe("cache 0%")
|
||||||
.toBe("enter send · shift+enter newline · ctrl+c stop")
|
expect(unsupported.chunks).toHaveLength(0)
|
||||||
expect(legacy.chunks.map(({ text }) => text).join(""))
|
|
||||||
.toBe("enter send · ctrl+j newline · ctrl+c stop")
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+60
-23
@@ -1,6 +1,7 @@
|
|||||||
import { RGBA, StyledText, TextAttributes, type TextChunk } from "@opentui/core"
|
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 {
|
export interface FooterHint {
|
||||||
key: string
|
key: string
|
||||||
@@ -30,10 +31,63 @@ export function contextualFooterHints(
|
|||||||
mode: FooterMode,
|
mode: FooterMode,
|
||||||
width: number,
|
width: number,
|
||||||
theme: FooterHintTheme,
|
theme: FooterHintTheme,
|
||||||
platform: string = process.platform,
|
_platform: string = process.platform,
|
||||||
shiftedEnter = false,
|
_shiftedEnter = false,
|
||||||
): StyledText {
|
): 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. */
|
/** 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(
|
function hintsFor(
|
||||||
mode: FooterMode,
|
mode: FooterMode,
|
||||||
width: number,
|
width: number,
|
||||||
platform: string,
|
|
||||||
shiftedEnter: boolean,
|
|
||||||
): FooterHint[] {
|
): FooterHint[] {
|
||||||
if (mode === "runtime") return width >= 64
|
if (mode === "runtime") return width >= 64
|
||||||
? [hint("↑↓/click", "choose"), hint("enter", "apply"), hint("esc", "close")]
|
? [hint("↑↓/click", "choose"), hint("enter", "apply"), hint("esc", "close")]
|
||||||
@@ -60,9 +112,7 @@ function hintsFor(
|
|||||||
if (mode === "mention") return width >= 64
|
if (mode === "mention") return width >= 64
|
||||||
? [hint("↑↓", "choose"), hint("tab/enter", "insert"), hint("esc", "close")]
|
? [hint("↑↓", "choose"), hint("tab/enter", "insert"), hint("esc", "close")]
|
||||||
: [hint("enter", "insert"), hint("esc", "close")]
|
: [hint("enter", "insert"), hint("esc", "close")]
|
||||||
if (mode === "active") return width >= 96
|
if (mode === "active") return []
|
||||||
? [hint("enter", "steer"), hint("tab", "queue"), hint(optionArrowUp(platform), "edit"), stopHint()]
|
|
||||||
: width >= 64 ? [hint("enter", "steer"), hint("tab", "queue"), stopHint()] : []
|
|
||||||
if (mode === "branch") return width >= 64
|
if (mode === "branch") return width >= 64
|
||||||
? [hint("type", "filter"), hint("↑↓", "choose"), hint("enter", "branch"), hint("esc", "close")]
|
? [hint("type", "filter"), hint("↑↓", "choose"), hint("enter", "branch"), hint("esc", "close")]
|
||||||
: [hint("enter", "branch"), hint("esc", "close")]
|
: [hint("enter", "branch"), hint("esc", "close")]
|
||||||
@@ -76,26 +126,13 @@ function hintsFor(
|
|||||||
if (mode === "history") return width >= 72
|
if (mode === "history") return width >= 72
|
||||||
? [hint("ctrl+end", "latest"), hint("pgup/pgdn", "scroll")]
|
? [hint("ctrl+end", "latest"), hint("pgup/pgdn", "scroll")]
|
||||||
: width >= 48 ? [hint("ctrl+end", "latest")] : []
|
: width >= 48 ? [hint("ctrl+end", "latest")] : []
|
||||||
const newline = shiftedEnter ? "shift+enter" : "ctrl+j"
|
return []
|
||||||
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")] : []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hint(key: string, label: string): FooterHint {
|
function hint(key: string, label: string): FooterHint {
|
||||||
return { key, label }
|
return { key, label }
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopHint(): FooterHint {
|
|
||||||
return { key: "ctrl+c", label: "stop", tone: "danger" }
|
|
||||||
}
|
|
||||||
|
|
||||||
function chunk(text: string, color: string, bold = false): TextChunk {
|
function chunk(text: string, color: string, bold = false): TextChunk {
|
||||||
return {
|
return {
|
||||||
__isChunk: true,
|
__isChunk: true,
|
||||||
|
|||||||
@@ -181,6 +181,10 @@ export interface TokenUsage {
|
|||||||
provider_tokens?: number
|
provider_tokens?: number
|
||||||
estimated_tokens?: number
|
estimated_tokens?: number
|
||||||
cost_usd?: number
|
cost_usd?: number
|
||||||
|
generation_ms?: number
|
||||||
|
measured_completion_tokens?: number
|
||||||
|
ttft_ms?: number
|
||||||
|
timed_requests?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionContextSnapshot {
|
export interface SessionContextSnapshot {
|
||||||
@@ -325,6 +329,10 @@ function isTokenUsage(value: unknown): value is TokenUsage {
|
|||||||
"provider_tokens",
|
"provider_tokens",
|
||||||
"estimated_tokens",
|
"estimated_tokens",
|
||||||
"cost_usd",
|
"cost_usd",
|
||||||
|
"generation_ms",
|
||||||
|
"measured_completion_tokens",
|
||||||
|
"ttft_ms",
|
||||||
|
"timed_requests",
|
||||||
].every((key) => optional(value[key], "number"))
|
].every((key) => optional(value[key], "number"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user