diff --git a/internal/evidence/evidence.go b/internal/evidence/evidence.go index 50c98cf..6f53921 100644 --- a/internal/evidence/evidence.go +++ b/internal/evidence/evidence.go @@ -6,6 +6,8 @@ package evidence import ( "context" "regexp" + "strings" + "unicode" ) // EvidenceHealth records whether the sensor could observe the active window. @@ -32,8 +34,24 @@ type Source interface { // keys (e.g. "1:23", "50.5%", "-3.0"). Ported verbatim from the Rust impl. var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`) -// ScrubTitle removes volatile numeric runs so window titles bucket stably. -// The raw event log keeps the unscrubbed title; only bucket keys are scrubbed. +// extraSpace collapses the whitespace runs left behind by the scrubs below. +var extraSpace = regexp.MustCompile(`\s+`) + +// ScrubTitle normalizes a window title into a stable bucket key. It drops +// volatile numeric runs (clocks, ratios, percentages) and any non-ASCII symbol +// or control rune. The latter covers the animated spinner frames terminal apps +// (e.g. Claude Code) cycle through their title — braille and dingbat glyphs are +// both Unicode symbol category, so a single task no longer fragments into dozens +// of buckets. Letters and digits of every script survive, as do ASCII symbols +// (so "C++" is not mangled). Leftover whitespace is collapsed. The raw event log +// keeps the unscrubbed title; only bucket keys (and their display) are scrubbed. func ScrubTitle(title string) string { - return titleNoise.ReplaceAllString(title, "") + title = titleNoise.ReplaceAllString(title, "") + title = strings.Map(func(r rune) rune { + if unicode.IsControl(r) || (r > unicode.MaxASCII && unicode.IsSymbol(r)) { + return -1 + } + return r + }, title) + return strings.TrimSpace(extraSpace.ReplaceAllString(title, " ")) } diff --git a/internal/evidence/evidence_test.go b/internal/evidence/evidence_test.go index 7d1f56b..44f103a 100644 --- a/internal/evidence/evidence_test.go +++ b/internal/evidence/evidence_test.go @@ -8,12 +8,19 @@ import ( func TestScrubTitle(t *testing.T) { cases := []struct{ in, want string }{ - {"Plain title", "Plain title"}, // no digits-with-separator: untouched - {"Buy 2 eggs", "Buy 2 eggs"}, // bare integer: untouched (group is mandatory) - {"12.5%", ""}, // percent decimal: stripped whole - {"1:23:45 remaining", " remaining"}, // clock ratio: stripped - {"-3.0 delta", " delta"}, // leading sign + decimal: stripped - {"Download 50.5% complete", "Download complete"}, // embedded percent decimal + {"Plain title", "Plain title"}, // no digits-with-separator: untouched + {"Buy 2 eggs", "Buy 2 eggs"}, // bare integer: untouched (group is mandatory) + {"12.5%", ""}, // percent decimal: stripped whole + {"1:23:45 remaining", "remaining"}, // clock ratio: stripped, space collapsed + {"-3.0 delta", "delta"}, // leading sign + decimal: stripped + {"Download 50.5% complete", "Download complete"}, // embedded percent decimal + {"⠸ antidrift", "antidrift"}, // braille spinner frame stripped + {"⠼ antidrift", "antidrift"}, // different frame collapses to same key + {"✳ Check latest commit date", "Check latest commit date"}, // dingbat-star indicator + {"⠂ Check latest commit date", "Check latest commit date"}, // same task, different frame + {"✳ ⠼ done", "done"}, // multiple glyphs in one title + {"C++ build", "C++ build"}, // ASCII symbols kept (not all-symbols) + {"日本語 window", "日本語 window"}, // non-ASCII letters preserved } for _, c := range cases { if got := ScrubTitle(c.in); got != c.want {