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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,15 @@ go test -v -cover
- `renderPreview()` - Renders conversation preview with highlights
- `formatListItem()` - Formats a single list row
- `deleteConversation()` - Removes conversation file and updates UI state
- `pruneConversation()` - Prunes the selected conversation in place (Ctrl+R) and refreshes its size
- `getTopic()` - Extracts first user message as topic
- `pruneFile()` / `pruneStream()` - Shrink a conversation by dropping duplicate/redundant data (never touches user/assistant lines)
- `runPrune()` - `ccs prune` subcommand driver

### TUI Layout

```
ccs · claude code search Resume:Enter Delete:Ctrl+D Scroll:Ctrl+J/K Exit:Esc
ccs · claude code search Resume:Enter Delete:Ctrl+D Prune:Ctrl+R Scroll:Ctrl+J/K Exit:Esc
> type to search... (N/total)

DATE PROJECT TOPIC MSGS HITS SIZE
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ ccs buyer -- --plan
- `↑/↓` or `Ctrl+P/N` - Navigate list
- `Enter` - Resume selected conversation
- `Ctrl+D` - Delete selected conversation (with confirmation)
- `Ctrl+R` - Prune selected conversation - shrink it losslessly (with confirmation)
- `Ctrl+J/K` - Scroll preview
- `Mouse wheel` - Scroll list or preview (context-aware)
- `Ctrl+U` - Clear search
Expand All @@ -105,6 +106,8 @@ ccs prune --apply --no-tool-results # keep tool results, only drop snapshot bac

Run `ccs prune --help` for all flags.

You can also prune a single conversation from the search interface: select it and press `Ctrl+R` (with confirmation).

## How it works

ccs reads conversation history from `~/.claude/projects/` and presents them in an interactive TUI. When you select a conversation, it changes to the original project directory and runs `claude --resume <session-id>`.
Expand Down
76 changes: 73 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ type model struct {
mouseInPreview bool // Track if mouse is in preview area
confirmDelete bool // Are we in delete confirmation mode?
deleteIndex int // Index of item to delete
errorMsg string // Show deletion errors
confirmPrune bool // Are we in prune confirmation mode?
pruneIndex int // Index of item to prune
pruneSaved int64 // Bytes the pending prune would reclaim (measured on Ctrl+R)
errorMsg string // Show deletion/prune errors
}

func initialModel(items []listItem, filterQuery string, claudeFlags []string) model {
Expand Down Expand Up @@ -192,6 +195,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil // Ignore all other keys
}

// Handle prune confirmation mode
if m.confirmPrune {
switch msg.String() {
case "y", "Y":
m.pruneConversation()
return m, nil
case "n", "N", "esc":
m.confirmPrune = false
return m, nil
}
return m, nil // Ignore all other keys
}

// Clear error message on any keypress in normal mode
if m.errorMsg != "" {
m.errorMsg = ""
Expand All @@ -216,6 +232,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil

case "ctrl+r":
if len(m.filtered) > 0 {
// Measure the projected saving so the prompt can show it.
// ponytail: reads the file once now (and again on confirm) - a
// multi-GB file briefly blocks, acceptable for a manual action.
st, err := pruneFile(m.filtered[m.cursor].conv.FilePath, false, pruneOpts{dropSnapshots: true, stripToolResults: true})
if err != nil {
m.errorMsg = fmt.Sprintf("Prune preview failed: %v", err)
return m, nil
}
m.confirmPrune = true
m.pruneIndex = m.cursor
m.pruneSaved = st.bytesIn - st.bytesOut
}
return m, nil

case "up", "ctrl+p":
if m.cursor > 0 {
m.cursor--
Expand Down Expand Up @@ -267,7 +299,7 @@ func (m model) View() string {

// Title line with help right-aligned
title := fmt.Sprintf("ccs · claude code search · %s", version)
help := "Resume:Enter Delete:Ctrl+D Scroll:Ctrl+J/K Exit:Esc"
help := "Resume:Enter Delete:Ctrl+D Prune:Ctrl+R Scroll:Ctrl+J/K Exit:Esc"
titlePadding := tableWidth - 2 - len(title) - len(help)
if titlePadding < 1 {
titlePadding = 1
Expand All @@ -278,7 +310,14 @@ func (m model) View() string {
// Search line or delete confirmation
var sections []string
var inputSection string
if m.confirmDelete {
if m.confirmPrune {
conv := m.filtered[m.pruneIndex].conv
inputSection = lipgloss.NewStyle().
Foreground(lipgloss.Color("214")). // Amber
Render(fmt.Sprintf("Prune \"%s\"? %s -> %s, saves %s (keeps dialogue). [y/N]",
truncate(getTopic(conv), 32), formatBytes(conv.Size), formatBytes(conv.Size-m.pruneSaved), formatBytes(m.pruneSaved)))
sections = append(sections, " "+inputSection)
} else if m.confirmDelete {
topic := getTopic(m.filtered[m.deleteIndex].conv)
inputSection = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")). // Red
Expand Down Expand Up @@ -903,6 +942,36 @@ func (m *model) deleteConversation() {
m.errorMsg = ""
}

// pruneConversation prunes the selected conversation file in place and refreshes
// its displayed size. The conversation stays in the list (only shrunk).
// ponytail: synchronous - a multi-GB file briefly blocks the UI, same as delete.
func (m *model) pruneConversation() {
m.confirmPrune = false
if m.pruneIndex >= len(m.filtered) {
return
}
conv := m.filtered[m.pruneIndex].conv

st, err := pruneFile(conv.FilePath, true, pruneOpts{dropSnapshots: true, stripToolResults: true})
if err != nil {
m.errorMsg = fmt.Sprintf("Prune failed: %v", err)
return
}

newSize := conv.Size - (st.bytesIn - st.bytesOut)
if info, e := os.Stat(conv.FilePath); e == nil {
newSize = info.Size()
}
for _, items := range [][]listItem{m.items, m.filtered} {
for i := range items {
if items[i].conv.SessionID == conv.SessionID {
items[i].conv.Size = newSize
}
}
}
m.errorMsg = ""
}

// buildItems creates list items from conversations
func buildItems(conversations []Conversation) []listItem {
items := make([]listItem, 0, len(conversations))
Expand Down Expand Up @@ -1243,6 +1312,7 @@ Key bindings:
↑/↓, Ctrl+P/N Navigate list
Enter Select and resume conversation
Ctrl+D Delete conversation (with confirmation)
Ctrl+R Prune conversation - shrink it losslessly (with confirmation)
Ctrl+J/K Scroll preview
Mouse wheel Scroll list or preview (based on position)
Ctrl+U Clear search
Expand Down
68 changes: 68 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1479,3 +1479,71 @@ func TestPruneFileDryRunLeavesFileUnchanged(t *testing.T) {
t.Error("dry run must not modify the file")
}
}

func TestPruneConversationShrinksAndUpdatesSize(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "s1.jsonl")
content := `{"type":"user","message":{"content":"keep"},"uuid":"u1"}` + "\n" +
`{"type":"file-history-snapshot","snapshot":{"data":"` + strings.Repeat("x", 4000) + `"}}` + "\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
info, _ := os.Stat(path)

item := listItem{conv: Conversation{SessionID: "s1", FilePath: path, Size: info.Size(),
Messages: []Message{{Role: "user", Text: "keep"}}}}
m := initialModel([]listItem{item}, "", nil)
m.confirmPrune = true
m.pruneIndex = 0
m.pruneConversation()

if m.errorMsg != "" {
t.Fatalf("prune errored: %s", m.errorMsg)
}
if m.confirmPrune {
t.Error("should exit confirm mode after pruning")
}
after, _ := os.Stat(path)
if after.Size() >= info.Size() {
t.Errorf("file should shrink: %d -> %d", info.Size(), after.Size())
}
if m.items[0].conv.Size != after.Size() || m.filtered[0].conv.Size != after.Size() {
t.Errorf("displayed size not updated to %d (items=%d filtered=%d)", after.Size(), m.items[0].conv.Size, m.filtered[0].conv.Size)
}
data, _ := os.ReadFile(path)
if strings.Contains(string(data), "file-history-snapshot") {
t.Error("snapshot should be removed")
}
if !strings.Contains(string(data), "keep") {
t.Error("conversation message should be kept")
}
}

func TestCtrlREntersAndCancelsPruneConfirm(t *testing.T) {
// Ctrl+R measures the file to preview savings, so it needs a real file.
dir := t.TempDir()
path := filepath.Join(dir, "s1.jsonl")
content := `{"type":"user","message":{"content":"hi"}}` + "\n" +
`{"type":"file-history-snapshot","snapshot":{"data":"` + strings.Repeat("x", 3000) + `"}}` + "\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
info, _ := os.Stat(path)
item := listItem{conv: Conversation{SessionID: "s1", FilePath: path, Size: info.Size(),
Messages: []Message{{Role: "user", Text: "hi"}}}}
m := initialModel([]listItem{item}, "", nil)

res, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlR})
m = res.(model)
if !m.confirmPrune {
t.Fatalf("ctrl+r should enter prune confirm mode (err=%q)", m.errorMsg)
}
if m.pruneSaved <= 0 {
t.Errorf("ctrl+r should measure a positive saving, got %d", m.pruneSaved)
}
res, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = res.(model)
if m.confirmPrune {
t.Error("esc should cancel prune confirm")
}
}
Loading