From e6e4abc3781bda7fc4b040bb526c1d85c3b42b82 Mon Sep 17 00:00:00 2001 From: fujitani sora Date: Fri, 3 Jul 2026 14:37:46 +0900 Subject: [PATCH] perf: precompute preview highlight state --- internal/preview/preview.go | 30 +++++++++------ internal/preview/preview_test.go | 63 +++++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 21 deletions(-) diff --git a/internal/preview/preview.go b/internal/preview/preview.go index 887f43e..52c1f32 100644 --- a/internal/preview/preview.go +++ b/internal/preview/preview.go @@ -235,19 +235,19 @@ func renderWith(s *session.Session, out io.Writer, opts Options, messages []sess return nil } + highlight := compileHighlight(opts, c) tail := messages if limit := messageLimit(opts); len(tail) > limit { tail = tail[len(tail)-limit:] } for _, m := range tail { - writeMessage(w, m, opts) + writeMessage(w, m, c, highlight) } return nil } -func writeMessage(w io.Writer, m session.Message, opts Options) { +func writeMessage(w io.Writer, m session.Message, c colors, highlight *regexp.Regexp) { role := m.Role - c := colorsFor(opts) color := c.green if role == "assistant" { role = "asst" @@ -259,20 +259,19 @@ func writeMessage(w io.Writer, m session.Message, opts Options) { if !m.Timestamp.IsZero() { stamp = m.Timestamp.Local().Format("15:04") } - body := highlightMatches(truncateBody(m.Body), opts) + body := highlightMatches(truncateBody(m.Body), c, highlight) fmt.Fprintf(w, "%s[%s %s]%s %s\n", color, role, stamp, c.reset, body) } -// highlightMatches wraps every case-insensitive match of opts.Query in s with -// the highlight color. A fixed-string query is escaped so regex metacharacters -// are treated literally; an invalid regex (in Regex mode) leaves s untouched. -func highlightMatches(s string, opts Options) string { +// compileHighlight returns the per-render highlighter. A fixed-string query is +// escaped so regex metacharacters are treated literally; an invalid regex (in +// Regex mode) leaves text untouched. +func compileHighlight(opts Options, c colors) *regexp.Regexp { if strings.TrimSpace(opts.Query) == "" { - return s + return nil } - c := colorsFor(opts) if c.highlight == "" { - return s + return nil } pattern := opts.Query if !opts.Regex { @@ -280,6 +279,15 @@ func highlightMatches(s string, opts Options) string { } re, err := regexp.Compile("(?i)" + pattern) if err != nil { + return nil + } + return re +} + +// highlightMatches wraps every case-insensitive match in s with the highlight +// color. A nil regexp leaves s untouched. +func highlightMatches(s string, c colors, re *regexp.Regexp) string { + if re == nil { return s } locs := re.FindAllStringIndex(s, -1) diff --git a/internal/preview/preview_test.go b/internal/preview/preview_test.go index ea621ba..53321c3 100644 --- a/internal/preview/preview_test.go +++ b/internal/preview/preview_test.go @@ -454,7 +454,7 @@ func TestReadJSONLLine_KeepsLinesLargerThanReaderBuffer(t *testing.T) { } func TestHighlightMatches_BasicWrapsMatch(t *testing.T) { - got := highlightMatches("fix the login bug", Options{Query: "login", Color: "always"}) + got := highlightForTest("fix the login bug", Options{Query: "login", Color: "always"}) want := "fix the " + ansi.Highlight + "login" + ansi.Reset + " bug" if got != want { t.Errorf("got %q, want %q", got, want) @@ -462,55 +462,60 @@ func TestHighlightMatches_BasicWrapsMatch(t *testing.T) { } func TestHighlightMatches_CaseInsensitive(t *testing.T) { - got := highlightMatches("the Login flow", Options{Query: "login", Color: "always"}) + got := highlightForTest("the Login flow", Options{Query: "login", Color: "always"}) if !strings.Contains(got, ansi.Highlight+"Login"+ansi.Reset) { t.Errorf("expected original-case match highlighted, got %q", got) } } func TestHighlightMatches_MultipleMatches(t *testing.T) { - got := highlightMatches("foo and foo", Options{Query: "foo", Color: "always"}) + got := highlightForTest("foo and foo", Options{Query: "foo", Color: "always"}) if strings.Count(got, ansi.Highlight) != 2 { t.Errorf("expected 2 highlights, got %q", got) } } func TestHighlightMatches_NoMatchOrEmptyReturnsInput(t *testing.T) { - if got := highlightMatches("hello", Options{Query: "zzz"}); got != "hello" { + if got := highlightForTest("hello", Options{Query: "zzz"}); got != "hello" { t.Errorf("no match should return input, got %q", got) } - if got := highlightMatches("hello", Options{Query: ""}); got != "hello" { + if got := highlightForTest("hello", Options{Query: ""}); got != "hello" { t.Errorf("empty query should return input, got %q", got) } - if got := highlightMatches("hello", Options{Query: " "}); got != "hello" { + if got := highlightForTest("hello", Options{Query: " "}); got != "hello" { t.Errorf("whitespace query should return input, got %q", got) } } func TestHighlightMatches_FixedStringTreatsMetacharsLiterally(t *testing.T) { // "a.b" must match the literal "a.b", not "axb". - if got := highlightMatches("axb", Options{Query: "a.b"}); got != "axb" { + if got := highlightForTest("axb", Options{Query: "a.b"}); got != "axb" { t.Errorf("metachar should be literal: got %q", got) } - got := highlightMatches("a.b", Options{Query: "a.b", Color: "always"}) + got := highlightForTest("a.b", Options{Query: "a.b", Color: "always"}) if !strings.Contains(got, ansi.Highlight+"a.b"+ansi.Reset) { t.Errorf("expected literal a.b highlighted, got %q", got) } } func TestHighlightMatches_RegexMode(t *testing.T) { - got := highlightMatches("axb", Options{Query: "a.b", Regex: true, Color: "always"}) + got := highlightForTest("axb", Options{Query: "a.b", Regex: true, Color: "always"}) if !strings.Contains(got, ansi.Highlight+"axb"+ansi.Reset) { t.Errorf("expected regex match, got %q", got) } } func TestHighlightMatches_InvalidRegexReturnsInput(t *testing.T) { - if got := highlightMatches("hello", Options{Query: "(", Regex: true}); got != "hello" { + if got := highlightForTest("hello", Options{Query: "(", Regex: true, Color: "always"}); got != "hello" { t.Errorf("invalid regex should return input, got %q", got) } } +func highlightForTest(s string, opts Options) string { + c := colorsFor(opts) + return highlightMatches(s, c, compileHighlight(opts, c)) +} + func TestRender_HighlightsQueryInBody(t *testing.T) { tmp := t.TempDir() body := `{"type":"user","timestamp":"2026-05-26T10:00:00Z","message":{"role":"user","content":"please fix the login flow"}}` + "\n" @@ -534,6 +539,33 @@ func TestRender_HighlightsQueryInBody(t *testing.T) { } } +func TestRender_InvalidRegexRendersUnhighlightedText(t *testing.T) { + tmp := t.TempDir() + body := `{"type":"user","timestamp":"2026-05-26T10:00:00Z","message":{"role":"user","content":"please fix the login flow"}}` + "\n" + jsonl := filepath.Join(tmp, "a.jsonl") + if err := os.WriteFile(jsonl, []byte(body), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + s := &session.Session{ + ID: "abc", + JSONLPath: jsonl, + CWD: tmp, + CWDExists: true, + LastTime: time.Date(2026, 5, 26, 10, 0, 0, 0, time.UTC), + } + var buf bytes.Buffer + if err := render(s, &buf, Options{Query: "(", Regex: true, Color: "always"}); err != nil { + t.Fatalf("render: %v", err) + } + out := buf.String() + if !strings.Contains(out, "please fix the login flow") { + t.Fatalf("body was not rendered: %q", out) + } + if strings.Contains(out, ansi.Highlight) { + t.Fatalf("invalid regex should not highlight text: %q", out) + } +} + // itoa is a tiny strconv.Itoa stand-in to keep imports minimal. func itoa(n int) string { if n == 0 { @@ -635,6 +667,17 @@ func BenchmarkRenderTextLargeTranscript(b *testing.B) { } } +func BenchmarkRenderTextHighlightLargeTranscript(b *testing.B) { + s := benchmarkPreviewSession(b, 1000) + opts := Options{Query: "message", Color: "always"} + b.ResetTimer() + for range b.N { + if err := render(s, ioDiscard{}, opts); err != nil { + b.Fatalf("render: %v", err) + } + } +} + func benchmarkPreviewSession(b *testing.B, messages int) *session.Session { b.Helper() tmp := b.TempDir()