diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go
index 0bbfde7ff..3c242eecb 100644
--- a/pkg/agent/agent.go
+++ b/pkg/agent/agent.go
@@ -527,10 +527,11 @@ func (al *AgentLoop) runAgentLoop(
opts.Dispatch.ChatID(),
opts.Dispatch.ReplyToMessageID(),
),
- AgentID: agentID,
- SessionKey: sessionKey,
- Scope: scope,
- Content: result.finalContent,
+ AgentID: agentID,
+ SessionKey: sessionKey,
+ Scope: scope,
+ Content: result.finalContent,
+ ContextUsage: computeContextUsage(agent, opts.Dispatch.SessionKey),
})
}
diff --git a/pkg/agent/agent_command.go b/pkg/agent/agent_command.go
index f6b4ab5bc..277ef77cd 100644
--- a/pkg/agent/agent_command.go
+++ b/pkg/agent/agent_command.go
@@ -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
}
diff --git a/pkg/agent/agent_outbound.go b/pkg/agent/agent_outbound.go
index 906bea5d3..7e36e4ad8 100644
--- a/pkg/agent/agent_outbound.go
+++ b/pkg/agent/agent_outbound.go
@@ -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,
diff --git a/pkg/agent/context.go b/pkg/agent/context.go
index ecf5da3dc..1e5a75d92 100644
--- a/pkg/agent/context.go
+++ b/pkg/agent/context.go
@@ -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.
diff --git a/pkg/agent/context_usage.go b/pkg/agent/context_usage.go
new file mode 100644
index 000000000..39d4f3dee
--- /dev/null
+++ b/pkg/agent/context_usage.go
@@ -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,
+ }
+}
diff --git a/pkg/bus/types.go b/pkg/bus/types.go
index aa06ca173..953e69d9c 100644
--- a/pkg/bus/types.go
+++ b/pkg/bus/types.go
@@ -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.
diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go
index f998712c8..8b41023f0 100644
--- a/pkg/channels/pico/pico.go
+++ b/pkg/channels/pico/pico.go
@@ -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,
+ }
+}
diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go
index 5cf9425cb..a7e401bb8 100644
--- a/pkg/commands/builtin.go
+++ b/pkg/commands/builtin.go
@@ -15,6 +15,7 @@ func BuiltinDefinitions() []Definition {
switchCommand(),
checkCommand(),
clearCommand(),
+ contextCommand(),
subagentsCommand(),
reloadCommand(),
}
diff --git a/pkg/commands/cmd_context.go b/pkg/commands/cmd_context.go
new file mode 100644
index 000000000..55481662c
--- /dev/null
+++ b/pkg/commands/cmd_context.go
@@ -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,
+ )
+}
diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go
index 69373f561..68c286dde 100644
--- a/pkg/commands/runtime.go
+++ b/pkg/commands/runtime.go
@@ -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
diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx
index cb3016842..b3354cc33 100644
--- a/web/frontend/src/components/chat/chat-composer.tsx
+++ b/web/frontend/src/components/chat/chat-composer.tsx
@@ -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,30 +126,35 @@ export function ChatComposer({
- {canInput ? (
-