diff --git a/internal/agents/conductor.go b/internal/agents/conductor.go index e847e49..adb2f6b 100644 --- a/internal/agents/conductor.go +++ b/internal/agents/conductor.go @@ -751,7 +751,19 @@ func (a *ConductorAgent) Run(ctx context.Context, input string, mem *memory.Conv if choice.Content != "" { if a.Publisher != nil { - a.Publisher.Publish("ai_response", choice.Content, a.Name()) + metadata := map[string]interface{}{} + if resp.Usage != nil { + metadata["usage"] = map[string]interface{}{ + "prompt_tokens": resp.Usage.PromptTokens, + "completion_tokens": resp.Usage.CompletionTokens, + "total_tokens": resp.Usage.TotalTokens, + } + } + if len(metadata) > 0 { + a.Publisher.PublishWithMetadata("ai_response", choice.Content, a.Name(), metadata) + } else { + a.Publisher.Publish("ai_response", choice.Content, a.Name()) + } } } diff --git a/internal/agents/executor.go b/internal/agents/executor.go index 4312c7c..d5d219d 100644 --- a/internal/agents/executor.go +++ b/internal/agents/executor.go @@ -72,7 +72,19 @@ func RunAgentLoop(ctx context.Context, cfg ExecutorConfig) (string, error) { choice := resp.Choices[0] if choice.Content != "" && cfg.Publisher != nil { - cfg.Publisher.Publish("ai_response", choice.Content, cfg.AgentName) + metadata := map[string]interface{}{} + if resp.Usage != nil { + metadata["usage"] = map[string]interface{}{ + "prompt_tokens": resp.Usage.PromptTokens, + "completion_tokens": resp.Usage.CompletionTokens, + "total_tokens": resp.Usage.TotalTokens, + } + } + if len(metadata) > 0 { + cfg.Publisher.PublishWithMetadata("ai_response", choice.Content, cfg.AgentName, metadata) + } else { + cfg.Publisher.Publish("ai_response", choice.Content, cfg.AgentName) + } } // Build assistant message diff --git a/internal/agents/meta.go b/internal/agents/meta.go index ebad7bd..4aca676 100644 --- a/internal/agents/meta.go +++ b/internal/agents/meta.go @@ -68,7 +68,19 @@ func (a *MetaAgent) Run(ctx context.Context, input string) (string, error) { } if a.Publisher != nil { - a.Publisher.Publish("ai_response", content, a.Name()) + metadata := map[string]interface{}{} + if resp.Usage != nil { + metadata["usage"] = map[string]interface{}{ + "prompt_tokens": resp.Usage.PromptTokens, + "completion_tokens": resp.Usage.CompletionTokens, + "total_tokens": resp.Usage.TotalTokens, + } + } + if len(metadata) > 0 { + a.Publisher.PublishWithMetadata("ai_response", content, a.Name(), metadata) + } else { + a.Publisher.Publish("ai_response", content, a.Name()) + } } return content, nil diff --git a/internal/llm/engine.go b/internal/llm/engine.go index d032152..cf31383 100644 --- a/internal/llm/engine.go +++ b/internal/llm/engine.go @@ -50,9 +50,17 @@ type FunctionCall struct { Arguments string `json:"arguments"` } +// TokenUsage contains token usage information returned by the LLM API. +type TokenUsage struct { + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + TotalTokens int64 `json:"total_tokens"` +} + // Response represents the LLM's response to a GenerateContent call. type Response struct { - Choices []Choice `json:"choices"` + Choices []Choice `json:"choices"` + Usage *TokenUsage `json:"usage,omitempty"` } // Choice represents a single choice in the LLM response. diff --git a/internal/llm/engine_openai.go b/internal/llm/engine_openai.go index 08a980b..32e241d 100644 --- a/internal/llm/engine_openai.go +++ b/internal/llm/engine_openai.go @@ -129,12 +129,23 @@ func (e *OpenAIEngine) generateStreaming(ctx context.Context, params openai.Chat return nil, fmt.Errorf("openai streaming: %w", err) } + // Extract real token usage from streaming accumulator + var usage *TokenUsage + if acc := stream.Current(); acc.Usage.PromptTokens > 0 || acc.Usage.CompletionTokens > 0 { + usage = &TokenUsage{ + PromptTokens: acc.Usage.PromptTokens, + CompletionTokens: acc.Usage.CompletionTokens, + TotalTokens: acc.Usage.TotalTokens, + } + } + return &Response{ Choices: []Choice{{ Content: content, Reasoning: reasoning, ToolCalls: toolCalls, }}, + Usage: usage, }, nil } @@ -260,5 +271,15 @@ func (e *OpenAIEngine) toResponse(completion *openai.ChatCompletion) *Response { resp.Choices = append(resp.Choices, c) } + + // Extract real token usage from API response + if completion.Usage.PromptTokens > 0 || completion.Usage.CompletionTokens > 0 { + resp.Usage = &TokenUsage{ + PromptTokens: completion.Usage.PromptTokens, + CompletionTokens: completion.Usage.CompletionTokens, + TotalTokens: completion.Usage.TotalTokens, + } + } + return resp } diff --git a/internal/tui/i18n.go b/internal/tui/i18n.go index a895635..cb2bc56 100644 --- a/internal/tui/i18n.go +++ b/internal/tui/i18n.go @@ -51,7 +51,6 @@ type translations struct { ConfirmDialogYes string ConfirmDialogNo string // Command mode (vim-like modal editing) - CommandModePrompt string CommandModeTips string CommandModeIdleTips string EditModeTips string @@ -97,7 +96,6 @@ var langMap = map[Language]translations{ ConfirmCancelMessage: "确定要取消当前任务吗?", ConfirmDialogYes: "确认 (Enter)", ConfirmDialogNo: "取消 (Esc)", - CommandModePrompt: "命令", CommandModeTips: "gg/G:首/尾 j/k:上下 f/b:翻页 ctrl+d/u:半页 i:编辑 ctrl+e:编辑模式 ZZ:退出", CommandModeIdleTips: "gg/G:首/尾 j/k:上下 f/b:翻页 ctrl+d/u:半页 ::命令 /:搜索 ?:帮助 i:编辑 ZZ:退出", EditModeTips: "ctrl+s:提交 ctrl+e:命令模式 ctrl+h:历史 ctrl+l:语言 ctrl+c:退出", @@ -161,7 +159,6 @@ var langMap = map[Language]translations{ ConfirmCancelMessage: "Are you sure you want to cancel the current task?", ConfirmDialogYes: "Confirm (Enter)", ConfirmDialogNo: "Cancel (Esc)", - CommandModePrompt: "COMMAND", CommandModeTips: "gg/G:top/btm j/k:scroll f/b:pgdn/up ctrl+d/u:half i:edit ctrl+e:edit ZZ:quit", CommandModeIdleTips: "gg/G:top/btm j/k:scroll f/b:pgdn/up ctrl+d/u:half ::cmd /:search ?:help i:edit ZZ:quit", EditModeTips: "ctrl+s:submit ctrl+e:cmd ctrl+h:history ctrl+l:lang ctrl+c:quit", @@ -288,8 +285,6 @@ func (lm *LanguageManager) GetText(key string) string { return translations.ConfirmDialogYes case "ConfirmDialogNo": return translations.ConfirmDialogNo - case "CommandModePrompt": - return translations.CommandModePrompt case "CommandModeTips": return translations.CommandModeTips case "CommandModeIdleTips": diff --git a/internal/tui/render.go b/internal/tui/render.go index f73d09e..7cb3a14 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -126,7 +126,7 @@ func RenderToolLine(entry *ToolEntry, anim *Anim, width int) string { // Render body if we have a result if entry.Result != nil && entry.Result.Content != "" { - body := RenderResultBody(entry.Result.Content, width) + body := RenderResultBody(entry.Call.Name, entry.Result.Content, width) if body != "" { // Border only wraps header; body goes below the border return addToolCallBorders(header, width) + "\n" + body @@ -145,7 +145,7 @@ func addToolCallBorders(header string, width int) string { } // RenderResultBody renders the tool result content with smart detection. -func RenderResultBody(content string, width int) string { +func RenderResultBody(toolName string, content string, width int) string { // Determine content width bodyWidth := width - 4 // account for padding if bodyWidth > MaxContentWidth { @@ -157,6 +157,10 @@ func RenderResultBody(content string, width int) string { // 1. Try JSON — check for embedded fields first if isJSON(content) { + // Detect codebase tool results by JSON structure (not tool name) + if formatted := tryFormatCodebaseResult(content, bodyWidth); formatted != "" { + return formatted + } // Check if JSON contains a "diff" field — extract and render as colored diff if diff := extractDiffField(content); diff != "" { return RenderDiffContent(diff, bodyWidth) @@ -216,6 +220,236 @@ func extractOutputField(jsonStr string) string { return "" } +// tryFormatCodebaseResult detects codebase tool result JSON by structure and formats it. +// Returns empty string if the JSON doesn't match any known codebase result pattern. +func tryFormatCodebaseResult(content string, width int) string { + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(content), &parsed); err != nil { + return "" + } + + data, hasData := parsed["data"].(map[string]interface{}) + if !hasData { + return "" + } + + // Pattern 1: semantic_search — data.results array with file_path/semantic_distance + if resultsRaw, ok := data["results"]; ok { + if results, ok := resultsRaw.([]interface{}); ok && len(results) > 0 { + // Verify it looks like semantic_search (has file_path or semantic_distance) + if first, ok := results[0].(map[string]interface{}); ok { + if _, hasPath := first["file_path"]; hasPath { + return formatSemanticSearchResults(results, width) + } + } + } + } + + // Pattern 2: query_code_snippet — data.code_snippet with filepath + if snippet, ok := data["code_snippet"].(string); ok && snippet != "" { + filepath, _ := data["filepath"].(string) + funcName, _ := data["function_name"].(string) + lineStart, _ := data["line_start"].(float64) + lineEnd, _ := data["line_end"].(float64) + language, _ := data["language"].(string) + return formatCodeSnippetResult(filepath, funcName, snippet, int(lineStart), int(lineEnd), language, width) + } + + // Pattern 3: query_code_skeleton — data.skeletons array + if skelsRaw, ok := data["skeletons"]; ok { + if skels, ok := skelsRaw.([]interface{}); ok && len(skels) > 0 { + return formatCodeSkeletonResults(skels, width) + } + } + + return "" +} + +// formatSemanticSearchResults formats semantic_search results array in a human-readable way. +func formatSemanticSearchResults(results []interface{}, width int) string { + if len(results) == 0 { + return ContentLine.Render(" (no results)") + } + + idxStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("243")).Faint(true) + fileStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true) + scoreStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + codeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + symbolStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Faint(true) + + maxResults := MaxBodyLines + totalResults := len(results) + if totalResults > maxResults { + results = results[:maxResults] + } + + var lines []string + for i, r := range results { + item, ok := r.(map[string]interface{}) + if !ok { + continue + } + + var headerParts []string + num := idxStyle.Render(fmt.Sprintf("#%d", i+1)) + + filePath := "" + if fp, ok := item["file_path"].(string); ok { + filePath = fp + } + maxPathLen := width - 25 + if maxPathLen < 20 { + maxPathLen = 20 + } + if len(filePath) > maxPathLen { + filePath = "..." + filePath[len(filePath)-maxPathLen+3:] + } + filePart := fileStyle.Render(filePath) + headerParts = append(headerParts, num, " ", filePart) + + if score, ok := item["semantic_distance"].(float64); ok { + headerParts = append(headerParts, " ", scoreStyle.Render(fmt.Sprintf("(score: %.3f)", score))) + } + + if sym, ok := item["symbol_name"].(string); ok && sym != "" && !strings.HasPrefix(sym, "anon-") { + headerParts = append(headerParts, " ", symbolStyle.Render(sym)) + } + + lines = append(lines, strings.Join(headerParts, "")) + + if cb, ok := item["code_block"].(string); ok && cb != "" { + firstLine := strings.SplitN(cb, "\n", 2)[0] + firstLine = strings.TrimSpace(firstLine) + maxCodeLen := width - 6 + if maxCodeLen < 30 { + maxCodeLen = 30 + } + if len(firstLine) > maxCodeLen { + firstLine = firstLine[:maxCodeLen-1] + "…" + } + lines = append(lines, " "+codeStyle.Render(" "+firstLine)) + } + } + + if totalResults > maxResults { + hidden := totalResults - maxResults + lines = append(lines, ContentTrunc.Render(fmt.Sprintf("... (%d more results hidden)", hidden))) + } + + return strings.Join(lines, "\n") +} + +// formatCodeSnippetResult formats a query_code_snippet result in a human-readable way. +func formatCodeSnippetResult(filepath, funcName, snippet string, lineStart, lineEnd int, language string, width int) string { + fileStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true) + funcStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + langStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Faint(true) + locStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("243")).Faint(true) + codeLineStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + + var lines []string + + // Header: filepath funcName (language) L{start}-{end} + headerParts := []string{ + fileStyle.Render(filepath), + " ", + funcStyle.Render(funcName), + } + if language != "" { + headerParts = append(headerParts, " ", langStyle.Render(language)) + } + if lineStart > 0 { + lineRange := fmt.Sprintf("L%d-%d", lineStart, lineEnd) + headerParts = append(headerParts, " ", locStyle.Render(lineRange)) + } + lines = append(lines, strings.Join(headerParts, "")) + + // Code content — show each line with prefix + codeLines := strings.Split(snippet, "\n") + maxLines := MaxBodyLines - 1 // reserve one line for header + truncated := false + if len(codeLines) > maxLines { + codeLines = codeLines[:maxLines] + truncated = true + } + for _, cl := range codeLines { + truncated := truncateLine(cl, width-2) + lines = append(lines, " "+codeLineStyle.Render(truncated)) + } + if truncated { + hidden := len(strings.Split(snippet, "\n")) - maxLines + lines = append(lines, ContentTrunc.Render(fmt.Sprintf("... (%d more lines hidden)", hidden))) + } + + return strings.Join(lines, "\n") +} + +// formatCodeSkeletonResults formats query_code_skeleton results array in a human-readable way. +func formatCodeSkeletonResults(skels []interface{}, width int) string { + if len(skels) == 0 { + return ContentLine.Render(" (no skeletons)") + } + + idxStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("243")).Faint(true) + fileStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true) + langStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Faint(true) + codeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + + maxResults := MaxBodyLines + totalResults := len(skels) + if totalResults > maxResults { + skels = skels[:maxResults] + } + + var lines []string + for i, s := range skels { + item, ok := s.(map[string]interface{}) + if !ok { + continue + } + + num := idxStyle.Render(fmt.Sprintf("#%d", i+1)) + filepath, _ := item["filepath"].(string) + language, _ := item["language"].(string) + + // Truncate filepath + maxPathLen := width - 20 + if maxPathLen < 20 { + maxPathLen = 20 + } + if len(filepath) > maxPathLen { + filepath = "..." + filepath[len(filepath)-maxPathLen+3:] + } + + headerParts := []string{num, " ", fileStyle.Render(filepath)} + if language != "" { + headerParts = append(headerParts, " ", langStyle.Render(language)) + } + lines = append(lines, strings.Join(headerParts, "")) + + // Skeleton text + if st, ok := item["skeleton_text"].(string); ok && st != "" { + firstLine := strings.SplitN(st, "\n", 2)[0] + firstLine = strings.TrimSpace(firstLine) + maxCodeLen := width - 6 + if maxCodeLen < 30 { + maxCodeLen = 30 + } + if len(firstLine) > maxCodeLen { + firstLine = firstLine[:maxCodeLen-1] + "…" + } + lines = append(lines, " "+codeStyle.Render(" "+firstLine)) + } + } + + if totalResults > maxResults { + hidden := totalResults - maxResults + lines = append(lines, ContentTrunc.Render(fmt.Sprintf("... (%d more skeletons hidden)", hidden))) + } + + return strings.Join(lines, "\n") +} + // RenderDiffContent renders a unified diff string with ANSI color styling. func RenderDiffContent(diffText string, maxWidth int) string { lines := strings.Split(diffText, "\n") diff --git a/internal/tui/tui_model.go b/internal/tui/tui_model.go index 768a264..e3bf50b 100644 --- a/internal/tui/tui_model.go +++ b/internal/tui/tui_model.go @@ -61,8 +61,10 @@ var ( // Mode-specific styles (vim-like edit / command modes) commandPrefixStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) // orange ":" - commandLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) // "COMMAND" - commandHintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) // tips text + commandModeBarStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("214")). + Foreground(lipgloss.Color("0")). + Bold(true) ) // logEntry represents a single message in the TUI log area. @@ -205,6 +207,10 @@ type model struct { // Current LLM model being used (extracted from model_info events) currentModel string + // Token consumption tracking + inputTokens int64 // accumulated input tokens + outputTokens int64 // accumulated output tokens + // Animation state for running tools anim *Anim activeAnim bool // true when there are running tool entries diff --git a/internal/tui/tui_render.go b/internal/tui/tui_render.go index f87e4d0..ff65e78 100644 --- a/internal/tui/tui_render.go +++ b/internal/tui/tui_render.go @@ -12,7 +12,7 @@ import ( ) func (m *model) resizeViewport() { - footerHeight := 6 + footerHeight := 7 if m.errMsg != "" { footerHeight++ } diff --git a/internal/tui/tui_tasks.go b/internal/tui/tui_tasks.go index b609589..08f695a 100644 --- a/internal/tui/tui_tasks.go +++ b/internal/tui/tui_tasks.go @@ -6,6 +6,7 @@ import ( "time" "codeactor/internal/app" + "codeactor/internal/compact" "codeactor/internal/datamanager" "codeactor/internal/http" "codeactor/internal/memory" @@ -17,6 +18,16 @@ import ( func (m *model) submitTask() tea.Cmd { taskDesc := strings.TrimSpace(m.input.Value()) + + // Count input tokens + if taskDesc != "" { + if tok := compact.GetGlobalTokenizer(); tok != nil { + if count, err := tok.CountTokens(taskDesc); err == nil && count > 0 { + m.inputTokens += int64(count) + } + } + } + m.input.SetValue("") m.taskRunning = true m.commandMode = true @@ -55,6 +66,15 @@ func (m *model) submitTask() tea.Cmd { // submitFollowUp sends a follow-up message to an existing task. func (m *model) submitFollowUp(message string) tea.Cmd { + // Count input tokens + if message != "" { + if tok := compact.GetGlobalTokenizer(); tok != nil { + if count, err := tok.CountTokens(message); err == nil && count > 0 { + m.inputTokens += int64(count) + } + } + } + m.input.SetValue("") m.taskRunning = true m.commandMode = true diff --git a/internal/tui/tui_update.go b/internal/tui/tui_update.go index a55c90a..e8505b1 100644 --- a/internal/tui/tui_update.go +++ b/internal/tui/tui_update.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "codeactor/internal/compact" "codeactor/internal/datamanager" "codeactor/internal/http" @@ -744,6 +745,45 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case taskEventMsg: + // Count tokens for AI response events + // Prefer real token usage from API metadata, fallback to estimation + if msg.event.Type == "ai_response" || msg.event.Type == "ai_stream_end" { + if usageData, ok := msg.event.Metadata["usage"]; ok { + if usageMap, ok := usageData.(map[string]interface{}); ok { + if completionTokens, ok := usageMap["completion_tokens"]; ok { + switch v := completionTokens.(type) { + case float64: + m.outputTokens += int64(v) + case int64: + m.outputTokens += v + case int: + m.outputTokens += int64(v) + } + } + // Also track input tokens from API (PromptTokens) + if promptTokens, ok := usageMap["prompt_tokens"]; ok { + switch v := promptTokens.(type) { + case float64: + m.inputTokens += int64(v) + case int64: + m.inputTokens += v + case int: + m.inputTokens += int64(v) + } + } + } + } else { + // Fallback: estimate tokens from content string + if content, ok := msg.event.Content.(string); ok && content != "" { + if tok := compact.GetGlobalTokenizer(); tok != nil { + if count, err := tok.CountTokens(content); err == nil && count > 0 { + m.outputTokens += int64(count) + } + } + } + } + } + // Capture model info for status bar display if msg.event.Type == "model_info" { if contentMap, ok := msg.event.Content.(map[string]interface{}); ok { diff --git a/internal/tui/tui_view.go b/internal/tui/tui_view.go index 71b3f60..d2f067b 100644 --- a/internal/tui/tui_view.go +++ b/internal/tui/tui_view.go @@ -60,32 +60,20 @@ func (m model) View() string { if m.commandMode { // ── Command mode (vim-like): hidden input, ":" prefix, colored bar ── - modeBar := lipgloss.NewStyle(). - Background(lipgloss.Color("214")). - Foreground(lipgloss.Color("214")). - Render("▊") + modeBar := commandModeBarStyle.Render(" COMMAND ") var cmdLine string cmdPrefix := commandPrefixStyle.Render(" :") - cmdLabel := commandLabelStyle.Render(" " + langManager.GetText("CommandModePrompt") + " ") if m.commandBuffer != "" { cmdBufDisplay := lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Render(m.commandBuffer) cmdCursor := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Blink(true).Render("▊") - cmdLine = modeBar + cmdPrefix + cmdLabel + " " + cmdBufDisplay + cmdCursor + cmdLine = modeBar + cmdPrefix + " " + cmdBufDisplay + cmdCursor } else { cmdCursor := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Blink(true).Render("▊") - cmdLine = modeBar + cmdPrefix + cmdLabel + " " + cmdCursor + cmdLine = modeBar + cmdPrefix + " " + cmdCursor } - var cmdTips string - if m.taskRunning { - cmdTips = langManager.GetText("CommandModeTips") - } else { - cmdTips = langManager.GetText("CommandModeIdleTips") - } - cmdTipsLine := commandHintStyle.Render(" " + cmdTips) - - footer.WriteString(cmdLine + cmdTipsLine) + footer.WriteString(cmdLine) footer.WriteString("\n") } else { // ── Edit mode: textarea with dark background (via Base style), no bar ── @@ -101,6 +89,10 @@ func (m model) View() string { footer.WriteString("\n") } + // Token consumption display (before status line) + footer.WriteString(m.renderTokenLine()) + footer.WriteString("\n") + // Status line: mode indicator + task indicator + model name taskIndicator := "" if m.taskRunning { @@ -113,7 +105,11 @@ func (m model) View() string { footer.WriteString("\n") var statusLine string if m.commandMode { - statusLine = footerStyle.Render("COMMAND MODE") + if m.taskRunning { + statusLine = footerStyle.Render(langManager.GetText("CommandModeTips")) + } else { + statusLine = footerStyle.Render(langManager.GetText("CommandModeIdleTips")) + } } else { statusLine = footerStyle.Render(langManager.GetText("EditModeTips")) } @@ -190,3 +186,24 @@ func renderBanner() string { } return bannerPadStyle.Render(lipgloss.JoinVertical(lipgloss.Left, rendered...)) } + +// formatToken formats a token count with k/m suffixes (e.g. "1.2k", "1.5m") +func formatToken(n int64) string { + switch { + case n >= 1000000: + return fmt.Sprintf("%.1fm", float64(n)/1000000) + case n >= 1000: + return fmt.Sprintf("%.1fk", float64(n)/1000) + default: + return fmt.Sprintf("%d", n) + } +} + +// renderTokenLine renders the token consumption line in the footer. +// Format: "In: 1.2k | Out: 3.5k" +func (m model) renderTokenLine() string { + tokenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) // muted gray + inStr := formatToken(m.inputTokens) + outStr := formatToken(m.outputTokens) + return tokenStyle.Render(fmt.Sprintf("In: %s | Out: %s", inStr, outStr)) +} diff --git a/pkg/messaging/message_publisher.go b/pkg/messaging/message_publisher.go index 8c95547..775787d 100644 --- a/pkg/messaging/message_publisher.go +++ b/pkg/messaging/message_publisher.go @@ -26,4 +26,17 @@ func (p *MessagePublisher) Publish(eventType string, content interface{}, from s Timestamp: time.Now(), }) } +} + +// PublishWithMetadata 发布带元数据的消息 +func (p *MessagePublisher) PublishWithMetadata(eventType string, content interface{}, from string, metadata map[string]interface{}) { + if p.dispatcher != nil { + p.dispatcher.Publish(&MessageEvent{ + Type: eventType, + From: from, + Content: content, + Timestamp: time.Now(), + Metadata: metadata, + }) + } } \ No newline at end of file