From dc3fd23372a95ff8548c013e32cc51963787a451 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 09:08:55 +0700 Subject: [PATCH 01/15] chore: update Go version and dependencies in go.mod and go.sum --- .github/workflows/ci.yml | 1 + README.md | 1 + go.mod | 30 +++++++++++--------- go.sum | 59 +++++++++++++++++++++++----------------- 4 files changed, 53 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c98486f..fe9c2fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: go: - "1.23" - "1.24" + - "1.25" steps: - uses: actions/checkout@v5 diff --git a/README.md b/README.md index 521d950..1a4746a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![Go version](https://img.shields.io/github/go-mod/go-version/ePlus-DEV/claude-cleaner)](go.mod) [![Go Report Card](https://goreportcard.com/badge/github.com/ePlus-DEV/claude-cleaner)](https://goreportcard.com/report/github.com/ePlus-DEV/claude-cleaner) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Known Vulnerabilities](https://snyk.io/test/github/ePlus-DEV/claude-cleaner/badge.svg)](https://snyk.io/test/github/ePlus-DEV/claude-cleaner) **Claude Cleaner** is an interactive terminal UI — built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) and [Lip Gloss](https://github.com/charmbracelet/lipgloss) — that inspects Claude Code project session history, displays disk usage, and safely deletes only the sessions you select. diff --git a/go.mod b/go.mod index fdc5060..c2067c5 100644 --- a/go.mod +++ b/go.mod @@ -1,28 +1,32 @@ module github.com/ePlus-DEV/claude-cleaner -go 1.23 +go 1.25.0 require ( - github.com/charmbracelet/bubbles v0.20.0 - github.com/charmbracelet/bubbletea v1.2.4 - github.com/charmbracelet/lipgloss v1.0.0 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 ) require ( - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/x/ansi v0.4.5 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.15.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/sync v0.9.0 // indirect - golang.org/x/sys v0.27.0 // indirect - golang.org/x/text v0.3.8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index bbfb7de..6df2169 100644 --- a/go.sum +++ b/go.sum @@ -1,41 +1,50 @@ -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= -github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= -github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= -github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= -github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= -github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= -github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= -github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= From 1d7b23e2dd2bfe02a28180105b7806db2edc9f81 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 09:27:57 +0700 Subject: [PATCH 02/15] chore: update Go version to 1.25 in workflows and documentation --- .github/workflows/ci.yml | 3 +-- .github/workflows/demo.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 11 +++++++++++ CONTRIBUTING.md | 15 ++++----------- README.md | 1 + 6 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe9c2fb..f7054c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,9 +22,8 @@ jobs: - macos-latest - windows-latest go: - - "1.23" - - "1.24" - "1.25" + - "1.26" steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml index 8c1ae91..043250f 100644 --- a/.github/workflows/demo.yml +++ b/.github/workflows/demo.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: "1.24" + go-version: "1.25" cache: true - name: Cache VHS binary diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7fea3b..9fb7171 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: "1.24" + go-version: "1.25" cache: true - name: Run GoReleaser diff --git a/CHANGELOG.md b/CHANGELOG.md index ca8e274..a13177d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- Bumped minimum Go version to 1.25 (go.mod). +- Updated CI matrix to Go 1.25 / 1.26 across Windows, macOS, and Linux. +- Updated all workflows (ci, demo, release) to Go 1.25. +- Added Snyk security scanning workflow (push / PR + weekly schedule). +- Upgraded dependencies to fix HIGH/MEDIUM Snyk findings: + - `golang.org/x/text` v0.3.8 → v0.38.0 (CWE-1327) + - `golang.org/x/sys` v0.27.0 → v0.46.0 (CWE-190) + - `github.com/charmbracelet/bubbletea` v1.2.4 → v1.3.10 + - `github.com/charmbracelet/bubbles` v0.20.0 → v1.0.0 + - `github.com/charmbracelet/lipgloss` v1.0.0 → v1.1.0 +- Restructured README: install section promoted, dev content moved to CONTRIBUTING.md. - Improved asynchronous directory scanning and deletion safety. - Added automated tests on Windows, macOS, and Linux. - Added OIDC-based npm publishing and tag-based GitHub Release automation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e3e0d7..b8bd95b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,14 +6,14 @@ Thank you for your interest in contributing! | Layer | Tool | | --- | --- | -| CLI / TUI | Go 1.22+, [Bubble Tea](https://github.com/charmbracelet/bubbletea), [Lip Gloss](https://github.com/charmbracelet/lipgloss) | +| CLI / TUI | Go 1.25+, [Bubble Tea](https://github.com/charmbracelet/bubbletea), [Lip Gloss](https://github.com/charmbracelet/lipgloss) | | npm wrapper | Node.js 20+ (thin shim — downloads Go binary on install) | | Releases | [GoReleaser](https://goreleaser.com) + GitHub Actions | | Demo GIFs | [VHS](https://github.com/charmbracelet/vhs) | ## Prerequisites -- [Go 1.22+](https://go.dev/dl/) +- [Go 1.25+](https://go.dev/dl/) - [Node.js 20+](https://nodejs.org/) (for npm wrapper scripts) - Git @@ -65,14 +65,6 @@ go run . --claude-dir $env:TEMP\claude-demo Creates 5 fake project sessions of various sizes — enough to test all TUI flows (navigate, select, delete, cancel) without touching real Claude data. -### Simulate an update prompt - -```bash -go run . --mock-update -``` - -Injects fake `v99.0.0` — triggers the update prompt on startup. Press `n` to skip into the list. - ## Tests ```bash @@ -116,9 +108,10 @@ demo/ *.tape — VHS scripts for demo GIFs .github/workflows/ - ci.yml — Go tests on every push / PR + ci.yml — Go tests (1.25 / 1.26 × Windows / macOS / Linux) on push / PR release.yml — GoReleaser (binaries) + npm publish (wrapper) demo.yml — regenerate demo GIFs on change + snyk.yml — dependency vulnerability scan (push / PR + weekly) ``` ### Key data flow diff --git a/README.md b/README.md index 1a4746a..cbb6f62 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ claude-cleaner --version ```text --claude-dir Custom Claude config directory (default: ~/.claude) +--mock-update Simulate a newer version available (for testing the update flow) -h, --help Show help -v, --version Show version ``` From 03dbce205c1b59eeb8fb437c0bbbb5ff14d91859 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 10:06:33 +0700 Subject: [PATCH 03/15] docs: enhance usage instructions and key bindings in help output --- main.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index a90b414..d9ab4f1 100644 --- a/main.go +++ b/main.go @@ -27,16 +27,21 @@ Usage: Options: --claude-dir Custom Claude config directory (default: ~/.claude) + --mock-update Simulate a newer version available (for testing) -h, --help Show help -v, --version Show version Key bindings: - ↑/↓ or j/k Navigate + ↑/↓ or j/k Navigate list space Toggle selection a Select / deselect all - enter Confirm selection - esc Go back - q / ctrl+c Quit + enter Confirm — show delete screen (when items selected) + p Purge selected (confirm screen) + x Force-purge item at cursor — no confirm + r Rescan / refresh project list + u Update claude-cleaner in-place (when update available) + esc Go back / cancel + q / ctrl+c Quit (any screen) Safety: Only session folders inside ~/.claude/projects are deleted. From 33168a84e34689e2ce24917e0ed73d21060ea9d1 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 10:31:38 +0700 Subject: [PATCH 04/15] chore: optimize sleep durations and add environment variables in demo scripts --- demo/cancel.tape | 17 +++++++++++------ demo/full.tape | 25 +++++++++++++++---------- demo/help.tape | 7 ++++++- demo/update.tape | 13 +++++++++---- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/demo/cancel.tape b/demo/cancel.tape index d36b080..2639b4d 100644 --- a/demo/cancel.tape +++ b/demo/cancel.tape @@ -2,32 +2,37 @@ Output demo/cancel.gif Set Theme "Dracula" Set FontSize 16 +Set Width 140 +Set Height 30 Set Framerate 12 Set Margin 0 Set PlaybackSpeed 1 +Env COLORTERM truecolor +Env TERM xterm-256color + Type `PATH="$PWD/demo/mock-bin:$PATH" ./claude-cleaner --claude-dir /tmp/claude-demo` Enter -Sleep 3s +Sleep 1500ms # Skip update prompt if present (n = skip; harmless in list state) Type "n" -Sleep 500ms +Sleep 200ms # Select two items Space Down -Sleep 300ms +Sleep 150ms Space -Sleep 500ms +Sleep 250ms # Proceed to confirm screen Enter -Sleep 800ms +Sleep 400ms # Default is No (safe default) — press Enter to cancel back to list Enter -Sleep 500ms +Sleep 250ms # Quit Type "q" diff --git a/demo/full.tape b/demo/full.tape index 9cf7427..3645017 100644 --- a/demo/full.tape +++ b/demo/full.tape @@ -2,45 +2,50 @@ Output demo/full.gif Set Theme "Dracula" Set FontSize 16 +Set Width 140 +Set Height 30 Set Framerate 12 Set Margin 0 Set PlaybackSpeed 1 +Env COLORTERM truecolor +Env TERM xterm-256color + Type `PATH="$PWD/demo/mock-bin:$PATH" ./claude-cleaner --claude-dir /tmp/claude-demo` Enter -Sleep 3s +Sleep 1500ms # Skip update prompt if present (n = skip; harmless in list state) Type "n" -Sleep 500ms +Sleep 200ms # Pause to show list with token usage and status columns -Sleep 1s +Sleep 500ms # Navigate and select 2 items Down -Sleep 300ms +Sleep 150ms Space Down -Sleep 300ms +Sleep 150ms Space -Sleep 500ms +Sleep 250ms # Proceed to confirm screen Enter -Sleep 800ms +Sleep 400ms # Default is No — move Right to highlight Yes Right -Sleep 600ms +Sleep 300ms # Confirm deletion — watch live progress bar Enter -Sleep 3s +Sleep 1500ms # Done screen — rescan to show r key Type "r" -Sleep 2500ms +Sleep 1200ms # Back at list — quit Type "q" diff --git a/demo/help.tape b/demo/help.tape index 2f679e3..61b180e 100644 --- a/demo/help.tape +++ b/demo/help.tape @@ -2,10 +2,15 @@ Output demo/help.gif Set Theme "Dracula" Set FontSize 16 +Set Width 140 +Set Height 30 Set Framerate 12 Set Margin 0 Set PlaybackSpeed 1 +Env COLORTERM truecolor +Env TERM xterm-256color + Type "./claude-cleaner --help" Enter -Sleep 4s +Sleep 2s diff --git a/demo/update.tape b/demo/update.tape index 10df291..6e8f29e 100644 --- a/demo/update.tape +++ b/demo/update.tape @@ -2,20 +2,25 @@ Output demo/update.gif Set Theme "Dracula" Set FontSize 16 +Set Width 140 +Set Height 30 Set Framerate 12 Set Margin 0 Set PlaybackSpeed 1 +Env COLORTERM truecolor +Env TERM xterm-256color + Type `PATH="$PWD/demo/mock-bin:$PATH" ./claude-cleaner --claude-dir /tmp/claude-demo --mock-update` Enter -Sleep 3s +Sleep 1500ms # Update prompt shown — Yes is highlighted by default -Sleep 1s +Sleep 500ms # Confirm update (Enter with Yes selected) Enter -Sleep 3s +Sleep 1500ms # Updating spinner plays, then app restarts / exits -Sleep 1s +Sleep 500ms From a5177020a7fd21170f0b9417f5930aa32f2eda6f Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 10:37:30 +0700 Subject: [PATCH 05/15] chore: add screenshot.tape for demo GIF generation and update artifact paths --- .github/workflows/demo.yml | 7 +++++-- demo/screenshot.tape | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 demo/screenshot.tape diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml index 043250f..584ab6c 100644 --- a/.github/workflows/demo.yml +++ b/.github/workflows/demo.yml @@ -74,6 +74,7 @@ jobs: vhs demo/full.tape vhs demo/cancel.tape vhs demo/update.tape + vhs demo/screenshot.tape # On PRs: upload as artifact for preview — cannot commit to fork branches - name: Upload GIFs as artifact (PR preview) @@ -82,7 +83,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: demo-gifs - path: demo/*.gif + path: | + demo/*.gif + demo/*.png retention-days: 7 - name: Publish GIFs for inline PR preview @@ -202,4 +205,4 @@ jobs: uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: "chore: update demo GIFs [skip ci]" - file_pattern: demo/*.gif + file_pattern: demo/*.gif demo/*.png diff --git a/demo/screenshot.tape b/demo/screenshot.tape new file mode 100644 index 0000000..d727811 --- /dev/null +++ b/demo/screenshot.tape @@ -0,0 +1,28 @@ +Output demo/screenshot.png + +Set Theme "Dracula" +Set FontSize 16 +Set Width 120 +Set Height 32 +Set Margin 20 +Set MarginFill "#282a36" + +Env COLORTERM truecolor +Env TERM xterm-256color + +Type `PATH="$PWD/demo/mock-bin:$PATH" ./claude-cleaner --claude-dir /tmp/claude-demo` +Enter +Sleep 1500ms + +# Skip update prompt +Type "n" +Sleep 200ms + +# Select a couple of items to show the UI in action +Down +Sleep 100ms +Space +Down +Sleep 100ms +Space +Sleep 300ms From 95643871c757efe706c1d6882c42daeefa92d346 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 10:43:58 +0700 Subject: [PATCH 06/15] chore: remove height settings from demo scripts for consistency --- demo/cancel.tape | 1 - demo/full.tape | 1 - demo/help.tape | 1 - demo/screenshot.tape | 3 +-- demo/update.tape | 1 - 5 files changed, 1 insertion(+), 6 deletions(-) diff --git a/demo/cancel.tape b/demo/cancel.tape index 2639b4d..b1292f7 100644 --- a/demo/cancel.tape +++ b/demo/cancel.tape @@ -3,7 +3,6 @@ Output demo/cancel.gif Set Theme "Dracula" Set FontSize 16 Set Width 140 -Set Height 30 Set Framerate 12 Set Margin 0 Set PlaybackSpeed 1 diff --git a/demo/full.tape b/demo/full.tape index 3645017..d09636b 100644 --- a/demo/full.tape +++ b/demo/full.tape @@ -3,7 +3,6 @@ Output demo/full.gif Set Theme "Dracula" Set FontSize 16 Set Width 140 -Set Height 30 Set Framerate 12 Set Margin 0 Set PlaybackSpeed 1 diff --git a/demo/help.tape b/demo/help.tape index 61b180e..84afdd8 100644 --- a/demo/help.tape +++ b/demo/help.tape @@ -3,7 +3,6 @@ Output demo/help.gif Set Theme "Dracula" Set FontSize 16 Set Width 140 -Set Height 30 Set Framerate 12 Set Margin 0 Set PlaybackSpeed 1 diff --git a/demo/screenshot.tape b/demo/screenshot.tape index d727811..72725e0 100644 --- a/demo/screenshot.tape +++ b/demo/screenshot.tape @@ -2,8 +2,7 @@ Output demo/screenshot.png Set Theme "Dracula" Set FontSize 16 -Set Width 120 -Set Height 32 +Set Width 140 Set Margin 20 Set MarginFill "#282a36" diff --git a/demo/update.tape b/demo/update.tape index 6e8f29e..0eee320 100644 --- a/demo/update.tape +++ b/demo/update.tape @@ -3,7 +3,6 @@ Output demo/update.gif Set Theme "Dracula" Set FontSize 16 Set Width 140 -Set Height 30 Set Framerate 12 Set Margin 0 Set PlaybackSpeed 1 From 65cbe0bb23578a99888f7001cf23dc57c0dae0e6 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 10:47:07 +0700 Subject: [PATCH 07/15] chore: update width setting in demo script for improved layout --- demo/screenshot.tape | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/screenshot.tape b/demo/screenshot.tape index 72725e0..1dfdc5e 100644 --- a/demo/screenshot.tape +++ b/demo/screenshot.tape @@ -2,7 +2,7 @@ Output demo/screenshot.png Set Theme "Dracula" Set FontSize 16 -Set Width 140 +Set Width 180 Set Margin 20 Set MarginFill "#282a36" From ef038f886e0ddceff80bda764c67b0e711a818db Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 10:54:22 +0700 Subject: [PATCH 08/15] chore: remove width settings from demo scripts for consistency --- demo/cancel.tape | 1 - demo/full.tape | 1 - demo/help.tape | 1 - demo/screenshot.tape | 2 +- demo/update.tape | 1 - 5 files changed, 1 insertion(+), 5 deletions(-) diff --git a/demo/cancel.tape b/demo/cancel.tape index b1292f7..dff54d6 100644 --- a/demo/cancel.tape +++ b/demo/cancel.tape @@ -2,7 +2,6 @@ Output demo/cancel.gif Set Theme "Dracula" Set FontSize 16 -Set Width 140 Set Framerate 12 Set Margin 0 Set PlaybackSpeed 1 diff --git a/demo/full.tape b/demo/full.tape index d09636b..cade0b0 100644 --- a/demo/full.tape +++ b/demo/full.tape @@ -2,7 +2,6 @@ Output demo/full.gif Set Theme "Dracula" Set FontSize 16 -Set Width 140 Set Framerate 12 Set Margin 0 Set PlaybackSpeed 1 diff --git a/demo/help.tape b/demo/help.tape index 84afdd8..9cff1e2 100644 --- a/demo/help.tape +++ b/demo/help.tape @@ -2,7 +2,6 @@ Output demo/help.gif Set Theme "Dracula" Set FontSize 16 -Set Width 140 Set Framerate 12 Set Margin 0 Set PlaybackSpeed 1 diff --git a/demo/screenshot.tape b/demo/screenshot.tape index 1dfdc5e..cbb806a 100644 --- a/demo/screenshot.tape +++ b/demo/screenshot.tape @@ -2,7 +2,7 @@ Output demo/screenshot.png Set Theme "Dracula" Set FontSize 16 -Set Width 180 +Set Width 220 Set Margin 20 Set MarginFill "#282a36" diff --git a/demo/update.tape b/demo/update.tape index 0eee320..c562dfb 100644 --- a/demo/update.tape +++ b/demo/update.tape @@ -2,7 +2,6 @@ Output demo/update.gif Set Theme "Dracula" Set FontSize 16 -Set Width 140 Set Framerate 12 Set Margin 0 Set PlaybackSpeed 1 From 9b14177807116697e9380a3223c5505ba9592cdc Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 11:54:46 +0700 Subject: [PATCH 09/15] test: update boundary navigation tests to reflect cursor wrapping behavior --- main.go | 34 +- model.go | 882 ++++++++++++++++++++++++++++++++++++++++++++----- model_test.go | 12 +- preferences.go | 36 ++ scanner.go | 228 +++++++++++++ 5 files changed, 1104 insertions(+), 88 deletions(-) create mode 100644 preferences.go diff --git a/main.go b/main.go index d9ab4f1..3138bde 100644 --- a/main.go +++ b/main.go @@ -27,25 +27,37 @@ Usage: Options: --claude-dir Custom Claude config directory (default: ~/.claude) + --dry-run Preview what would be deleted without touching any files --mock-update Simulate a newer version available (for testing) -h, --help Show help -v, --version Show version Key bindings: ↑/↓ or j/k Navigate list + g / G Jump to top / bottom space Toggle selection - a Select / deselect all + a Select / deselect all (visible) + n Unselect all + o Select all orphaned projects (○) + d Reset sort / filter / search / selection to defaults enter Confirm — show delete screen (when items selected) p Purge selected (confirm screen) x Force-purge item at cursor — no confirm + s Cycle sort: recent → size → tokens → name + f Cycle filter: all → has data → orphaned + e Cycle expiry: off → 7d → 14d → 30d → 60d → 90d + c Open category cleanup (debug logs, telemetry, history…) + / Search by project name / path r Rescan / refresh project list u Update claude-cleaner in-place (when update available) - esc Go back / cancel + ? Show key bindings + esc Go back / clear search / cancel q / ctrl+c Quit (any screen) Safety: Only session folders inside ~/.claude/projects are deleted. Source code directories are never touched. + --dry-run shows exactly what would be deleted without modifying anything. `, version) } @@ -65,7 +77,7 @@ func resolveClaudeDir(dir string) (string, error) { func main() { var claudeDirFlag string - var helpFlag, versionFlag, mockUpdateFlag bool + var helpFlag, versionFlag, mockUpdateFlag, dryRunFlag bool flag.StringVar(&claudeDirFlag, "claude-dir", "", "Custom Claude config directory") flag.BoolVar(&helpFlag, "help", false, "Show help") @@ -73,6 +85,7 @@ func main() { flag.BoolVar(&versionFlag, "version", false, "Show version") flag.BoolVar(&versionFlag, "v", false, "Show version") flag.BoolVar(&mockUpdateFlag, "mock-update", false, "Simulate a newer version available (for testing)") + flag.BoolVar(&dryRunFlag, "dry-run", false, "Preview deletions without modifying any files") flag.Parse() if versionFlag { @@ -106,7 +119,22 @@ func main() { // ~/.claude.json is one level above claudeDir (~/.claude → ~) claudeJSONPath := filepath.Join(filepath.Dir(claudeDir), ".claude.json") + prefs := loadPrefs(claudeDir) + m := newModel(claudeDir, claudeJSONPath, projectsDir) + m.sortMode = sortMode(prefs.SortMode) + m.filterMode = filterMode(prefs.FilterMode) + m.expiryDays = prefs.ExpiryDays + // restore expiryIdx so cycling works correctly + for i, v := range []int{0, 7, 14, 30, 60, 90} { + if v == prefs.ExpiryDays { + m.expiryIdx = i + break + } + } + if dryRunFlag { + m.dryRun = true + } if mockUpdateFlag { m.latestVersion = "99.0.0" m.hasUpdate = true diff --git a/model.go b/model.go index d898852..8614c03 100644 --- a/model.go +++ b/model.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os/exec" + "sort" "strings" "time" @@ -28,9 +29,9 @@ var ( BorderForeground(clrPurple). Padding(0, 2) - clrBg = lipgloss.Color("#282A36") - clrSelection = lipgloss.Color("#44475A") - clrCursor = lipgloss.Color("#6272A4") + clrBg = lipgloss.Color("#282A36") + clrSelection = lipgloss.Color("#44475A") + clrCursor = lipgloss.Color("#6272A4") dimStyle = lipgloss.NewStyle().Foreground(clrComment) nameStyle = lipgloss.NewStyle().Foreground(clrFg) @@ -56,6 +57,51 @@ var ( PaddingTop(1) ) +// ── Sort / filter modes ────────────────────────────────────────────────────── + +type sortMode int + +const ( + sortRecent sortMode = iota + sortSize + sortTokens + sortName +) + +func (s sortMode) label() string { + switch s { + case sortSize: + return "size ↓" + case sortTokens: + return "tokens ↓" + case sortName: + return "name A–Z" + default: + return "recent" + } +} + +type filterMode int + +const ( + filterAll filterMode = iota + filterHasData + filterOrphaned +) + +func (f filterMode) label() string { + switch f { + case filterHasData: + return "has data" + case filterOrphaned: + return "orphaned" + default: + return "all" + } +} + +// ── App state ──────────────────────────────────────────────────────────────── + type appState int const ( @@ -66,6 +112,8 @@ const ( stateConfirm stateDeleting stateDone + stateCategories + stateCategoryConfirm ) const bannerLogo = ` ██████╗██╗ █████╗ ██╗ ██╗██████╗ ███████╗ @@ -76,6 +124,8 @@ const bannerLogo = ` ██████╗██╗ █████╗ █ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝ C L E A N E R` +// ── Messages ───────────────────────────────────────────────────────────────── + type sessionsLoadedMsg struct { sessions []Session err error @@ -106,6 +156,15 @@ type deleteItemMsg struct { nextIdx int } +type categoriesLoadedMsg struct{ categories []Category } + +type categoryCleanDoneMsg struct { + cleaned []string + failed []string +} + +// ── Model ──────────────────────────────────────────────────────────────────── + type model struct { state appState claudeDir string @@ -116,25 +175,44 @@ type model struct { cursor int spinner spinner.Model confirmIdx int // 0 = No (default), 1 = Yes - purgeMode bool // true = full purge via claude CLI, false = session files only + purgeMode bool // true = full purge via claude CLI deleted []string failed []string width int deleteTotal int deleteProgress int deleteSelectedSnap map[int]bool - claudeCLIVersion string // "" = not yet detected + claudeCLIVersion string claudeCLIDetected bool - latestVersion string - hasUpdate bool - updateChecked bool - pendingUpdatePrompt bool // update arrived before sessions loaded - sessionsReady bool // sessions loaded flag - lastScanTime time.Time - rescanning bool // manual rescan in progress — keep list visible - skipUpdateCheck bool // true when --mock-update; ignore real npm check - updatePromptIdx int // 0 = Yes (default), 1 = No - restartAfterUpdate bool + latestVersion string + hasUpdate bool + updateChecked bool + pendingUpdatePrompt bool + sessionsReady bool + lastScanTime time.Time + rescanning bool + skipUpdateCheck bool + updatePromptIdx int + restartAfterUpdate bool + + // sort / filter / search / help + sortMode sortMode + filterMode filterMode + searchQuery string + searching bool + showHelp bool + + // expiry threshold (0 = no filter) + expiryDays int + expiryIdx int // index into expiryOptions + + // category cleanup + categories []Category + categorySelected map[string]bool + categoryCursor int + categoryMode bool // true when done screen shows category cleanup result + + dryRun bool // --dry-run: simulate deletions without touching files } func newModel(claudeDir, claudeJSONPath, projectsDir string) model { @@ -143,16 +221,74 @@ func newModel(claudeDir, claudeJSONPath, projectsDir string) model { sp.Style = lipgloss.NewStyle().Foreground(clrPurple) return model{ - state: stateLoading, - claudeDir: claudeDir, - claudeJSONPath: claudeJSONPath, - projectsDir: projectsDir, - selected: make(map[int]bool), - spinner: sp, + state: stateLoading, + claudeDir: claudeDir, + claudeJSONPath: claudeJSONPath, + projectsDir: projectsDir, + selected: make(map[int]bool), + categorySelected: make(map[string]bool), + spinner: sp, } } +// filteredSessions returns sessions after applying filter, search, and sort. +func (m model) filteredSessions() []Session { + result := make([]Session, 0, len(m.sessions)) + q := strings.ToLower(m.searchQuery) + + for _, s := range m.sessions { + switch m.filterMode { + case filterHasData: + if !s.HasData { + continue + } + case filterOrphaned: + if s.HasData { + continue + } + } + if q != "" { + name := strings.ToLower(s.Name) + path := strings.ToLower(s.ProjectPath) + if !strings.Contains(name, q) && !strings.Contains(path, q) { + continue + } + } + if m.expiryDays > 0 && s.HasData { + cutoff := time.Now().AddDate(0, 0, -m.expiryDays) + if s.Modified.After(cutoff) { + continue // too recent + } + } + result = append(result, s) + } + + switch m.sortMode { + case sortSize: + sort.Slice(result, func(i, j int) bool { return result[i].Size > result[j].Size }) + case sortTokens: + sort.Slice(result, func(i, j int) bool { return result[i].TotalTokens > result[j].TotalTokens }) + case sortName: + sort.Slice(result, func(i, j int) bool { + ni := result[i].ProjectPath + if ni == "" { + ni = result[i].Name + } + nj := result[j].ProjectPath + if nj == "" { + nj = result[j].Name + } + return strings.ToLower(ni) < strings.ToLower(nj) + }) + } + + return result +} + +// ── Init ───────────────────────────────────────────────────────────────────── + func (m model) Init() tea.Cmd { + claudeDir := m.claudeDir return tea.Batch( m.spinner.Tick, func() tea.Msg { @@ -166,9 +302,14 @@ func (m model) Init() tea.Cmd { latest, hasUpdate := CheckLatestVersion(version) return updateCheckMsg{latest, hasUpdate} }, + func() tea.Msg { + return categoriesLoadedMsg{scanCategories(claudeDir)} + }, ) } +// ── Update ─────────────────────────────────────────────────────────────────── + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { @@ -178,6 +319,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: if msg.Type == tea.KeyCtrlC || msg.String() == "q" { + if m.searching { + // q inside search = add to query + break + } + m.persistPrefs() return m, tea.Quit } if m.state == stateUpdatePrompt { @@ -186,6 +332,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.state == stateConfirm { return m.handleConfirmKey(msg) } + if m.state == stateCategories { + return m.handleCategoryKey(msg) + } + if m.state == stateCategoryConfirm { + return m.handleCategoryConfirmKey(msg) + } return m.handleListKey(msg) case sessionsLoadedMsg: @@ -198,7 +350,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.rescanning = false m.lastScanTime = time.Now() if wasRescanning { - // manual rescan: reset cursor/selection, go straight to list m.cursor = 0 m.selected = make(map[int]bool) m.state = stateList @@ -235,7 +386,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case updateCheckMsg: if m.skipUpdateCheck { - return m, nil // mock mode — ignore real npm check + return m, nil } m.latestVersion = msg.latest m.hasUpdate = msg.hasUpdate @@ -255,9 +406,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Quit + case categoriesLoadedMsg: + m.categories = msg.categories + return m, nil + + case categoryCleanDoneMsg: + m.deleted = msg.cleaned + m.failed = msg.failed + m.categoryMode = true + m.state = stateDone + m.categorySelected = make(map[string]bool) + return m, nil + case deleteDoneMsg: m.deleted = msg.deleted m.failed = msg.failed + m.categoryMode = false m.state = stateDone m.selected = make(map[int]bool) m.deleteSelectedSnap = nil @@ -272,16 +436,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } - return m, nil } +// ── Key handlers ───────────────────────────────────────────────────────────── + func (m model) handleUpdatePromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "left", "h", "tab": - m.updatePromptIdx = 0 // Yes + m.updatePromptIdx = 0 case "right", "l": - m.updatePromptIdx = 1 // No + m.updatePromptIdx = 1 case "y", "Y": m.updatePromptIdx = 0 return m.doUpdate() @@ -307,7 +472,7 @@ func (m model) doUpdate() (tea.Model, tea.Cmd) { m.spinner.Tick, func() tea.Msg { if isMock { - time.Sleep(2 * time.Second) // simulate npm install duration + time.Sleep(2 * time.Second) return updateDoneMsg{nil} } prepareWindowsUpdate() @@ -317,55 +482,141 @@ func (m model) doUpdate() (tea.Model, tea.Cmd) { ) } -func (m model) viewUpdatePrompt() string { - var sb strings.Builder - sb.WriteString("\n") - sb.WriteString(" " + lipgloss.NewStyle().Foreground(clrGreen).Bold(true).Render("⬆ New version available: v"+m.latestVersion) + "\n\n") - sb.WriteString(" " + dimStyle.Render("Current: v"+version) + "\n\n") - sb.WriteString(" Update now via " + lipgloss.NewStyle().Foreground(clrCyan).Render("npm install -g claude-cleaner@latest") + "?\n\n") - - yes := dimStyle.Render("[ Y ] Yes, update now") - no := dimStyle.Render("[ N ] No, skip") - if m.updatePromptIdx == 0 { - yes = lipgloss.NewStyle().Foreground(clrGreen).Bold(true).Render("[ Y ] Yes, update now") - } else { - no = lipgloss.NewStyle().Foreground(clrFg).Bold(true).Render("[ N ] No, skip") +func (m model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Search mode: intercept all printable input + if m.searching { + switch msg.String() { + case "esc": + m.searching = false + m.searchQuery = "" + m.cursor = 0 + case "enter": + m.searching = false + case "backspace", "ctrl+h": + if len([]rune(m.searchQuery)) > 0 { + runes := []rune(m.searchQuery) + m.searchQuery = string(runes[:len(runes)-1]) + m.cursor = 0 + } + default: + if len(msg.Runes) > 0 { + m.searchQuery += string(msg.Runes) + m.cursor = 0 + } + } + return m, nil } - sb.WriteString(" " + yes + " " + no + "\n\n") - sb.WriteString(" " + dimStyle.Render("←/→ select enter confirm y yes n/esc skip")) - return sb.String() -} -func (m model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - n := len(m.sessions) + sessions := m.filteredSessions() + n := len(sessions) switch msg.String() { case "up", "k": if m.cursor > 0 { m.cursor-- + } else if n > 0 { + m.cursor = n - 1 // wrap to bottom } case "down", "j": if m.cursor < n-1 { m.cursor++ + } else { + m.cursor = 0 // wrap to top + } + + case "g": + m.cursor = 0 + + case "G": + if n > 0 { + m.cursor = n - 1 + } + + case "s": + m.sortMode = (m.sortMode + 1) % 4 + m.cursor = 0 + m.persistPrefs() + + case "f": + m.filterMode = (m.filterMode + 1) % 3 + m.cursor = 0 + m.persistPrefs() + + case "e": + expiryOptions := []int{0, 7, 14, 30, 60, 90} + m.expiryIdx = (m.expiryIdx + 1) % len(expiryOptions) + m.expiryDays = expiryOptions[m.expiryIdx] + m.cursor = 0 + m.persistPrefs() + + case "c": + // Open category cleanup screen; rescan categories on entry + claudeDir := m.claudeDir + m.state = stateCategories + m.categoryCursor = 0 + return m, tea.Batch( + m.spinner.Tick, + func() tea.Msg { + return categoriesLoadedMsg{scanCategories(claudeDir)} + }, + ) + + case "/": + m.searching = true + m.searchQuery = "" + m.cursor = 0 + + case "?": + m.showHelp = !m.showHelp + + case "esc": + if m.searchQuery != "" || m.filterMode != filterAll || m.sortMode != sortRecent { + m.searchQuery = "" + m.filterMode = filterAll + m.sortMode = sortRecent + m.cursor = 0 + } else { + m.showHelp = false } case "a": allOn := n > 0 - for _, s := range m.sessions { + for _, s := range sessions { if !m.selected[s.Index] { allOn = false break } } - for _, s := range m.sessions { + for _, s := range sessions { m.selected[s.Index] = !allOn } + case "n": + // Unselect all + m.selected = make(map[int]bool) + + case "o": + // Select orphaned projects only (○ = no local data) + m.selected = make(map[int]bool) + for _, s := range m.sessions { + if !s.HasData { + m.selected[s.Index] = true + } + } + + case "d": + // Reset everything to defaults + m.sortMode = sortRecent + m.filterMode = filterAll + m.searchQuery = "" + m.searching = false + m.selected = make(map[int]bool) + m.cursor = 0 + case " ": - // Toggle selection on current row - if n > 0 { - idx := m.sessions[m.cursor].Index + if n > 0 && m.cursor < n { + idx := sessions[m.cursor].Index m.selected[idx] = !m.selected[idx] } return m, nil @@ -374,6 +625,7 @@ func (m model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.state == stateDone { m.deleted = nil m.failed = nil + m.categoryMode = false return m.doRescan() } count := 0 @@ -386,7 +638,6 @@ func (m model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.purgeMode = false m.state = stateConfirm m.confirmIdx = 0 - return m, nil } return m, nil @@ -401,16 +652,14 @@ func (m model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.purgeMode = true m.state = stateConfirm m.confirmIdx = 0 - return m, nil } case "r", "R": return m.doRescan() case "x", "X": - // Force purge project at cursor — no confirm screen, single project only. - if n > 0 { - return m.doPurgeDirect(m.sessions[m.cursor]) + if n > 0 && m.cursor < n { + return m.doPurgeDirect(sessions[m.cursor]) } case "u", "U": @@ -464,6 +713,151 @@ func (m model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// ── Category key handlers ───────────────────────────────────────────────────── + +func (m model) handleCategoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + n := len(m.categories) + switch msg.String() { + case "up", "k": + if m.categoryCursor > 0 { + m.categoryCursor-- + } else if n > 0 { + m.categoryCursor = n - 1 + } + case "down", "j": + if m.categoryCursor < n-1 { + m.categoryCursor++ + } else { + m.categoryCursor = 0 + } + case "g": + m.categoryCursor = 0 + case "G": + if n > 0 { + m.categoryCursor = n - 1 + } + case " ": + if n > 0 && m.categoryCursor < n { + cat := m.categories[m.categoryCursor] + if cat.Exists { + m.categorySelected[cat.Key] = !m.categorySelected[cat.Key] + } + } + case "a": + allOn := true + for _, cat := range m.categories { + if cat.Exists && !m.categorySelected[cat.Key] { + allOn = false + break + } + } + for _, cat := range m.categories { + if cat.Exists { + m.categorySelected[cat.Key] = !allOn + } + } + case "n": + m.categorySelected = make(map[string]bool) + case "enter": + count := 0 + for _, cat := range m.categories { + if m.categorySelected[cat.Key] { + count++ + } + } + if count > 0 { + m.state = stateCategoryConfirm + m.confirmIdx = 0 + } + case "esc": + m.state = stateList + m.categorySelected = make(map[string]bool) + } + return m, nil +} + +func (m model) handleCategoryConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "n": + m.state = stateCategories + m.confirmIdx = 0 + return m, nil + case "left", "h", "tab": + m.confirmIdx = 0 + case "right", "l": + m.confirmIdx = 1 + case "y": + m.confirmIdx = 1 + return m.doCategoryClean() + case "enter": + if m.confirmIdx == 1 { + return m.doCategoryClean() + } + m.state = stateCategories + m.confirmIdx = 0 + return m, nil + } + return m, nil +} + +// ── Category clean action ───────────────────────────────────────────────────── + +func (m model) doCategoryClean() (tea.Model, tea.Cmd) { + if m.dryRun { + var cleaned []string + for _, cat := range m.categories { + if m.categorySelected[cat.Key] { + cleaned = append(cleaned, cat.Label) + } + } + m.categoryMode = true + m.state = stateDone + m.deleted = cleaned + m.failed = nil + m.categorySelected = make(map[string]bool) + return m, nil + } + + m.state = stateDeleting + selected := make(map[string]bool, len(m.categorySelected)) + for k, v := range m.categorySelected { + selected[k] = v + } + cats := make([]Category, len(m.categories)) + copy(cats, m.categories) + claudeDir := m.claudeDir + + return m, tea.Batch( + m.spinner.Tick, + func() tea.Msg { + var cleaned, failed []string + for _, cat := range cats { + if !selected[cat.Key] { + continue + } + if err := cleanCategory(cat, claudeDir); err != nil { + failed = append(failed, cat.Label) + } else { + cleaned = append(cleaned, cat.Label) + } + } + return categoryCleanDoneMsg{cleaned, failed} + }, + ) +} + +// ── Preferences ─────────────────────────────────────────────────────────────── + +func (m model) persistPrefs() { + writePrefs(m.claudeDir, Preferences{ + SortMode: int(m.sortMode), + FilterMode: int(m.filterMode), + ExpiryDays: m.expiryDays, + }) +} + +// ── Actions ─────────────────────────────────────────────────────────────────── + func (m model) doRescan() (tea.Model, tea.Cmd) { m.sessionsReady = false m.pendingUpdatePrompt = false @@ -471,20 +865,17 @@ func (m model) doRescan() (tea.Model, tea.Cmd) { projectsDir := m.projectsDir if m.state == stateList { - // Keep list visible, overlay spinner. Bundle with CLI detect so - // goroutine takes ~100-200ms (subprocess) — long enough to see. m.rescanning = true return m, tea.Batch( m.spinner.Tick, func() tea.Msg { sessions, err := scanSessions(claudeJSONPath, projectsDir) - cliVersion := DetectClaudeCLI() // adds real latency + cliVersion := DetectClaudeCLI() return rescanDoneMsg{sessions, err, cliVersion} }, ) } - // stateDone or other: full loading screen m.state = stateLoading m.cursor = 0 m.selected = make(map[int]bool) @@ -498,6 +889,28 @@ func (m model) doRescan() (tea.Model, tea.Cmd) { } func (m model) doDelete() (tea.Model, tea.Cmd) { + // Dry run: simulate without touching any files + if m.dryRun { + var deleted []string + for _, s := range m.sessions { + if m.selected[s.Index] { + name := s.Name + if s.ProjectPath != "" { + name = s.ProjectPath + } + deleted = append(deleted, name) + } + } + m.state = stateDone + m.deleted = deleted + m.failed = nil + m.selected = make(map[int]bool) + m.deleteTotal = 0 + m.deleteProgress = 0 + m.purgeMode = false + return m, nil + } + m.state = stateDeleting snap := make(map[int]bool, len(m.selected)) @@ -515,7 +928,6 @@ func (m model) doDelete() (tea.Model, tea.Cmd) { m.deleteProgress = 0 m.deleteSelectedSnap = snap - // All selected: use RunDelete (--all optimization, single shot) allSelected := total == len(m.sessions) && total > 0 if allSelected { sessions := m.sessions @@ -529,15 +941,12 @@ func (m model) doDelete() (tea.Model, tea.Cmd) { ) } - // Partial selection: per-item sequential cmd for live progress bar. return m, tea.Batch( m.spinner.Tick, nextDeleteCmd(m.sessions, snap, 0, 0, total, nil, nil, m.projectsDir), ) } -// nextDeleteCmd processes one selected session starting from startIdx and -// returns either a deleteItemMsg (more items remain) or deleteDoneMsg (all done). func nextDeleteCmd(sessions []Session, selected map[int]bool, startIdx, done, total int, deleted, failed []string, projectsDir string) tea.Cmd { return func() tea.Msg { for i := startIdx; i < len(sessions); i++ { @@ -572,6 +981,17 @@ func (m model) doPurge() (tea.Model, tea.Cmd) { } func (m model) doPurgeDirect(s Session) (tea.Model, tea.Cmd) { + if m.dryRun { + name := s.Name + if s.ProjectPath != "" { + name = s.ProjectPath + } + m.state = stateDone + m.deleted = []string{name} + m.failed = nil + return m, nil + } + m.state = stateDeleting projectsDir := m.projectsDir return m, tea.Batch( @@ -585,9 +1005,15 @@ func (m model) doPurgeDirect(s Session) (tea.Model, tea.Cmd) { ) } +// ── Views ───────────────────────────────────────────────────────────────────── + func (m model) View() string { header := m.renderHeader() + if m.showHelp { + return header + m.viewHelp() + } + var body string switch m.state { case stateLoading: @@ -606,18 +1032,87 @@ func (m model) View() string { body = m.viewDeleting() case stateDone: body = m.viewDone() + case stateCategories: + body = m.viewCategories() + case stateCategoryConfirm: + body = m.viewCategoryConfirm() } return header + body } +func (m model) viewHelp() string { + cyan := func(s string) string { return lipgloss.NewStyle().Foreground(clrCyan).Bold(true).Render(s) } + dim := func(s string) string { return dimStyle.Render(s) } + + row := func(keys, desc string) string { + return fmt.Sprintf(" %-24s %s\n", cyan(keys), dim(desc)) + } + + var sb strings.Builder + sb.WriteString("\n") + sb.WriteString(" " + lipgloss.NewStyle().Foreground(clrPurple).Bold(true).Render("Key bindings") + "\n") + sb.WriteString(" " + dimStyle.Render(strings.Repeat("─", 44)) + "\n\n") + + sb.WriteString(row("↑/↓ j/k", "Navigate list")) + sb.WriteString(row("g / G", "Jump to top / bottom")) + sb.WriteString(row("space", "Toggle selection")) + sb.WriteString(row("a", "Select / deselect all (visible)")) + sb.WriteString(row("n", "Unselect all")) + sb.WriteString(row("o", "Select all orphaned projects (○)")) + sb.WriteString(row("d", "Reset sort / filter / search / selection")) + sb.WriteString(row("enter", "Confirm delete (when items selected)")) + sb.WriteString(row("p", "Purge mode (full claude project purge)")) + sb.WriteString(row("x", "Force-purge at cursor — no confirm")) + sb.WriteString("\n") + sb.WriteString(row("s", "Cycle sort: recent → size → tokens → name")) + sb.WriteString(row("f", "Cycle filter: all → has data → orphaned")) + sb.WriteString(row("e", "Cycle expiry: off → 7d → 14d → 30d → 60d → 90d")) + sb.WriteString(row("/", "Search by project name / path")) + sb.WriteString(row("c", "Open category cleanup (debug, telemetry, history…)")) + sb.WriteString(row("esc", "Clear search / filter / sort (or close help)")) + sb.WriteString("\n") + sb.WriteString(row("r", "Rescan / refresh project list")) + sb.WriteString(row("u", "Update claude-cleaner in-place")) + sb.WriteString(row("?", "Toggle this help")) + sb.WriteString(row("q / ctrl+c", "Quit")) + + sb.WriteString("\n " + dimStyle.Render("Press ? or esc to close")) + return sb.String() +} + +func (m model) viewUpdatePrompt() string { + var sb strings.Builder + sb.WriteString("\n") + sb.WriteString(" " + lipgloss.NewStyle().Foreground(clrGreen).Bold(true).Render("⬆ New version available: v"+m.latestVersion) + "\n\n") + sb.WriteString(" " + dimStyle.Render("Current: v"+version) + "\n\n") + sb.WriteString(" Update now via " + lipgloss.NewStyle().Foreground(clrCyan).Render("npm install -g claude-cleaner@latest") + "?\n\n") + + yes := dimStyle.Render("[ Y ] Yes, update now") + no := dimStyle.Render("[ N ] No, skip") + if m.updatePromptIdx == 0 { + yes = lipgloss.NewStyle().Foreground(clrGreen).Bold(true).Render("[ Y ] Yes, update now") + } else { + no = lipgloss.NewStyle().Foreground(clrFg).Bold(true).Render("[ N ] No, skip") + } + sb.WriteString(" " + yes + " " + no + "\n\n") + sb.WriteString(" " + dimStyle.Render("←/→ select enter confirm y yes n/esc skip")) + return sb.String() +} + func (m model) viewList() string { - if len(m.sessions) == 0 { + sessions := m.filteredSessions() + + if len(sessions) == 0 { if m.rescanning { return "\n " + m.spinner.View() + " Rescanning…\n" } - return "\n " + dimStyle.Render("No Claude project sessions found.") + "\n" + - "\n " + dimStyle.Render("r rescan q quit") + msg := "No Claude project sessions found." + if m.searchQuery != "" || m.filterMode != filterAll { + msg = "No sessions match current filter / search." + } + return "\n " + dimStyle.Render(msg) + "\n" + + "\n " + dimStyle.Render("esc clear filter r rescan q quit") } const ( @@ -635,7 +1130,27 @@ func (m model) viewList() string { lipgloss.NewStyle().Foreground(clrPurple).Render("Rescanning…") + "\n\n") } - // Header row + // Sort / filter / search / expiry status bar + var statusParts []string + if m.sortMode != sortRecent { + statusParts = append(statusParts, "sort: "+m.sortMode.label()) + } + if m.filterMode != filterAll { + statusParts = append(statusParts, "filter: "+m.filterMode.label()) + } + if m.searching { + statusParts = append(statusParts, "search: "+m.searchQuery+"▌") + } else if m.searchQuery != "" { + statusParts = append(statusParts, "search: "+m.searchQuery) + } + if m.expiryDays > 0 { + statusParts = append(statusParts, fmt.Sprintf("expiry: >%dd", m.expiryDays)) + } + if len(statusParts) > 0 { + sb.WriteString(" " + lipgloss.NewStyle().Foreground(clrCyan).Render(strings.Join(statusParts, " ")) + "\n\n") + } + + // Column header sb.WriteString(dimStyle.Render(fmt.Sprintf(" %-*s %-*s %-*s %s", nameW, "Name", timeW, "Last modified", @@ -649,8 +1164,8 @@ func (m model) viewList() string { rowW = 82 } - for _, s := range m.sessions { - isCursor := m.cursor == s.Index-1 + for i, s := range sessions { + isCursor := m.cursor == i isSelected := m.selected[s.Index] var bg lipgloss.Color @@ -677,7 +1192,6 @@ func (m model) viewList() string { check = lipgloss.NewStyle().Foreground(clrGreen).Background(bg).Bold(true).Render("[✓]") } - // ● green = has session data ○ dim = no local data (can still purge config) var status string if s.HasData { status = lipgloss.NewStyle().Foreground(clrGreen).Background(bg).Render("●") @@ -718,16 +1232,39 @@ func (m model) viewList() string { sb.WriteString(rowStyle.Width(rowW).Render(content) + "\n") } + // Selection summary with size + tokens selected := 0 - for _, v := range m.selected { - if v { + var selSize int64 + var selTokens int64 + var anySelTokens bool + for _, s := range m.sessions { + if m.selected[s.Index] { selected++ + selSize += s.Size + if s.HasTokenData { + selTokens += s.TotalTokens + anySelTokens = true + } } } + selInfo := countStyle.Render(fmt.Sprintf("%d", selected)) + " selected" + if selected > 0 { + if selSize > 0 { + selInfo += " • " + lipgloss.NewStyle().Foreground(clrCyan).Render(formatSize(selSize)) + } + if anySelTokens { + selInfo += " • " + lipgloss.NewStyle().Foreground(clrPurple).Render(formatTokens(selTokens)+" tok") + } + } + + expiryLabel := "off" + if m.expiryDays > 0 { + expiryLabel = fmt.Sprintf("%dd", m.expiryDays) + } footer := fmt.Sprintf( - "↑/↓ navigate space select a select all enter delete p purge x force-purge q quit %s selected", - countStyle.Render(fmt.Sprintf("%d", selected)), + "↑/↓ navigate space select a all enter delete s sort f filter e expiry:%s c categories / search ? help q quit %s", + expiryLabel, selInfo, ) sb.WriteString(helpStyle.Render(footer)) @@ -827,15 +1364,172 @@ func (m model) viewDone() string { } if len(m.deleted) > 0 { - sb.WriteString(fmt.Sprintf("\n %s\n", - successStyle.Render(fmt.Sprintf("%d session(s) deleted", len(m.deleted))), - )) + var label string + if m.categoryMode { + label = fmt.Sprintf("%d category/categories cleaned", len(m.deleted)) + if m.dryRun { + label = fmt.Sprintf("%d category/categories would be cleaned (dry run — nothing was modified)", len(m.deleted)) + } + } else { + label = fmt.Sprintf("%d session(s) deleted", len(m.deleted)) + if m.dryRun { + label = fmt.Sprintf("%d session(s) would be deleted (dry run — nothing was modified)", len(m.deleted)) + } + } + sb.WriteString(fmt.Sprintf("\n %s\n", successStyle.Render(label))) } sb.WriteString("\n " + dimStyle.Render("enter back to list q quit")) return sb.String() } +func (m model) viewCategories() string { + var sb strings.Builder + sb.WriteString("\n") + sb.WriteString(" " + lipgloss.NewStyle().Foreground(clrPurple).Bold(true).Render("Category Cleanup") + "\n") + sb.WriteString(" " + dimStyle.Render(strings.Repeat("─", 60)) + "\n\n") + + if len(m.categories) == 0 { + sb.WriteString(" " + m.spinner.View() + " Scanning…\n") + return sb.String() + } + + rowW := m.width + if rowW < 82 { + rowW = 82 + } + + for i, cat := range m.categories { + isCursor := m.categoryCursor == i + isSelected := m.categorySelected[cat.Key] + + var bg lipgloss.Color + var rowStyle lipgloss.Style + switch { + case isCursor: + bg = clrCursor + rowStyle = rowCursorStyle + case isSelected: + bg = clrSelection + rowStyle = rowSelectedStyle + default: + bg = clrBg + rowStyle = rowNormalStyle + } + + cur := lipgloss.NewStyle().Background(bg).Render(" ") + if isCursor { + cur = lipgloss.NewStyle().Foreground(clrPurple).Background(bg).Bold(true).Render("▶ ") + } + + check := lipgloss.NewStyle().Foreground(clrComment).Background(bg).Render("[ ]") + if isSelected { + check = lipgloss.NewStyle().Foreground(clrGreen).Background(bg).Bold(true).Render("[✓]") + } + + status := lipgloss.NewStyle().Foreground(clrComment).Background(bg).Render("○") + if cat.Exists { + status = lipgloss.NewStyle().Foreground(clrGreen).Background(bg).Render("●") + } + + label := lipgloss.NewStyle().Foreground(clrFg).Background(bg).Width(42).Render(truncate(cat.Label, 42)) + + var info string + switch cat.Key { + case "json-orphans": + if cat.FileCount > 0 { + info = lipgloss.NewStyle().Foreground(clrCyan).Background(bg).Render( + fmt.Sprintf("%d orphan entries", cat.FileCount)) + } else { + info = dimStyle.Render("clean") + } + case "history-trim": + if cat.Exists { + info = lipgloss.NewStyle().Foreground(clrCyan).Background(bg).Render(formatSize(cat.Size)) + } else { + info = dimStyle.Render("not found") + } + default: + if cat.Exists && (cat.Size > 0 || cat.FileCount > 0) { + sizeStr := formatSize(cat.Size) + if cat.FileCount > 0 { + info = lipgloss.NewStyle().Foreground(clrCyan).Background(bg).Render( + fmt.Sprintf("%-10s (%d files)", sizeStr, cat.FileCount)) + } else { + info = lipgloss.NewStyle().Foreground(clrCyan).Background(bg).Render(sizeStr) + } + } else { + info = dimStyle.Render("empty") + } + } + + content := cur + check + " " + status + " " + label + " " + info + sb.WriteString(rowStyle.Width(rowW).Render(content) + "\n") + } + + // Total selected size + var selSize int64 + selCount := 0 + for _, cat := range m.categories { + if m.categorySelected[cat.Key] { + selCount++ + selSize += cat.Size + } + } + selInfo := countStyle.Render(fmt.Sprintf("%d", selCount)) + " selected" + if selCount > 0 && selSize > 0 { + selInfo += " • " + lipgloss.NewStyle().Foreground(clrCyan).Render(formatSize(selSize)) + } + + footer := fmt.Sprintf("↑/↓ navigate space select a all n unselect enter clean selected esc back to projects %s", selInfo) + sb.WriteString(helpStyle.Render(footer)) + return sb.String() +} + +func (m model) viewCategoryConfirm() string { + var sb strings.Builder + sb.WriteString("\n") + sb.WriteString(" " + dangerStyle.Render("⚠ Will clean the following categories:") + "\n\n") + + var totalSize int64 + for _, cat := range m.categories { + if !m.categorySelected[cat.Key] { + continue + } + totalSize += cat.Size + var detail string + switch cat.Key { + case "json-orphans": + detail = fmt.Sprintf("%d orphan entries", cat.FileCount) + case "history-trim": + detail = fmt.Sprintf("trim to 500 lines (%s)", formatSize(cat.Size)) + default: + detail = formatSize(cat.Size) + } + sb.WriteString(fmt.Sprintf(" %s %s %s\n", + checkOnStyle.Render("✓"), + nameStyle.Render(truncate(cat.Label, 44)), + sizeStyle.Render(detail), + )) + } + + if totalSize > 0 { + sb.WriteString(fmt.Sprintf("\n Total: %s\n", sizeStyle.Render(formatSize(totalSize)))) + } + sb.WriteString("\n " + dimStyle.Render("Original source code and settings.json are NOT affected.") + "\n\n") + + no := dimStyle.Render("[ N ] No, cancel") + yes := dimStyle.Render("[ Y ] Yes, clean") + if m.confirmIdx == 0 { + no = lipgloss.NewStyle().Foreground(clrFg).Bold(true).Render("[ N ] No, cancel") + } else { + yes = lipgloss.NewStyle().Foreground(clrRed).Bold(true).Render("[ Y ] Yes, clean") + } + sb.WriteString(" " + no + " " + yes + "\n\n") + sb.WriteString(" " + dimStyle.Render("←/→ or y/n select enter confirm esc back")) + return sb.String() +} + func (m model) renderHeader() string { logoLines := strings.Split(bannerLogo, "\n") art := strings.Join(logoLines[:6], "\n") @@ -854,6 +1548,7 @@ func (m model) renderHeader() string { divider := lipgloss.NewStyle().Foreground(clrPurple).Render(strings.Repeat("─", 36)) dirLine := label("Dir") + lipgloss.NewStyle().Foreground(clrFg).Render(m.claudeDir) webLine := label("Web") + lipgloss.NewStyle().Foreground(clrCyan).Render("https://eplus.dev") + var verBadge string switch { case !m.updateChecked: @@ -883,7 +1578,36 @@ func (m model) renderHeader() string { } scanLine := label("Scanned") + lipgloss.NewStyle().Foreground(clrComment).Render(scanLabel) - info := strings.Join([]string{title, divider, dirLine, webLine, claudeLine, verLine, scanLine}, "\n") + // Total stats across all projects + var totalSize int64 + var totalTokens int64 + var anyTok bool + for _, s := range m.sessions { + totalSize += s.Size + if s.HasTokenData { + totalTokens += s.TotalTokens + anyTok = true + } + } + totalTokStr := "—" + if anyTok { + totalTokStr = formatTokens(totalTokens) + } + statsLine := label("Projects") + lipgloss.NewStyle().Foreground(clrFg).Render( + fmt.Sprintf("%d • %s • %s tokens", len(m.sessions), formatSize(totalSize), totalTokStr), + ) + + lines := []string{title, divider, dirLine, webLine, claudeLine, verLine, scanLine, statsLine} + if m.dryRun { + dryBadge := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFB86C")).Bold(true). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#FFB86C")). + Padding(0, 1). + Render("DRY RUN — no files will be modified") + lines = append(lines, dryBadge) + } + info := strings.Join(lines, "\n") infoPanel := lipgloss.NewStyle().Padding(0, 2).Render(info) if m.width > 0 && m.width < 90 { diff --git a/model_test.go b/model_test.go index 1d17dfe..0273df9 100644 --- a/model_test.go +++ b/model_test.go @@ -103,18 +103,18 @@ func TestNavigateDown(t *testing.T) { func TestNavigateUpBoundary(t *testing.T) { m := makeTestModel(fakeSessions(3)) - m = pressKey(m, "up") - if m.cursor != 0 { - t.Errorf("cursor should stay 0, got %d", m.cursor) + m = pressKey(m, "up") // wraps to last item + if m.cursor != 2 { + t.Errorf("cursor should wrap to 2, got %d", m.cursor) } } func TestNavigateDownBoundary(t *testing.T) { m := makeTestModel(fakeSessions(3)) m.cursor = 2 - m = pressKey(m, "down") - if m.cursor != 2 { - t.Errorf("cursor should stay 2, got %d", m.cursor) + m = pressKey(m, "down") // wraps to first item + if m.cursor != 0 { + t.Errorf("cursor should wrap to 0, got %d", m.cursor) } } diff --git a/preferences.go b/preferences.go new file mode 100644 index 0000000..401ace8 --- /dev/null +++ b/preferences.go @@ -0,0 +1,36 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// Preferences persisted between sessions at ~/.claude/cleaner-preferences.json +type Preferences struct { + SortMode int `json:"sort_mode"` + FilterMode int `json:"filter_mode"` + ExpiryDays int `json:"expiry_days"` +} + +func prefsPath(claudeDir string) string { + return filepath.Join(claudeDir, "cleaner-preferences.json") +} + +func loadPrefs(claudeDir string) Preferences { + var p Preferences + data, err := os.ReadFile(prefsPath(claudeDir)) + if err != nil { + return p + } + _ = json.Unmarshal(data, &p) + return p +} + +func writePrefs(claudeDir string, p Preferences) { + data, err := json.Marshal(p) + if err != nil { + return + } + _ = os.WriteFile(prefsPath(claudeDir), data, 0644) +} diff --git a/scanner.go b/scanner.go index fb4a637..28a5de8 100644 --- a/scanner.go +++ b/scanner.go @@ -318,6 +318,234 @@ func safeRemove(projectsDir, targetPath string) error { return os.RemoveAll(targetPath) } +// ── Category cleanup ────────────────────────────────────────────────────────── + +// Category represents a cleanable data directory or special cleanup operation. +type Category struct { + Key string + Label string + Path string + Size int64 + FileCount int + Exists bool + Special bool // JSON orphan cleanup / history trim — not a plain RemoveAll +} + +var cleanableDirs = []struct { + key string + label string + dir string +}{ + {"debug", "Debug logs", "debug"}, + {"file-history", "File history", "file-history"}, + {"telemetry", "Telemetry", "telemetry"}, + {"shell-snapshots", "Shell snapshots", "shell-snapshots"}, + {"transcripts", "Transcripts", "transcripts"}, + {"todos", "Todos", "todos"}, + {"plans", "Plans", "plans"}, + {"usage-data", "Usage data", "usage-data"}, + {"tasks", "Tasks", "tasks"}, + {"paste-cache", "Paste cache", "paste-cache"}, + {"plugins", "Plugins cache", "plugins"}, +} + +// dirSizeCount returns total size and file count for a flat directory. +func dirSizeCount(path string) (size int64, count int) { + entries, err := os.ReadDir(path) + if err != nil { + return + } + for _, e := range entries { + info, err := e.Info() + if err != nil { + continue + } + if !e.IsDir() { + size += info.Size() + count++ + } + } + return +} + +// scanCategories returns cleanable categories inside claudeDir plus special entries. +func scanCategories(claudeDir string) []Category { + var result []Category + + for _, cat := range cleanableDirs { + path := filepath.Join(claudeDir, cat.dir) + _, statErr := os.Stat(path) + size, count := dirSizeCount(path) + result = append(result, Category{ + Key: cat.key, + Label: cat.label, + Path: path, + Size: size, + FileCount: count, + Exists: statErr == nil, + }) + } + + // Config backups: ~/.claude.json.backup* (sibling of claudeDir) + parentDir := filepath.Dir(claudeDir) + backups, _ := filepath.Glob(filepath.Join(parentDir, ".claude.json.backup*")) + var backupSize int64 + for _, b := range backups { + if info, err := os.Stat(b); err == nil { + backupSize += info.Size() + } + } + result = append(result, Category{ + Key: "config-backups", + Label: "Config backups (.claude.json.backup*)", + Path: parentDir, + Size: backupSize, + FileCount: len(backups), + Exists: len(backups) > 0, + }) + + // Orphan project entries in ~/.claude.json + claudeJSONPath := filepath.Join(parentDir, ".claude.json") + orphanCount, jsonExists := countOrphanEntries(claudeJSONPath) + result = append(result, Category{ + Key: "json-orphans", + Label: "Orphan project entries in ~/.claude.json", + Path: claudeJSONPath, + Size: 0, + FileCount: orphanCount, + Exists: jsonExists && orphanCount > 0, + Special: true, + }) + + // History trim: ~/.claude/history.jsonl + histPath := filepath.Join(claudeDir, "history.jsonl") + histSize := int64(0) + histExists := false + if info, err := os.Stat(histPath); err == nil { + histSize = info.Size() + histExists = histSize > 0 + } + result = append(result, Category{ + Key: "history-trim", + Label: "Trim history.jsonl (keep last 500 lines)", + Path: histPath, + Size: histSize, + Exists: histExists, + Special: true, + }) + + return result +} + +// countOrphanEntries returns the number of project paths in ~/.claude.json +// whose directories no longer exist, plus whether the file was readable. +func countOrphanEntries(claudeJSONPath string) (count int, exists bool) { + data, err := os.ReadFile(claudeJSONPath) + if err != nil { + return 0, false + } + var cfg struct { + Projects map[string]json.RawMessage `json:"projects"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + return 0, true + } + for path := range cfg.Projects { + if _, err := os.Stat(path); os.IsNotExist(err) { + count++ + } + } + return count, true +} + +// cleanCategory executes the cleanup operation for a category. +func cleanCategory(cat Category, claudeDir string) error { + switch cat.Key { + case "json-orphans": + parentDir := filepath.Dir(claudeDir) + return cleanOrphanEntries(filepath.Join(parentDir, ".claude.json")) + case "history-trim": + return trimHistory(cat.Path, 500) + case "config-backups": + parentDir := filepath.Dir(claudeDir) + backups, _ := filepath.Glob(filepath.Join(parentDir, ".claude.json.backup*")) + for _, b := range backups { + _ = os.Remove(b) + } + return nil + default: + if cat.Path == "" || !cat.Exists { + return nil + } + return os.RemoveAll(cat.Path) + } +} + +// cleanOrphanEntries removes project entries from ~/.claude.json where the +// project directory no longer exists, writing back atomically. +func cleanOrphanEntries(claudeJSONPath string) error { + data, err := os.ReadFile(claudeJSONPath) + if err != nil { + return err + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + projectsRaw, ok := raw["projects"] + if !ok { + return nil + } + var projects map[string]json.RawMessage + if err := json.Unmarshal(projectsRaw, &projects); err != nil { + return err + } + changed := false + for path := range projects { + if _, err := os.Stat(path); os.IsNotExist(err) { + delete(projects, path) + changed = true + } + } + if !changed { + return nil + } + newProjects, err := json.Marshal(projects) + if err != nil { + return err + } + raw["projects"] = newProjects + newData, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return err + } + tmpPath := claudeJSONPath + ".tmp" + if err := os.WriteFile(tmpPath, newData, 0644); err != nil { + return err + } + return os.Rename(tmpPath, claudeJSONPath) +} + +// trimHistory keeps only the last keepLines lines of histPath, writing atomically. +func trimHistory(histPath string, keepLines int) error { + data, err := os.ReadFile(histPath) + if err != nil { + return err + } + content := strings.TrimRight(string(data), "\n") + lines := strings.Split(content, "\n") + if len(lines) <= keepLines { + return nil + } + kept := lines[len(lines)-keepLines:] + newContent := strings.Join(kept, "\n") + "\n" + tmpPath := histPath + ".tmp" + if err := os.WriteFile(tmpPath, []byte(newContent), 0644); err != nil { + return err + } + return os.Rename(tmpPath, histPath) +} + func formatSize(b int64) string { const ( gb = 1 << 30 From 46edf1045552f26c6394d50a354263235bd68226 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 13:52:04 +0700 Subject: [PATCH 10/15] fix: update display name to show only the base project path --- model.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/model.go b/model.go index 8614c03..72c2287 100644 --- a/model.go +++ b/model.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os/exec" + "path/filepath" "sort" "strings" "time" @@ -1201,7 +1202,7 @@ func (m model) viewList() string { displayName := s.Name if s.ProjectPath != "" { - displayName = s.ProjectPath + displayName = filepath.Base(s.ProjectPath) } nameFg := clrFg @@ -1293,7 +1294,7 @@ func (m model) viewConfirm() string { } displayName := s.Name if s.ProjectPath != "" { - displayName = s.ProjectPath + displayName = filepath.Base(s.ProjectPath) } tokLabel := "—" if s.HasTokenData { From aace6e8a29c77d4c6c1d6ede1f0f8ce7393f5478 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 14:11:38 +0700 Subject: [PATCH 11/15] feat: enhance token usage aggregation and fallback mechanism in session scanning --- CHANGELOG.md | 2 + README.md | 36 +++++++++++++++++- scanner.go | 77 +++++++++++++++++++++++++++++++++----- scanner_test.go | 99 ++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 202 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a13177d..722cb17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Token column now falls back to summing `message.usage` from session `.jsonl` files when `~/.claude.json` does not contain `lastTotal*` token fields (common on newer Claude Code installs). +- UI Project column shows only the last folder name (e.g. `g-front`) instead of the full path; full path is still used internally for correct deletion. - Bumped minimum Go version to 1.25 (go.mod). - Updated CI matrix to Go 1.25 / 1.26 across Windows, macOS, and Linux. - Updated all workflows (ci, demo, release) to Go 1.25. diff --git a/README.md b/README.md index cbb6f62..3d7b21b 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ claude-cleaner --version ## Features - Reads project list from `~/.claude.json` — shows all projects Claude Code knows about, even those with no local session files. -- Displays **token usage** per project (from `lastTotal*` fields in `~/.claude.json`), formatted as K / M / B / T / P / E. +- Displays **token usage** per project — reads `lastTotal*` fields from `~/.claude.json` when available, otherwise aggregates `message.usage` from session `.jsonl` files. Formatted as K / M / B / T / P / E. - Status column `●` (session files on disk) / `○` (config only, no local data). - Windows path dedup — `d:/foo` and `D:/foo` treated as the same project; higher-token entry wins. - Multi-select with `space`, select all with `a`, confirm with `enter`. @@ -101,6 +101,40 @@ claude-cleaner --version - Concurrent filesystem scanning. - Supports custom Claude configuration directories via `--claude-dir` or `CLAUDE_CONFIG_DIR`. +## How it works + +### Session discovery + +```mermaid +flowchart TD + A([Start scan]) --> B{"~/.claude.json\nexists?"} + B -- Yes --> C{"Has projects\nmap?"} + B -- No / unreadable --> D[scanFromDir\nenumerate subdirs] + C -- Yes --> E[deduplicateProjects\ncase-insensitive merge] + C -- "No / malformed" --> D + E --> F[["For each project path\n(concurrent goroutines)"]] + D --> F + F --> G["encodePath → dir name\ne.g. d--laragon-www-g-front"] + G --> H["projectStats\nsize + mtime"] + H --> I[Resolve tokens] + I --> J([Session list]) +``` + +### Token resolution + +```mermaid +flowchart TD + A([Project entry]) --> B{"claude.json has\nlastTotal* fields?"} + B -- Yes --> C["Sum all 4 fields\ninput + output +\ncache_creation +\ncache_read"] + B -- No --> D["Scan .jsonl files\nbufio line-by-line"] + D --> E{"assistant message\nwith usage found?"} + E -- Yes --> F["Sum tokens\nacross all sessions"] + E -- No --> G(["HasTokenData = false\nDisplay —"]) + C --> H(["HasTokenData = true"]) + F --> H + H --> I["formatTokens\n→ 108.6K / 9.9M / ..."] +``` + ## What it deletes Only project session folders directly inside `~/.claude/projects` (or `$CLAUDE_CONFIG_DIR/projects`). diff --git a/scanner.go b/scanner.go index 28a5de8..e2b4b7e 100644 --- a/scanner.go +++ b/scanner.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "encoding/json" "fmt" "os" @@ -20,7 +21,7 @@ type Session struct { Modified time.Time Size int64 TotalTokens int64 - HasTokenData bool // false = token fields absent in ~/.claude.json (show "—") + HasTokenData bool // false = no token data in ~/.claude.json or session .jsonl files (show "—") HasData bool // false = directory absent or empty (no session files found) } @@ -56,6 +57,56 @@ func (e projectEntry) hasAnyField() bool { e.LastTotalCacheReadInputTokens != nil } +// jsonlTokens holds just the four token fields from message.usage in a .jsonl line. +type jsonlTokens struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` +} + +// scanProjectTokens sums token usage from all top-level .jsonl session files in +// dirPath. Returns (total, hasData); hasData is true when at least one usage +// record was found. +func scanProjectTokens(dirPath string) (int64, bool) { + entries, err := os.ReadDir(dirPath) + if err != nil { + return 0, false + } + var total int64 + var hasData bool + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { + continue + } + f, err := os.Open(filepath.Join(dirPath, e.Name())) + if err != nil { + continue + } + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 1<<20), 1<<20) // 1 MB per line – handles large tool outputs + for sc.Scan() { + var row struct { + Type string `json:"type"` + Message struct { + Usage *jsonlTokens `json:"usage"` + } `json:"message"` + } + if json.Unmarshal(sc.Bytes(), &row) != nil { + continue + } + if row.Type != "assistant" || row.Message.Usage == nil { + continue + } + u := row.Message.Usage + total += u.InputTokens + u.OutputTokens + u.CacheCreationInputTokens + u.CacheReadInputTokens + hasData = true + } + f.Close() + } + return total, hasData +} + // normalizePath lowercases and normalises separators so Windows paths are // compared case-insensitively (d:/Foo and D:\foo → d:/foo). func normalizePath(p string) string { @@ -147,14 +198,19 @@ func scanSessions(claudeJSONPath, projectsDir string) ([]Session, error) { dirPath := filepath.Join(projectsDir, encoded) size, modified := projectStats(dirPath) + totalTokens := e.total() + hasTokenData := e.hasAnyField() + if !hasTokenData { + totalTokens, hasTokenData = scanProjectTokens(dirPath) + } ch <- Session{ Name: encoded, Path: dirPath, ProjectPath: projPath, Modified: modified, Size: size, - TotalTokens: e.total(), - HasTokenData: e.hasAnyField(), + TotalTokens: totalTokens, + HasTokenData: hasTokenData, HasData: !modified.IsZero(), } }(projectPath, entry) @@ -179,7 +235,7 @@ func scanSessions(claudeJSONPath, projectsDir string) ([]Session, error) { } // scanFromDir is the fallback: enumerate subdirectories of projectsDir directly. -// Token data is unavailable without ~/.claude.json. +// Token data is read from .jsonl session files since ~/.claude.json is absent. func scanFromDir(projectsDir string) ([]Session, error) { entries, err := os.ReadDir(projectsDir) if err != nil { @@ -198,12 +254,15 @@ func scanFromDir(projectsDir string) ([]Session, error) { defer wg.Done() dirPath := filepath.Join(projectsDir, e.Name()) size, modified := projectStats(dirPath) + totalTokens, hasTokenData := scanProjectTokens(dirPath) ch <- Session{ - Name: e.Name(), - Path: dirPath, - Modified: modified, - Size: size, - HasData: !modified.IsZero(), + Name: e.Name(), + Path: dirPath, + Modified: modified, + Size: size, + TotalTokens: totalTokens, + HasTokenData: hasTokenData, + HasData: !modified.IsZero(), } }(entry) } diff --git a/scanner_test.go b/scanner_test.go index a6bcc65..48fd6b6 100644 --- a/scanner_test.go +++ b/scanner_test.go @@ -120,12 +120,14 @@ func TestSafeRemove(t *testing.T) { func TestScanSessionsFromDir(t *testing.T) { projectsDir := t.TempDir() names := []string{"proj-a", "proj-b", "proj-c"} + // Each session has real usage data so we can verify token reading. + usageLine := `{"type":"assistant","message":{"usage":{"input_tokens":1000,"output_tokens":500,"cache_creation_input_tokens":200,"cache_read_input_tokens":300}}}` + "\n" for _, name := range names { dir := filepath.Join(projectsDir, name) if err := os.Mkdir(dir, 0755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "session.jsonl"), []byte("{}"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "session.jsonl"), []byte(usageLine), 0644); err != nil { t.Fatal(err) } } @@ -144,6 +146,13 @@ func TestScanSessionsFromDir(t *testing.T) { if s.Size == 0 { t.Errorf("session[%d].Size should be > 0", i) } + if !s.HasTokenData { + t.Errorf("session[%d] should have token data from .jsonl", i) + } + // 1000 + 500 + 200 + 300 = 2000 + if s.TotalTokens != 2000 { + t.Errorf("session[%d] tokens want 2000, got %d", i, s.TotalTokens) + } } } @@ -195,12 +204,98 @@ func TestScanSessionsFromClaudeJSON(t *testing.T) { } if s.ProjectPath == "/home/user/other-proj" { if s.HasTokenData { - t.Error("other-proj should not have token data (no fields in JSON)") + t.Error("other-proj should not have token data (no fields in JSON and no usage in .jsonl)") } } } } +func TestScanProjectTokens(t *testing.T) { + dir := t.TempDir() + // Two assistant messages with usage; one user message (ignored); one assistant with no usage (ignored). + lines := "" + + `{"type":"user","message":{"content":"hello"}}` + "\n" + + `{"type":"assistant","message":{"usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":200,"cache_read_input_tokens":30}}}` + "\n" + + `{"type":"assistant","message":{"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}` + "\n" + + `{"type":"assistant","message":{}}` + "\n" + if err := os.WriteFile(filepath.Join(dir, "session.jsonl"), []byte(lines), 0644); err != nil { + t.Fatal(err) + } + + total, hasData := scanProjectTokens(dir) + if !hasData { + t.Error("hasData should be true") + } + // (100+50+200+30) + (10+5+0+0) = 395 + want := int64(395) + if total != want { + t.Errorf("total want %d, got %d", want, total) + } +} + +func TestScanProjectTokensEmptyDir(t *testing.T) { + dir := t.TempDir() + total, hasData := scanProjectTokens(dir) + if hasData { + t.Error("empty dir: hasData should be false") + } + if total != 0 { + t.Errorf("empty dir: total should be 0, got %d", total) + } +} + +func TestScanProjectTokensNoUsage(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "s.jsonl"), []byte("{}\n"), 0644); err != nil { + t.Fatal(err) + } + total, hasData := scanProjectTokens(dir) + if hasData { + t.Error("no usage lines: hasData should be false") + } + if total != 0 { + t.Errorf("total should be 0, got %d", total) + } +} + +func TestScanSessionsClaudeJSONFallsBackToJSONL(t *testing.T) { + projectsDir := t.TempDir() + + // Project exists in claude.json but has no token fields — .jsonl has usage. + projPath := "/home/user/no-fields-proj" + encoded := encodePath(projPath) + dir := filepath.Join(projectsDir, encoded) + if err := os.Mkdir(dir, 0755); err != nil { + t.Fatal(err) + } + jsonl := `{"type":"assistant","message":{"usage":{"input_tokens":500,"output_tokens":250,"cache_creation_input_tokens":100,"cache_read_input_tokens":50}}}` + "\n" + if err := os.WriteFile(filepath.Join(dir, "session.jsonl"), []byte(jsonl), 0644); err != nil { + t.Fatal(err) + } + + claudeJSON := `{"projects":{"/home/user/no-fields-proj":{}}}` + jsonPath := filepath.Join(t.TempDir(), ".claude.json") + if err := os.WriteFile(jsonPath, []byte(claudeJSON), 0644); err != nil { + t.Fatal(err) + } + + sessions, err := scanSessions(jsonPath, projectsDir) + if err != nil { + t.Fatal(err) + } + if len(sessions) != 1 { + t.Fatalf("expected 1 session, got %d", len(sessions)) + } + s := sessions[0] + if !s.HasTokenData { + t.Error("should have token data from .jsonl fallback") + } + // 500 + 250 + 100 + 50 = 900 + if s.TotalTokens != 900 { + t.Errorf("total tokens want 900, got %d", s.TotalTokens) + } +} + func TestFormatTokens(t *testing.T) { cases := []struct { n int64 From f7599602814233ae53706e072c46ac4a26a7d17a Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 14:23:00 +0700 Subject: [PATCH 12/15] feat: add screenshot GIF generation and update README with demo screenshot --- .github/workflows/demo.yml | 6 ++++++ README.md | 2 ++ demo/screenshot.tape | 1 + 3 files changed, 9 insertions(+) diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml index 584ab6c..ae40306 100644 --- a/.github/workflows/demo.yml +++ b/.github/workflows/demo.yml @@ -137,11 +137,13 @@ jobs: full_markdown="$(publish_markdown demo/full.gif)" cancel_markdown="$(publish_markdown demo/cancel.gif)" update_markdown="$(publish_markdown demo/update.gif)" + screenshot_markdown="$(publish_markdown demo/screenshot.gif)" set_output help "$help_markdown" set_output full "$full_markdown" set_output cancel "$cancel_markdown" set_output update "$update_markdown" + set_output screenshot "$screenshot_markdown" - name: Comment demo GIF preview on PR if: github.event_name == 'pull_request' @@ -151,6 +153,7 @@ jobs: FULL_GIF: ${{ steps.publish-gifs.outputs.full }} CANCEL_GIF: ${{ steps.publish-gifs.outputs.cancel }} UPDATE_GIF: ${{ steps.publish-gifs.outputs.update }} + SCREENSHOT_GIF: ${{ steps.publish-gifs.outputs.screenshot }} with: script: | const marker = ''; @@ -160,6 +163,9 @@ jobs: '', 'The newest generated terminal GIFs for this pull request are shown below:', '', + '### Screenshot', + process.env.SCREENSHOT_GIF, + '', '### Help', process.env.HELP_GIF, '', diff --git a/README.md b/README.md index 3d7b21b..690985c 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,8 @@ claude-cleaner ## Demos +![Screenshot](demo/screenshot.png) + | Scenario | Preview | | --- | --- | | `--help` | ![Help](demo/help.gif) | diff --git a/demo/screenshot.tape b/demo/screenshot.tape index cbb806a..45916f4 100644 --- a/demo/screenshot.tape +++ b/demo/screenshot.tape @@ -1,4 +1,5 @@ Output demo/screenshot.png +Output demo/screenshot.gif Set Theme "Dracula" Set FontSize 16 From 92f130ca3bd38839e681e3eb1bec394a18b133e4 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 14:53:49 +0700 Subject: [PATCH 13/15] feat: add features demo GIF and update README with new demo section --- .github/workflows/demo.yml | 13 +- ARCHITECTURE.md | 33 ++ README.md | 37 +-- demo/features.tape | 98 ++++++ demo/screenshot.tape | 1 - model_test.go | 633 +++++++++++++++++++++++++++++++++++++ 6 files changed, 775 insertions(+), 40 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 demo/features.tape diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml index ae40306..c5483a6 100644 --- a/.github/workflows/demo.yml +++ b/.github/workflows/demo.yml @@ -74,6 +74,7 @@ jobs: vhs demo/full.tape vhs demo/cancel.tape vhs demo/update.tape + vhs demo/features.tape vhs demo/screenshot.tape # On PRs: upload as artifact for preview — cannot commit to fork branches @@ -137,13 +138,13 @@ jobs: full_markdown="$(publish_markdown demo/full.gif)" cancel_markdown="$(publish_markdown demo/cancel.gif)" update_markdown="$(publish_markdown demo/update.gif)" - screenshot_markdown="$(publish_markdown demo/screenshot.gif)" + features_markdown="$(publish_markdown demo/features.gif)" set_output help "$help_markdown" set_output full "$full_markdown" set_output cancel "$cancel_markdown" set_output update "$update_markdown" - set_output screenshot "$screenshot_markdown" + set_output features "$features_markdown" - name: Comment demo GIF preview on PR if: github.event_name == 'pull_request' @@ -153,7 +154,8 @@ jobs: FULL_GIF: ${{ steps.publish-gifs.outputs.full }} CANCEL_GIF: ${{ steps.publish-gifs.outputs.cancel }} UPDATE_GIF: ${{ steps.publish-gifs.outputs.update }} - SCREENSHOT_GIF: ${{ steps.publish-gifs.outputs.screenshot }} + FEATURES_GIF: ${{ steps.publish-gifs.outputs.features }} + ARTIFACT_URL: ${{ steps.upload-gifs.outputs.artifact-url }} with: script: | const marker = ''; @@ -164,7 +166,10 @@ jobs: 'The newest generated terminal GIFs for this pull request are shown below:', '', '### Screenshot', - process.env.SCREENSHOT_GIF, + `[Download screenshot.png](${process.env.ARTIFACT_URL})`, + '', + '### Search, sort, filter, category', + process.env.FEATURES_GIF, '', '### Help', process.env.HELP_GIF, diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..ff3929d --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,33 @@ +# Architecture + +## Session discovery + +```mermaid +flowchart TD + A([Start scan]) --> B{"~/.claude.json\nexists?"} + B -- Yes --> C{"Has projects\nmap?"} + B -- No / unreadable --> D[scanFromDir\nenumerate subdirs] + C -- Yes --> E[deduplicateProjects\ncase-insensitive merge] + C -- "No / malformed" --> D + E --> F[["For each project path\n(concurrent goroutines)"]] + D --> F + F --> G["encodePath → dir name\ne.g. d--laragon-www-g-front"] + G --> H["projectStats\nsize + mtime"] + H --> I[Resolve tokens] + I --> J([Session list]) +``` + +## Token resolution + +```mermaid +flowchart TD + A([Project entry]) --> B{"claude.json has\nlastTotal* fields?"} + B -- Yes --> C["Sum all 4 fields\ninput + output +\ncache_creation +\ncache_read"] + B -- No --> D["Scan .jsonl files\nbufio line-by-line"] + D --> E{"assistant message\nwith usage found?"} + E -- Yes --> F["Sum tokens\nacross all sessions"] + E -- No --> G(["HasTokenData = false\nDisplay —"]) + C --> H(["HasTokenData = true"]) + F --> H + H --> I["formatTokens\n→ 108.6K / 9.9M / ..."] +``` diff --git a/README.md b/README.md index 690985c..878dab5 100644 --- a/README.md +++ b/README.md @@ -101,40 +101,6 @@ claude-cleaner --version - Concurrent filesystem scanning. - Supports custom Claude configuration directories via `--claude-dir` or `CLAUDE_CONFIG_DIR`. -## How it works - -### Session discovery - -```mermaid -flowchart TD - A([Start scan]) --> B{"~/.claude.json\nexists?"} - B -- Yes --> C{"Has projects\nmap?"} - B -- No / unreadable --> D[scanFromDir\nenumerate subdirs] - C -- Yes --> E[deduplicateProjects\ncase-insensitive merge] - C -- "No / malformed" --> D - E --> F[["For each project path\n(concurrent goroutines)"]] - D --> F - F --> G["encodePath → dir name\ne.g. d--laragon-www-g-front"] - G --> H["projectStats\nsize + mtime"] - H --> I[Resolve tokens] - I --> J([Session list]) -``` - -### Token resolution - -```mermaid -flowchart TD - A([Project entry]) --> B{"claude.json has\nlastTotal* fields?"} - B -- Yes --> C["Sum all 4 fields\ninput + output +\ncache_creation +\ncache_read"] - B -- No --> D["Scan .jsonl files\nbufio line-by-line"] - D --> E{"assistant message\nwith usage found?"} - E -- Yes --> F["Sum tokens\nacross all sessions"] - E -- No --> G(["HasTokenData = false\nDisplay —"]) - C --> H(["HasTokenData = true"]) - F --> H - H --> I["formatTokens\n→ 108.6K / 9.9M / ..."] -``` - ## What it deletes Only project session folders directly inside `~/.claude/projects` (or `$CLAUDE_CONFIG_DIR/projects`). @@ -178,6 +144,7 @@ claude-cleaner | Delete a session | ![Full flow](demo/full.gif) | | Cancel confirmation | ![Cancel](demo/cancel.gif) | | In-place update | ![Update](demo/update.gif) | +| Search, sort, filter, category | ![Features](demo/features.gif) | ## Troubleshooting @@ -204,7 +171,7 @@ go build -o claude-cleaner.exe . ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, build, test, and release instructions. +See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, build, test, and release instructions. For internal data flow diagrams see [ARCHITECTURE.md](ARCHITECTURE.md). ## License diff --git a/demo/features.tape b/demo/features.tape new file mode 100644 index 0000000..8e3b249 --- /dev/null +++ b/demo/features.tape @@ -0,0 +1,98 @@ +Output demo/features.gif + +Set Theme "Dracula" +Set FontSize 16 +Set Framerate 12 +Set Margin 0 +Set PlaybackSpeed 1 + +Env COLORTERM truecolor +Env TERM xterm-256color + +Type `PATH="$PWD/demo/mock-bin:$PATH" ./claude-cleaner --claude-dir /tmp/claude-demo` +Enter +Sleep 1500ms + +# Skip update prompt +Type "n" +Sleep 300ms + +# Pause to show initial list (sort: recent) +Sleep 600ms + +# --- Sort --- +# Cycle sort: recent → size → tokens → name +Type "s" +Sleep 500ms +Type "s" +Sleep 500ms +Type "s" +Sleep 500ms +Type "s" +Sleep 400ms + +# --- Filter --- +# Cycle filter: all → has-data → orphaned → all +Type "f" +Sleep 500ms +Type "f" +Sleep 500ms +Type "f" +Sleep 400ms + +# --- Search --- +# Press / to open search prompt +Type "/" +Sleep 300ms +Type "api" +Sleep 600ms + +# Clear search with Esc +Escape +Sleep 400ms + +# --- Expiry threshold --- +# Press e to cycle expiry (show only older sessions) +Type "e" +Sleep 500ms +Type "e" +Sleep 400ms +# Reset back to no expiry +Type "d" +Sleep 400ms + +# --- Select orphaned with o --- +Type "o" +Sleep 400ms +# Deselect all +Type "n" +Sleep 300ms + +# --- Category cleanup screen --- +Type "c" +Sleep 800ms + +# Navigate down a couple of items +Down +Sleep 200ms +Down +Sleep 200ms + +# Select current item +Space +Sleep 300ms + +# Select all +Type "a" +Sleep 400ms + +# Deselect all with n +Type "n" +Sleep 300ms + +# Back to list with Esc +Escape +Sleep 400ms + +# Quit +Type "q" diff --git a/demo/screenshot.tape b/demo/screenshot.tape index 45916f4..cbb806a 100644 --- a/demo/screenshot.tape +++ b/demo/screenshot.tape @@ -1,5 +1,4 @@ Output demo/screenshot.png -Output demo/screenshot.gif Set Theme "Dracula" Set FontSize 16 diff --git a/model_test.go b/model_test.go index 0273df9..c943d35 100644 --- a/model_test.go +++ b/model_test.go @@ -484,3 +484,636 @@ func TestEmptySessionsNoActionOnSpace(t *testing.T) { t.Errorf("space on empty list should stay stateList, got %v", m.state) } } + +// --- sort --- + +func TestSortKeyCyclesModes(t *testing.T) { + m := makeTestModel(realisticSessions()) + if m.sortMode != sortRecent { + t.Fatalf("initial sortMode want sortRecent, got %d", m.sortMode) + } + m = pressKey(m, "s") + if m.sortMode != sortSize { + t.Errorf("after 1×s want sortSize, got %d", m.sortMode) + } + m = pressKey(m, "s") + if m.sortMode != sortTokens { + t.Errorf("after 2×s want sortTokens, got %d", m.sortMode) + } + m = pressKey(m, "s") + if m.sortMode != sortName { + t.Errorf("after 3×s want sortName, got %d", m.sortMode) + } + m = pressKey(m, "s") + if m.sortMode != sortRecent { + t.Errorf("after 4×s should wrap to sortRecent, got %d", m.sortMode) + } +} + +func TestSortResetsCursor(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.cursor = 3 + m = pressKey(m, "s") + if m.cursor != 0 { + t.Errorf("sort should reset cursor to 0, got %d", m.cursor) + } +} + +func TestSortByTokensDescending(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.sortMode = sortTokens + result := m.filteredSessions() + for i := 1; i < len(result); i++ { + if result[i].TotalTokens > result[i-1].TotalTokens { + t.Errorf("sortTokens: index %d (%d tok) > index %d (%d tok)", i, result[i].TotalTokens, i-1, result[i-1].TotalTokens) + } + } +} + +func TestSortBySizeDescending(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.sortMode = sortSize + result := m.filteredSessions() + for i := 1; i < len(result); i++ { + if result[i].Size > result[i-1].Size { + t.Errorf("sortSize: index %d (%d B) > index %d (%d B)", i, result[i].Size, i-1, result[i-1].Size) + } + } +} + +func TestSortByNameAscending(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.sortMode = sortName + result := m.filteredSessions() + for i := 1; i < len(result); i++ { + ni := result[i-1].ProjectPath + nj := result[i].ProjectPath + if ni > nj { + t.Errorf("sortName: %q should come before %q", ni, nj) + } + } +} + +// --- filter --- + +func TestFilterKeyCyclesModes(t *testing.T) { + m := makeTestModel(realisticSessions()) + if m.filterMode != filterAll { + t.Fatalf("initial filterMode want filterAll, got %d", m.filterMode) + } + m = pressKey(m, "f") + if m.filterMode != filterHasData { + t.Errorf("after 1×f want filterHasData, got %d", m.filterMode) + } + m = pressKey(m, "f") + if m.filterMode != filterOrphaned { + t.Errorf("after 2×f want filterOrphaned, got %d", m.filterMode) + } + m = pressKey(m, "f") + if m.filterMode != filterAll { + t.Errorf("after 3×f should wrap to filterAll, got %d", m.filterMode) + } +} + +func TestFilterResetsCursor(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.cursor = 2 + m = pressKey(m, "f") + if m.cursor != 0 { + t.Errorf("filter should reset cursor to 0, got %d", m.cursor) + } +} + +func TestFilterHasDataExcludesOrphaned(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.filterMode = filterHasData + result := m.filteredSessions() + for _, s := range result { + if !s.HasData { + t.Errorf("filterHasData: session %q has HasData=false, should be excluded", s.Name) + } + } +} + +func TestFilterOrphanedExcludesWithData(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.filterMode = filterOrphaned + result := m.filteredSessions() + for _, s := range result { + if s.HasData { + t.Errorf("filterOrphaned: session %q has HasData=true, should be excluded", s.Name) + } + } +} + +func TestFilterAllReturnsEverything(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.filterMode = filterAll + result := m.filteredSessions() + if len(result) != len(m.sessions) { + t.Errorf("filterAll want %d sessions, got %d", len(m.sessions), len(result)) + } +} + +// --- search --- + +func TestSlashEntersSearchMode(t *testing.T) { + m := makeTestModel(realisticSessions()) + m = pressKey(m, "/") + if !m.searching { + t.Error("'/' should set searching=true") + } + if m.searchQuery != "" { + t.Errorf("searchQuery should be empty on entry, got %q", m.searchQuery) + } +} + +func TestSlashResetsCursor(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.cursor = 3 + m = pressKey(m, "/") + if m.cursor != 0 { + t.Errorf("'/' should reset cursor to 0, got %d", m.cursor) + } +} + +func TestSearchAppendRune(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.searching = true + m.searchQuery = "api" + m = pressKey(m, "-") + if m.searchQuery != "api-" { + t.Errorf("searchQuery want 'api-', got %q", m.searchQuery) + } +} + +func TestSearchBackspace(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.searching = true + m.searchQuery = "api" + msg := tea.KeyMsg{Type: tea.KeyBackspace} + next, _ := m.Update(msg) + m = next.(model) + if m.searchQuery != "ap" { + t.Errorf("backspace want 'ap', got %q", m.searchQuery) + } +} + +func TestSearchBackspaceEmptyNoOp(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.searching = true + m.searchQuery = "" + msg := tea.KeyMsg{Type: tea.KeyBackspace} + next, _ := m.Update(msg) + m = next.(model) + if m.searchQuery != "" { + t.Errorf("backspace on empty query should stay empty, got %q", m.searchQuery) + } +} + +func TestSearchEnterExitsKeepsQuery(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.searching = true + m.searchQuery = "api" + m = pressKey(m, "enter") + if m.searching { + t.Error("enter should exit search mode") + } + if m.searchQuery != "api" { + t.Errorf("enter should keep query, got %q", m.searchQuery) + } +} + +func TestSearchEscClearsAndExits(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.searching = true + m.searchQuery = "api" + m = pressKey(m, "esc") + if m.searching { + t.Error("esc should exit search mode") + } + if m.searchQuery != "" { + t.Errorf("esc should clear query, got %q", m.searchQuery) + } +} + +func TestSearchFiltersSessionsByName(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.searchQuery = "api" + result := m.filteredSessions() + for _, s := range result { + found := false + if s.Name != "" { + found = found || len(s.Name) > 0 && containsCI(s.Name, "api") + } + if s.ProjectPath != "" { + found = found || containsCI(s.ProjectPath, "api") + } + if !found { + t.Errorf("session %q should not match query 'api'", s.Name) + } + } +} + +func TestSearchFiltersSessionsByPath(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.searchQuery = "webapp" + result := m.filteredSessions() + if len(result) != 1 { + t.Errorf("query 'webapp' should match 1 session, got %d", len(result)) + } +} + +func TestSearchCaseInsensitive(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.searchQuery = "WEBAPP" + upper := m.filteredSessions() + m.searchQuery = "webapp" + lower := m.filteredSessions() + if len(upper) != len(lower) { + t.Errorf("search should be case-insensitive: WEBAPP=%d, webapp=%d", len(upper), len(lower)) + } +} + +func TestSearchResetsCursorOnAppend(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.searching = true + m.cursor = 2 + m = pressKey(m, "a") + if m.cursor != 0 { + t.Errorf("typing in search should reset cursor, got %d", m.cursor) + } +} + +// containsCI is a case-insensitive contains helper used only in tests. +func containsCI(s, sub string) bool { + return len(s) >= len(sub) && + func() bool { + sl := make([]rune, 0, len(s)) + subl := make([]rune, 0, len(sub)) + for _, r := range s { + if r >= 'A' && r <= 'Z' { + sl = append(sl, r+32) + } else { + sl = append(sl, r) + } + } + for _, r := range sub { + if r >= 'A' && r <= 'Z' { + subl = append(subl, r+32) + } else { + subl = append(subl, r) + } + } + s2 := string(sl) + sub2 := string(subl) + for i := 0; i <= len(s2)-len(sub2); i++ { + if s2[i:i+len(sub2)] == sub2 { + return true + } + } + return false + }() +} + +// --- expiry --- + +func TestExpiryKeyCycles(t *testing.T) { + m := makeTestModel(realisticSessions()) + if m.expiryDays != 0 { + t.Fatalf("initial expiryDays want 0, got %d", m.expiryDays) + } + expected := []int{7, 14, 30, 60, 90, 0} + for i, want := range expected { + m = pressKey(m, "e") + if m.expiryDays != want { + t.Errorf("after %d×e want expiryDays=%d, got %d", i+1, want, m.expiryDays) + } + } +} + +func TestExpiryFiltersRecentSessions(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.expiryDays = 7 // only show sessions older than 7 days + result := m.filteredSessions() + for _, s := range result { + if !s.HasData { + continue // orphaned sessions have zero mtime, skip + } + cutoff := time.Now().AddDate(0, 0, -7) + if s.Modified.After(cutoff) { + t.Errorf("session %q modified %v is within 7-day window, should be excluded", s.Name, s.Modified) + } + } +} + +func TestExpiryZeroShowsAll(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.expiryDays = 0 + withExpiry := m.filteredSessions() + m.expiryDays = 7 + withoutRecent := m.filteredSessions() + if len(withExpiry) <= len(withoutRecent) { + t.Errorf("expiryDays=0 should show more sessions than expiryDays=7 (got %d vs %d)", len(withExpiry), len(withoutRecent)) + } +} + +// --- esc reset --- + +func TestEscResetsSearchAndFilters(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.searchQuery = "api" + m.filterMode = filterHasData + m.sortMode = sortSize + m = pressKey(m, "esc") + if m.searchQuery != "" { + t.Errorf("esc should clear searchQuery, got %q", m.searchQuery) + } + if m.filterMode != filterAll { + t.Errorf("esc should reset filterMode to filterAll, got %d", m.filterMode) + } + if m.sortMode != sortRecent { + t.Errorf("esc should reset sortMode to sortRecent, got %d", m.sortMode) + } +} + +func TestDKeyResetsAll(t *testing.T) { + m := makeTestModel(realisticSessions()) + m.sortMode = sortName + m.filterMode = filterOrphaned + m.searchQuery = "api" + m.cursor = 3 + m.selected = map[int]bool{1: true, 2: true} + m = pressKey(m, "d") + if m.sortMode != sortRecent { + t.Errorf("d should reset sortMode, got %d", m.sortMode) + } + if m.filterMode != filterAll { + t.Errorf("d should reset filterMode, got %d", m.filterMode) + } + if m.searchQuery != "" { + t.Errorf("d should clear searchQuery, got %q", m.searchQuery) + } + if m.cursor != 0 { + t.Errorf("d should reset cursor, got %d", m.cursor) + } + if len(m.selected) != 0 { + t.Errorf("d should clear selected map, got %v", m.selected) + } +} + +// --- orphaned select --- + +func TestOKeySelectsOnlyOrphaned(t *testing.T) { + m := makeTestModel(realisticSessions()) + m = pressKey(m, "o") + for _, s := range m.sessions { + selected := m.selected[s.Index] + if !s.HasData && !selected { + t.Errorf("session %q (orphaned) should be selected after 'o'", s.Name) + } + if s.HasData && selected { + t.Errorf("session %q (has data) should NOT be selected after 'o'", s.Name) + } + } +} + +// --- category --- + +func TestCKeyOpensCategories(t *testing.T) { + m := makeTestModel(realisticSessions()) + m = pressKey(m, "c") + if m.state != stateCategories { + t.Errorf("'c' should set state to stateCategories, got %v", m.state) + } + if m.categoryCursor != 0 { + t.Errorf("categoryCursor should reset to 0, got %d", m.categoryCursor) + } +} + +func fakeCategories() []Category { + return []Category{ + {Key: "debug", Label: "Debug logs", Exists: true, Size: 1024}, + {Key: "telemetry", Label: "Telemetry", Exists: true, Size: 2048}, + {Key: "transcripts", Label: "Transcripts", Exists: false, Size: 0}, + } +} + +func makeCategoryModel() model { + m := makeTestModel(realisticSessions()) + m.state = stateCategories + m.categories = fakeCategories() + m.categorySelected = make(map[string]bool) + m.categoryCursor = 0 + return m +} + +func TestCategoryNavigateDown(t *testing.T) { + m := makeCategoryModel() + m = pressKey(m, "j") + if m.categoryCursor != 1 { + t.Errorf("j should move categoryCursor to 1, got %d", m.categoryCursor) + } +} + +func TestCategoryNavigateUp(t *testing.T) { + m := makeCategoryModel() + m.categoryCursor = 1 + m = pressKey(m, "k") + if m.categoryCursor != 0 { + t.Errorf("k should move categoryCursor to 0, got %d", m.categoryCursor) + } +} + +func TestCategoryBoundaryWrapDown(t *testing.T) { + m := makeCategoryModel() + m.categoryCursor = len(m.categories) - 1 + m = pressKey(m, "j") + if m.categoryCursor != 0 { + t.Errorf("j at last item should wrap to 0, got %d", m.categoryCursor) + } +} + +func TestCategoryBoundaryWrapUp(t *testing.T) { + m := makeCategoryModel() + m.categoryCursor = 0 + m = pressKey(m, "k") + if m.categoryCursor != len(m.categories)-1 { + t.Errorf("k at first item should wrap to last (%d), got %d", len(m.categories)-1, m.categoryCursor) + } +} + +func TestCategoryGJumpsToTop(t *testing.T) { + m := makeCategoryModel() + m.categoryCursor = 2 + m = pressKey(m, "g") + if m.categoryCursor != 0 { + t.Errorf("g should jump to top, got %d", m.categoryCursor) + } +} + +func TestCategoryGShiftJumpsToBottom(t *testing.T) { + m := makeCategoryModel() + m.categoryCursor = 0 + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")} + next, _ := m.Update(msg) + m = next.(model) + if m.categoryCursor != len(m.categories)-1 { + t.Errorf("G should jump to bottom (%d), got %d", len(m.categories)-1, m.categoryCursor) + } +} + +func TestCategorySpaceTogglesExisting(t *testing.T) { + m := makeCategoryModel() + m.categoryCursor = 0 // "debug", Exists=true + m = pressKey(m, " ") + if !m.categorySelected["debug"] { + t.Error("space should select 'debug'") + } + m = pressKey(m, " ") + if m.categorySelected["debug"] { + t.Error("space again should deselect 'debug'") + } +} + +func TestCategorySpaceSkipsNonExistent(t *testing.T) { + m := makeCategoryModel() + m.categoryCursor = 2 // "transcripts", Exists=false + m = pressKey(m, " ") + if m.categorySelected["transcripts"] { + t.Error("space on non-existent category should not select it") + } +} + +func TestCategorySelectAll(t *testing.T) { + m := makeCategoryModel() + m = pressKey(m, "a") + for _, cat := range m.categories { + if cat.Exists && !m.categorySelected[cat.Key] { + t.Errorf("'a' should select existing category %q", cat.Key) + } + if !cat.Exists && m.categorySelected[cat.Key] { + t.Errorf("'a' should not select non-existent category %q", cat.Key) + } + } +} + +func TestCategorySelectAllThenDeselectAll(t *testing.T) { + m := makeCategoryModel() + m = pressKey(m, "a") // select all + m = pressKey(m, "a") // toggle: deselect all + for _, cat := range m.categories { + if m.categorySelected[cat.Key] { + t.Errorf("second 'a' should deselect %q", cat.Key) + } + } +} + +func TestCategoryNDeselectsAll(t *testing.T) { + m := makeCategoryModel() + m.categorySelected["debug"] = true + m.categorySelected["telemetry"] = true + m = pressKey(m, "n") + if len(m.categorySelected) != 0 { + t.Errorf("'n' should clear categorySelected, got %v", m.categorySelected) + } +} + +func TestCategoryEnterWithSelectionGoesToConfirm(t *testing.T) { + m := makeCategoryModel() + m.categorySelected["debug"] = true + m = pressKey(m, "enter") + if m.state != stateCategoryConfirm { + t.Errorf("enter with selection should go to stateCategoryConfirm, got %v", m.state) + } + if m.confirmIdx != 0 { + t.Errorf("confirmIdx should be 0 (No default), got %d", m.confirmIdx) + } +} + +func TestCategoryEnterNoSelectionStays(t *testing.T) { + m := makeCategoryModel() + // no selection + m = pressKey(m, "enter") + if m.state != stateCategories { + t.Errorf("enter with no selection should stay stateCategories, got %v", m.state) + } +} + +func TestCategoryEscReturnsToList(t *testing.T) { + m := makeCategoryModel() + m = pressKey(m, "esc") + if m.state != stateList { + t.Errorf("esc in stateCategories should return to stateList, got %v", m.state) + } + if len(m.categorySelected) != 0 { + t.Error("esc should clear categorySelected") + } +} + +func TestCategoryConfirmEscReturnsToCategories(t *testing.T) { + m := makeCategoryModel() + m.state = stateCategoryConfirm + m.confirmIdx = 1 + m = pressKey(m, "esc") + if m.state != stateCategories { + t.Errorf("esc in stateCategoryConfirm should go to stateCategories, got %v", m.state) + } + if m.confirmIdx != 0 { + t.Errorf("confirmIdx should reset to 0, got %d", m.confirmIdx) + } +} + +func TestCategoryConfirmNReturnsToCategories(t *testing.T) { + m := makeCategoryModel() + m.state = stateCategoryConfirm + m = pressKey(m, "n") + if m.state != stateCategories { + t.Errorf("'n' in stateCategoryConfirm should go to stateCategories, got %v", m.state) + } +} + +func TestCategoryConfirmRightMovesToYes(t *testing.T) { + m := makeCategoryModel() + m.state = stateCategoryConfirm + msg := tea.KeyMsg{Type: tea.KeyRight} + next, _ := m.Update(msg) + m = next.(model) + if m.confirmIdx != 1 { + t.Errorf("right should set confirmIdx=1 (Yes), got %d", m.confirmIdx) + } +} + +func TestCategoryConfirmLeftMovesToNo(t *testing.T) { + m := makeCategoryModel() + m.state = stateCategoryConfirm + m.confirmIdx = 1 + msg := tea.KeyMsg{Type: tea.KeyLeft} + next, _ := m.Update(msg) + m = next.(model) + if m.confirmIdx != 0 { + t.Errorf("left should set confirmIdx=0 (No), got %d", m.confirmIdx) + } +} + +func TestCategoryConfirmTabMovesToNo(t *testing.T) { + m := makeCategoryModel() + m.state = stateCategoryConfirm + m.confirmIdx = 1 + msg := tea.KeyMsg{Type: tea.KeyTab} + next, _ := m.Update(msg) + m = next.(model) + if m.confirmIdx != 0 { + t.Errorf("tab should set confirmIdx=0 (No), got %d", m.confirmIdx) + } +} + +func TestCategoryConfirmEnterOnNoReturnsToCategories(t *testing.T) { + m := makeCategoryModel() + m.state = stateCategoryConfirm + m.confirmIdx = 0 // No + m = pressKey(m, "enter") + if m.state != stateCategories { + t.Errorf("enter on No should return to stateCategories, got %v", m.state) + } +} From 77872d09ba7bf1e4330dad417a6f9c7ee7f30d4c Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 14:58:32 +0700 Subject: [PATCH 14/15] 1.1.0 --- main.go | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 3138bde..a949d0e 100644 --- a/main.go +++ b/main.go @@ -12,7 +12,7 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -var version = "1.0.1" +var version = "1.1.0" func printHelp() { fmt.Printf(`Claude Cleaner v%s — ePlus.DEV diff --git a/package.json b/package.json index 4067eba..fabde46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-cleaner", - "version": "1.0.1", + "version": "1.1.0", "description": "Safely inspect and delete selected Claude Code project session history from an interactive cross-platform TUI.", "bin": { "claude-cleaner": "scripts/run.js" From 75ae946b63d1ca5c59e5da4d243cd3518dfc3714 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 15:23:40 +0700 Subject: [PATCH 15/15] feat: add SCREENSHOTS.md for scenario walkthroughs and demo previews --- README.md | 14 ++------------ SCREENSHOTS.md | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 12 deletions(-) create mode 100644 SCREENSHOTS.md diff --git a/README.md b/README.md index 878dab5..a6e68ab 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Runs on Windows, macOS, and Linux. No runtime required when using a pre-built bi ![Full demo](demo/full.gif) +> See [SCREENSHOTS.md](SCREENSHOTS.md) for all scenario walkthroughs. + ## Install ### Run without installing @@ -134,18 +136,6 @@ $env:CLAUDE_CONFIG_DIR = "D:\ClaudeData" claude-cleaner ``` -## Demos - -![Screenshot](demo/screenshot.png) - -| Scenario | Preview | -| --- | --- | -| `--help` | ![Help](demo/help.gif) | -| Delete a session | ![Full flow](demo/full.gif) | -| Cancel confirmation | ![Cancel](demo/cancel.gif) | -| In-place update | ![Update](demo/update.gif) | -| Search, sort, filter, category | ![Features](demo/features.gif) | - ## Troubleshooting **Claude directory not found** — Run Claude Code at least once so the directory is created, or point to the correct path: diff --git a/SCREENSHOTS.md b/SCREENSHOTS.md new file mode 100644 index 0000000..75cc89b --- /dev/null +++ b/SCREENSHOTS.md @@ -0,0 +1,15 @@ +# Screenshots & Demos + +## Quick look + +![Full demo](demo/full.gif) + +## Scenario walkthroughs + +| Scenario | Preview | +| --- | --- | +| `--help` | ![Help](demo/help.gif) | +| Delete a session | ![Full flow](demo/full.gif) | +| Cancel confirmation | ![Cancel](demo/cancel.gif) | +| In-place update | ![Update](demo/update.gif) | +| Search, sort, filter, category | ![Features](demo/full.gif) |