feat: add accurate token-based progress tracking with pre‑request estimation
- Enhance LLM client to return actual token usage from API response
- Add `TokenUsage` struct with prompt and completion token counts
- Update `SummariseArticles` and `callAPIWithMessages` to return
`(*TokenUsage, error)`
- Log token usage through progress logger as before
- Add token estimation before API call
(`contentProc.EstimateTokenCount`)
- Estimate total prompt tokens using tiktoken with the selected model
- Pass estimate to progress bar for realistic pre‑request progress
display
- Extend progress interface with new tracking methods
- `SetTokenEstimate(total, used int)` for token‑based progress updates
- `StartWaiting()` to signal the TUI that we are waiting for the LLM
response
- Implement no‑op stubs in `SimpleLogger` and `NoopLogger`
- Update TUI (`internal/tui/`) to show token‑based progress
- Add `TokenEstimateMsg` and `WaitingMsg` channel messages
- Modify progress bar calculation: use token ratio once estimate is
available
- During LLM wait period, show a simulated progress (85‑100% over 60
seconds)
- Display "Tokens: used / total" in statistics area
- Revise `README.md` to include an animated demo GIF
(`./assets/demo.gif`)
- Bump `CHANGELOG.md` with version `0.11.0` entry describing the token
tracking feature
@@ -6,6 +6,12 @@ The format is based on [Keep a
Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.11.0] - 2026-04-23 + +- Progress bar now tracks estimated token count (via tiktoken) before sending + to LLM, then updates with actual completion tokens from the API response. + Gives a more accurate picture of how much content is being sent. + ## [0.10.0] - 2026-04-23 ### Added
@@ -115,6 +115,8 @@ 8. **Format and output the result**: the AI’s response is printed in the chosen
format (plain text, GitHub‑flavoured markdown, or JSON). If JSON output is selected, the original articles can be included alongside the summary. + + When the `--tui` flag is used, the entire process is wrapped in a `bubbletea` TUI that shows a colourful progress bar, live article counters, and elapsed time. The TUI renders Markdown content from the LLM with proper styling for
@@ -76,6 +76,12 @@ func (dc *LLMClient) SetLogger(logger *progress.Context) {
dc.logger = logger } +// TokenUsage holds token usage information from the API +type TokenUsage struct { + PromptTokens int + CompletionTokens int +} + // SummariseArticles summarises a list of articles based on user prompt. // articles: List of article maps // userPrompt: User's query/summarisation request@@ -84,9 +90,9 @@ func (dc *LLMClient) SummariseArticles(
articles []map[string]any, userPrompt string, systemPrompt string, -) (string, error) { +) (string, *TokenUsage, error) { if len(articles) == 0 { - return "No articles to summarise.", nil + return "No articles to summarise.", nil, nil } // Prepare context from articles@@ -174,7 +180,7 @@
return messages } -func (dc *LLMClient) callAPIWithMessages(messages []openai.ChatCompletionMessageParamUnion) (string, error) { +func (dc *LLMClient) callAPIWithMessages(messages []openai.ChatCompletionMessageParamUnion) (string, *TokenUsage, error) { ctx := context.Background() if dc.logger != nil {@@ -191,41 +197,42 @@ Temperature: openai.Float(dc.temperature),
}) if err != nil { - // Provide more specific error messages based on error type errStr := err.Error() - // Log the full error for debugging if dc.logger != nil { dc.logger.Logf("API error: %s", errStr) } if strings.Contains(errStr, "401") { - return "", fmt.Errorf("invalid API key. Please check your LLM API key") + return "", nil, fmt.Errorf("invalid API key. Please check your LLM API key") } else if strings.Contains(errStr, "429") { - return "", fmt.Errorf("rate limit exceeded. Please try again later") + return "", nil, fmt.Errorf("rate limit exceeded. Please try again later") } else if strings.Contains(errStr, "500") { - return "", fmt.Errorf("LLM API server error. Please try again later") + return "", nil, fmt.Errorf("LLM API server error. Please try again later") } else if strings.Contains(errStr, "404") { - return "", fmt.Errorf("API endpoint not found. Please check the base URL and endpoint. OpenAI API uses /chat/completions") + return "", nil, fmt.Errorf("API endpoint not found. Please check the base URL and endpoint. OpenAI API uses /chat/completions") } - return "", fmt.Errorf("failed to connect to LLM API: %w", err) + return "", nil, fmt.Errorf("failed to connect to LLM API: %w", err) } - // Extract text from response if len(response.Choices) == 0 { - return "", fmt.Errorf("no response choices returned from API") + return "", nil, fmt.Errorf("no response choices returned from API") } outputText := response.Choices[0].Message.Content - // Print token usage + usage := &TokenUsage{ + PromptTokens: int(response.Usage.PromptTokens), + CompletionTokens: int(response.Usage.CompletionTokens), + } + if dc.logger != nil { dc.logger.Logf( "LLM API response: %d prompt tokens, %d completion tokens", - response.Usage.PromptTokens, - response.Usage.CompletionTokens, + usage.PromptTokens, + usage.CompletionTokens, ) } - return outputText, nil + return outputText, usage, nil }
@@ -20,6 +20,8 @@ Logger
SetStage(stage string) SetSubStage(status string) SetArticleCount(total, processed int) + SetTokenEstimate(total, used int) + StartWaiting() } // SimpleLogger implements Logger with io.Writer output@@ -52,9 +54,11 @@ fmt.Fprintf(sl.writer, "%s\n", style.Debugf(format, args...))
} } -func (sl *SimpleLogger) SetStage(stage string) {} -func (sl *SimpleLogger) SetSubStage(stage string) {} -func (sl *SimpleLogger) SetArticleCount(total, processed int) {} +func (sl *SimpleLogger) SetStage(stage string) {} +func (sl *SimpleLogger) SetSubStage(stage string) {} +func (sl *SimpleLogger) SetArticleCount(total, processed int) {} +func (sl *SimpleLogger) SetTokenEstimate(total, used int) {} +func (sl *SimpleLogger) StartWaiting() {} // NoopLogger implements Logger with no output type NoopLogger struct{}@@ -62,9 +66,11 @@
func (nl *NoopLogger) Logf(format string, args ...any) {} func (nl *NoopLogger) Warningf(format string, args ...any) {} func (nl *NoopLogger) Debugf(format string, args ...any) {} -func (nl *NoopLogger) SetStage(stage string) {} -func (nl *NoopLogger) SetSubStage(status string) {} -func (nl *NoopLogger) SetArticleCount(total, processed int) {} +func (nl *NoopLogger) SetStage(stage string) {} +func (nl *NoopLogger) SetSubStage(status string) {} +func (nl *NoopLogger) SetArticleCount(total, processed int) {} +func (nl *NoopLogger) SetTokenEstimate(total, used int) {} +func (nl *NoopLogger) StartWaiting() {} // Context provides progress context for components type Context struct {
@@ -92,6 +92,10 @@ r.Progress.SetArticleCount(len(articles), len(articles)-len(processedArticles))
r.Articles = processedArticles + // Estimate tokens for progress tracking + tokenEst := contentProc.EstimateTokenCount(processedArticles, r.Model) + r.Progress.SetTokenEstimate(tokenEst, tokenEst) + // Step 3: Initialise LLM client r.Progress.SetStage("Connecting to LLM") r.Progress.SetSubStage(fmt.Sprintf("Using model: %s", r.Model))@@ -111,8 +115,9 @@
// Step 4: Get summary from LLM r.Progress.SetStage("Getting summary") r.Progress.SetSubStage(fmt.Sprintf("Sending %d articles to LLM", len(processedArticles))) + r.Progress.StartWaiting() - summary, err := llm.SummariseArticles( + summary, usage, err := llm.SummariseArticles( processedArticles, r.Prompt, r.SystemPrompt,@@ -122,7 +127,9 @@ return fmt.Errorf("error getting summary from LLM: %w", err)
} r.Summary = summary - r.Progress.SetArticleCount(len(processedArticles), len(processedArticles)) + if usage != nil { + r.Progress.SetTokenEstimate(tokenEst, tokenEst+usage.CompletionTokens) + } return nil }
@@ -89,6 +89,12 @@ type ArticleCountMsg struct {
Total, Processed int } +type TokenEstimateMsg struct { + Total, Used int +} + +type WaitingMsg struct{} + type processingDoneMsg struct { err error }@@ -112,6 +118,10 @@ done bool
errorMsg string articlesCount int processedCount int + tokenCount int + tokenUsed int + waiting bool + waitingStart time.Time showSummary bool showThinking bool // Toggle state for thinking tags visibility thinkingContent string // Extracted thinking content for toggle@@ -252,7 +262,16 @@ case ArticleCountMsg:
m.articlesCount = msg.Total m.processedCount = msg.Processed + case TokenEstimateMsg: + m.tokenCount = msg.Total + m.tokenUsed = msg.Used + + case WaitingMsg: + m.waiting = true + m.waitingStart = time.Now() + case processingDoneMsg: + m.waiting = false if msg.err != nil { m.errorMsg = msg.err.Error() m.done = true // Also mark as done on error@@ -314,8 +333,18 @@ // If done, show 100% progress.
var progressPercent float64 if m.done && m.errorMsg == "" { progressPercent = 1.0 - } else if m.articlesCount > 0 && m.currentStep >= 4 { - progressPercent = float64(m.processedCount) / float64(m.articlesCount) + } else if m.waiting { + // Fake progress: 85% base + up to 15% over 60 seconds + elapsed := time.Since(m.waitingStart).Seconds() + waitFraction := min(elapsed/60.0, 1.0) + progressPercent = 0.85 + (waitFraction * 0.15) + } else if m.tokenCount > 0 { + // Use token-based progress once articles are ready + // Tokens sent to LLM as a fraction of model's max token window (8k context assumed) + progressPercent = float64(m.tokenUsed) / float64(m.tokenCount) + if progressPercent > 1.0 { + progressPercent = 1.0 + } } else { progressPercent = float64(m.currentStep) / float64(m.totalSteps) }@@ -354,6 +383,12 @@ sb.WriteString(" ")
} if m.processedCount > 0 { sb.WriteString(infoStyle.Render(fmt.Sprintf("Processed: %d", m.processedCount))) + sb.WriteString(" ") + } + if m.tokenCount > 0 { + sb.WriteString(infoStyle.Render(fmt.Sprintf("Tokens: %d / %d", m.tokenUsed, m.tokenCount))) + sb.WriteString("\n") + } else { sb.WriteString("\n") }
@@ -36,6 +36,16 @@ func (tp *TUIProgress) SetArticleCount(total, processed int) {
tp.program.Send(ArticleCountMsg{Total: total, Processed: processed}) } +// SetTokenEstimate sends a message to update the token estimates. +func (tp *TUIProgress) SetTokenEstimate(total, used int) { + tp.program.Send(TokenEstimateMsg{Total: total, Used: used}) +} + +// StartWaiting signals that we're waiting for the LLM response. +func (tp *TUIProgress) StartWaiting() { + tp.program.Send(WaitingMsg{}) +} + // Logf sends a generic log message (can be displayed as a sub-status). func (tp *TUIProgress) Logf(format string, args ...any) { tp.program.Send(SubStageMsg(fmt.Sprintf(format, args...)))