Skip to content

Commit d9b3b88

Browse files
brtkwrclaude
andcommitted
feat: add delete conversation feature with Ctrl+D
- Add delete confirmation prompt with y/N option - Show red confirmation message and error handling - Store full file path in Conversation struct for correct deletion - Update help text and documentation Test improvements: - Increase coverage from 20.5% to 69.3% (3.4x improvement) - Add comprehensive tests for TUI logic, keyboard navigation, mouse scrolling - Test delete flow, filter logic, and UI rendering - Refactor getProjectsDir to variable for testability - Fix bug: updateFilter now properly copies slice to avoid corruption Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 72fc1b4 commit d9b3b88

4 files changed

Lines changed: 818 additions & 16 deletions

File tree

AGENTS.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ go build
1313
go test -v -cover
1414
```
1515

16+
### Test Coverage
17+
18+
- **Target**: 60%+ statement coverage
19+
- **Current**: ~69%
20+
- All new features must include tests
21+
- Break logic into testable functions when possible
22+
- Refactor for testability (e.g., use variables instead of functions for dependency injection)
23+
1624
### Run locally
1725

1826
```bash
@@ -69,24 +77,26 @@ go test -v -cover
6977
- `Conversation` - Parsed conversation with messages, timestamps, cwd
7078
- `Message` - Single message (role, text, timestamp)
7179
- `listItem` - Display item with conversation and search text
72-
- `model` - Bubbletea application state
80+
- `model` - Bubbletea application state (includes delete confirmation state)
7381

7482
### Key Functions
7583

7684
- `getConversations(cutoff, maxSize)` - Loads conversations from `~/.claude/projects/` with filters
7785
- `parseConversationFile(path, cutoff, maxSize)` - Parses JSONL files, skips by mtime/size
7886
- `buildItems()` - Creates list items with searchable text
7987
- `initialModel()` - Sets up bubbletea TUI
80-
- `Update()` - Handles keyboard/mouse input
81-
- `View()` - Renders the TUI
88+
- `Update()` - Handles keyboard/mouse input, including delete confirmation
89+
- `View()` - Renders the TUI with delete confirmation prompt
8290
- `renderPreview()` - Renders conversation preview with highlights
8391
- `formatListItem()` - Formats a single list row
92+
- `deleteConversation()` - Removes conversation file and updates UI state
93+
- `getTopic()` - Extracts first user message as topic
8494

8595
### TUI Layout
8696

8797
```
88-
ccs · claude code search ↑/↓ Enter Ctrl+J/K Esc
89-
> type to search... (N/total)
98+
ccs · claude code search Resume:Enter Delete:Ctrl+D Scroll:Ctrl+J/K Exit:Esc
99+
> type to search... (N/total)
90100
91101
DATE PROJECT TOPIC MSGS HITS
92102
────────────────────────────────────────────────────────────────────────────

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Globally search and resume [Claude Code](https://claude.ai/claude-code) conversa
1414
- Preview conversation context with search term highlighting
1515
- See message counts and hit counts per conversation
1616
- Resume conversations directly from the search interface
17+
- Delete conversations with confirmation prompt
1718
- Pass flags through to `claude` (e.g., `--plan`)
1819
- Mouse wheel scrolling support
1920

@@ -75,6 +76,7 @@ ccs buyer -- --plan
7576

7677
- `↑/↓` or `Ctrl+P/N` - Navigate list
7778
- `Enter` - Resume selected conversation
79+
- `Ctrl+D` - Delete selected conversation (with confirmation)
7880
- `Ctrl+J/K` - Scroll preview
7981
- `Mouse wheel` - Scroll list or preview (context-aware)
8082
- `Ctrl+U` - Clear search

main.go

Lines changed: 116 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type Conversation struct {
3434
FirstTimestamp string `json:"first_timestamp"`
3535
LastTimestamp string `json:"last_timestamp"`
3636
Messages []Message `json:"messages"`
37+
FilePath string `json:"file_path"` // Full path to the .jsonl file
3738
}
3839

3940
// RawMessage represents the JSON structure in conversation files
@@ -107,6 +108,9 @@ type model struct {
107108
quitting bool
108109
claudeFlags []string
109110
mouseInPreview bool // Track if mouse is in preview area
111+
confirmDelete bool // Are we in delete confirmation mode?
112+
deleteIndex int // Index of item to delete
113+
errorMsg string // Show deletion errors
110114
}
111115

112116
func initialModel(items []listItem, filterQuery string, claudeFlags []string) model {
@@ -129,7 +133,9 @@ func initialModel(items []listItem, filterQuery string, claudeFlags []string) mo
129133
func (m *model) updateFilter() {
130134
query := m.textInput.Value()
131135
if query == "" {
132-
m.filtered = m.items
136+
// Make a copy to avoid sharing backing array with m.items
137+
m.filtered = make([]listItem, len(m.items))
138+
copy(m.filtered, m.items)
133139
} else {
134140
// Exact substring matching (case-insensitive)
135141
queryLower := strings.ToLower(query)
@@ -193,6 +199,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
193199
return m, nil
194200

195201
case tea.KeyMsg:
202+
// Handle delete confirmation mode
203+
if m.confirmDelete {
204+
switch msg.String() {
205+
case "y", "Y":
206+
m.deleteConversation()
207+
return m, nil
208+
case "n", "N", "esc":
209+
m.confirmDelete = false
210+
return m, nil
211+
}
212+
return m, nil // Ignore all other keys
213+
}
214+
215+
// Clear error message on any keypress in normal mode
216+
if m.errorMsg != "" {
217+
m.errorMsg = ""
218+
}
219+
196220
switch msg.String() {
197221
case "ctrl+c", "esc":
198222
m.quitting = true
@@ -205,6 +229,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
205229
m.quitting = true
206230
return m, tea.Quit
207231

232+
case "ctrl+d":
233+
if len(m.filtered) > 0 {
234+
m.confirmDelete = true
235+
m.deleteIndex = m.cursor
236+
}
237+
return m, nil
238+
208239
case "up", "ctrl+p":
209240
if m.cursor > 0 {
210241
m.cursor--
@@ -256,22 +287,41 @@ func (m model) View() string {
256287

257288
// Title line with help right-aligned
258289
title := fmt.Sprintf("ccs · claude code search · %s", version)
259-
help := "↑/↓ Enter Ctrl+J/K Esc"
290+
help := "Resume:Enter Delete:Ctrl+D Scroll:Ctrl+J/K Exit:Esc"
260291
titlePadding := tableWidth - 2 - len(title) - len(help)
261292
if titlePadding < 1 {
262293
titlePadding = 1
263294
}
264295
b.WriteString(fmt.Sprintf(" \033[1;36mccs\033[0m \033[90m· claude code search · %s%s%s\033[0m\n",
265296
version, strings.Repeat(" ", titlePadding), help))
266297

267-
// Search line with count right-aligned
268-
count := fmt.Sprintf("(%d/%d)", len(m.filtered), len(m.items))
269-
searchPadding := tableWidth - 2 - 2 - 40 - len(count) - 1 // 2 for indent, 2 for "> ", 40 for textInput, -1 to shift left
270-
if searchPadding < 1 {
271-
searchPadding = 1
298+
// Search line or delete confirmation
299+
var sections []string
300+
var inputSection string
301+
if m.confirmDelete {
302+
topic := getTopic(m.filtered[m.deleteIndex].conv)
303+
inputSection = lipgloss.NewStyle().
304+
Foreground(lipgloss.Color("196")). // Red
305+
Render(fmt.Sprintf("Delete conversation \"%s\"? [y/N]", truncate(topic, 50)))
306+
sections = append(sections, " "+inputSection)
307+
} else {
308+
count := fmt.Sprintf("(%d/%d)", len(m.filtered), len(m.items))
309+
searchPadding := tableWidth - 2 - 2 - 40 - len(count) - 1 // 2 for indent, 2 for "> ", 40 for textInput, -1 to shift left
310+
if searchPadding < 1 {
311+
searchPadding = 1
312+
}
313+
inputSection = fmt.Sprintf(" %s%s\033[90m%s\033[0m", m.textInput.View(), strings.Repeat(" ", searchPadding), count)
314+
sections = append(sections, inputSection)
272315
}
273-
b.WriteString(fmt.Sprintf(" %s%s\033[90m%s\033[0m\n\n",
274-
m.textInput.View(), strings.Repeat(" ", searchPadding), count))
316+
317+
// Show error message if set
318+
if m.errorMsg != "" {
319+
errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
320+
sections = append(sections, " "+errorStyle.Render(m.errorMsg))
321+
}
322+
323+
b.WriteString(strings.Join(sections, "\n"))
324+
b.WriteString("\n\n")
275325

276326
// Calculate heights
277327
listHeight := m.height * 30 / 100
@@ -519,7 +569,9 @@ func padRight(s string, length int) string {
519569
// Data loading (preserved from original)
520570
// ============================================================================
521571

522-
func getProjectsDir() string {
572+
// getProjectsDir returns the path to the Claude projects directory
573+
// Declared as a variable so it can be overridden in tests
574+
var getProjectsDir = func() string {
523575
home, _ := os.UserHomeDir()
524576
return filepath.Join(home, ".claude", "projects")
525577
}
@@ -573,7 +625,10 @@ func parseConversationFile(path string, cutoff time.Time, maxSize int64) (*Conve
573625
}
574626

575627
sessionID := strings.TrimSuffix(info.Name(), ".jsonl")
576-
conv := &Conversation{SessionID: sessionID}
628+
conv := &Conversation{
629+
SessionID: sessionID,
630+
FilePath: path,
631+
}
577632

578633
file, err := os.Open(path)
579634
if err != nil {
@@ -712,6 +767,55 @@ func truncate(s string, maxLen int) string {
712767
return s[:maxLen-3] + "..."
713768
}
714769

770+
// getTopic returns the first user message or session ID
771+
func getTopic(conv Conversation) string {
772+
for _, msg := range conv.Messages {
773+
if msg.Role == "user" {
774+
return msg.Text
775+
}
776+
}
777+
return conv.SessionID
778+
}
779+
780+
// deleteConversation removes the selected conversation from disk and UI
781+
func (m *model) deleteConversation() {
782+
if m.deleteIndex >= len(m.filtered) {
783+
return
784+
}
785+
786+
conv := m.filtered[m.deleteIndex].conv
787+
788+
// Delete the file (ignore if already deleted)
789+
if err := os.Remove(conv.FilePath); err != nil && !os.IsNotExist(err) {
790+
m.errorMsg = fmt.Sprintf("Delete failed: %v", err)
791+
m.confirmDelete = false
792+
return
793+
}
794+
795+
// Remove from filtered slice
796+
m.filtered = append(m.filtered[:m.deleteIndex], m.filtered[m.deleteIndex+1:]...)
797+
798+
// Remove from items slice (find by SessionID)
799+
for i, item := range m.items {
800+
if item.conv.SessionID == conv.SessionID {
801+
m.items = append(m.items[:i], m.items[i+1:]...)
802+
break
803+
}
804+
}
805+
806+
// Adjust cursor
807+
if len(m.filtered) == 0 {
808+
m.cursor = 0
809+
} else if m.cursor >= len(m.filtered) {
810+
m.cursor = len(m.filtered) - 1
811+
}
812+
// Otherwise cursor stays at same position (shows next item)
813+
814+
// Exit confirmation mode
815+
m.confirmDelete = false
816+
m.errorMsg = ""
817+
}
818+
715819
// buildItems creates list items from conversations
716820
func buildItems(conversations []Conversation) []listItem {
717821
items := make([]listItem, 0, len(conversations))
@@ -769,6 +873,7 @@ Examples:
769873
Key bindings:
770874
↑/↓, Ctrl+P/N Navigate list
771875
Enter Select and resume conversation
876+
Ctrl+D Delete conversation (with confirmation)
772877
Ctrl+J/K Scroll preview
773878
Mouse wheel Scroll list or preview (based on position)
774879
Ctrl+U Clear search

0 commit comments

Comments
 (0)