Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions internal/preview/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -259,27 +259,35 @@ 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 {
pattern = regexp.QuoteMeta(opts.Query)
}
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)
Expand Down
63 changes: 53 additions & 10 deletions internal/preview/preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,63 +454,68 @@ 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)
}
}

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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
Loading