feat(tui): replace footer hints with model telemetry

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent c320d08dfe
commit 783d381710
8 changed files with 218 additions and 70 deletions
+15 -6
View File
@@ -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" })
})
+14 -19
View File
@@ -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<void> {
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.
}
+27 -18
View File
@@ -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)
})
})
+60 -23
View File
@@ -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,
+8
View File
@@ -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"))
}