Skip to content

Commit 479e498

Browse files
brtkwrclaude
andcommitted
feat: show one row per conversation with improved search
- Display one row per conversation instead of per message - Sort by most recent activity (LastTimestamp) - Search across all user messages in conversation - Preview shows all matches with context, truncating gaps - Add CI test workflow (reused by release workflow) - Improve test coverage to 39% 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 61601c9 commit 479e498

4 files changed

Lines changed: 509 additions & 36 deletions

File tree

.github/workflows/release.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ permissions:
99
contents: write
1010

1111
jobs:
12+
test:
13+
uses: ./.github/workflows/test.yaml
14+
1215
release:
16+
needs: test
1317
runs-on: ubuntu-latest
1418
steps:
1519
- uses: actions/checkout@v4

.github/workflows/test.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_call:
9+
10+
jobs:
11+
test:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- uses: actions/setup-go@v5
17+
with:
18+
go-version: "1.21"
19+
20+
- name: Run tests
21+
run: go test -v -cover ./...
22+
23+
- name: Build
24+
run: go build -o ccs .

main.go

Lines changed: 83 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import (
1515
"time"
1616
)
1717

18-
const version = "0.2.1"
18+
const version = "0.3.0"
1919

2020
// Message represents a conversation message
2121
type Message struct {
@@ -29,6 +29,7 @@ type Conversation struct {
2929
SessionID string `json:"session_id"`
3030
Cwd string `json:"cwd"`
3131
FirstTimestamp string `json:"first_timestamp"`
32+
LastTimestamp string `json:"last_timestamp"`
3233
Messages []Message `json:"messages"`
3334
}
3435

@@ -145,6 +146,9 @@ func parseConversationFile(path string) (*Conversation, error) {
145146
return nil, nil
146147
}
147148

149+
// Set LastTimestamp from the last message
150+
conv.LastTimestamp = conv.Messages[len(conv.Messages)-1].Ts
151+
148152
if conv.Cwd == "" {
149153
conv.Cwd = "unknown"
150154
}
@@ -202,7 +206,7 @@ func getConversations() ([]Conversation, error) {
202206
}
203207

204208
sort.Slice(conversations, func(i, j int) bool {
205-
return conversations[i].FirstTimestamp > conversations[j].FirstTimestamp
209+
return conversations[i].LastTimestamp > conversations[j].LastTimestamp
206210
})
207211

208212
return conversations, nil
@@ -249,21 +253,33 @@ func buildSearchLines(conversations []Conversation) ([]string, map[string]Conver
249253
project = conv.Cwd[idx+1:]
250254
}
251255

252-
for i, msg := range conv.Messages {
253-
if msg.Role != "user" {
254-
continue
256+
// Collect all user messages for search, display first one
257+
var firstUserMsg *Message
258+
var allUserText []string
259+
for i := range conv.Messages {
260+
if conv.Messages[i].Role == "user" {
261+
if firstUserMsg == nil {
262+
firstUserMsg = &conv.Messages[i]
263+
}
264+
allUserText = append(allUserText, conv.Messages[i].Text)
255265
}
266+
}
256267

257-
text := truncate(msg.Text, 100)
258-
ts := formatTimestamp(msg.Ts)
259-
projectPad := padOrTruncate(project, 25)
260-
261-
// Format: id \t date \t project \t message
262-
// Colors: date=dim, project=yellow/bold, message=white
263-
line := fmt.Sprintf("%s:%d\t\033[90m%s\033[0m\t\033[1;33m%s\033[0m\t%s",
264-
conv.SessionID, i, ts, projectPad, text)
265-
lines = append(lines, line)
268+
if firstUserMsg == nil {
269+
continue
266270
}
271+
272+
displayText := truncate(firstUserMsg.Text, 100)
273+
searchText := strings.Join(strings.Fields(strings.Join(allUserText, " ")), " ")
274+
ts := formatTimestamp(conv.LastTimestamp)
275+
projectPad := padOrTruncate(project, 25)
276+
277+
// Format: id \t date \t project \t display_message \t search_text
278+
// Colors: date=dim, project=yellow/bold, message=white
279+
// search_text is hidden (column 5) but used for matching
280+
line := fmt.Sprintf("%s\t\033[90m%s\033[0m\t\033[1;33m%s\033[0m\t%s\t%s",
281+
conv.SessionID, ts, projectPad, displayText, searchText)
282+
lines = append(lines, line)
267283
}
268284

269285
return lines, convMap
@@ -344,15 +360,7 @@ func showPreview(line, query string) {
344360
return
345361
}
346362

347-
sessionMsg := parts[0]
348-
lastColon := strings.LastIndex(sessionMsg, ":")
349-
if lastColon < 0 {
350-
return
351-
}
352-
353-
sessionID := sessionMsg[:lastColon]
354-
var msgIdx int
355-
fmt.Sscanf(sessionMsg[lastColon+1:], "%d", &msgIdx)
363+
sessionID := parts[0]
356364

357365
conv, ok := convMap[sessionID]
358366
if !ok {
@@ -364,19 +372,56 @@ func showPreview(line, query string) {
364372
fmt.Printf("\033[1;33mSession:\033[0m %s\n", sessionID)
365373
fmt.Printf("\033[1;33mTotal messages:\033[0m %d\n\n", len(conv.Messages))
366374

367-
start := msgIdx - 2
368-
if start < 0 {
369-
start = 0
375+
// Find all messages containing the query
376+
var matchIndices []int
377+
matchSet := make(map[int]bool)
378+
if query != "" {
379+
queryLower := strings.ToLower(query)
380+
for i, msg := range conv.Messages {
381+
if strings.Contains(strings.ToLower(msg.Text), queryLower) {
382+
matchIndices = append(matchIndices, i)
383+
matchSet[i] = true
384+
}
385+
}
370386
}
371-
end := msgIdx + 4
372-
if end > len(conv.Messages) {
373-
end = len(conv.Messages)
387+
388+
// Build set of indices to show (matches + 1 context on each side)
389+
showSet := make(map[int]bool)
390+
if len(matchIndices) > 0 {
391+
for _, idx := range matchIndices {
392+
if idx > 0 {
393+
showSet[idx-1] = true
394+
}
395+
showSet[idx] = true
396+
if idx < len(conv.Messages)-1 {
397+
showSet[idx+1] = true
398+
}
399+
}
400+
} else {
401+
// No matches, show first 6 messages
402+
for i := 0; i < 6 && i < len(conv.Messages); i++ {
403+
showSet[i] = true
404+
}
374405
}
375406

376-
for i := start; i < end; i++ {
407+
// Display messages with gaps
408+
lastShown := -1
409+
for i := 0; i < len(conv.Messages); i++ {
410+
if !showSet[i] {
411+
continue
412+
}
413+
414+
// Show gap indicator if we skipped messages
415+
if lastShown >= 0 && i > lastShown+1 {
416+
skipped := i - lastShown - 1
417+
fmt.Printf("\033[90m ... %d messages ...\033[0m\n\n", skipped)
418+
} else if lastShown == -1 && i > 0 {
419+
fmt.Printf("\033[90m ... %d earlier messages\033[0m\n\n", i)
420+
}
421+
377422
msg := conv.Messages[i]
378423
var prefix string
379-
if i == msgIdx {
424+
if matchSet[i] {
380425
if msg.Role == "user" {
381426
prefix = "\033[1;32m>>> User:\033[0m"
382427
} else {
@@ -398,10 +443,12 @@ func showPreview(line, query string) {
398443
fmt.Println(prefix)
399444
fmt.Println(formatCodeBlock(text, query, " "))
400445
fmt.Println()
446+
447+
lastShown = i
401448
}
402449

403-
remaining := len(conv.Messages) - end
404-
if remaining > 0 {
450+
if lastShown < len(conv.Messages)-1 {
451+
remaining := len(conv.Messages) - lastShown - 1
405452
fmt.Printf("\033[90m ... %d more messages\033[0m\n", remaining)
406453
}
407454
}
@@ -504,6 +551,8 @@ func main() {
504551
"--ansi",
505552
"--delimiter=\t",
506553
"--with-nth=2,3,4",
554+
"--nth=2..",
555+
"--no-sort",
507556
"--tabstop=4",
508557
"--preview", fmt.Sprintf("%s --preview {} {q}", self),
509558
"--preview-window=bottom:70%:wrap:+5",
@@ -529,9 +578,7 @@ func main() {
529578
}
530579

531580
parts := strings.Split(selected, "\t")
532-
sessionMsg := parts[0]
533-
lastColon := strings.LastIndex(sessionMsg, ":")
534-
sessionID := sessionMsg[:lastColon]
581+
sessionID := parts[0]
535582

536583
conv, ok := convMap[sessionID]
537584
if !ok {

0 commit comments

Comments
 (0)