• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

fogfish / iq / 19345142292

13 Nov 2025 08:37PM UTC coverage: 35.926% (-1.5%) from 37.39%
19345142292

push

github

web-flow
Compose AI workflow within the shell (#26)

* refactor the application structure
* create workflow engine for YAML based composition 
* custom DSL for workflow definition
* abstract I/O using iosystem including multiple sources
* use builder pattern, director to structure the commands
* restructure examples
* support MCP (consumer and server) 
* write documentation and user guide
* support shell commands in the workflow
* support image generation (via google api)

1163 of 3153 new or added lines in 36 files covered. (36.89%)

1 existing line in 1 file now uncovered.

1187 of 3304 relevant lines covered (35.93%)

0.39 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

29.62
/internal/progress/progress.go
1
//
2
// Copyright (C) 2025 Dmitry Kolesnikov
3
//
4
// This file may be modified and distributed under the terms
5
// of the MIT license.  See the LICENSE file for details.
6
// https://github.com/fogfish/iq
7
//
8

9
package progress
10

11
import (
12
        "fmt"
13
        "io"
14
        "os"
15
        "strings"
16
        "sync"
17
        "time"
18

19
        "github.com/nickwells/twrap.mod/twrap"
20
        "golang.org/x/term"
21
)
22

23
// TokenUsage represents token usage statistics
24
type TokenUsage struct {
25
        InputTokens int
26
        ReplyTokens int
27
}
28

29
// ProfileTokenUsage represents token usage for a specific profile
30
type ProfileTokenUsage struct {
31
        Name  string
32
        Usage TokenUsage
33
}
34

35
// TokenSource interface for accessing token usage from LLM routers
36
type TokenSource interface {
37
        Usage() TokenUsage
38
        ProfileUsage() []ProfileTokenUsage
39
}
40

41
// Icons used for progress reporting - centralized for easy customization
42
const (
43
        IconWorkflowLoad     = "📋" // Loading workflow file
44
        IconWorkflowCompiled = "✅" // Workflow successfully compiled
45
        IconWorkflowError    = "❌" // Workflow error
46

47
        IconDocumentStart    = "📄"  // Document processing started
48
        IconDocumentComplete = "✅"  // Document processed successfully
49
        IconDocumentError    = "❌"  // Document processing error
50
        IconDocumentSkipped  = "⏭️" // Document skipped
51

52
        IconStepProcessing = "⚙️ " // Step is processing (mutable line)
53
        IconStepComplete   = "✅"   // Step completed successfully
54
        IconStepError      = "❌"   // Step failed with error
55

56
        IconRouterEvaluating = "🧭" // Router evaluating conditions
57
        IconRouterMatched    = "↳" // Router matched a route (text, not emoji)
58

59
        IconForeachStart = "🔁" // Foreach loop started
60
        IconForeachItem  = "✅" // Foreach item completed
61

62
        IconRetryAttempt   = "🔄" // Retry attempt
63
        IconRetrySuccess   = "✅" // Retry succeeded
64
        IconRetryExhausted = "❌" // All retries exhausted
65

66
        IconBatchProcessing = "📂"  // Batch processing
67
        IconChunking        = "✂️" // Document chunking
68

69
        IconThinking = "💭" // LLM thinking/reasoning content
70

71
        IconSummary = "📊"  // Pipeline summary
72
        IconWarning = "⚠️" // Warning message
73
)
74

75
// Reporter handles all progress output to stderr with educational messages
76
type Reporter struct {
77
        w               io.Writer
78
        mu              sync.Mutex
79
        quiet           bool
80
        lastLine        string
81
        hasThinking     bool // Set to true when thinking content is shown
82
        isInForeachMode bool // Set to true when inside a foreach loop
83
        startTime       time.Time
84
        stats           Stats
85
        tokenSource     TokenSource // Source for final token usage reporting
86
}
87

88
// Stats tracks processing metrics
89
type Stats struct {
90
        DocsProcessed int
91
        DocsSkipped   int
92
        DocsErrors    int
93
        TokensInput   int
94
        TokensOutput  int
95
        Errors        []error
96
}
97

98
// New creates a new progress reporter
99
func New(quiet bool) *Reporter {
1✔
100
        return &Reporter{
1✔
101
                w:         os.Stderr,
1✔
102
                quiet:     quiet,
1✔
103
                startTime: time.Now(),
1✔
104
        }
1✔
105
}
1✔
106

107
// NewWithWriter creates a reporter with a custom writer (for testing)
108
func NewWithWriter(w io.Writer, quiet bool) *Reporter {
1✔
109
        return &Reporter{
1✔
110
                w:         w,
1✔
111
                quiet:     quiet,
1✔
112
                startTime: time.Now(),
1✔
113
        }
1✔
114
}
1✔
115

116
// SetTokenSource sets the token source for final usage reporting
NEW
117
func (r *Reporter) SetTokenSource(source TokenSource) {
×
NEW
118
        r.mu.Lock()
×
NEW
119
        defer r.mu.Unlock()
×
NEW
120
        r.tokenSource = source
×
NEW
121
}
×
122

123
// SetForeachMode sets whether we're inside a foreach loop
NEW
124
func (r *Reporter) SetForeachMode(inForeach bool) {
×
NEW
125
        r.mu.Lock()
×
NEW
126
        defer r.mu.Unlock()
×
NEW
127
        r.isInForeachMode = inForeach
×
NEW
128
}
×
129

130
// Workflow lifecycle events
131

132
// WorkflowLoading indicates workflow file is being loaded
133
func (r *Reporter) WorkflowLoading(path string) {
1✔
134
        r.println(fmt.Sprintf("%s Loading workflow from %s", IconWorkflowLoad, path))
1✔
135
}
1✔
136

137
// WorkflowCompiled indicates successful workflow compilation
NEW
138
func (r *Reporter) WorkflowCompiled(name string, jobCount, stepCount int) {
×
NEW
139
        if name == "" {
×
NEW
140
                name = "(unnamed)"
×
NEW
141
        }
×
NEW
142
        r.println(fmt.Sprintf("%s Workflow compiled: \"%s\" (%d jobs, %d steps)", IconWorkflowCompiled, name, jobCount, stepCount))
×
NEW
143
        r.println("")
×
144
}
145

146
// WorkflowError reports a workflow-level error
NEW
147
func (r *Reporter) WorkflowError(err error) {
×
NEW
148
        r.println(fmt.Sprintf("%s Workflow error: %v", IconWorkflowError, err))
×
NEW
149
}
×
150

151
// Document processing events
152

153
// DocumentStart indicates a document is starting to process
154
func (r *Reporter) DocumentStart(path string, sizeKB float64) {
1✔
155
        if sizeKB > 0 {
2✔
156
                r.println(fmt.Sprintf("%s Processing: %s (%.1f KB)", IconDocumentStart, path, sizeKB))
1✔
157
        } else {
1✔
NEW
158
                r.println(fmt.Sprintf("%s Processing: %s", IconDocumentStart, path))
×
NEW
159
        }
×
160
}
161

162
// DocumentComplete indicates successful document completion
163
func (r *Reporter) DocumentComplete(path string, duration time.Duration) {
1✔
164
        r.stats.DocsProcessed++
1✔
165
        r.println(fmt.Sprintf("%s Completed: %s (%.1fs total)", IconDocumentComplete, path, duration.Seconds()))
1✔
166
        r.println("")
1✔
167
}
1✔
168

169
// DocumentError reports a document processing error
NEW
170
func (r *Reporter) DocumentError(path string, err error) {
×
NEW
171
        r.stats.DocsErrors++
×
NEW
172
        r.stats.Errors = append(r.stats.Errors, err)
×
NEW
173
        r.println(fmt.Sprintf("%s Failed: %s - %v", IconDocumentError, path, err))
×
NEW
174
}
×
175

176
// DocumentSkipped indicates a document was skipped
NEW
177
func (r *Reporter) DocumentSkipped(path string, reason string) {
×
NEW
178
        r.stats.DocsSkipped++
×
NEW
179
        r.println(fmt.Sprintf("%s Skipped: %s (%s)", IconDocumentSkipped, path, reason))
×
NEW
180
}
×
181

182
// Step execution events
183

184
// StepStart indicates a workflow step is starting
185
func (r *Reporter) StepStart(jobName, stepName string, stepNum, totalSteps int) {
1✔
186
        r.mu.Lock()
1✔
187
        r.hasThinking = false // Reset thinking flag for new step
1✔
188
        r.mu.Unlock()
1✔
189

1✔
190
        if stepName != "" {
2✔
191
                r.updateLine(fmt.Sprintf("   %s Step %d/%d: %s.%s → Processing...",
1✔
192
                        IconStepProcessing, stepNum, totalSteps, jobName, stepName))
1✔
193
        } else {
1✔
NEW
194
                r.updateLine(fmt.Sprintf("   %s Step %d/%d: %s → Processing...",
×
NEW
195
                        IconStepProcessing, stepNum, totalSteps, jobName))
×
NEW
196
        }
×
197
}
198

199
// StepProgress updates the current step status (mutable)
NEW
200
func (r *Reporter) StepProgress(message string) {
×
NEW
201
        r.updateLine(fmt.Sprintf("   %s %s", IconStepProcessing, message))
×
NEW
202
}
×
203

204
// StepComplete indicates step completion
205
func (r *Reporter) StepComplete(jobName, stepName string, stepNum, totalSteps int, duration time.Duration, tokens int) {
1✔
206
        r.stats.TokensInput += tokens
1✔
207

1✔
208
        // Don't show step completion messages when inside a foreach loop
1✔
209
        // The foreach will handle its own reporting
1✔
210
        if r.isInForeachMode {
1✔
NEW
211
                return
×
NEW
212
        }
×
213

214
        if tokens > 0 {
2✔
215
                if stepName != "" {
2✔
216
                        r.println(fmt.Sprintf("   %s Step %d/%d: %s.%s completed in %.1fs (%d tokens)",
1✔
217
                                IconStepComplete, stepNum, totalSteps, jobName, stepName, duration.Seconds(), tokens))
1✔
218
                } else {
1✔
NEW
219
                        r.println(fmt.Sprintf("   %s Step %d/%d: %s completed in %.1fs (%d tokens)",
×
NEW
220
                                IconStepComplete, stepNum, totalSteps, jobName, duration.Seconds(), tokens))
×
NEW
221
                }
×
NEW
222
        } else {
×
NEW
223
                if stepName != "" {
×
NEW
224
                        r.println(fmt.Sprintf("   %s Step %d/%d: %s.%s completed in %.1fs",
×
NEW
225
                                IconStepComplete, stepNum, totalSteps, jobName, stepName, duration.Seconds()))
×
NEW
226
                } else {
×
NEW
227
                        r.println(fmt.Sprintf("   %s Step %d/%d: %s completed in %.1fs",
×
NEW
228
                                IconStepComplete, stepNum, totalSteps, jobName, duration.Seconds()))
×
NEW
229
                }
×
230
        }
231
}
232

233
// StepError reports a step error
NEW
234
func (r *Reporter) StepError(jobName, stepName string, stepNum, totalSteps int, err error) {
×
NEW
235
        if stepName != "" {
×
NEW
236
                r.println(fmt.Sprintf("   %s Step %d/%d: %s.%s failed - %v",
×
NEW
237
                        IconStepError, stepNum, totalSteps, jobName, stepName, err))
×
NEW
238
        } else {
×
NEW
239
                r.println(fmt.Sprintf("   %s Step %d/%d: %s failed - %v",
×
NEW
240
                        IconStepError, stepNum, totalSteps, jobName, err))
×
NEW
241
        }
×
242
}
243

244
// Router and control flow events
245

246
// RouterEvaluating indicates router is evaluating conditions
NEW
247
func (r *Reporter) RouterEvaluating() {
×
NEW
248
        r.updateLine(fmt.Sprintf("   %s Router evaluating conditions...", IconRouterEvaluating))
×
NEW
249
}
×
250

251
// RouterMatched indicates router matched a route
NEW
252
func (r *Reporter) RouterMatched(routeName string, targetJob string) {
×
NEW
253
        r.println(fmt.Sprintf("   %s Routing decision:", IconRouterEvaluating))
×
NEW
254
        r.println(fmt.Sprintf("      %s Matched route: %s → %s", IconRouterMatched, routeName, targetJob))
×
NEW
255
        r.println("")
×
NEW
256
}
×
257

258
// RouterDefault indicates router is using default route
NEW
259
func (r *Reporter) RouterDefault(targetJob string) {
×
NEW
260
        r.println(fmt.Sprintf("   %s Routing decision:", IconRouterEvaluating))
×
NEW
261
        r.println(fmt.Sprintf("      %s Using default route → %s", IconRouterMatched, targetJob))
×
NEW
262
        r.println("")
×
NEW
263
}
×
264

265
// RouterNoMatch indicates no route was matched
NEW
266
func (r *Reporter) RouterNoMatch() {
×
NEW
267
        r.println(fmt.Sprintf("   %s Router: No matching route found", IconWarning))
×
NEW
268
}
×
269

270
// Foreach iteration events
271

272
// ForeachStart indicates foreach loop is starting
NEW
273
func (r *Reporter) ForeachStart(itemCount int) {
×
NEW
274
        r.println(fmt.Sprintf("   %s Foreach: Processing %d items", IconForeachStart, itemCount))
×
NEW
275
}
×
276

277
// ForeachItem reports progress on a foreach item
NEW
278
func (r *Reporter) ForeachItem(index, total int, status string) {
×
NEW
279
        r.println(fmt.Sprintf("      [%d/%d] %s", index, total, status))
×
NEW
280
}
×
281

282
// ForeachComplete indicates foreach loop completed
NEW
283
func (r *Reporter) ForeachComplete(successCount, totalCount int, duration time.Duration) {
×
NEW
284
        r.println(fmt.Sprintf("   %s Foreach completed: %d/%d items succeeded (%.1fs)",
×
NEW
285
                IconForeachItem, successCount, totalCount, duration.Seconds()))
×
NEW
286
        r.println("")
×
NEW
287
}
×
288

289
// Retry and recovery events
290

291
// RetryAttempt indicates a retry is being attempted
NEW
292
func (r *Reporter) RetryAttempt(attempt, maxAttempts int, delay time.Duration) {
×
NEW
293
        if attempt == 1 {
×
NEW
294
                r.println(fmt.Sprintf("   %s Step failed (attempt %d/%d)", IconWarning, attempt, maxAttempts))
×
NEW
295
                if delay > 0 {
×
NEW
296
                        r.println(fmt.Sprintf("      %s Retrying in %.0fs...", IconRouterMatched, delay.Seconds()))
×
NEW
297
                }
×
NEW
298
        } else {
×
NEW
299
                r.updateLine(fmt.Sprintf("   %s Retry attempt %d/%d...", IconRetryAttempt, attempt, maxAttempts))
×
NEW
300
        }
×
301
}
302

303
// RetrySuccess indicates retry succeeded
NEW
304
func (r *Reporter) RetrySuccess(attempt int) {
×
NEW
305
        r.println(fmt.Sprintf("   %s Retry succeeded on attempt %d", IconRetrySuccess, attempt))
×
NEW
306
}
×
307

308
// RetryExhausted indicates all retry attempts failed
NEW
309
func (r *Reporter) RetryExhausted(maxAttempts int) {
×
NEW
310
        r.println(fmt.Sprintf("   %s All %d retry attempts exhausted", IconRetryExhausted, maxAttempts))
×
NEW
311
}
×
312

313
// LLM thinking events
314

315
// ThinkingContent displays LLM thinking/reasoning content
316
// This is used when --llm-think is enabled to show intermediate reasoning
NEW
317
func (r *Reporter) ThinkingContent(text string) {
×
NEW
318
        r.mu.Lock()
×
NEW
319
        defer r.mu.Unlock()
×
NEW
320

×
NEW
321
        if r.quiet {
×
NEW
322
                return
×
NEW
323
        }
×
324

325
        // Mark that we've shown thinking content
NEW
326
        r.hasThinking = true
×
NEW
327

×
NEW
328
        // Store the current processing line to restore it after thinking
×
NEW
329
        processingLine := r.lastLine
×
NEW
330

×
NEW
331
        // If there's a mutable line (e.g., "   ⚙️  Step 1/2: main → Processing..."),
×
NEW
332
        // finalize it before showing thinking header
×
NEW
333
        if r.lastLine != "" {
×
NEW
334
                fmt.Fprintln(r.w) // Move to new line, keep the processing line visible
×
NEW
335
                r.lastLine = ""
×
NEW
336
        }
×
337

338
        // Print thinking header (e.g., "   💭 Step 1/2: main → Thinking")
NEW
339
        if processingLine != "" {
×
NEW
340
                // Extract step info from processing line and create thinking header
×
NEW
341
                thinkingHeader := strings.Replace(processingLine, "Processing", "Thinking", 1)
×
NEW
342
                thinkingHeader = strings.Replace(thinkingHeader, IconStepProcessing, IconThinking, 1)
×
NEW
343
                fmt.Fprintln(r.w, thinkingHeader)
×
NEW
344
        }
×
345

346
        // Get terminal width for wrapping (default to 80 if not a terminal)
NEW
347
        width := 80
×
NEW
348
        if fd, ok := r.w.(*os.File); ok {
×
NEW
349
                if w, _, err := term.GetSize(int(fd.Fd())); err == nil && w > 0 {
×
NEW
350
                        width = w
×
NEW
351
                }
×
352
        }
353

354
        // Print thinking content with proper indentation and wrapping
355
        // Format: "   💭 <first line text>"
356
        //         "      <continuation lines>"
357

358
        // Use a buffer to capture wrapped output
NEW
359
        var buf strings.Builder
×
NEW
360
        tw, err := twrap.NewTWConf(
×
NEW
361
                twrap.SetWriter(&buf),
×
NEW
362
                twrap.SetTargetLineLen(width-6), // Reserve space for indent (6 chars: 3 spaces + "💭 ")
×
NEW
363
        )
×
NEW
364
        if err != nil {
×
NEW
365
                // Fallback: print without wrapping if configuration fails
×
NEW
366
                fmt.Fprintf(r.w, "   %s %s\n", IconThinking, text)
×
NEW
367
                return
×
NEW
368
        }
×
369

370
        // Wrap text without indent first
NEW
371
        tw.Wrap(text, 0)
×
NEW
372

×
NEW
373
        // Now add indentation to each line (thinking content is indented)
×
NEW
374
        lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
×
NEW
375
        for _, line := range lines {
×
NEW
376
                fmt.Fprintf(r.w, "      %s\n", line) // All lines indented (no emoji on content lines)
×
NEW
377
        }
×
378

379
        // Restore the processing line as mutable after thinking
NEW
380
        if processingLine != "" {
×
NEW
381
                r.lastLine = processingLine
×
NEW
382
                fmt.Fprint(r.w, processingLine) // Print without newline (mutable)
×
NEW
383
        } else {
×
NEW
384
                fmt.Fprintln(r.w) // Just add blank line if no processing line to restore
×
NEW
385
        }
×
386
}
387

388
// Batch processing events
389

390
// BatchStart indicates batch processing is starting
NEW
391
func (r *Reporter) BatchStart(inputPath, outputPath string, mutable bool) {
×
NEW
392
        if mutable {
×
NEW
393
                r.println(fmt.Sprintf("%s Batch processing: %s → %s (mutable mode)", IconBatchProcessing, inputPath, outputPath))
×
NEW
394
        } else {
×
NEW
395
                r.println(fmt.Sprintf("%s Batch processing: %s → %s", IconBatchProcessing, inputPath, outputPath))
×
NEW
396
        }
×
397
}
398

399
// BatchProgress reports batch processing progress
NEW
400
func (r *Reporter) BatchProgress(processed, total int) {
×
NEW
401
        if total > 0 {
×
NEW
402
                percent := int(float64(processed) / float64(total) * 100)
×
NEW
403
                bars := percent / 5 // 20 bars total
×
NEW
404
                progress := strings.Repeat("█", bars) + strings.Repeat("░", 20-bars)
×
NEW
405
                r.updateLine(fmt.Sprintf("Processing: %s %d/%d files (%d%%)",
×
NEW
406
                        progress, processed, total, percent))
×
NEW
407
        } else {
×
NEW
408
                r.updateLine(fmt.Sprintf("Processing: %d files...", processed))
×
NEW
409
        }
×
410
}
411

412
// BatchComplete finalizes batch progress
NEW
413
func (r *Reporter) BatchComplete() {
×
NEW
414
        if r.lastLine != "" {
×
NEW
415
                r.println("") // Move to new line
×
NEW
416
        }
×
417
}
418

419
// Chunking events
420

421
// ChunkingStart indicates document is being split
NEW
422
func (r *Reporter) ChunkingStart(strategy string, chunkCount int) {
×
NEW
423
        r.println(fmt.Sprintf("   %s Splitting into %d chunks (strategy: %s)", IconChunking, chunkCount, strategy))
×
NEW
424
}
×
425

426
// Summary and statistics
427

428
// Summary prints final execution summary
NEW
429
func (r *Reporter) Summary() {
×
NEW
430
        duration := time.Since(r.startTime)
×
NEW
431

×
NEW
432
        r.println("")
×
NEW
433
        r.println(fmt.Sprintf("%s Pipeline Summary:", IconSummary))
×
NEW
434

×
NEW
435
        if r.stats.DocsProcessed > 0 {
×
NEW
436
                r.println(fmt.Sprintf("   %s Processed: %d documents", IconDocumentComplete, r.stats.DocsProcessed))
×
NEW
437
        }
×
438

NEW
439
        if r.stats.DocsSkipped > 0 {
×
NEW
440
                r.println(fmt.Sprintf("   %s Skipped: %d documents", IconDocumentSkipped, r.stats.DocsSkipped))
×
NEW
441
        }
×
442

NEW
443
        if r.stats.DocsErrors > 0 {
×
NEW
444
                r.println(fmt.Sprintf("   %s Errors: %d documents", IconWarning, r.stats.DocsErrors))
×
NEW
445
        }
×
446

447
        // Use token source for authoritative usage if available, otherwise fall back to step-level stats
NEW
448
        r.mu.Lock()
×
NEW
449
        tokenSource := r.tokenSource
×
NEW
450
        r.mu.Unlock()
×
NEW
451

×
NEW
452
        if tokenSource != nil {
×
NEW
453
                // Use Router's authoritative token data with per-profile breakdown
×
NEW
454
                usage := tokenSource.Usage()
×
NEW
455
                profiles := tokenSource.ProfileUsage()
×
NEW
456

×
NEW
457
                if usage.InputTokens > 0 || usage.ReplyTokens > 0 {
×
NEW
458
                        total := usage.InputTokens + usage.ReplyTokens
×
NEW
459
                        r.println(fmt.Sprintf("   📈 Tokens: %s total",
×
NEW
460
                                formatNumber(total)))
×
NEW
461

×
NEW
462
                        // Show per-profile breakdown
×
NEW
463
                        for _, profile := range profiles {
×
NEW
464
                                if profile.Usage.InputTokens > 0 || profile.Usage.ReplyTokens > 0 {
×
NEW
465
                                        profileTotal := profile.Usage.InputTokens + profile.Usage.ReplyTokens
×
NEW
466
                                        r.println(fmt.Sprintf("      └─ %s: %s (input: %s | output: %s)",
×
NEW
467
                                                profile.Name,
×
NEW
468
                                                formatNumber(profileTotal),
×
NEW
469
                                                formatNumber(profile.Usage.InputTokens),
×
NEW
470
                                                formatNumber(profile.Usage.ReplyTokens)))
×
NEW
471
                                }
×
472
                        }
473
                }
NEW
474
        } else if r.stats.TokensInput > 0 || r.stats.TokensOutput > 0 {
×
NEW
475
                // Fall back to step-level token tracking (legacy)
×
NEW
476
                total := r.stats.TokensInput + r.stats.TokensOutput
×
NEW
477
                r.println(fmt.Sprintf("   📈 Tokens: %s (input: %s | output: %s)",
×
NEW
478
                        formatNumber(total),
×
NEW
479
                        formatNumber(r.stats.TokensInput),
×
NEW
480
                        formatNumber(r.stats.TokensOutput)))
×
NEW
481
        }
×
482

NEW
483
        r.println(fmt.Sprintf("   ⏱️  Duration: %s", formatDuration(duration)))
×
NEW
484

×
NEW
485
        // Add visual separator before output
×
NEW
486
        r.println("")
×
NEW
487
        r.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
×
NEW
488
        r.println("")
×
489
}
490

491
// SummaryQuick prints a compact one-line summary
NEW
492
func (r *Reporter) SummaryQuick(docCount int, duration time.Duration) {
×
NEW
493
        tokens := r.stats.TokensInput + r.stats.TokensOutput
×
NEW
494
        if tokens > 0 {
×
NEW
495
                r.println(fmt.Sprintf("\n📊 Summary: %d document(s), %s tokens, %s",
×
NEW
496
                        docCount, formatNumber(tokens), formatDuration(duration)))
×
NEW
497
        } else {
×
NEW
498
                r.println(fmt.Sprintf("\n📊 Summary: %d document(s), %s",
×
NEW
499
                        docCount, formatDuration(duration)))
×
NEW
500
        }
×
501
}
502

503
// Helper methods
504

505
// UpdateTokens updates token statistics
506
func (r *Reporter) UpdateTokens(inputTokens, outputTokens int) {
1✔
507
        r.mu.Lock()
1✔
508
        defer r.mu.Unlock()
1✔
509
        r.stats.TokensInput += inputTokens
1✔
510
        r.stats.TokensOutput += outputTokens
1✔
511
}
1✔
512

513
// GetStats returns a copy of current statistics
514
func (r *Reporter) GetStats() Stats {
1✔
515
        r.mu.Lock()
1✔
516
        defer r.mu.Unlock()
1✔
517
        return r.stats
1✔
518
}
1✔
519

520
// updateLine updates the current line (mutable output)
521
func (r *Reporter) updateLine(text string) {
1✔
522
        if r.quiet {
2✔
523
                return
1✔
524
        }
1✔
525
        r.mu.Lock()
1✔
526
        defer r.mu.Unlock()
1✔
527

1✔
528
        // Clear previous line
1✔
529
        if r.lastLine != "" {
1✔
NEW
530
                fmt.Fprintf(r.w, "\r\033[K")
×
NEW
531
        }
×
532

533
        fmt.Fprint(r.w, text)
1✔
534
        r.lastLine = text
1✔
535
}
536

537
// println writes a new line (finalize any mutable line first)
538
func (r *Reporter) println(text string) {
1✔
539
        if r.quiet {
2✔
540
                return
1✔
541
        }
1✔
542
        r.mu.Lock()
1✔
543
        defer r.mu.Unlock()
1✔
544

1✔
545
        if r.lastLine != "" {
2✔
546
                if r.hasThinking {
1✔
NEW
547
                        // In thinking mode: keep the previous line, just move to next line
×
NEW
548
                        fmt.Fprintln(r.w)
×
549
                } else {
1✔
550
                        // Without thinking: clear the mutable line and replace it
1✔
551
                        fmt.Fprint(r.w, "\r\033[K")
1✔
552
                }
1✔
553
                r.lastLine = ""
1✔
554
        }
555
        fmt.Fprintln(r.w, text)
1✔
556
}
557

558
// Formatting helpers
559

560
func formatNumber(n int) string {
1✔
561
        if n >= 1000000 {
2✔
562
                return fmt.Sprintf("%.1fM", float64(n)/1000000)
1✔
563
        }
1✔
564
        if n >= 1000 {
2✔
565
                return fmt.Sprintf("%.1fK", float64(n)/1000)
1✔
566
        }
1✔
567
        return fmt.Sprintf("%d", n)
1✔
568
}
569

570
func formatDuration(d time.Duration) string {
1✔
571
        if d < time.Second {
2✔
572
                return fmt.Sprintf("%.0fms", d.Seconds()*1000)
1✔
573
        }
1✔
574
        if d < time.Minute {
2✔
575
                return fmt.Sprintf("%.1fs", d.Seconds())
1✔
576
        }
1✔
577
        minutes := int(d.Minutes())
1✔
578
        seconds := int(d.Seconds()) % 60
1✔
579
        return fmt.Sprintf("%dm %ds", minutes, seconds)
1✔
580
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc