Skip to content

Commit bfbe237

Browse files
committed
param uplifts
1 parent 00f2b60 commit bfbe237

3 files changed

Lines changed: 181 additions & 8 deletions

File tree

cmd/chatgrep/main.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,23 @@ func run() error {
3434
showVersion bool
3535
)
3636

37-
flag.StringVar(&agent, "agent", "all", "agent to search: claude, copilot, codex, all")
38-
flag.StringVar(&agent, "A", "all", "agent to search (shorthand)")
39-
flag.StringVar(&project, "project", "", "filter to sessions in this directory (use . for cwd)")
40-
flag.StringVar(&project, "p", "", "filter to sessions in this directory (shorthand)")
41-
flag.BoolVar(&plain, "plain", false, "plain text output (no fzf)")
42-
flag.BoolVar(&first, "first", false, "print resume command for top match and exit")
43-
flag.BoolVar(&previewMode, "preview", false, "internal: render preview for fzf")
44-
flag.BoolVar(&showVersion, "version", false, "print version and exit")
37+
flag.StringVar(&agent, "agent", "all", "")
38+
flag.StringVar(&agent, "a", "all", "")
39+
flag.StringVar(&project, "project", "", "")
40+
flag.StringVar(&project, "p", "", "")
41+
flag.BoolVar(&plain, "plain", false, "")
42+
flag.BoolVar(&first, "first", false, "")
43+
flag.BoolVar(&first, "f", false, "")
44+
flag.BoolVar(&previewMode, "preview", false, "")
45+
flag.BoolVar(&showVersion, "version", false, "")
46+
flag.Usage = func() {
47+
fmt.Fprintf(os.Stderr, "Usage: chatgrep [flags] <query>\n\nFlags:\n")
48+
fmt.Fprintf(os.Stderr, " -a, --agent <name> agent to search: claude, copilot, codex, all (default \"all\")\n")
49+
fmt.Fprintf(os.Stderr, " -p, --project <path> filter to sessions in this directory (use . for cwd)\n")
50+
fmt.Fprintf(os.Stderr, " --plain plain text output (no fzf)\n")
51+
fmt.Fprintf(os.Stderr, " -f, --first print resume command for first match and exit\n")
52+
fmt.Fprintf(os.Stderr, " --version print version and exit\n")
53+
}
4554
flag.Parse()
4655

4756
if showVersion {

internal/text/text.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,41 @@ import (
77

88
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
99

10+
// Compiled regexes for markdown stripping, ordered to handle ambiguity
11+
// (e.g. *** is HR before bold-italic).
12+
var (
13+
mdFencedCode = regexp.MustCompile("```[a-zA-Z]*")
14+
mdHRule = regexp.MustCompile(`(?:^|(?:\s))(?:---+|\*\*\*+|___+)(?:(?:\s)|$)`)
15+
mdBoldStars = regexp.MustCompile(`\*\*(.+?)\*\*`)
16+
mdBoldUnder = regexp.MustCompile(`__(.+?)__`)
17+
mdItalicStars = regexp.MustCompile(`\*(\S[^*]*?\S|\S)\*`)
18+
mdInlineCode = regexp.MustCompile("`([^`]+)`")
19+
mdHeading = regexp.MustCompile(`(?m)^#{1,6}\s+`)
20+
mdPipe = regexp.MustCompile(`\|`)
21+
mdBlockquote = regexp.MustCompile(`(?m)(?:^|(?:\s))>\s`)
22+
mdBullet = regexp.MustCompile(`(?m)^[\t ]*[-*+]\s+`)
23+
mdOrderedList = regexp.MustCompile(`(?m)^[\t ]*\d+\.\s+`)
24+
)
25+
1026
const MaxSnippetLen = 200
1127

28+
// StripMarkdown removes common markdown formatting so snippets read cleanly.
29+
// Display-only - the full text is preserved elsewhere for matching.
30+
func StripMarkdown(s string) string {
31+
s = mdFencedCode.ReplaceAllString(s, " ")
32+
s = mdHRule.ReplaceAllString(s, " ")
33+
s = mdBoldStars.ReplaceAllString(s, "$1")
34+
s = mdBoldUnder.ReplaceAllString(s, "$1")
35+
s = mdItalicStars.ReplaceAllString(s, "$1")
36+
s = mdInlineCode.ReplaceAllString(s, "$1")
37+
s = mdHeading.ReplaceAllString(s, " ")
38+
s = mdPipe.ReplaceAllString(s, " ")
39+
s = mdBlockquote.ReplaceAllString(s, " ")
40+
s = mdBullet.ReplaceAllString(s, " ")
41+
s = mdOrderedList.ReplaceAllString(s, " ")
42+
return s
43+
}
44+
1245
// CollapseWhitespace strips ANSI escapes, normalizes all whitespace runs
1346
// to a single space, and trims leading/trailing space.
1447
func CollapseWhitespace(s string) string {
@@ -34,6 +67,8 @@ func CollapseWhitespace(s string) string {
3467
// occurrence of query. Falls back to the start if query is empty or not found.
3568
func Snippet(s, query string, maxLen int) string {
3669
s = CollapseWhitespace(s)
70+
s = StripMarkdown(s)
71+
s = strings.Join(strings.Fields(s), " ")
3772
runes := []rune(s)
3873
if len(runes) <= maxLen {
3974
return s
@@ -60,6 +95,29 @@ func Snippet(s, query string, maxLen int) string {
6095
}
6196
}
6297

98+
// Snap window edges to word boundaries within 20-rune tolerance
99+
const snapTolerance = 20
100+
if start > 0 {
101+
best := start
102+
for i := start; i < start+snapTolerance && i < len(runes); i++ {
103+
if runes[i] == ' ' {
104+
best = i + 1 // start after the space
105+
break
106+
}
107+
}
108+
start = best
109+
}
110+
if end < len(runes) {
111+
best := end
112+
for i := end - 1; i >= end-snapTolerance && i >= start; i-- {
113+
if runes[i] == ' ' {
114+
best = i // end before the space
115+
break
116+
}
117+
}
118+
end = best
119+
}
120+
63121
prefix := ""
64122
suffix := ""
65123
if start > 0 {

internal/text/text_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,112 @@ func TestSnippet_FlattensNewlines(t *testing.T) {
6363
}
6464
}
6565

66+
func TestStripMarkdown(t *testing.T) {
67+
tests := []struct {
68+
name, in, want string
69+
}{
70+
{"bold stars", "the **bold** word", "the bold word"},
71+
{"bold underscores", "the __bold__ word", "the bold word"},
72+
{"italic stars", "an *italic* word", "an italic word"},
73+
{"inline code", "use `fmt.Println` here", "use fmt.Println here"},
74+
{"fenced code block", "before ```go\ncode\n``` after", "before code after"},
75+
{"heading h3", "### My heading", "My heading"},
76+
{"heading h1", "# Title", "Title"},
77+
{"horizontal rule dashes", "above --- below", "above below"},
78+
{"horizontal rule stars", "above *** below", "above below"},
79+
{"horizontal rule underscores", "above ___ below", "above below"},
80+
{"pipe table", "| col1 | col2 |", "col1 col2"},
81+
{"bullet dash", "- item one", "item one"},
82+
{"bullet star", "* item two", "item two"},
83+
{"bullet plus", "+ item three", "item three"},
84+
{"ordered list", "1. first item", "first item"},
85+
{"blockquote", "> quoted text", "quoted text"},
86+
{"plain text", "no markdown here", "no markdown here"},
87+
{"real world", "**Goal:** describe the system", "Goal: describe the system"},
88+
}
89+
for _, tt := range tests {
90+
t.Run(tt.name, func(t *testing.T) {
91+
got := StripMarkdown(tt.in)
92+
// Normalize whitespace for comparison
93+
got = strings.Join(strings.Fields(got), " ")
94+
want := strings.Join(strings.Fields(tt.want), " ")
95+
if got != want {
96+
t.Errorf("StripMarkdown(%q) = %q, want %q", tt.in, got, want)
97+
}
98+
})
99+
}
100+
}
101+
102+
func TestSnippet_DoesNotCutWords(t *testing.T) {
103+
// Place query at rune 150 so start = 150-100 = 50.
104+
// Put a 30-char word at rune 40 so it spans the window edge.
105+
prefix := strings.Repeat("a ", 20) // 40 runes
106+
longWord := "abcdefghijklmnopqrstuvwxyzabcd" // 30 runes, ends at 70
107+
// pad to push TARGET to ~rune 150
108+
mid := strings.Repeat("z ", 40) // 80 runes (total so far: 40+30+1+80 = 151)
109+
text := prefix + longWord + " " + mid + "TARGET" + strings.Repeat(" mm", 80)
110+
got := Snippet(text, "TARGET", MaxSnippetLen)
111+
trimmed := strings.TrimPrefix(got, "...")
112+
if len(trimmed) > 0 {
113+
firstSpace := strings.IndexByte(trimmed, ' ')
114+
if firstSpace > 0 {
115+
firstWord := trimmed[:firstSpace]
116+
// Should not be a fragment of the long word
117+
if len(firstWord) > 2 && strings.Contains(longWord, firstWord) && firstWord != longWord {
118+
t.Errorf("snippet starts with word fragment %q: %q", firstWord, got)
119+
}
120+
}
121+
}
122+
}
123+
124+
func TestSnippet_WordBoundaryPreservesWindowSize(t *testing.T) {
125+
text := strings.Repeat("abcdefgh ", 50) // ~450 chars
126+
got := Snippet(text, "abcdefgh", MaxSnippetLen)
127+
runeLen := len([]rune(strings.TrimPrefix(strings.TrimSuffix(got, "..."), "...")))
128+
// Should be within 40 runes of MaxSnippetLen
129+
if runeLen < MaxSnippetLen-40 {
130+
t.Errorf("snippet too short after word snapping: %d runes (min %d)", runeLen, MaxSnippetLen-40)
131+
}
132+
}
133+
134+
func TestSnippet_WordBoundary_AllOneWord(t *testing.T) {
135+
// No spaces to snap to - should still work without panic
136+
text := strings.Repeat("x", 400)
137+
got := Snippet(text, "x", MaxSnippetLen)
138+
if len([]rune(got)) > MaxSnippetLen+10 {
139+
t.Errorf("snippet too long for all-one-word input: %d runes", len([]rune(got)))
140+
}
141+
}
142+
143+
func TestSnippet_WordBoundary_QueryAtStart(t *testing.T) {
144+
text := "target " + strings.Repeat("filler ", 60)
145+
got := Snippet(text, "target", MaxSnippetLen)
146+
if !strings.HasPrefix(got, "target") {
147+
t.Errorf("snippet should start with query when at start: %q", got)
148+
}
149+
}
150+
151+
func TestSnippet_WordBoundary_QueryAtEnd(t *testing.T) {
152+
text := strings.Repeat("filler ", 60) + "target"
153+
got := Snippet(text, "target", MaxSnippetLen)
154+
if !strings.HasSuffix(got, "target") {
155+
t.Errorf("snippet should end with query when at end: %q", got)
156+
}
157+
}
158+
159+
func TestSnippet_StripsMarkdown(t *testing.T) {
160+
text := "### **Goal:** describe the | system | using `code` and > quoted text"
161+
got := Snippet(text, "describe", MaxSnippetLen)
162+
for _, bad := range []string{"**", "###", "|", "`", ">"} {
163+
if strings.Contains(got, bad) {
164+
t.Errorf("snippet still contains %q: %q", bad, got)
165+
}
166+
}
167+
if !strings.Contains(got, "describe") {
168+
t.Errorf("snippet should contain query 'describe': %q", got)
169+
}
170+
}
171+
66172
func TestMatchesAll_SingleTerm(t *testing.T) {
67173
if !MatchesAll("hello world", []string{"hello"}) {
68174
t.Error("should match single term")

0 commit comments

Comments
 (0)