feat: add context window usage display to task session (#66)
* feat: add context window usage display to task session - Add TokenUsageDisplay component showing context window usage as a horizontal progress bar - Add useSessionContextWindow hook with proper Zustand reactivity (subscribe to primitives) - Add ContextWindowState to store with setContextWindow action - Backend: propagate context_window data through orchestrator, watcher, and lifecycle events - WebSocket handler extracts context_window from session.state_changed metadata - Display positioned on left side of action bar, updates in real-time * refactor: remove agent stream event WebSocket broadcasting Agent stream events (message_chunk, tool_call, etc.) are now handled internally by the lifecycle manager rather than being broadcast to the frontend. Messages are accumulated and flushed as comments at step boundaries.
This commit is contained in:
@@ -386,56 +386,26 @@ func main() {
|
||||
result = append(result, gitStatusNotification)
|
||||
}
|
||||
}
|
||||
|
||||
if contextWindow, ok := session.Metadata["context_window"]; ok {
|
||||
notification, err := ws.NewNotification(ws.ActionSessionStateChanged, map[string]interface{}{
|
||||
"session_id": sessionID,
|
||||
"task_id": session.TaskID,
|
||||
"new_state": string(session.State),
|
||||
"metadata": map[string]interface{}{
|
||||
"context_window": contextWindow,
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
result = append(result, notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
})
|
||||
log.Info("Session data provider configured for session subscriptions (git status)")
|
||||
|
||||
// NOTE: Pending permissions are now stored as messages and retrieved via the historical logs provider.
|
||||
// The separate pending permissions provider is no longer needed.
|
||||
|
||||
// NOTE: We no longer create comments for each ACP message chunk.
|
||||
// Instead, the lifecycle manager accumulates message chunks and:
|
||||
// 1. Flushes the buffer as a comment when a tool use starts (step boundary)
|
||||
// 2. Flushes the buffer as a comment when the prompt completes (final response)
|
||||
// This is handled via the prompt.complete and step.complete events.
|
||||
|
||||
// Wire stream handler to broadcast agent stream events to WebSocket clients
|
||||
// NOTE: Permission requests are now handled via message creation in the lifecycle manager
|
||||
// and broadcast automatically via the session.message.added event
|
||||
orchestratorSvc.RegisterStreamHandler(func(payload *lifecycle.AgentStreamEventPayload) {
|
||||
if payload == nil || payload.Data == nil {
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := payload.SessionID
|
||||
if sessionID == "" {
|
||||
log.Warn("agent stream event missing session_id, skipping",
|
||||
zap.String("task_id", payload.TaskID),
|
||||
zap.String("type", payload.Data.Type))
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast agent stream event to session subscribers
|
||||
action := "agent." + payload.Data.Type
|
||||
notification, _ := ws.NewNotification(action, map[string]interface{}{
|
||||
"task_id": payload.TaskID,
|
||||
"session_id": sessionID,
|
||||
"type": payload.Data.Type,
|
||||
"text": payload.Data.Text,
|
||||
"tool_call_id": payload.Data.ToolCallID,
|
||||
"tool_name": payload.Data.ToolName,
|
||||
"tool_title": payload.Data.ToolTitle,
|
||||
"tool_status": payload.Data.ToolStatus,
|
||||
"tool_args": payload.Data.ToolArgs,
|
||||
"tool_result": payload.Data.ToolResult,
|
||||
"error": payload.Data.Error,
|
||||
"timestamp": payload.Timestamp,
|
||||
})
|
||||
gateway.Hub.BroadcastToSession(sessionID, notification)
|
||||
})
|
||||
|
||||
if err := orchestratorSvc.Start(ctx); err != nil {
|
||||
log.Fatal("Failed to start orchestrator", zap.Error(err))
|
||||
}
|
||||
|
||||
@@ -137,3 +137,20 @@ type ShellExitEventPayload struct {
|
||||
func (p ShellExitEventPayload) GetSessionID() string {
|
||||
return p.SessionID
|
||||
}
|
||||
|
||||
// ContextWindowEventPayload is the payload for context window update events.
|
||||
type ContextWindowEventPayload struct {
|
||||
TaskID string `json:"task_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
ContextWindowSize int64 `json:"context_window_size"`
|
||||
ContextWindowUsed int64 `json:"context_window_used"`
|
||||
ContextWindowRemaining int64 `json:"context_window_remaining"`
|
||||
ContextEfficiency float64 `json:"context_efficiency"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// GetSessionID returns the session ID for this event (used by event routing).
|
||||
func (p ContextWindowEventPayload) GetSessionID() string {
|
||||
return p.SessionID
|
||||
}
|
||||
|
||||
@@ -323,3 +323,34 @@ func (p *EventPublisher) PublishShellExit(execution *AgentExecution, exitCode in
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// PublishContextWindow publishes a context window update event to the event bus.
|
||||
func (p *EventPublisher) PublishContextWindow(execution *AgentExecution, size, used, remaining int64, efficiency float64) {
|
||||
if p.eventBus == nil {
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := execution.SessionID
|
||||
|
||||
payload := ContextWindowEventPayload{
|
||||
TaskID: execution.TaskID,
|
||||
SessionID: sessionID,
|
||||
AgentID: execution.ID,
|
||||
ContextWindowSize: size,
|
||||
ContextWindowUsed: used,
|
||||
ContextWindowRemaining: remaining,
|
||||
ContextEfficiency: efficiency,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
|
||||
event := bus.NewEvent(events.ContextWindowUpdated, "agent-manager", payload)
|
||||
subject := events.BuildContextWindowSubject(sessionID)
|
||||
|
||||
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
|
||||
p.logger.Error("failed to publish context window event",
|
||||
zap.String("instance_id", execution.ID),
|
||||
zap.String("task_id", execution.TaskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,6 +901,24 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age
|
||||
|
||||
// Handle permission request inline
|
||||
m.handlePermissionRequestEvent(execution, event)
|
||||
|
||||
case "context_window":
|
||||
m.logger.Debug("context window update received",
|
||||
zap.String("execution_id", execution.ID),
|
||||
zap.Int64("size", event.ContextWindowSize),
|
||||
zap.Int64("used", event.ContextWindowUsed),
|
||||
zap.Float64("efficiency", event.ContextEfficiency))
|
||||
|
||||
// Publish context window event to event bus for orchestrator to persist
|
||||
m.eventPublisher.PublishContextWindow(
|
||||
execution,
|
||||
event.ContextWindowSize,
|
||||
event.ContextWindowUsed,
|
||||
event.ContextWindowRemaining,
|
||||
event.ContextEfficiency,
|
||||
)
|
||||
// Return early - context window events don't need to be published as stream events
|
||||
return
|
||||
}
|
||||
|
||||
// Publish agent stream event to event bus for WebSocket streaming
|
||||
|
||||
@@ -35,6 +35,7 @@ const (
|
||||
EventTypeComplete = streams.EventTypeComplete
|
||||
EventTypeError = streams.EventTypeError
|
||||
EventTypePermissionRequest = streams.EventTypePermissionRequest
|
||||
EventTypeContextWindow = streams.EventTypeContextWindow
|
||||
)
|
||||
|
||||
// AgentInfo contains information about the connected agent.
|
||||
|
||||
@@ -645,6 +645,55 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage)
|
||||
ToolResult: p.Delta,
|
||||
})
|
||||
|
||||
case codex.NotifyThreadTokenUsageUpdated:
|
||||
var p codex.ThreadTokenUsageUpdatedParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
a.logger.Warn("failed to parse thread token usage updated notification", zap.Error(err))
|
||||
return
|
||||
}
|
||||
// Extract context window information from the token usage update
|
||||
if p.TokenUsage != nil && p.TokenUsage.ModelContextWindow > 0 {
|
||||
contextWindowSize := p.TokenUsage.ModelContextWindow
|
||||
contextWindowUsed := int64(p.TokenUsage.Last.TotalTokens)
|
||||
|
||||
remaining := contextWindowSize - contextWindowUsed
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
efficiency := float64(contextWindowUsed) / float64(contextWindowSize) * 100
|
||||
|
||||
a.logger.Debug("emitting context window event",
|
||||
zap.Int64("size", contextWindowSize),
|
||||
zap.Int64("used", contextWindowUsed),
|
||||
zap.Int64("remaining", remaining),
|
||||
zap.Float64("efficiency", efficiency))
|
||||
|
||||
a.sendUpdate(AgentEvent{
|
||||
Type: EventTypeContextWindow,
|
||||
SessionID: threadID,
|
||||
OperationID: turnID,
|
||||
ContextWindowSize: contextWindowSize,
|
||||
ContextWindowUsed: contextWindowUsed,
|
||||
ContextWindowRemaining: remaining,
|
||||
ContextEfficiency: efficiency,
|
||||
})
|
||||
}
|
||||
|
||||
case codex.NotifyTokenCount:
|
||||
// Legacy token_count notification - ignore as we now use thread/tokenUsage/updated
|
||||
a.logger.Debug("ignoring legacy token_count notification")
|
||||
|
||||
case codex.NotifyContextCompacted:
|
||||
var p codex.ContextCompactedParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
a.logger.Warn("failed to parse context compacted notification", zap.Error(err))
|
||||
return
|
||||
}
|
||||
a.logger.Info("context compacted",
|
||||
zap.String("thread_id", p.ThreadID),
|
||||
zap.String("turn_id", p.TurnID))
|
||||
// We could emit an event here if we want to notify the frontend about compaction
|
||||
|
||||
default:
|
||||
// Log unhandled notifications at debug level
|
||||
a.logger.Debug("unhandled notification", zap.String("method", method))
|
||||
|
||||
@@ -28,6 +28,9 @@ const (
|
||||
|
||||
// EventTypeSessionStatus indicates a session status update (resumed or new).
|
||||
EventTypeSessionStatus = "session_status"
|
||||
|
||||
// EventTypeContextWindow indicates a context window update.
|
||||
EventTypeContextWindow = "context_window"
|
||||
)
|
||||
|
||||
// Session status constants for EventTypeSessionStatus events.
|
||||
@@ -129,6 +132,20 @@ type AgentEvent struct {
|
||||
|
||||
// Data contains raw protocol-specific extensions.
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
|
||||
// --- Context window fields ---
|
||||
|
||||
// ContextWindowSize is the total available tokens in the context window.
|
||||
ContextWindowSize int64 `json:"context_window_size,omitempty"`
|
||||
|
||||
// ContextWindowUsed is the number of tokens currently consumed.
|
||||
ContextWindowUsed int64 `json:"context_window_used,omitempty"`
|
||||
|
||||
// ContextWindowRemaining is the number of available tokens left.
|
||||
ContextWindowRemaining int64 `json:"context_window_remaining,omitempty"`
|
||||
|
||||
// ContextEfficiency is the percentage utilization (0-100).
|
||||
ContextEfficiency float64 `json:"context_efficiency,omitempty"`
|
||||
}
|
||||
|
||||
// PlanEntry represents an entry in the agent's execution plan.
|
||||
|
||||
@@ -110,6 +110,11 @@ const (
|
||||
ShellExit = "shell.exit" // Shell process exited
|
||||
)
|
||||
|
||||
// Event types for context window
|
||||
const (
|
||||
ContextWindowUpdated = "context_window.updated" // Context window usage updated
|
||||
)
|
||||
|
||||
// BuildShellOutputSubject creates a shell output subject for a specific session
|
||||
func BuildShellOutputSubject(sessionID string) string {
|
||||
return ShellOutput + "." + sessionID
|
||||
@@ -169,3 +174,13 @@ func BuildPermissionRequestSubject(sessionID string) string {
|
||||
func BuildPermissionRequestWildcardSubject() string {
|
||||
return PermissionRequestReceived + ".*"
|
||||
}
|
||||
|
||||
// BuildContextWindowSubject creates a context window subject for a specific session
|
||||
func BuildContextWindowSubject(sessionID string) string {
|
||||
return ContextWindowUpdated + "." + sessionID
|
||||
}
|
||||
|
||||
// BuildContextWindowWildcardSubject creates a wildcard subscription for all context window events
|
||||
func BuildContextWindowWildcardSubject() string {
|
||||
return ContextWindowUpdated + ".*"
|
||||
}
|
||||
|
||||
@@ -88,6 +88,19 @@ func (b *TaskEventBroadcaster) subscribe(eventBus bus.EventBus, subject, action
|
||||
} else if data, ok := event.Data.(interface{ GetSessionID() string }); ok {
|
||||
sessionID = data.GetSessionID()
|
||||
}
|
||||
|
||||
// Debug logging for session state changes with metadata
|
||||
if action == ws.ActionSessionStateChanged {
|
||||
if data, ok := event.Data.(map[string]interface{}); ok {
|
||||
if metadata, ok := data["metadata"]; ok {
|
||||
b.logger.Info("received session.state_changed with metadata",
|
||||
zap.String("action", action),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Any("metadata", metadata))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch action {
|
||||
case ws.ActionSessionAgentctlStarting, ws.ActionSessionAgentctlReady, ws.ActionSessionAgentctlError, ws.ActionSessionStateChanged:
|
||||
if sessionID != "" {
|
||||
|
||||
@@ -138,14 +138,15 @@ func NewService(
|
||||
|
||||
// Create the watcher with event handlers that wire everything together
|
||||
handlers := watcher.EventHandlers{
|
||||
OnTaskStateChanged: s.handleTaskStateChanged,
|
||||
OnAgentReady: s.handleAgentReady,
|
||||
OnAgentCompleted: s.handleAgentCompleted,
|
||||
OnAgentFailed: s.handleAgentFailed,
|
||||
OnAgentStreamEvent: s.handleAgentStreamEvent,
|
||||
OnACPSessionCreated: s.handleACPSessionCreated,
|
||||
OnPermissionRequest: s.handlePermissionRequest,
|
||||
OnGitStatusUpdated: s.handleGitStatusUpdated,
|
||||
OnTaskStateChanged: s.handleTaskStateChanged,
|
||||
OnAgentReady: s.handleAgentReady,
|
||||
OnAgentCompleted: s.handleAgentCompleted,
|
||||
OnAgentFailed: s.handleAgentFailed,
|
||||
OnAgentStreamEvent: s.handleAgentStreamEvent,
|
||||
OnACPSessionCreated: s.handleACPSessionCreated,
|
||||
OnPermissionRequest: s.handlePermissionRequest,
|
||||
OnGitStatusUpdated: s.handleGitStatusUpdated,
|
||||
OnContextWindowUpdated: s.handleContextWindowUpdated,
|
||||
}
|
||||
s.watcher = watcher.NewWatcher(eventBus, handlers, log)
|
||||
|
||||
@@ -1237,6 +1238,72 @@ func (s *Service) handleGitStatusUpdated(ctx context.Context, data watcher.GitSt
|
||||
}()
|
||||
}
|
||||
|
||||
// handleContextWindowUpdated handles context window updates and persists them to session metadata
|
||||
func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.ContextWindowData) {
|
||||
s.logger.Debug("handling context window update",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", data.TaskSessionID),
|
||||
zap.Int64("size", data.ContextWindowSize),
|
||||
zap.Int64("used", data.ContextWindowUsed))
|
||||
|
||||
if data.TaskSessionID == "" {
|
||||
s.logger.Debug("missing session_id for context window update",
|
||||
zap.String("task_id", data.TaskID))
|
||||
return
|
||||
}
|
||||
|
||||
session, err := s.repo.GetTaskSession(ctx, data.TaskSessionID)
|
||||
if err != nil {
|
||||
s.logger.Debug("no task session for context window update",
|
||||
zap.String("session_id", data.TaskSessionID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Update session metadata with context window info
|
||||
if session.Metadata == nil {
|
||||
session.Metadata = make(map[string]interface{})
|
||||
}
|
||||
contextWindowData := map[string]interface{}{
|
||||
"size": data.ContextWindowSize,
|
||||
"used": data.ContextWindowUsed,
|
||||
"remaining": data.ContextWindowRemaining,
|
||||
"efficiency": data.ContextEfficiency,
|
||||
}
|
||||
session.Metadata["context_window"] = contextWindowData
|
||||
|
||||
// Persist to database asynchronously
|
||||
go func() {
|
||||
if err := s.repo.UpdateTaskSession(context.Background(), session); err != nil {
|
||||
s.logger.Error("failed to update session with context window",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", session.ID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Debug("persisted context window to session",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", session.ID))
|
||||
}
|
||||
}()
|
||||
|
||||
// Broadcast session state change with metadata so frontend can update
|
||||
// This uses the existing session.state_changed event with metadata included
|
||||
if s.eventBus != nil {
|
||||
_ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(
|
||||
events.TaskSessionStateChanged,
|
||||
"orchestrator",
|
||||
map[string]interface{}{
|
||||
"task_id": data.TaskID,
|
||||
"session_id": session.ID,
|
||||
"new_state": string(session.State),
|
||||
"metadata": map[string]interface{}{
|
||||
"context_window": contextWindowData,
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// handlePermissionRequest handles permission request events and saves as message
|
||||
func (s *Service) handlePermissionRequest(ctx context.Context, data watcher.PermissionRequestData) {
|
||||
s.logger.Info("handling permission request",
|
||||
|
||||
@@ -70,6 +70,18 @@ type GitStatusData struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ContextWindowData contains data from context window events
|
||||
type ContextWindowData struct {
|
||||
TaskID string `json:"task_id"`
|
||||
TaskSessionID string `json:"session_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
ContextWindowSize int64 `json:"context_window_size"`
|
||||
ContextWindowUsed int64 `json:"context_window_used"`
|
||||
ContextWindowRemaining int64 `json:"context_window_remaining"`
|
||||
ContextEfficiency float64 `json:"context_efficiency"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// EventHandlers contains callbacks for different event types
|
||||
type EventHandlers struct {
|
||||
// Task events
|
||||
@@ -94,6 +106,9 @@ type EventHandlers struct {
|
||||
|
||||
// Git status events
|
||||
OnGitStatusUpdated func(ctx context.Context, data GitStatusData)
|
||||
|
||||
// Context window events
|
||||
OnContextWindowUpdated func(ctx context.Context, data ContextWindowData)
|
||||
}
|
||||
|
||||
// Watcher subscribes to events and dispatches to handlers
|
||||
@@ -166,6 +181,12 @@ func (w *Watcher) Start(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Subscribe to context window events
|
||||
if err := w.subscribeToContextWindowEvents(); err != nil {
|
||||
w.unsubscribeAll()
|
||||
return err
|
||||
}
|
||||
|
||||
w.running = true
|
||||
w.logger.Info("Event watcher started", zap.Int("subscriptions", len(w.subscriptions)))
|
||||
return nil
|
||||
@@ -484,6 +505,50 @@ func (w *Watcher) createGitStatusHandler() bus.EventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// subscribeToContextWindowEvents subscribes to context window events
|
||||
func (w *Watcher) subscribeToContextWindowEvents() error {
|
||||
if w.handlers.OnContextWindowUpdated == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use wildcard to subscribe to all context window events (context_window.updated.{session_id})
|
||||
subject := events.BuildContextWindowWildcardSubject()
|
||||
|
||||
// Use regular subscription (each instance needs all messages)
|
||||
sub, err := w.eventBus.Subscribe(subject, w.createContextWindowHandler())
|
||||
if err != nil {
|
||||
w.logger.Error("Failed to subscribe to context window events",
|
||||
zap.String("subject", subject),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
w.subscriptions = append(w.subscriptions, sub)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createContextWindowHandler creates a bus.EventHandler for context window events
|
||||
func (w *Watcher) createContextWindowHandler() bus.EventHandler {
|
||||
return func(ctx context.Context, event *bus.Event) error {
|
||||
var data ContextWindowData
|
||||
if err := w.parseEventData(event.Data, &data); err != nil {
|
||||
w.logger.Error("Failed to parse context window event data",
|
||||
zap.String("event_type", event.Type),
|
||||
zap.String("event_id", event.ID),
|
||||
zap.Error(err))
|
||||
return nil // Don't return error to continue processing other events
|
||||
}
|
||||
|
||||
w.logger.Debug("Handling context window event",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", data.TaskSessionID),
|
||||
zap.Int64("size", data.ContextWindowSize),
|
||||
zap.Int64("used", data.ContextWindowUsed))
|
||||
|
||||
w.handlers.OnContextWindowUpdated(ctx, data)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseEventData converts event data (map or struct) to a typed struct
|
||||
func (w *Watcher) parseEventData(data interface{}, target interface{}) error {
|
||||
// Marshal to JSON and unmarshal to target type
|
||||
|
||||
@@ -78,6 +78,9 @@ const (
|
||||
NotifyAccountUpdated = "account/updated"
|
||||
NotifyAccountLoginCompleted = "account/login/completed"
|
||||
NotifyError = "error"
|
||||
NotifyTokenCount = "token_count"
|
||||
NotifyThreadTokenUsageUpdated = "thread/tokenUsage/updated"
|
||||
NotifyContextCompacted = "context_compacted"
|
||||
)
|
||||
|
||||
// InitializeParams for initialize request
|
||||
@@ -340,3 +343,69 @@ type ErrorParams struct {
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// TokenCountParams for token_count notification
|
||||
// This is emitted by Codex after each turn to report token usage.
|
||||
type TokenCountParams struct {
|
||||
Info *TokenUsageInfo `json:"info,omitempty"`
|
||||
RateLimits *RateLimitSnapshot `json:"rateLimits,omitempty"`
|
||||
}
|
||||
|
||||
// TokenUsageInfo contains detailed token usage information.
|
||||
type TokenUsageInfo struct {
|
||||
TotalTokenUsage *TokenUsage `json:"totalTokenUsage,omitempty"`
|
||||
LastTokenUsage *TokenUsage `json:"lastTokenUsage,omitempty"`
|
||||
ModelContextWindow *int64 `json:"modelContextWindow,omitempty"`
|
||||
}
|
||||
|
||||
// TokenUsage contains token counts for a request/response cycle.
|
||||
type TokenUsage struct {
|
||||
InputTokens int32 `json:"inputTokens"`
|
||||
CachedInputTokens int32 `json:"cachedInputTokens"`
|
||||
OutputTokens int32 `json:"outputTokens"`
|
||||
ReasoningOutputTokens int32 `json:"reasoningOutputTokens"`
|
||||
TotalTokens int32 `json:"totalTokens"`
|
||||
}
|
||||
|
||||
// ThreadTokenUsageUpdatedParams for thread/tokenUsage/updated notification.
|
||||
// This is emitted by Codex after each turn completes with token usage info.
|
||||
type ThreadTokenUsageUpdatedParams struct {
|
||||
ThreadID string `json:"threadId"`
|
||||
TurnID string `json:"turnId"`
|
||||
TokenUsage *ThreadTokenUsage `json:"tokenUsage"`
|
||||
}
|
||||
|
||||
// ThreadTokenUsage contains the token usage summary for a thread.
|
||||
type ThreadTokenUsage struct {
|
||||
Total *TokenUsage `json:"total,omitempty"`
|
||||
Last *TokenUsage `json:"last,omitempty"`
|
||||
ModelContextWindow int64 `json:"modelContextWindow"`
|
||||
}
|
||||
|
||||
// RateLimitSnapshot contains rate limit information.
|
||||
type RateLimitSnapshot struct {
|
||||
Primary *RateLimitWindow `json:"primary,omitempty"`
|
||||
Secondary *RateLimitWindow `json:"secondary,omitempty"`
|
||||
Credits *CreditsSnapshot `json:"credits,omitempty"`
|
||||
PlanType *string `json:"planType,omitempty"`
|
||||
}
|
||||
|
||||
// RateLimitWindow contains rate limit window information.
|
||||
type RateLimitWindow struct {
|
||||
UsedPercent int32 `json:"usedPercent"`
|
||||
WindowDurationMins *int64 `json:"windowDurationMins,omitempty"`
|
||||
ResetsAt *int64 `json:"resetsAt,omitempty"`
|
||||
}
|
||||
|
||||
// CreditsSnapshot contains credits information (placeholder for future use).
|
||||
type CreditsSnapshot struct {
|
||||
Remaining *int64 `json:"remaining,omitempty"`
|
||||
Used *int64 `json:"used,omitempty"`
|
||||
}
|
||||
|
||||
// ContextCompactedParams for context_compacted notification.
|
||||
// Emitted when the context has been compacted due to reaching limits.
|
||||
type ContextCompactedParams struct {
|
||||
ThreadID string `json:"threadId"`
|
||||
TurnID string `json:"turnId"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ContextWindowEntry } from '@/lib/state/store';
|
||||
|
||||
type TokenUsageDisplayProps = {
|
||||
contextWindow: ContextWindowEntry;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1_000_000) {
|
||||
return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1_000) {
|
||||
return `${(num / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
function getBarColor(efficiency: number): string {
|
||||
if (efficiency >= 90) return 'bg-red-500';
|
||||
if (efficiency >= 75) return 'bg-orange-500';
|
||||
if (efficiency >= 50) return 'bg-yellow-500';
|
||||
return 'bg-emerald-500';
|
||||
}
|
||||
|
||||
export function TokenUsageDisplay({ contextWindow, className }: TokenUsageDisplayProps) {
|
||||
const { size, used, remaining, efficiency } = contextWindow;
|
||||
|
||||
if (size === 0) return null;
|
||||
|
||||
const usagePercent = Math.min(efficiency, 100);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={cn('flex items-center gap-1.5 cursor-default', className)}>
|
||||
<div className="w-16 h-1.5 rounded-full bg-muted/50 overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', getBarColor(efficiency))}
|
||||
style={{ width: `${usagePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{efficiency.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="font-medium">Context: {formatNumber(used)} / {formatNumber(size)}</div>
|
||||
<div className="text-muted-foreground">{formatNumber(remaining)} remaining</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,8 +27,10 @@ import { useSessionMessages } from '@/hooks/use-session-messages';
|
||||
import { useSettingsData } from '@/hooks/use-settings-data';
|
||||
import { MessageRenderer } from '@/components/task/chat/message-renderer';
|
||||
import { RunningIndicator } from '@/components/task/chat/messages/running-indicator';
|
||||
import { TokenUsageDisplay } from '@/components/task/chat/token-usage-display';
|
||||
import { TodoSummary } from '@/components/task/chat/todo-summary';
|
||||
import { SessionsDropdown } from '@/components/task/sessions-dropdown';
|
||||
import { useSessionContextWindow } from '@/hooks/use-session-context-window';
|
||||
|
||||
type AgentOption = {
|
||||
id: string;
|
||||
@@ -82,6 +84,9 @@ export function TaskChatPanel({
|
||||
|
||||
const sessionModel = sessionProfile?.model ?? null;
|
||||
|
||||
// Fetch context window usage for this session
|
||||
const contextWindow = useSessionContextWindow(resolvedSessionId);
|
||||
|
||||
// Fetch messages for this session
|
||||
const { messages, isLoading: messagesLoading } = useSessionMessages(resolvedSessionId);
|
||||
const isInitialLoading = messagesLoading && messages.length === 0;
|
||||
@@ -338,7 +343,7 @@ export function TaskChatPanel({
|
||||
|
||||
{/* Session info - shows agent state and model */}
|
||||
{session?.state && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<RunningIndicator state={session.state} />
|
||||
{/* Model indicator */}
|
||||
{sessionModel && (
|
||||
@@ -420,6 +425,8 @@ export function TaskChatPanel({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle plan mode</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Context window usage indicator */}
|
||||
{contextWindow && <TokenUsageDisplay contextWindow={contextWindow} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SessionsDropdown
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import type { ContextWindowEntry } from '@/lib/state/store';
|
||||
|
||||
export function useSessionContextWindow(sessionId: string | null): ContextWindowEntry | undefined {
|
||||
// Subscribe to individual primitive values to ensure reactivity
|
||||
const size = useAppStore((state) =>
|
||||
sessionId ? state.contextWindow.bySessionId[sessionId]?.size : undefined
|
||||
);
|
||||
const used = useAppStore((state) =>
|
||||
sessionId ? state.contextWindow.bySessionId[sessionId]?.used : undefined
|
||||
);
|
||||
const remaining = useAppStore((state) =>
|
||||
sessionId ? state.contextWindow.bySessionId[sessionId]?.remaining : undefined
|
||||
);
|
||||
const efficiency = useAppStore((state) =>
|
||||
sessionId ? state.contextWindow.bySessionId[sessionId]?.efficiency : undefined
|
||||
);
|
||||
const timestamp = useAppStore((state) =>
|
||||
sessionId ? state.contextWindow.bySessionId[sessionId]?.timestamp : undefined
|
||||
);
|
||||
|
||||
// Memoize the combined object
|
||||
const contextWindow = useMemo(() => {
|
||||
if (size === undefined) return undefined;
|
||||
return { size, used: used ?? 0, remaining: remaining ?? 0, efficiency: efficiency ?? 0, timestamp };
|
||||
}, [size, used, remaining, efficiency, timestamp]);
|
||||
|
||||
const session = useAppStore((state) =>
|
||||
sessionId ? state.taskSessions.items[sessionId] : undefined
|
||||
);
|
||||
const setContextWindow = useAppStore((state) => state.setContextWindow);
|
||||
|
||||
// Populate context window from session metadata if not already in store
|
||||
useEffect(() => {
|
||||
if (!sessionId || contextWindow) return;
|
||||
|
||||
// Try to extract context_window from session metadata
|
||||
const metadata = session?.metadata;
|
||||
if (!metadata || typeof metadata !== 'object') return;
|
||||
|
||||
const storedContextWindow = (metadata as Record<string, unknown>).context_window;
|
||||
if (!storedContextWindow || typeof storedContextWindow !== 'object') return;
|
||||
|
||||
// Map stored context window to ContextWindowEntry
|
||||
const cw = storedContextWindow as Record<string, unknown>;
|
||||
const entry: ContextWindowEntry = {
|
||||
size: (cw.size as number) ?? 0,
|
||||
used: (cw.used as number) ?? 0,
|
||||
remaining: (cw.remaining as number) ?? 0,
|
||||
efficiency: (cw.efficiency as number) ?? 0,
|
||||
timestamp: (cw.timestamp as string) ?? undefined,
|
||||
};
|
||||
|
||||
setContextWindow(sessionId, entry);
|
||||
}, [sessionId, contextWindow, session?.metadata, setContextWindow]);
|
||||
|
||||
// Subscribe to session updates via WebSocket
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
const client = getWebSocketClient();
|
||||
if (client) {
|
||||
const unsubscribe = client.subscribeSession(sessionId);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
// Don't clear context window on cleanup - keep it cached
|
||||
};
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
return contextWindow;
|
||||
}
|
||||
|
||||
@@ -189,6 +189,18 @@ export type GitStatusState = {
|
||||
bySessionId: Record<string, GitStatusEntry>;
|
||||
};
|
||||
|
||||
export type ContextWindowEntry = {
|
||||
size: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
efficiency: number;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
export type ContextWindowState = {
|
||||
bySessionId: Record<string, ContextWindowEntry>;
|
||||
};
|
||||
|
||||
export type Worktree = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
@@ -270,6 +282,7 @@ export type AppState = {
|
||||
shell: ShellState;
|
||||
diffs: DiffState;
|
||||
gitStatus: GitStatusState;
|
||||
contextWindow: ContextWindowState;
|
||||
connection: ConnectionState;
|
||||
messages: MessagesState;
|
||||
taskSessions: TaskSessionsState;
|
||||
@@ -337,6 +350,7 @@ export type AppState = {
|
||||
setSessionWorktrees: (sessionId: string, worktreeIds: string[]) => void;
|
||||
setGitStatus: (sessionId: string, gitStatus: GitStatusEntry) => void;
|
||||
clearGitStatus: (sessionId: string) => void;
|
||||
setContextWindow: (sessionId: string, contextWindow: ContextWindowEntry) => void;
|
||||
bumpAgentProfilesVersion: () => void;
|
||||
};
|
||||
|
||||
@@ -380,6 +394,7 @@ const defaultState: AppState = {
|
||||
shell: { outputs: {}, statuses: {} },
|
||||
diffs: { files: [] },
|
||||
gitStatus: { bySessionId: {} },
|
||||
contextWindow: { bySessionId: {} },
|
||||
connection: { status: 'disconnected', error: null },
|
||||
messages: { bySession: {}, metaBySession: {} },
|
||||
taskSessions: { items: {} },
|
||||
@@ -433,6 +448,7 @@ const defaultState: AppState = {
|
||||
setSessionWorktrees: () => undefined,
|
||||
setGitStatus: () => undefined,
|
||||
clearGitStatus: () => undefined,
|
||||
setContextWindow: () => undefined,
|
||||
bumpAgentProfilesVersion: () => undefined,
|
||||
};
|
||||
|
||||
@@ -486,6 +502,7 @@ function mergeInitialState(
|
||||
| 'setSessionWorktrees'
|
||||
| 'setGitStatus'
|
||||
| 'clearGitStatus'
|
||||
| 'setContextWindow'
|
||||
| 'bumpAgentProfilesVersion'
|
||||
> {
|
||||
if (!initialState) return defaultState;
|
||||
@@ -515,6 +532,7 @@ function mergeInitialState(
|
||||
shell: { ...defaultState.shell, ...initialState.shell },
|
||||
diffs: { ...defaultState.diffs, ...initialState.diffs },
|
||||
gitStatus: { ...defaultState.gitStatus, ...initialState.gitStatus },
|
||||
contextWindow: { ...defaultState.contextWindow, ...initialState.contextWindow },
|
||||
connection: { ...defaultState.connection, ...initialState.connection },
|
||||
messages: { ...defaultState.messages, ...initialState.messages },
|
||||
taskSessions: { ...defaultState.taskSessions, ...initialState.taskSessions },
|
||||
@@ -883,6 +901,10 @@ export function createAppStore(initialState?: Partial<AppState>) {
|
||||
set((draft) => {
|
||||
delete draft.gitStatus.bySessionId[sessionId];
|
||||
}),
|
||||
setContextWindow: (sessionId, contextWindow) =>
|
||||
set((draft) => {
|
||||
draft.contextWindow.bySessionId[sessionId] = contextWindow;
|
||||
}),
|
||||
bumpAgentProfilesVersion: () =>
|
||||
set((draft) => {
|
||||
draft.agentProfiles.version += 1;
|
||||
|
||||
@@ -155,6 +155,7 @@ export type TaskSessionStateChangedPayload = {
|
||||
session_id: string;
|
||||
old_state?: string;
|
||||
new_state: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TaskSessionWaitingForInputPayload = {
|
||||
|
||||
@@ -54,6 +54,22 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
|
||||
store.getState().setTaskSessionsForTask(taskId, nextSessions);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract context window data from metadata if present
|
||||
const metadata = payload.metadata;
|
||||
if (metadata && typeof metadata === 'object') {
|
||||
const contextWindow = (metadata as Record<string, unknown>).context_window;
|
||||
if (contextWindow && typeof contextWindow === 'object') {
|
||||
const cw = contextWindow as Record<string, unknown>;
|
||||
store.getState().setContextWindow(sessionId, {
|
||||
size: (cw.size as number) ?? 0,
|
||||
used: (cw.used as number) ?? 0,
|
||||
remaining: (cw.remaining as number) ?? 0,
|
||||
efficiency: (cw.efficiency as number) ?? 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'session.agentctl_starting': (message) => {
|
||||
|
||||
Reference in New Issue
Block a user