1//usr/bin/env go run "$0" "$@"; exit
2package main
3
4import (
5 "bytes"
6 "encoding/json"
7 "flag"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "regexp"
15 "sort"
16 "strings"
17 "sync"
18 "time"
19)
20
21const (
22 syntheticAPI = "https://api.synthetic.new/anthropic/v1/messages/count_tokens"
23 model = "hf:deepseek-ai/DeepSeek-V3-0324"
24 workerCount = 5 // Number of parallel API workers
25)
26
27var httpClient = &http.Client{
28 Timeout: 30 * time.Second,
29}
30
31type Frontmatter struct {
32 Name string
33 Description string
34}
35
36type TokenCount struct {
37 Name int
38 Description int
39 Body int
40 References map[string]int
41 Total int
42}
43
44type SkillInfo struct {
45 Dir string
46 Frontmatter Frontmatter
47 BodyLines int
48 Tokens TokenCount
49 Errors []string
50}
51
52type TokenJob struct {
53 ID string
54 Text string
55}
56
57type TokenResult struct {
58 ID string
59 Count int
60 Err error
61}
62
63type SkillComparison struct {
64 PrevTotal int
65 PrevMetadata int
66 PrevBody int
67 Delta int
68 MetadataDelta int
69 BodyDelta int
70 Percent float64
71 IsNew bool
72}
73
74func main() {
75 compare := flag.Bool("compare", false, "Compare with HEAD commit")
76 workers := flag.Int("workers", workerCount, "Number of parallel API workers")
77 flag.Parse()
78
79 apiKey := os.Getenv("SYNTHETIC_API_KEY")
80 if apiKey == "" {
81 fmt.Fprintln(os.Stderr, "Error: SYNTHETIC_API_KEY environment variable not set")
82 os.Exit(1)
83 }
84
85 // Start worker pool
86 counter := newTokenCounter(apiKey, *workers)
87 defer counter.Close()
88
89 skills, err := analyzeSkills(counter)
90 if err != nil {
91 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
92 os.Exit(1)
93 }
94
95 // Build comparison map if requested
96 var comparisons map[string]SkillComparison
97 if *compare {
98 comparisons = buildComparisons(skills, counter)
99 }
100
101 // Sort skills by name for consistent output
102 sort.Slice(skills, func(i, j int) bool {
103 return skills[i].Dir < skills[j].Dir
104 })
105
106 // Print reports
107 for _, skill := range skills {
108 var comp *SkillComparison
109 if comparisons != nil {
110 if c, ok := comparisons[skill.Dir]; ok {
111 comp = &c
112 }
113 }
114 printSkillReport(skill, comp)
115 }
116
117 // Print summary
118 printSummary(skills, comparisons)
119}
120
121// TokenCounter manages a pool of workers for parallel token counting
122type TokenCounter struct {
123 apiKey string
124 jobs chan TokenJob
125 results chan TokenResult
126 wg sync.WaitGroup
127}
128
129func newTokenCounter(apiKey string, workers int) *TokenCounter {
130 tc := &TokenCounter{
131 apiKey: apiKey,
132 jobs: make(chan TokenJob, 100),
133 results: make(chan TokenResult, 100),
134 }
135
136 // Start workers
137 for i := 0; i < workers; i++ {
138 tc.wg.Add(1)
139 go tc.worker()
140 }
141
142 return tc
143}
144
145func (tc *TokenCounter) worker() {
146 defer tc.wg.Done()
147 for job := range tc.jobs {
148 count, err := countTokensAPI(tc.apiKey, job.Text)
149 tc.results <- TokenResult{ID: job.ID, Count: count, Err: err}
150 }
151}
152
153func (tc *TokenCounter) Count(id, text string) {
154 tc.jobs <- TokenJob{ID: id, Text: text}
155}
156
157func (tc *TokenCounter) GetResult() TokenResult {
158 return <-tc.results
159}
160
161func (tc *TokenCounter) TryGetResult() (TokenResult, bool) {
162 select {
163 case r := <-tc.results:
164 return r, true
165 default:
166 return TokenResult{}, false
167 }
168}
169
170func (tc *TokenCounter) Close() {
171 close(tc.jobs)
172 tc.wg.Wait()
173 close(tc.results)
174}
175
176func analyzeSkills(counter *TokenCounter) ([]SkillInfo, error) {
177 skillsDir := "skills"
178 entries, err := os.ReadDir(skillsDir)
179 if err != nil {
180 return nil, fmt.Errorf("cannot read skills directory: %w", err)
181 }
182
183 var skills []SkillInfo
184 for _, entry := range entries {
185 if !entry.IsDir() {
186 continue
187 }
188
189 skillPath := filepath.Join(skillsDir, entry.Name())
190 skill, err := analyzeSkill(skillPath, counter)
191 if err != nil {
192 fmt.Fprintf(os.Stderr, "Warning: error analyzing %s: %v\n", entry.Name(), err)
193 continue
194 }
195 skills = append(skills, skill)
196 }
197
198 return skills, nil
199}
200
201func analyzeSkill(path string, counter *TokenCounter) (SkillInfo, error) {
202 skill := SkillInfo{
203 Dir: filepath.Base(path),
204 Tokens: TokenCount{
205 References: make(map[string]int),
206 },
207 }
208
209 // Read SKILL.md
210 skillMdPath := filepath.Join(path, "SKILL.md")
211 content, err := os.ReadFile(skillMdPath)
212 if err != nil {
213 skill.Errors = append(skill.Errors, fmt.Sprintf("Cannot read SKILL.md: %v", err))
214 return skill, nil
215 }
216
217 // Parse frontmatter and body
218 fm, body, err := parseFrontmatter(string(content))
219 if err != nil {
220 skill.Errors = append(skill.Errors, fmt.Sprintf("Cannot parse frontmatter: %v", err))
221 return skill, nil
222 }
223 skill.Frontmatter = fm
224 trimmedBody := strings.TrimSpace(body)
225 if trimmedBody == "" {
226 skill.BodyLines = 0
227 } else {
228 skill.BodyLines = len(strings.Split(trimmedBody, "\n"))
229 }
230
231 // Validate
232 skill.Errors = append(skill.Errors, validateSkill(skill)...)
233
234 fmt.Fprintf(os.Stderr, "Analyzing %s...\n", skill.Dir)
235
236 // Collect all jobs first (no channel use yet)
237 var jobs []TokenJob
238 jobs = append(jobs, TokenJob{ID: fmt.Sprintf("%s:name", skill.Dir), Text: fm.Name})
239 jobs = append(jobs, TokenJob{ID: fmt.Sprintf("%s:description", skill.Dir), Text: fm.Description})
240 jobs = append(jobs, TokenJob{ID: fmt.Sprintf("%s:body", skill.Dir), Text: body})
241
242 // Collect reference file jobs
243 refsPath := filepath.Join(path, "references")
244 entries, err := os.ReadDir(refsPath)
245 if err != nil {
246 if !os.IsNotExist(err) {
247 skill.Errors = append(skill.Errors, fmt.Sprintf("Cannot read references directory: %v", err))
248 }
249 } else {
250 for _, entry := range entries {
251 if entry.IsDir() {
252 continue
253 }
254 refPath := filepath.Join(refsPath, entry.Name())
255 refContent, err := os.ReadFile(refPath)
256 if err != nil {
257 skill.Errors = append(skill.Errors, fmt.Sprintf("Cannot read reference %s: %v", entry.Name(), err))
258 continue
259 }
260 jobs = append(jobs, TokenJob{
261 ID: fmt.Sprintf("%s:ref:%s", skill.Dir, entry.Name()),
262 Text: string(refContent),
263 })
264 }
265 }
266
267 // Interleave enqueue and drain to prevent deadlock
268 processResult := func(result TokenResult) {
269 if result.Err != nil {
270 skill.Errors = append(skill.Errors, fmt.Sprintf("Token count failed for %s: %v", result.ID, result.Err))
271 return
272 }
273 parts := strings.SplitN(result.ID, ":", 3)
274 if len(parts) < 2 {
275 return
276 }
277 switch parts[1] {
278 case "name":
279 skill.Tokens.Name = result.Count
280 case "description":
281 skill.Tokens.Description = result.Count
282 case "body":
283 skill.Tokens.Body = result.Count
284 case "ref":
285 if len(parts) == 3 {
286 skill.Tokens.References[parts[2]] = result.Count
287 }
288 }
289 }
290
291 outstanding := 0
292 for _, job := range jobs {
293 counter.Count(job.ID, job.Text)
294 outstanding++
295 // Drain any available results to prevent backpressure
296 for {
297 if result, ok := counter.TryGetResult(); ok {
298 processResult(result)
299 outstanding--
300 } else {
301 break
302 }
303 }
304 }
305
306 // Drain remaining results
307 for outstanding > 0 {
308 result := counter.GetResult()
309 processResult(result)
310 outstanding--
311 }
312
313 // Calculate total
314 skill.Tokens.Total = skill.Tokens.Name + skill.Tokens.Description + skill.Tokens.Body
315 for _, count := range skill.Tokens.References {
316 skill.Tokens.Total += count
317 }
318
319 return skill, nil
320}
321
322func parseFrontmatter(content string) (Frontmatter, string, error) {
323 lines := strings.Split(content, "\n")
324 if len(lines) < 3 || lines[0] != "---" {
325 return Frontmatter{}, "", fmt.Errorf("missing frontmatter")
326 }
327
328 var fm Frontmatter
329 var endIdx int
330 var inDescription bool
331 var descriptionLines []string
332
333 for i := 1; i < len(lines); i++ {
334 if lines[i] == "---" {
335 endIdx = i
336 break
337 }
338
339 line := lines[i]
340
341 // Parse name
342 if strings.HasPrefix(line, "name:") {
343 fm.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:"))
344 continue
345 }
346
347 // Parse description (might be multi-line)
348 if strings.HasPrefix(line, "description:") {
349 descPart := strings.TrimSpace(strings.TrimPrefix(line, "description:"))
350 if descPart != "" {
351 descriptionLines = append(descriptionLines, descPart)
352 }
353 inDescription = true
354 continue
355 }
356
357 // Continue multi-line description
358 if inDescription && strings.HasPrefix(line, " ") {
359 descriptionLines = append(descriptionLines, strings.TrimSpace(line))
360 continue
361 }
362
363 // End of description
364 if inDescription && !strings.HasPrefix(line, " ") {
365 inDescription = false
366 }
367 }
368
369 fm.Description = strings.Join(descriptionLines, " ")
370
371 if endIdx == 0 {
372 return Frontmatter{}, "", fmt.Errorf("unclosed frontmatter")
373 }
374
375 body := strings.Join(lines[endIdx+1:], "\n")
376 return fm, body, nil
377}
378
379func validateSkill(skill SkillInfo) []string {
380 var errors []string
381
382 // Validate name
383 if len(skill.Frontmatter.Name) < 1 || len(skill.Frontmatter.Name) > 64 {
384 errors = append(errors, "name must be 1-64 characters")
385 }
386
387 namePattern := regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
388 if !namePattern.MatchString(skill.Frontmatter.Name) {
389 errors = append(errors, "name must be lowercase letters, numbers, hyphens only; no leading/trailing/consecutive hyphens")
390 }
391
392 if skill.Frontmatter.Name != skill.Dir {
393 errors = append(errors, fmt.Sprintf("name '%s' doesn't match directory '%s'", skill.Frontmatter.Name, skill.Dir))
394 }
395
396 // Validate description
397 if len(skill.Frontmatter.Description) < 1 {
398 errors = append(errors, "description is empty")
399 } else if len(skill.Frontmatter.Description) > 1024 {
400 errors = append(errors, fmt.Sprintf("description is %d characters (max 1024)", len(skill.Frontmatter.Description)))
401 }
402
403 // Check body line count
404 if skill.BodyLines > 500 {
405 errors = append(errors, fmt.Sprintf("body has %d lines (recommended: < 500)", skill.BodyLines))
406 }
407
408 return errors
409}
410
411func countTokensAPI(apiKey string, text string) (int, error) {
412 reqBody := map[string]interface{}{
413 "model": model,
414 "messages": []map[string]string{
415 {
416 "role": "user",
417 "content": text,
418 },
419 },
420 }
421
422 jsonData, err := json.Marshal(reqBody)
423 if err != nil {
424 return 0, fmt.Errorf("marshal request: %w", err)
425 }
426
427 req, err := http.NewRequest("POST", syntheticAPI, bytes.NewBuffer(jsonData))
428 if err != nil {
429 return 0, fmt.Errorf("create request: %w", err)
430 }
431
432 req.Header.Set("Authorization", "Bearer "+apiKey)
433 req.Header.Set("Content-Type", "application/json")
434 req.Header.Set("anthropic-version", "2023-06-01")
435
436 resp, err := httpClient.Do(req)
437 if err != nil {
438 return 0, fmt.Errorf("HTTP request: %w", err)
439 }
440 defer resp.Body.Close()
441
442 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
443 body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
444 return 0, fmt.Errorf("API status %d: %s", resp.StatusCode, string(body))
445 }
446
447 var result struct {
448 InputTokens int `json:"input_tokens"`
449 }
450
451 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
452 return 0, fmt.Errorf("decode response: %w", err)
453 }
454
455 return result.InputTokens, nil
456}
457
458type PrevTokens struct {
459 Total int
460 Metadata int
461 Body int
462}
463
464func buildComparisons(currentSkills []SkillInfo, counter *TokenCounter) map[string]SkillComparison {
465 comparisons := make(map[string]SkillComparison)
466
467 for _, skill := range currentSkills {
468 prev, err := getSkillTokensFromGit(skill.Dir, counter)
469 if err != nil {
470 // Skill is new
471 if skill.Tokens.Total > 0 {
472 comparisons[skill.Dir] = SkillComparison{
473 PrevTotal: 0,
474 PrevMetadata: 0,
475 PrevBody: 0,
476 Delta: skill.Tokens.Total,
477 MetadataDelta: skill.Tokens.Name + skill.Tokens.Description,
478 BodyDelta: skill.Tokens.Body,
479 Percent: 100.0,
480 IsNew: true,
481 }
482 }
483 continue
484 }
485
486 delta := skill.Tokens.Total - prev.Total
487 metadataDelta := (skill.Tokens.Name + skill.Tokens.Description) - prev.Metadata
488 bodyDelta := skill.Tokens.Body - prev.Body
489 var percent float64
490 if prev.Total > 0 {
491 percent = (float64(delta) / float64(prev.Total)) * 100
492 }
493
494 if delta != 0 || metadataDelta != 0 || bodyDelta != 0 {
495 comparisons[skill.Dir] = SkillComparison{
496 PrevTotal: prev.Total,
497 PrevMetadata: prev.Metadata,
498 PrevBody: prev.Body,
499 Delta: delta,
500 MetadataDelta: metadataDelta,
501 BodyDelta: bodyDelta,
502 Percent: percent,
503 IsNew: false,
504 }
505 }
506 }
507
508 return comparisons
509}
510
511func getSkillTokensFromGit(skillDir string, counter *TokenCounter) (PrevTokens, error) {
512 // Get file from HEAD
513 skillPath := fmt.Sprintf("skills/%s/SKILL.md", skillDir)
514 cmd := exec.Command("git", "show", fmt.Sprintf("HEAD:%s", skillPath))
515 output, err := cmd.Output()
516 if err != nil {
517 return PrevTokens{}, err
518 }
519
520 // Parse frontmatter and body
521 fm, body, err := parseFrontmatter(string(output))
522 if err != nil {
523 return PrevTokens{}, err
524 }
525
526 // Collect all jobs first (no channel use yet)
527 var jobs []TokenJob
528 jobs = append(jobs, TokenJob{ID: fmt.Sprintf("prev:%s:name", skillDir), Text: fm.Name})
529 jobs = append(jobs, TokenJob{ID: fmt.Sprintf("prev:%s:description", skillDir), Text: fm.Description})
530 jobs = append(jobs, TokenJob{ID: fmt.Sprintf("prev:%s:body", skillDir), Text: body})
531
532 // Get reference files from HEAD
533 refsPath := fmt.Sprintf("skills/%s/references", skillDir)
534 cmd = exec.Command("git", "ls-tree", "-r", "--name-only", "HEAD", refsPath)
535 output, err = cmd.Output()
536 if err != nil {
537 // Log non-fatal git errors (e.g., refs directory doesn't exist in HEAD)
538 if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() != 0 {
539 fmt.Fprintf(os.Stderr, "Warning: cannot list git refs for %s (may not exist in HEAD)\n", refsPath)
540 }
541 } else {
542 refPaths := strings.Split(strings.TrimSpace(string(output)), "\n")
543 for _, refPath := range refPaths {
544 if refPath == "" {
545 continue
546 }
547 cmd = exec.Command("git", "show", fmt.Sprintf("HEAD:%s", refPath))
548 refContent, err := cmd.Output()
549 if err != nil {
550 fmt.Fprintf(os.Stderr, "Warning: cannot read %s from HEAD: %v\n", refPath, err)
551 continue
552 }
553 jobs = append(jobs, TokenJob{ID: fmt.Sprintf("prev:%s:ref", skillDir), Text: string(refContent)})
554 }
555 }
556
557 // Interleave enqueue and drain to prevent deadlock
558 prev := PrevTokens{}
559 processResult := func(result TokenResult) {
560 if result.Err != nil {
561 fmt.Fprintf(os.Stderr, "Warning: token count failed for %s: %v\n", result.ID, result.Err)
562 return
563 }
564 parts := strings.SplitN(result.ID, ":", 3)
565 if len(parts) < 3 {
566 prev.Total += result.Count
567 return
568 }
569 switch parts[2] {
570 case "name":
571 prev.Metadata += result.Count
572 case "description":
573 prev.Metadata += result.Count
574 case "body":
575 prev.Body += result.Count
576 case "ref":
577 // References are counted in total but not metadata or body
578 }
579 prev.Total += result.Count
580 }
581
582 outstanding := 0
583 for _, job := range jobs {
584 counter.Count(job.ID, job.Text)
585 outstanding++
586 // Drain any available results to prevent backpressure
587 for {
588 if result, ok := counter.TryGetResult(); ok {
589 processResult(result)
590 outstanding--
591 } else {
592 break
593 }
594 }
595 }
596
597 // Drain remaining results
598 for outstanding > 0 {
599 result := counter.GetResult()
600 processResult(result)
601 outstanding--
602 }
603
604 return prev, nil
605}
606
607func printSkillReport(skill SkillInfo, comp *SkillComparison) {
608 fmt.Printf("\n=== %s ===\n", skill.Dir)
609
610 if len(skill.Errors) > 0 {
611 fmt.Println("\nValidation errors:")
612 for _, err := range skill.Errors {
613 fmt.Printf(" ✗ %s\n", err)
614 }
615 }
616
617 fmt.Println("\nToken breakdown:")
618 fmt.Printf(" Name: %5d tokens\n", skill.Tokens.Name)
619 fmt.Printf(" Description: %5d tokens\n", skill.Tokens.Description)
620 fmt.Printf(" Body: %5d tokens (%d lines)\n", skill.Tokens.Body, skill.BodyLines)
621
622 if len(skill.Tokens.References) > 0 {
623 fmt.Println(" References:")
624 // Sort reference names for consistent output
625 refNames := make([]string, 0, len(skill.Tokens.References))
626 for name := range skill.Tokens.References {
627 refNames = append(refNames, name)
628 }
629 sort.Strings(refNames)
630
631 for _, name := range refNames {
632 count := skill.Tokens.References[name]
633 fmt.Printf(" %-40s %5d tokens\n", name, count)
634 }
635 }
636
637 fmt.Println(" ───────────────────────────────────────────────")
638
639 // Print total with comparison if available
640 if comp != nil {
641 sign := "+"
642 if comp.Delta < 0 {
643 sign = ""
644 }
645 indicator := ""
646 if comp.IsNew {
647 indicator = " [NEW]"
648 } else if comp.Percent > 20 {
649 indicator = " ⚠️"
650 } else if comp.Percent < -20 {
651 indicator = " ✓"
652 }
653 fmt.Printf(" Total: %5d tokens (%s%d, %s%.1f%% from HEAD)%s\n",
654 skill.Tokens.Total, sign, comp.Delta, sign, comp.Percent, indicator)
655 } else {
656 fmt.Printf(" Total: %5d tokens\n", skill.Tokens.Total)
657 }
658
659 // Warn if approaching budget
660 if skill.Tokens.Body > 5000 {
661 fmt.Println(" ⚠️ Body exceeds recommended 5000 token budget!")
662 } else if skill.Tokens.Body > 4000 {
663 fmt.Println(" ⚠️ Body approaching 5000 token budget")
664 }
665}
666
667func printSummary(skills []SkillInfo, comparisons map[string]SkillComparison) {
668 fmt.Println("\n" + strings.Repeat("=", 60))
669 fmt.Println("SUMMARY")
670 fmt.Println(strings.Repeat("=", 60))
671
672 totalTokens := 0
673 totalMetadataTokens := 0
674 totalBodyTokens := 0
675 totalErrors := 0
676 totalDelta := 0
677 metadataDelta := 0
678 bodyDelta := 0
679
680 for _, skill := range skills {
681 totalTokens += skill.Tokens.Total
682 totalMetadataTokens += skill.Tokens.Name + skill.Tokens.Description
683 totalBodyTokens += skill.Tokens.Body
684 totalErrors += len(skill.Errors)
685 if comp, ok := comparisons[skill.Dir]; ok {
686 totalDelta += comp.Delta
687 metadataDelta += comp.MetadataDelta
688 bodyDelta += comp.BodyDelta
689 }
690 }
691
692 fmt.Printf("\nSkills: %d\n", len(skills))
693 if comparisons != nil && metadataDelta != 0 {
694 fmt.Printf("Metadata: %d tokens (%+d)\n", totalMetadataTokens, metadataDelta)
695 } else {
696 fmt.Printf("Metadata: %d tokens\n", totalMetadataTokens)
697 }
698 if comparisons != nil && bodyDelta != 0 {
699 fmt.Printf("Combined bodies: %d tokens (%+d)\n", totalBodyTokens, bodyDelta)
700 } else {
701 fmt.Printf("Combined bodies: %d tokens\n", totalBodyTokens)
702 }
703 if comparisons != nil && totalDelta != 0 {
704 fmt.Printf("Overall: %d tokens (%+d from HEAD)\n", totalTokens, totalDelta)
705 } else {
706 fmt.Printf("Overall: %d tokens\n", totalTokens)
707 }
708 fmt.Printf("Validation errors: %d\n", totalErrors)
709
710 // Find largest skills
711 sort.Slice(skills, func(i, j int) bool {
712 return skills[i].Tokens.Total > skills[j].Tokens.Total
713 })
714
715 fmt.Println("\nLargest skills (by total tokens):")
716 for i := 0; i < 5 && i < len(skills); i++ {
717 skill := skills[i]
718 if comp, ok := comparisons[skill.Dir]; ok {
719 sign := "+"
720 if comp.Delta < 0 {
721 sign = ""
722 }
723 fmt.Printf(" %d. %-40s %5d tokens (%s%d)\n",
724 i+1, skill.Dir, skill.Tokens.Total, sign, comp.Delta)
725 } else {
726 fmt.Printf(" %d. %-40s %5d tokens\n", i+1, skill.Dir, skill.Tokens.Total)
727 }
728 }
729
730 // Show biggest changes if comparing
731 if comparisons != nil && len(comparisons) > 0 {
732 type changeEntry struct {
733 name string
734 comp SkillComparison
735 }
736 var changes []changeEntry
737 for name, comp := range comparisons {
738 changes = append(changes, changeEntry{name, comp})
739 }
740
741 sort.Slice(changes, func(i, j int) bool {
742 absI := changes[i].comp.Delta
743 if absI < 0 {
744 absI = -absI
745 }
746 absJ := changes[j].comp.Delta
747 if absJ < 0 {
748 absJ = -absJ
749 }
750 return absI > absJ
751 })
752
753 fmt.Println("\nBiggest changes:")
754 displayed := 0
755 for _, change := range changes {
756 if displayed >= 5 {
757 break
758 }
759 sign := "+"
760 if change.comp.Delta < 0 {
761 sign = ""
762 }
763 indicator := ""
764 if change.comp.IsNew {
765 indicator = " [NEW]"
766 } else if change.comp.Percent > 20 {
767 indicator = " ⚠️"
768 } else if change.comp.Percent < -20 {
769 indicator = " ✓"
770 }
771 fmt.Printf(" %-40s %s%-5d tokens (%s%.1f%%)%s\n",
772 change.name, sign, change.comp.Delta, sign, change.comp.Percent, indicator)
773 displayed++
774 }
775 }
776}