feat(agent): add context usage ring indicator and /context command (#2537)

Add a context window usage indicator to the web chat UI and a /context
slash command that works across all channels.

Backend:
- Add computeContextUsage() estimating history + system + tool tokens
- Attach ContextUsage to outbound messages via the pico WebSocket protocol
- Add /context command showing context stats as formatted text
- Add EstimateSystemTokens() on ContextBuilder for system prompt estimation

Frontend:
- Add ContextUsageRing component (SVG ring + hover/tap popover)
- Show usage percentage, token counts, and compression threshold
- Hover on desktop (150ms leave delay), tap on mobile
- "View Details" sends /context with 1s cooldown
- i18n support (en/zh) for popover labels

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guoguo 2026-04-21 16:30:02 +08:00 committed by GitHub
parent 9c3dc0ee3a
commit 6ca7311273
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 462 additions and 35 deletions

View file

@ -531,6 +531,7 @@ func (al *AgentLoop) runAgentLoop(
SessionKey: sessionKey,
Scope: scope,
Content: result.finalContent,
ContextUsage: computeContextUsage(agent, opts.Dispatch.SessionKey),
})
}

View file

@ -214,6 +214,24 @@ func (al *AgentLoop) buildCommandsRuntime(
rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) {
return al.askSideQuestion(ctx, agent, opts, question)
}
rt.GetContextStats = func() *commands.ContextStats {
if opts == nil || agent.Sessions == nil {
return nil
}
usage := computeContextUsage(agent, opts.SessionKey)
if usage == nil {
return nil
}
history := agent.Sessions.GetHistory(opts.SessionKey)
return &commands.ContextStats{
UsedTokens: usage.UsedTokens,
TotalTokens: usage.TotalTokens,
CompressAtTokens: usage.CompressAtTokens,
UsedPercent: usage.UsedPercent,
MessageCount: len(history),
}
}
}
return rt
}

View file

@ -60,10 +60,14 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
return
}
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
msg := bus.OutboundMessage{
Context: bus.NewOutboundContext(channel, chatID, ""),
Content: response,
})
}
if sessionKey != "" {
msg.ContextUsage = computeContextUsage(al.agentForSession(sessionKey), sessionKey)
}
al.bus.PublishOutbound(ctx, msg)
logger.InfoCF("agent", "Published outbound response",
map[string]any{
"channel": channel,

View file

@ -11,6 +11,7 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@ -210,6 +211,36 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
return prompt
}
// EstimateSystemTokens estimates the token count of the full system message
// that would be sent to the LLM, mirroring the composition logic in BuildMessages.
// It includes: static prompt, dynamic context, active skills, and summary with
// wrapping prefixes and separators. This avoids needing all per-request parameters
// that BuildMessages requires (media, channel, chatID, sender, etc.).
func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []string) int {
staticPrompt := cb.BuildSystemPromptWithCache()
// Dynamic context is small and varies per request; use a representative estimate.
// Actual buildDynamicContext produces ~200-400 chars of time/runtime/session info.
const dynamicContextChars = 300
totalChars := utf8.RuneCountInString(staticPrompt) + dynamicContextChars
if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" {
totalChars += utf8.RuneCountInString(skillsText)
totalChars += 7 // separator \n\n---\n\n
}
if summary != "" {
// Matches the CONTEXT_SUMMARY: prefix added in BuildMessages
const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " +
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n"
totalChars += utf8.RuneCountInString(summaryPrefix) + utf8.RuneCountInString(summary)
totalChars += 7 // separator
}
return totalChars * 2 / 5 // same heuristic as tokenizer.EstimateMessageTokens
}
// InvalidateCache clears the cached system prompt.
// Normally not needed because the cache auto-invalidates via mtime checks,
// but this is useful for tests or explicit reload commands.

View file

@ -0,0 +1,78 @@
package agent
import (
"github.com/sipeed/picoclaw/pkg/bus"
)
// computeContextUsage estimates current context window consumption for the
// given agent and session. Includes history, system prompt (with dynamic context,
// summary, and skills — mirroring BuildMessages composition), and tool definitions.
// The output reserve (MaxTokens) is not counted as "used" but reduces the
// effective budget, matching isOverContextBudget's compression trigger:
//
// compress when: history + system + tools + maxTokens > contextWindow
// equivalent to: history + system + tools > contextWindow - maxTokens
//
// Returns nil when the agent or session is unavailable.
func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUsage {
if agent == nil || agent.Sessions == nil {
return nil
}
contextWindow := agent.ContextWindow
if contextWindow <= 0 {
return nil
}
// History tokens
history := agent.Sessions.GetHistory(sessionKey)
historyTokens := 0
for _, m := range history {
historyTokens += EstimateMessageTokens(m)
}
// System message tokens: uses EstimateSystemTokens which mirrors
// the full system message composition in BuildMessages (static prompt,
// dynamic context, active skills, summary with wrapping prefix).
systemTokens := 0
if agent.ContextBuilder != nil {
summary := agent.Sessions.GetSummary(sessionKey)
// Pass nil for active skills: skills are only injected when the user
// explicitly activates them via /use, which is rare. Using nil matches
// the common case and avoids over-counting all installed skills.
systemTokens = agent.ContextBuilder.EstimateSystemTokens(summary, nil)
}
// Tool definition tokens
toolTokens := 0
if agent.Tools != nil {
toolTokens = EstimateToolDefsTokens(agent.Tools.ToProviderDefs())
}
// Used = history + system (includes summary) + tools
usedTokens := historyTokens + systemTokens + toolTokens
// Effective budget = contextWindow minus output reserve (maxTokens)
effectiveWindow := contextWindow - agent.MaxTokens
if effectiveWindow < 0 {
effectiveWindow = contextWindow
}
// compressAt = effectiveWindow: aligns with isOverContextBudget's
// proactive trigger (msgTokens + toolTokens + maxTokens > contextWindow).
compressAt := effectiveWindow
usedPercent := 0
if compressAt > 0 {
usedPercent = usedTokens * 100 / compressAt
}
if usedPercent > 100 {
usedPercent = 100
}
return &bus.ContextUsage{
UsedTokens: usedTokens,
TotalTokens: contextWindow,
CompressAtTokens: compressAt,
UsedPercent: usedPercent,
}
}

View file

@ -61,6 +61,15 @@ type OutboundScope struct {
Values map[string]string `json:"values,omitempty"`
}
// ContextUsage describes how much of the model's context window the current
// session consumes, and how far it is from triggering compression.
type ContextUsage struct {
UsedTokens int `json:"used_tokens"`
TotalTokens int `json:"total_tokens"` // model context window
CompressAtTokens int `json:"compress_at_tokens"` // threshold that triggers compression
UsedPercent int `json:"used_percent"` // 0-100
}
type OutboundMessage struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
@ -70,6 +79,7 @@ type OutboundMessage struct {
Scope *OutboundScope `json:"scope,omitempty"`
Content string `json:"content"`
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
ContextUsage *ContextUsage `json:"context_usage,omitempty"`
}
// MediaPart describes a single media attachment to send.

View file

@ -262,10 +262,12 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
}
isThought := outboundMessageIsThought(msg)
outMsg := newMessage(TypeMessageCreate, map[string]any{
payload := map[string]any{
PayloadKeyContent: msg.Content,
PayloadKeyThought: isThought,
})
}
setContextUsagePayload(payload, msg.ContextUsage)
outMsg := newMessage(TypeMessageCreate, payload)
return nil, c.broadcastToSession(msg.ChatID, outMsg)
}
@ -716,3 +718,16 @@ func validateInlineImageDataURL(mediaURL string) error {
return nil
}
// setContextUsagePayload adds context window usage stats to a pico payload.
func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) {
if u == nil {
return
}
payload["context_usage"] = map[string]any{
"used_tokens": u.UsedTokens,
"total_tokens": u.TotalTokens,
"compress_at_tokens": u.CompressAtTokens,
"used_percent": u.UsedPercent,
}
}

View file

@ -15,6 +15,7 @@ func BuiltinDefinitions() []Definition {
switchCommand(),
checkCommand(),
clearCommand(),
contextCommand(),
subagentsCommand(),
reloadCommand(),
}

View file

@ -0,0 +1,42 @@
package commands
import (
"context"
"fmt"
)
func contextCommand() Definition {
return Definition{
Name: "context",
Description: "Show current session context and token usage",
Usage: "/context",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
if rt == nil || rt.GetContextStats == nil {
return req.Reply(unavailableMsg)
}
stats := rt.GetContextStats()
if stats == nil {
return req.Reply("No active session context.")
}
return req.Reply(formatContextStats(stats))
},
}
}
func formatContextStats(s *ContextStats) string {
remaining := s.CompressAtTokens - s.UsedTokens
if remaining < 0 {
remaining = 0
}
usedWindowPercent := s.UsedTokens * 100 / max(s.TotalTokens, 1)
return fmt.Sprintf(
"Context usage \nMessages: %d \nUsed: ~%d / %d tokens (%d%%) \nCompress at: %d tokens \nCompression progress: %d%% \nRemaining: ~%d tokens",
s.MessageCount,
s.UsedTokens,
s.TotalTokens,
usedWindowPercent,
s.CompressAtTokens,
s.UsedPercent,
remaining,
)
}

View file

@ -6,6 +6,15 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
)
// ContextStats describes current session context window usage.
type ContextStats struct {
UsedTokens int
TotalTokens int // model context window
CompressAtTokens int // compression threshold
UsedPercent int // 0-100
MessageCount int
}
// Runtime provides runtime dependencies to command handlers. It is constructed
// per-request by the agent loop so that per-request state (like session scope)
// can coexist with long-lived callbacks (like GetModelInfo).
@ -18,6 +27,7 @@ type Runtime struct {
ListSkillNames func() []string
GetEnabledChannels func() []string
GetActiveTurn func() any // Returning any to avoid circular dependency with agent package
GetContextStats func() *ContextStats
SwitchModel func(value string) (oldModel string, err error)
SwitchChannel func(value string) error
ClearHistory func() error

View file

@ -3,6 +3,7 @@ import type { KeyboardEvent } from "react"
import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize"
import { ContextUsageRing } from "@/components/chat/context-usage-ring"
import { Button } from "@/components/ui/button"
import {
Tooltip,
@ -10,7 +11,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import type { ChatAttachment } from "@/store/chat"
import type { ChatAttachment, ContextUsage } from "@/store/chat"
export type ChatInputDisabledReason =
| "gatewayUnknown"
@ -31,8 +32,10 @@ interface ChatComposerProps {
onAddImages: () => void
onRemoveAttachment: (index: number) => void
onSend: () => void
onContextDetail?: () => void
inputDisabledReason: ChatInputDisabledReason | null
canSend: boolean
contextUsage?: ContextUsage
}
export function ChatComposer({
@ -42,8 +45,10 @@ export function ChatComposer({
onAddImages,
onRemoveAttachment,
onSend,
onContextDetail,
inputDisabledReason,
canSend,
contextUsage,
}: ChatComposerProps) {
const { t } = useTranslation()
const canInput = inputDisabledReason === null
@ -121,6 +126,10 @@ export function ChatComposer({
</Button>
</div>
<div className="flex items-center gap-1.5">
{contextUsage && (
<ContextUsageRing usage={contextUsage} onDetailClick={onContextDetail} />
)}
{canInput ? (
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
@ -148,5 +157,6 @@ export function ChatComposer({
</div>
</div>
</div>
</div>
)
}

View file

@ -115,6 +115,7 @@ export function ChatPage() {
connectionState,
isTyping,
activeSessionId,
contextUsage,
sendMessage,
switchSession,
newChat,
@ -341,8 +342,14 @@ export function ChatPage() {
onAddImages={handleAddImages}
onRemoveAttachment={handleRemoveAttachment}
onSend={handleSend}
onContextDetail={() => {
if (sendMessage({ content: "/context", attachments: [] })) {
setInput("")
}
}}
inputDisabledReason={inputDisabledReason}
canSend={canSubmit}
contextUsage={contextUsage}
/>
</div>
)

View file

@ -0,0 +1,161 @@
import { IconArrowRight } from "@tabler/icons-react"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import type { ContextUsage } from "@/store/chat"
interface ContextUsageRingProps {
usage: ContextUsage
onDetailClick?: () => void
}
function formatTokens(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
return String(n)
}
export function ContextUsageRing({
usage,
onDetailClick,
}: ContextUsageRingProps) {
const { t } = useTranslation()
const [intent, setIntent] = useState(false) // user wants open
const [visible, setVisible] = useState(false) // DOM mounted
const [animated, setAnimated] = useState(false) // CSS target state
const [cooldown, setCooldown] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout>>(null)
const hoverIntent = useRef<ReturnType<typeof setTimeout>>(null)
const closeTimer = useRef<ReturnType<typeof setTimeout>>(null)
useEffect(() => {
if (intent) {
// Mount first, animate in on next frame
if (closeTimer.current) clearTimeout(closeTimer.current)
setVisible(true)
requestAnimationFrame(() => {
requestAnimationFrame(() => setAnimated(true))
})
} else if (visible) {
// Animate out, then unmount
setAnimated(false)
closeTimer.current = setTimeout(() => setVisible(false), 150)
}
}, [intent, visible])
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
if (hoverIntent.current) clearTimeout(hoverIntent.current)
if (closeTimer.current) clearTimeout(closeTimer.current)
}
}, [])
const percent = Math.min(usage.used_percent, 100)
const radius = 8
const circumference = 2 * Math.PI * radius
const offset = circumference - (percent / 100) * circumference
const barPercent = Math.min(percent, 100)
const handleDetail = () => {
if (cooldown || !onDetailClick) return
setCooldown(true)
onDetailClick()
setIntent(false)
timerRef.current = setTimeout(() => setCooldown(false), 1000)
}
// Desktop: hover to open, mouse leave to close (with small delay)
const handleMouseEnter = () => {
if (hoverIntent.current) clearTimeout(hoverIntent.current)
setIntent(true)
}
const handleMouseLeave = () => {
hoverIntent.current = setTimeout(() => setIntent(false), 150)
}
// Mobile: tap to toggle (preventDefault suppresses synthetic mouseenter)
const handleTouchStart = (e: React.TouchEvent) => {
e.preventDefault()
setIntent((v) => !v)
}
return (
<div
ref={containerRef}
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
type="button"
onTouchStart={handleTouchStart}
className="relative flex h-6 w-6 cursor-pointer items-center justify-center transition-opacity hover:opacity-70"
>
<svg className="h-6 w-6 -rotate-90" viewBox="0 0 20 20">
<circle
cx="10"
cy="10"
r={radius}
fill="none"
className="stroke-muted-foreground/30"
strokeWidth="2"
/>
<circle
cx="10"
cy="10"
r={radius}
fill="none"
className="stroke-muted-foreground"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
/>
</svg>
<span className="text-muted-foreground absolute text-[8px] font-medium tabular-nums">
{percent}
</span>
</button>
{visible && (
<div
className={`bg-popover text-popover-foreground absolute right-0 bottom-full z-50 mb-3 w-[220px] rounded-xl border p-4 shadow-lg transition-all duration-150 ${
animated
? "scale-100 opacity-100"
: "pointer-events-none scale-95 opacity-0"
}`}
>
<div className="bg-popover absolute -bottom-1.5 right-3 h-3 w-3 rotate-45 border-r border-b" />
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">
{t("chat.contextTitle")}
</span>
<span className="text-xs font-medium">
{formatTokens(usage.used_tokens)} /{" "}
{formatTokens(usage.compress_at_tokens)}
</span>
</div>
<div className="bg-muted mt-1.5 h-1.5 w-full overflow-hidden rounded-full">
<div
className="h-full rounded-full bg-violet-500 transition-all"
style={{ width: `${barPercent}%` }}
/>
</div>
<button
type="button"
onClick={handleDetail}
disabled={cooldown}
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-violet-600 transition-opacity hover:opacity-70 disabled:opacity-40 dark:text-violet-400"
>
{t("chat.contextDetail")}
<IconArrowRight className="h-3 w-3" />
</button>
</div>
)}
</div>
)
}

View file

@ -392,6 +392,7 @@ export async function switchChatSession(sessionId: string) {
messages: historyMessages,
isTyping: false,
hasHydratedActiveSession: true,
contextUsage: undefined,
})
if (store.get(gatewayAtom).status === "running") {
@ -415,6 +416,7 @@ export async function newChatSession() {
messages: [],
isTyping: false,
hasHydratedActiveSession: true,
contextUsage: undefined,
})
if (store.get(gatewayAtom).status === "running") {

View file

@ -1,7 +1,11 @@
import { toast } from "sonner"
import { normalizeUnixTimestamp } from "@/features/chat/state"
import { type AssistantMessageKind, updateChatStore } from "@/store/chat"
import {
type AssistantMessageKind,
type ContextUsage,
updateChatStore,
} from "@/store/chat"
export interface PicoMessage {
type: string
@ -21,6 +25,24 @@ function hasAssistantKindPayload(payload: Record<string, unknown>): boolean {
return typeof payload.thought === "boolean"
}
function parseContextUsage(
payload: Record<string, unknown>,
): ContextUsage | undefined {
const raw = payload.context_usage
if (!raw || typeof raw !== "object") return undefined
const obj = raw as Record<string, unknown>
const used = Number(obj.used_tokens)
const total = Number(obj.total_tokens)
if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0)
return undefined
return {
used_tokens: used,
total_tokens: total,
compress_at_tokens: Number(obj.compress_at_tokens) || 0,
used_percent: Number(obj.used_percent) || 0,
}
}
export function handlePicoMessage(
message: PicoMessage,
expectedSessionId: string,
@ -36,6 +58,7 @@ export function handlePicoMessage(
const content = (payload.content as string) || ""
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
const kind = parseAssistantMessageKind(payload)
const contextUsage = parseContextUsage(payload)
const timestamp =
message.timestamp !== undefined &&
Number.isFinite(Number(message.timestamp))
@ -54,6 +77,7 @@ export function handlePicoMessage(
},
],
isTyping: false,
...(contextUsage ? { contextUsage } : {}),
}))
break
}

View file

@ -55,7 +55,7 @@ export function formatMessageTime(dateRaw: number | string | Date): string {
}
export function usePicoChat() {
const { messages, connectionState, isTyping, activeSessionId } =
const { messages, connectionState, isTyping, activeSessionId, contextUsage } =
useAtomValue(chatAtom)
return {
@ -63,6 +63,7 @@ export function usePicoChat() {
connectionState,
isTyping,
activeSessionId,
contextUsage,
sendMessage: sendChatMessage,
switchSession: switchChatSession,
newChat: newChatSession,

View file

@ -75,6 +75,8 @@
},
"sendMessage": "Send message",
"sendHint": "Press Enter to send\nShift + Enter for a new line",
"contextTitle": "Context",
"contextDetail": "View Details",
"attachImage": "Add images",
"removeImage": "Remove image",
"uploadedImage": "Uploaded image",

View file

@ -75,6 +75,8 @@
},
"sendMessage": "发送消息",
"sendHint": "按 Enter 发送\nShift + Enter 换行",
"contextTitle": "上下文",
"contextDetail": "查看详情",
"attachImage": "添加图片",
"removeImage": "移除图片",
"uploadedImage": "已上传图片",

View file

@ -22,6 +22,13 @@ export interface ChatMessage {
attachments?: ChatAttachment[]
}
export interface ContextUsage {
used_tokens: number
total_tokens: number
compress_at_tokens: number
used_percent: number
}
export type ConnectionState =
| "disconnected"
| "connecting"
@ -34,6 +41,7 @@ export interface ChatStoreState {
isTyping: boolean
activeSessionId: string
hasHydratedActiveSession: boolean
contextUsage?: ContextUsage
}
type ChatStorePatch = Partial<ChatStoreState>