@@ -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
112116func initialModel (items []listItem , filterQuery string , claudeFlags []string ) model {
@@ -129,7 +133,9 @@ func initialModel(items []listItem, filterQuery string, claudeFlags []string) mo
129133func (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
716820func buildItems (conversations []Conversation ) []listItem {
717821 items := make ([]listItem , 0 , len (conversations ))
@@ -769,6 +873,7 @@ Examples:
769873Key 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