diff --git a/AGENTS.md b/AGENTS.md index a376b1a..bf4921d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,7 @@ 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 @@ -98,7 +99,7 @@ go test -v -cover ### 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 diff --git a/README.md b/README.md index fda13f7..ab452b6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 `. diff --git a/main.go b/main.go index e419def..51984b6 100644 --- a/main.go +++ b/main.go @@ -89,7 +89,9 @@ 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 + errorMsg string // Show deletion/prune errors } func initialModel(items []listItem, filterQuery string, claudeFlags []string) model { @@ -191,6 +193,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 = "" @@ -215,6 +230,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case "ctrl+r": + if len(m.filtered) > 0 { + m.confirmPrune = true + m.pruneIndex = m.cursor + } + return m, nil + case "up", "ctrl+p": if m.cursor > 0 { m.cursor-- @@ -266,7 +288,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 @@ -277,7 +299,13 @@ 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)? Removes duplicate data, keeps dialogue. [y/N]", truncate(getTopic(conv), 40), formatBytes(conv.Size))) + sections = append(sections, " "+inputSection) + } else if m.confirmDelete { topic := getTopic(m.filtered[m.deleteIndex].conv) inputSection = lipgloss.NewStyle(). Foreground(lipgloss.Color("196")). // Red @@ -880,6 +908,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)) @@ -1220,6 +1278,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 diff --git a/main_test.go b/main_test.go index 65effe8..dfef4d4 100644 --- a/main_test.go +++ b/main_test.go @@ -1446,3 +1446,57 @@ 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) { + item := listItem{conv: Conversation{SessionID: "s1", 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.Fatal("ctrl+r should enter prune confirm mode") + } + res, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = res.(model) + if m.confirmPrune { + t.Error("esc should cancel prune confirm") + } +}