├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── CODEOWNERS ├── LICENSE ├── README.md ├── config.toml.example ├── docs ├── RELEASE.md ├── claude_project_mapping.md └── images │ ├── demo-screenshot.png │ └── demo.gif ├── eslint.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── App.tsx ├── __tests__ │ ├── CommandEditor.test.tsx │ ├── ConversationList.test.tsx │ ├── ConversationPreview.test.tsx │ ├── ConversationPreviewFull.test.tsx │ ├── charWidth.test.ts │ ├── configLoader.test.ts │ ├── keyBindingHelper.test.ts │ └── messageUtils.test.ts ├── cli.tsx ├── components │ ├── CommandEditor.tsx │ ├── ConversationList.tsx │ ├── ConversationPreview.tsx │ └── ConversationPreviewFull.tsx ├── types.ts ├── types │ └── config.ts └── utils │ ├── charWidth.ts │ ├── configLoader.ts │ ├── conversationReader.ts │ ├── conversationUtils.ts │ ├── keyBindingHelper.ts │ ├── messageUtils.ts │ ├── shortcutHelper.ts │ ├── strictTruncate.ts │ └── stringUtils.ts └── tsconfig.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm 4 | - package-ecosystem: "npm" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "04:00" 10 | open-pull-requests-limit: 5 11 | reviewers: 12 | - "sasazame" 13 | labels: 14 | - "dependencies" 15 | - "npm" 16 | commit-message: 17 | prefix: "chore" 18 | include: "scope" 19 | groups: 20 | eslint: 21 | patterns: 22 | - "eslint*" 23 | - "@typescript-eslint/*" 24 | - "typescript-eslint" 25 | react: 26 | patterns: 27 | - "react*" 28 | - "@types/react*" 29 | testing: 30 | patterns: 31 | - "jest*" 32 | - "@types/jest" 33 | - "ts-jest" 34 | - "*testing*" 35 | ignore: 36 | # Ink doesn't support React 19 yet 37 | - dependency-name: "react" 38 | versions: [">=19.0.0"] 39 | - dependency-name: "@types/react" 40 | versions: [">=19.0.0"] 41 | 42 | # Keep GitHub Actions up to date 43 | - package-ecosystem: "github-actions" 44 | directory: "/" 45 | schedule: 46 | interval: "weekly" 47 | day: "monday" 48 | time: "04:00" 49 | reviewers: 50 | - "sasazame" 51 | labels: 52 | - "dependencies" 53 | - "github-actions" 54 | commit-message: 55 | prefix: "chore" 56 | include: "scope" -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main, master, develop ] 6 | pull_request: 7 | branches: [ main, master, develop ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 20.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v5 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v5 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Run linter 30 | run: npm run lint 31 | 32 | - name: Run type check 33 | run: npm run typecheck 34 | 35 | - name: Run tests 36 | run: npm test 37 | 38 | - name: Build 39 | run: npm run build 40 | 41 | - name: Upload coverage reports 42 | if: matrix.node-version == '20.x' 43 | uses: codecov/codecov-action@v5 44 | with: 45 | fail_ci_if_error: false 46 | flags: unittests 47 | name: codecov-umbrella 48 | token: ${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | *.log 4 | .DS_Store 5 | .env 6 | .env.local 7 | .env.*.local 8 | coverage/ 9 | .nyc_output/ 10 | *.tsbuildinfo 11 | .claude/settings.local.json -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [1.0.0] - 2025-08-06 11 | 12 | ### Added 13 | - Display git branch in conversation preview for newer Claude Code versions (#46) 14 | - New session feature - press `n` to start a new Claude session in selected project directory (#48) 15 | - Interactive command editor - press `-` to edit Claude CLI options before starting/resuming sessions (#49) 16 | - Full conversation view (experimental) - press `f` to toggle full message view (#50) 17 | 18 | ### Changed 19 | - Major version bump to indicate project maturity and feature completeness 20 | - Improved type safety and test coverage throughout the codebase (#51) 21 | 22 | ## [0.3.1] - 2025-07-13 23 | 24 | ### Fixed 25 | - Preserve terminal scrollback buffer when exiting (#34) 26 | - Windows terminal input compatibility improvements (#33) 27 | 28 | ### Changed 29 | - Refactored magic numbers to named constants for better code maintainability 30 | 31 | ## [0.3.0] - 2025-07-11 32 | 33 | ### Added 34 | - New `--hide` option to hide specific message types (#27) 35 | - Hide tool use messages with `--hide tool` 36 | - Hide thinking messages with `--hide thinking` 37 | - Hide user messages with `--hide user` 38 | - Hide assistant messages with `--hide assistant` 39 | - Default behavior (no arguments): `--hide` hides both tool and thinking messages 40 | - Multiple message types can be hidden: `--hide tool thinking user` 41 | - Improved command-line argument parsing with validation for hide options 42 | 43 | ## [0.2.0] - 2025-07-07 44 | 45 | ### Added 46 | - Pagination support for better navigation through large conversation lists (#18) 47 | - Performance optimizations for improved rendering and responsiveness (#18) 48 | 49 | ### Fixed 50 | - Corrected shortcut display order in bottom help text (#19) 51 | - Upgraded to ESLint v9 and TypeScript-ESLint v8 to fix compatibility issues (#17) 52 | 53 | ### Changed 54 | - Updated multiple dependencies including: 55 | - @types/node from 20.19.4 to 24.0.10 (#8) 56 | - jest from 30.0.3 to 30.0.4 (#10) 57 | - date-fns from 3.6.0 to 4.1.0 (#11) 58 | - codecov/codecov-action from 4 to 5 (#7) 59 | - Improved CI/CD pipeline configuration (#6) 60 | - Updated Node.js requirement to >= 18 61 | 62 | ### Security 63 | - Updated various dependencies to address security vulnerabilities 64 | 65 | ## [0.1.5] - 2024-11-20 66 | 67 | ### Fixed 68 | - Fixed conversation filtering logic 69 | 70 | ## [0.1.4] - 2024-11-19 71 | 72 | ### Fixed 73 | - Fixed issue with configuration loading 74 | 75 | ## [0.1.3] - 2024-11-19 76 | 77 | ### Added 78 | - Support for custom configuration paths 79 | - Better error handling for invalid configurations 80 | 81 | ## [0.1.2] - 2024-11-18 82 | 83 | ### Fixed 84 | - Fixed issue with message display truncation 85 | 86 | ## [0.1.1] - 2024-11-18 87 | 88 | ### Fixed 89 | - Fixed CLI binary path issue 90 | 91 | ## [0.1.0] - 2024-11-17 92 | 93 | ### Added 94 | - Initial release 95 | - TUI interface for browsing Claude Code conversations 96 | - Search functionality with conversation filtering 97 | - Message preview with syntax highlighting 98 | - Keyboard navigation and shortcuts 99 | - Configuration file support 100 | - Resume conversation functionality -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This file defines code ownership for automatic PR review assignments 2 | # Syntax: [pattern] [owner1] [owner2] ... 3 | # Later matches take precedence 4 | 5 | # Default owner for everything in the repo 6 | * @sasazame 7 | 8 | # Documentation 9 | *.md @sasazame 10 | docs/ @sasazame 11 | 12 | # CI/CD configuration 13 | .github/ @sasazame 14 | *.yml @sasazame 15 | *.yaml @sasazame 16 | 17 | # Source code 18 | src/ @sasazame 19 | tests/ @sasazame 20 | 21 | # Configuration files 22 | package.json @sasazame 23 | tsconfig.json @sasazame 24 | .eslintrc* @sasazame 25 | .prettierrc* @sasazame -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ccresume contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ccresume 2 | 3 | A character user interface (CUI) tool for browsing and resuming Claude Code conversations. 4 | 5 | ![ccresume screenshot](docs/images/demo-screenshot.png) 6 | 7 | **⚠️ DISCLAIMER: This is an unofficial third-party tool not affiliated with or endorsed by Anthropic. Use at your own risk.** 8 | 9 | ## Overview 10 | 11 | ccresume provides an interactive terminal interface to browse and manage your Claude Code conversation history. It reads conversation data from your local Claude Code configuration and displays them in an easy-to-navigate format. 12 | 13 | ### Key Features 14 | 15 | - 📋 Browse all Claude Code conversations across projects 16 | - 🔍 View detailed conversation information 17 | - 📎 Copy session IDs to clipboard 18 | - 🚀 Start new Claude sessions in selected project directories 19 | - 📁 Filter conversations to current directory with `.` argument 20 | - 🎭 Hide specific message types for cleaner display 21 | - ⚙️ Edit Claude command options interactively before starting sessions 22 | - 🔄 Toggle full conversation view to see complete message history 23 | 24 | ![ccresume demo](docs/images/demo.gif) 25 | 26 | ## Installation 27 | 28 | ### Via npx (Recommended) 29 | 30 | ```bash 31 | npx @sasazame/ccresume@latest 32 | ``` 33 | 34 | ### Global Installation 35 | 36 | ```bash 37 | npm install -g @sasazame/ccresume 38 | ``` 39 | 40 | ## Usage 41 | 42 | Run the command in your terminal: 43 | 44 | ```bash 45 | ccresume 46 | ``` 47 | 48 | Or if using npx: 49 | 50 | ```bash 51 | npx @sasazame/ccresume@latest 52 | ``` 53 | 54 | ### Command Line Options 55 | 56 | #### ccresume Options 57 | 58 | ```bash 59 | # Hide specific message types 60 | ccresume --hide # Default: hides tool and thinking messages 61 | ccresume --hide tool # Hide only tool messages 62 | ccresume --hide thinking # Hide only thinking messages 63 | ccresume --hide user # Hide only user messages 64 | ccresume --hide assistant # Hide only assistant messages 65 | ccresume --hide tool thinking user # Hide multiple types 66 | 67 | # Filter to current directory 68 | ccresume . 69 | 70 | # Show help 71 | ccresume --help 72 | ccresume -h 73 | 74 | # Show version 75 | ccresume --version 76 | ccresume -v 77 | ``` 78 | 79 | #### Passing Options to Claude 80 | 81 | All unrecognized command-line arguments are passed directly to the `claude` command when resuming a conversation. 82 | 83 | ```bash 84 | # Pass options to claude 85 | ccresume --dangerously-skip-permissions 86 | 87 | # Multiple options 88 | ccresume --model opus --dangerously-skip-permissions 89 | 90 | # Combine ccresume and claude options 91 | ccresume --hide tool --model opus 92 | ccresume . --hide --dangerously-skip-permissions 93 | ``` 94 | 95 | **⚠️ Warning**: Since unrecognized arguments are passed to claude, avoid using options that conflict with ccresume's functionality: 96 | - Don't use options like `--resume` or something like that changes claude's interactive behavior 97 | 98 | ## Requirements 99 | 100 | - **Node.js** >= 18 101 | - **Claude Code** - Must be installed and configured 102 | - **Operating System** - Works on macOS, Linux, and Windows (both native & WSL) 103 | 104 | ## Command Editor 105 | 106 | Press `-` to open the command editor, where you can configure Claude CLI options before starting or resuming a session. The editor provides: 107 | 108 | - **Autocomplete suggestions** - Type `-` to see matching Claude options 109 | - **Official help text** - View all available Claude CLI options 110 | - **Interactive editing** - Use arrow keys, Tab for autocomplete, Enter to confirm 111 | 112 | The configured options will be passed to Claude when you start a new session (`n`) or resume a conversation (`Enter`). 113 | 114 | **Note**: The options list is based on Claude's help text at a specific point in time. Please refer to `claude --help` for the latest available options. Some options like `-r`, `-c`, `-h` may interfere with ccresume's functionality. 115 | 116 | ## Keyboard Controls 117 | 118 | ### Default Key Bindings 119 | 120 | | Action | Keys | 121 | |--------|------| 122 | | Quit | `q` | 123 | | Select Previous | `↑` | 124 | | Select Next | `↓` | 125 | | Confirm/Resume | `Enter` | 126 | | Start New Session | `n` | 127 | | Edit Command Options | `-` | 128 | | Copy Session ID | `c` | 129 | | Scroll Up | `k` | 130 | | Scroll Down | `j` | 131 | | Page Up | `u`, `PageUp` | 132 | | Page Down | `d`, `PageDown` | 133 | | Scroll to Top | `g` | 134 | | Scroll to Bottom | `G` | 135 | | Next Page | `→`| 136 | | Previous Page | `←` | 137 | | Toggle Full View | `f` | 138 | 139 | ### Custom Key Bindings 140 | 141 | You can customize key bindings by creating a configuration file at `~/.config/ccresume/config.toml`: 142 | 143 | ```toml 144 | [keybindings] 145 | quit = ["q", "ctrl+c", "esc"] 146 | selectPrevious = ["up", "k"] 147 | selectNext = ["down", "j"] 148 | confirm = ["enter", "l"] 149 | copySessionId = ["y"] 150 | scrollUp = ["u", "ctrl+u"] 151 | scrollDown = ["d", "ctrl+d"] 152 | scrollPageUp = ["b", "ctrl+b"] 153 | scrollPageDown = ["f", "ctrl+f"] 154 | scrollTop = ["g"] 155 | scrollBottom = ["shift+g"] 156 | pageNext = ["right", "n"] 157 | pagePrevious = ["left", "p"] 158 | startNewSession = ["n"] 159 | openCommandEditor = ["-"] 160 | toggleFullView = ["f"] 161 | ``` 162 | 163 | See `config.toml.example` in the repository for a complete example. 164 | 165 | ## Development 166 | 167 | ### Setup 168 | 169 | ```bash 170 | # Clone the repository 171 | git clone https://github.com/yourusername/ccresume.git 172 | cd ccresume 173 | 174 | # Install dependencies 175 | npm install 176 | ``` 177 | 178 | ### Available Scripts 179 | 180 | ```bash 181 | # Run in development mode 182 | npm run dev 183 | 184 | # Build the project 185 | npm run build 186 | 187 | # Run tests 188 | npm test 189 | 190 | # Run tests in watch mode 191 | npm run test:watch 192 | 193 | # Generate test coverage 194 | npm run test:coverage 195 | 196 | # Run linter 197 | npm run lint 198 | 199 | # Type check 200 | npm run typecheck 201 | ``` 202 | 203 | ### Project Structure 204 | 205 | ``` 206 | ccresume/ 207 | ├── src/ # Source code 208 | │ ├── cli.tsx # CLI entry point 209 | │ ├── App.tsx # Main application component 210 | │ └── ... # Other components and utilities 211 | ├── dist/ # Compiled output 212 | ├── tests/ # Test files 213 | └── package.json # Project configuration 214 | ``` 215 | 216 | ## Contributing 217 | 218 | Contributions are welcome! Please feel free to submit a Pull Request. 219 | 220 | 1. Fork the repository 221 | 2. Create your feature branch (`git checkout -b feature/AmazingFeature`) 222 | 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) 223 | 4. Push to the branch (`git push origin feature/AmazingFeature`) 224 | 5. Open a Pull Request 225 | 226 | ## License 227 | 228 | MIT 229 | 230 | ## Support 231 | 232 | For issues and feature requests, please use the [GitHub issue tracker](https://github.com/sasazame/ccresume/issues). 233 | 234 | ## 🐞 Known Issues 235 | 236 | Below are known issues and limitations. Contributions and suggestions are welcome! 237 | 238 | | No. | Title | Description | Issue | 239 | |:---:|:------|:-------------|:-----| 240 | | 1 | **Incomplete conversation history restoration on resume** | When resuming with ccresume, sometimes, only the tail end of the history is restored. Although the interactive `claude -r` can restore full history. Workaround: use `claude -r` interactively or `claude -c`. | [#2](https://github.com/sasazame/ccresume/issues/2) | 241 | | 2 | **~~Restore original console state after exiting ccresume~~** | ~~Exiting `ccresume` leaves the chat selection interface visible and hides previous terminal content.~~ **This is fixed in v0.3.1**: Terminal scrollback buffer is now preserved when exiting. | [#3](https://github.com/sasazame/ccresume/issues/3) | 242 | | 3 | **Resume ordering may be incorrect** | For performance issue, `ccresume` sorts logs by file system timestamps (not chat content), so display order may not match actual chronology after migration. Workaround: preserve file timestamps. | – | 243 | | 4 | **Windows native terminal limitations** | On Windows native terminals, interactive features may have limited functionality due to terminal input handling differences. Temporarily, in the Windows native environment, a warning message will be displayed before startup. | [#32](https://github.com/sasazame/ccresume/issues/32) | 244 | 245 | Remember: This is an unofficial tool. For official Claude Code support, please refer to Anthropic's documentation. 246 | -------------------------------------------------------------------------------- /config.toml.example: -------------------------------------------------------------------------------- 1 | # ccresume configuration file 2 | # Copy this file to ~/.config/ccresume/config.toml and customize as needed 3 | 4 | [keybindings] 5 | # Exit the application 6 | quit = ["q"] 7 | 8 | # Navigate conversation list 9 | selectPrevious = ["up"] 10 | selectNext = ["down"] 11 | 12 | # Confirm selection (resume conversation) 13 | confirm = ["enter"] 14 | 15 | # Copy session ID to clipboard 16 | copySessionId = ["c"] 17 | 18 | # Scroll controls for conversation preview 19 | scrollUp = ["k"] 20 | scrollDown = ["j"] 21 | scrollPageUp = ["u", "pageup"] 22 | scrollPageDown = ["d", "pagedown"] 23 | scrollTop = ["g"] 24 | scrollBottom = ["G"] 25 | 26 | # Pagination controls 27 | pageNext = ["right"] 28 | pagePrevious = ["left"] 29 | 30 | # Session controls 31 | # Start a new Claude session in the selected project directory 32 | startNewSession = ["n"] 33 | 34 | # Open command editor to configure Claude CLI options 35 | openCommandEditor = ["-"] 36 | 37 | # Toggle full message view (experimental) 38 | toggleFullView = ["f"] -------------------------------------------------------------------------------- /docs/RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | This document outlines the standard release process for publishing updates to npm. 4 | 5 | ## Branch Strategy 6 | 7 | This project follows a develop/master branch strategy: 8 | - `develop`: Main development branch where features are integrated 9 | - `master`: Production branch for releases 10 | 11 | ## Prerequisites 12 | 13 | Before starting the release process, ensure you have: 14 | - npm authentication configured (`npm login`) 15 | - git push access to the repository 16 | - GitHub CLI installed and authenticated (`gh auth login`) 17 | 18 | ## Release Steps 19 | 20 | ### 1. Create Release PR 21 | 22 | Create a pull request from `develop` to `master`: 23 | 24 | ```bash 25 | # Ensure develop is up to date 26 | git checkout develop 27 | git pull origin develop 28 | 29 | # Create PR 30 | gh pr create --base master --head develop --title "Release vX.X.X" --body "Release description" 31 | ``` 32 | 33 | ### 2. Pre-release Checks 34 | 35 | Run all quality checks before creating a release: 36 | 37 | ```bash 38 | # Run linting 39 | npm run lint 40 | 41 | # Run type checking (if applicable) 42 | npm run typecheck 43 | 44 | # Run tests 45 | npm test 46 | 47 | # Verify package contents 48 | npm pack --dry-run 49 | ``` 50 | 51 | ### 3. Merge Release PR 52 | 53 | After PR approval and CI checks pass, merge the PR to master. 54 | 55 | ### 4. Update Version 56 | 57 | After merging, switch to master and update the version: 58 | 59 | ```bash 60 | # Switch to master and pull latest 61 | git checkout master 62 | git pull origin master 63 | 64 | # Update CHANGELOG.md if needed (add release date) 65 | # Then commit any changes 66 | 67 | # For bug fixes (1.0.0 → 1.0.1) 68 | npm version patch -m "Release v%s" 69 | 70 | # For new features (1.0.0 → 1.1.0) 71 | npm version minor -m "Release v%s" 72 | 73 | # For breaking changes (1.0.0 → 2.0.0) 74 | npm version major -m "Release v%s" 75 | ``` 76 | 77 | ### 5. Push Changes 78 | 79 | Push the version commit and tag to the remote repository: 80 | 81 | ```bash 82 | git push origin master --follow-tags 83 | ``` 84 | 85 | ### 6. Publish to npm 86 | 87 | Publish the package to npm registry: 88 | 89 | ```bash 90 | npm publish 91 | ``` 92 | 93 | ### 7. Create GitHub Release 94 | 95 | Create a GitHub release using the CHANGELOG content: 96 | 97 | ```bash 98 | # Extract the latest version section from CHANGELOG.md 99 | VERSION=$(node -p "require('./package.json').version") 100 | NOTES=$(awk "/^## \[$VERSION\]/{flag=1;next}/^## \[/{flag=0}flag" CHANGELOG.md) 101 | 102 | # Create release with CHANGELOG notes 103 | gh release create "v$VERSION" --notes "$NOTES" 104 | ``` 105 | 106 | ### 8. Merge Back to Develop 107 | 108 | Create a PR to merge master back to develop to keep branches in sync: 109 | 110 | ```bash 111 | gh pr create --base develop --head master --title "chore: merge back vX.X.X release changes" --body "Merge back release changes" 112 | ``` 113 | 114 | ## Version Guidelines 115 | 116 | Follow [Semantic Versioning](https://semver.org/): 117 | 118 | - **PATCH** (x.x.1): Bug fixes, documentation updates 119 | - **MINOR** (x.1.x): New features, backwards compatible changes 120 | - **MAJOR** (1.x.x): Breaking changes, major rewrites 121 | 122 | ## Changelog 123 | 124 | Update the CHANGELOG.md file following the [Keep a Changelog](https://keepachangelog.com/) format: 125 | - Update during development in the `develop` branch under `[Unreleased]` 126 | - Move changes to a versioned section when creating the release 127 | 128 | ## Quick Release Script 129 | 130 | For the develop/master workflow, the release process requires manual PR creation and merging. 131 | The automated steps after PR merge can be scripted: 132 | 133 | ```bash 134 | #!/bin/bash 135 | # release-after-merge.sh 136 | 137 | # Exit on error 138 | set -e 139 | 140 | # This script should be run after merging develop to master 141 | 142 | echo "Switching to master..." 143 | git checkout master 144 | git pull origin master 145 | 146 | echo "Running pre-release checks..." 147 | npm run lint 148 | npm run typecheck 149 | npm test 150 | 151 | echo "Creating release..." 152 | npm version "$1" -m "Release v%s" 153 | 154 | echo "Pushing to repository..." 155 | git push origin master --follow-tags 156 | 157 | echo "Publishing to npm..." 158 | npm publish 159 | 160 | echo "Creating GitHub release..." 161 | VERSION=$(node -p "require('./package.json').version") 162 | NOTES=$(awk "/^## \[$VERSION\]/{flag=1;next}/^## \[/{flag=0}flag" CHANGELOG.md) 163 | gh release create "v$VERSION" --notes "$NOTES" 164 | 165 | echo "Creating merge-back PR..." 166 | gh pr create --base develop --head master --title "chore: merge back v$(node -p "require('./package.json').version") release changes" --body "Merge back release changes" 167 | 168 | echo "Release complete!" 169 | ``` 170 | 171 | Usage: `./release-after-merge.sh patch|minor|major` 172 | 173 | ## Troubleshooting 174 | 175 | ### npm publish fails 176 | - Ensure you're logged in: `npm whoami` 177 | - Check registry: `npm config get registry` 178 | - Verify package name availability: `npm info ` 179 | 180 | ### Git push fails 181 | - Ensure you have push access to the repository 182 | - Check if branch protection rules are blocking the push 183 | - Verify remote URL: `git remote -v` 184 | 185 | ### Version conflicts 186 | - Always pull latest changes before releasing: `git pull origin master` 187 | - Resolve any conflicts before proceeding with release -------------------------------------------------------------------------------- /docs/claude_project_mapping.md: -------------------------------------------------------------------------------- 1 | # Claude Project Directory Mapping 2 | 3 | ## Overview 4 | 5 | Claude stores conversation files in `~/.claude/projects/` using a specific directory naming pattern that maps to actual project paths. 6 | 7 | ## Directory Naming Pattern 8 | 9 | Claude creates directory names by replacing certain characters in the absolute path with hyphens (`-`). 10 | 11 | ### Transformation Rules: 12 | 13 | 1. **Forward Slash**: `/` → `-` 14 | - Example: `/home/user/git/cc-resume` → `-home-user-git-cc-resume` 15 | 16 | 2. **Dot**: `.` → `-` 17 | - Example: `/home/user/.dotfiles` → `-home-user--dotfiles` 18 | - Note: This creates double hyphens when a dot follows a slash 19 | 20 | 3. **Edge Cases**: 21 | - Paths containing hyphens are preserved, which can lead to consecutive hyphens 22 | - Example: `/home/user/my-project` → `-home-user-my-project` 23 | 24 | ## Implementation 25 | 26 | ### Path to Directory Name 27 | 28 | ```bash 29 | # Convert a path to Claude's directory name format 30 | path_to_claude_dir() { 31 | echo "$1" | sed 's/[/.]/-/g' 32 | } 33 | 34 | # Example usage: 35 | path_to_claude_dir "/home/user/git/cc-resume" 36 | # Output: -home-user-git-cc-resume 37 | 38 | path_to_claude_dir "/home/user/.dotfiles" 39 | # Output: -home-user--dotfiles 40 | ``` 41 | 42 | In JavaScript/TypeScript: 43 | ```typescript 44 | function pathToClaudeDir(path: string): string { 45 | return path.replace(/[/.]/g, '-'); 46 | } 47 | ``` 48 | 49 | ### Directory Name to Path (Reverse) 50 | 51 | ```bash 52 | # Convert Claude's directory name back to path 53 | # WARNING: This transformation is NOT reversible! 54 | # Multiple different paths can map to the same directory name 55 | # For example: 56 | # /home/user/.config → -home-user--config 57 | # /home/user/-config → -home-user--config 58 | # Both produce the same directory name, making reverse mapping ambiguous 59 | ``` 60 | 61 | ## Filtering Conversations by Current Directory 62 | 63 | To filter conversations for the current directory without reading conversation files: 64 | 65 | ```bash 66 | # Get the Claude project directory name for the current path 67 | current_dir=$(pwd) 68 | claude_dir=$(echo "$current_dir" | sed 's/[/.]/-/g') 69 | project_path="$HOME/.claude/projects/$claude_dir" 70 | 71 | # Check if conversations exist for current directory 72 | if [ -d "$project_path" ]; then 73 | echo "Found conversations in: $project_path" 74 | ls -la "$project_path"/*.jsonl 2>/dev/null 75 | else 76 | echo "No conversations found for current directory" 77 | fi 78 | ``` 79 | 80 | ## Examples 81 | 82 | | Actual Path | Claude Directory Name | 83 | |------------|---------------------| 84 | | `/home/user` | `-home-user` | 85 | | `/home/user/git` | `-home-user-git` | 86 | | `/home/user/git/cc-resume` | `-home-user-git-cc-resume` | 87 | | `/home/user/.dotfiles` | `-home-user--dotfiles` | 88 | | `/home/user/playground-20250610` | `-home-user-playground-20250610` | 89 | 90 | ## Performance Implications 91 | 92 | Understanding this mapping is crucial for performance optimization in tools like ccresume: 93 | 94 | 1. **Without directory filtering**: Must read all conversation files to check their `projectPath` 95 | 2. **With directory filtering**: Can skip entire directories at the file system level 96 | 97 | For example, if you have 1000 conversations across 50 projects but only 30 in the current directory: 98 | - Without optimization: Reads 1000 files 99 | - With optimization: Reads only 30 files from the matching directory 100 | 101 | ## Notes 102 | 103 | - The transformation is **not reversible** due to both `/` and `.` mapping to `-` 104 | - All conversation files for a project are stored as `.jsonl` files within the project directory 105 | - Each `.jsonl` file is named with a UUID (e.g., `6b983a59-1103-4657-89fd-b32c30ebe875.jsonl`) 106 | - There is no separate metadata file mapping directory names to paths - the mapping is done through the naming convention itself -------------------------------------------------------------------------------- /docs/images/demo-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasazame/ccresume/74b9f83fb8b92027d11853dc6c4349a0e1d51b5a/docs/images/demo-screenshot.png -------------------------------------------------------------------------------- /docs/images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasazame/ccresume/74b9f83fb8b92027d11853dc6c4349a0e1d51b5a/docs/images/demo.gif -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | import reactPlugin from 'eslint-plugin-react'; 4 | import reactHooksPlugin from 'eslint-plugin-react-hooks'; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | ...tseslint.configs.recommended, 9 | { 10 | files: ['**/*.{ts,tsx}'], 11 | languageOptions: { 12 | ecmaVersion: 2022, 13 | sourceType: 'module', 14 | parserOptions: { 15 | ecmaFeatures: { 16 | jsx: true 17 | } 18 | } 19 | }, 20 | plugins: { 21 | 'react': reactPlugin, 22 | 'react-hooks': reactHooksPlugin 23 | }, 24 | rules: { 25 | ...reactPlugin.configs.recommended.rules, 26 | ...reactHooksPlugin.configs.recommended.rules, 27 | 'react/react-in-jsx-scope': 'off', 28 | '@typescript-eslint/no-explicit-any': 'warn', 29 | '@typescript-eslint/explicit-function-return-type': 'off' 30 | }, 31 | settings: { 32 | react: { 33 | version: '18.2' 34 | } 35 | } 36 | } 37 | ); -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest/presets/default-esm', 3 | testEnvironment: 'node', 4 | extensionsToTreatAsEsm: ['.ts', '.tsx'], 5 | moduleNameMapper: { 6 | '^(\\.{1,2}/.*)\\.js$': '$1' 7 | }, 8 | transform: { 9 | '^.+\\.tsx?$': [ 10 | 'ts-jest', 11 | { 12 | useESM: true, 13 | tsconfig: { 14 | module: 'esnext', 15 | moduleResolution: 'node' 16 | } 17 | }, 18 | ], 19 | }, 20 | transformIgnorePatterns: [ 21 | 'node_modules/(?!(ink-testing-library|ink|@inkjs|cli-cursor|cli-spinners|log-update|strip-ansi|ansi-regex|ansi-escapes|ansi-styles|chalk)/)' 22 | ], 23 | testMatch: ['**/__tests__/**/*.test.ts?(x)'], 24 | collectCoverageFrom: [ 25 | 'src/**/*.{ts,tsx}', 26 | '!src/**/*.d.ts', 27 | '!src/**/index.ts', 28 | '!src/cli.tsx' 29 | ] 30 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sasazame/ccresume", 3 | "version": "1.0.0", 4 | "description": "A TUI tool for browsing and resuming Claude Code conversations", 5 | "type": "module", 6 | "bin": { 7 | "ccresume": "./dist/cli.js" 8 | }, 9 | "main": "dist/index.js", 10 | "types": "dist/index.d.ts", 11 | "scripts": { 12 | "build": "tsc", 13 | "dev": "tsx src/cli.tsx", 14 | "prepare": "npm run build", 15 | "lint": "eslint src --ext .ts,.tsx", 16 | "typecheck": "tsc --noEmit", 17 | "test": "NODE_OPTIONS='--experimental-vm-modules' jest", 18 | "test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --watch", 19 | "test:coverage": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage" 20 | }, 21 | "keywords": [ 22 | "claude", 23 | "claude-code", 24 | "cli", 25 | "tui", 26 | "terminal" 27 | ], 28 | "author": "ccresume contributors", 29 | "license": "MIT", 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/sasazame/ccresume.git" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/sasazame/ccresume/issues" 36 | }, 37 | "homepage": "https://github.com/sasazame/ccresume#readme", 38 | "dependencies": { 39 | "@iarna/toml": "^2.2.5", 40 | "clipboardy": "^5.0.0", 41 | "date-fns": "^4.1.0", 42 | "ink": "^5.2.1", 43 | "react": "^18.3.1" 44 | }, 45 | "devDependencies": { 46 | "@types/jest": "^30.0.0", 47 | "@types/node": "^24.0.10", 48 | "@types/react": "^18.2.48", 49 | "@typescript-eslint/eslint-plugin": "^8.35.1", 50 | "@typescript-eslint/parser": "^8.35.1", 51 | "eslint": "^9.30.1", 52 | "eslint-plugin-react": "^7.33.2", 53 | "eslint-plugin-react-hooks": "^5.2.0", 54 | "ink-testing-library": "^4.0.0", 55 | "jest": "^30.0.3", 56 | "ts-jest": "^29.4.0", 57 | "tsx": "^4.7.0", 58 | "typescript": "^5.3.3", 59 | "typescript-eslint": "^8.35.1" 60 | }, 61 | "engines": { 62 | "node": ">=18" 63 | }, 64 | "files": [ 65 | "dist" 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Box, Text, useInput, useApp, useStdout } from 'ink'; 3 | import { ConversationList } from './components/ConversationList.js'; 4 | import { ConversationPreview } from './components/ConversationPreview.js'; 5 | import { ConversationPreviewFull } from './components/ConversationPreviewFull.js'; 6 | import { CommandEditor } from './components/CommandEditor.js'; 7 | import { getPaginatedConversations } from './utils/conversationReader.js'; 8 | import { spawn } from 'child_process'; 9 | import clipboardy from 'clipboardy'; 10 | import type { Conversation } from './types.js'; 11 | import { loadConfig } from './utils/configLoader.js'; 12 | import { matchesKeyBinding } from './utils/keyBindingHelper.js'; 13 | import type { Config } from './types/config.js'; 14 | 15 | interface AppProps { 16 | claudeArgs?: string[]; 17 | currentDirOnly?: boolean; 18 | hideOptions?: string[]; 19 | } 20 | 21 | // Layout constants 22 | const ITEMS_PER_PAGE = 30; 23 | const HEADER_HEIGHT = 2; // Title + pagination info 24 | const LIST_MAX_HEIGHT = 9; // Maximum height for conversation list 25 | const LIST_BASE_HEIGHT = 3; // Borders (2) + title (1) 26 | const MAX_VISIBLE_CONVERSATIONS = 4; // Maximum conversations shown per page 27 | const BOTTOM_MARGIN = 1; // Bottom margin to absorb overflow 28 | const SAFETY_MARGIN = 1; // Prevents Ink from clearing terminal when output approaches height limit 29 | const MIN_PREVIEW_HEIGHT = 10; // Minimum height for conversation preview 30 | const DEFAULT_TERMINAL_WIDTH = 80; 31 | const DEFAULT_TERMINAL_HEIGHT = 24; 32 | const EXECUTE_DELAY_MS = 500; // Delay before executing command to show status 33 | const STATUS_MESSAGE_DURATION_MS = 2000; // Duration to show status messages 34 | 35 | const App: React.FC = ({ claudeArgs = [], currentDirOnly = false, hideOptions = [] }) => { 36 | const { exit } = useApp(); 37 | const { stdout } = useStdout(); 38 | const [conversations, setConversations] = useState([]); 39 | const [selectedIndex, setSelectedIndex] = useState(0); 40 | const [loading, setLoading] = useState(true); 41 | const [error, setError] = useState(null); 42 | const [dimensions, setDimensions] = useState({ width: DEFAULT_TERMINAL_WIDTH, height: DEFAULT_TERMINAL_HEIGHT }); 43 | const [statusMessage, setStatusMessage] = useState(null); 44 | const [config, setConfig] = useState(null); 45 | const [showCommandEditor, setShowCommandEditor] = useState(false); 46 | const [editedArgs, setEditedArgs] = useState(claudeArgs); 47 | const [showFullView, setShowFullView] = useState(false); 48 | 49 | // Pagination state 50 | const [currentPage, setCurrentPage] = useState(0); 51 | const [totalCount, setTotalCount] = useState(0); 52 | const [paginating, setPaginating] = useState(false); 53 | 54 | useEffect(() => { 55 | // Load config on mount 56 | const loadedConfig = loadConfig(); 57 | setConfig(loadedConfig); 58 | }, []); 59 | 60 | useEffect(() => { 61 | loadConversations(); 62 | }, [currentDirOnly]); // eslint-disable-line react-hooks/exhaustive-deps 63 | 64 | useEffect(() => { 65 | // Update dimensions on terminal resize 66 | const updateDimensions = () => { 67 | setDimensions({ 68 | width: stdout.columns || DEFAULT_TERMINAL_WIDTH, 69 | height: stdout.rows || DEFAULT_TERMINAL_HEIGHT 70 | }); 71 | }; 72 | 73 | updateDimensions(); 74 | if (stdout) { 75 | stdout.on('resize', updateDimensions); 76 | return () => { 77 | stdout.off('resize', updateDimensions); 78 | }; 79 | } 80 | return undefined; 81 | }, [stdout]); 82 | 83 | const executeClaudeCommand = ( 84 | conversation: Conversation, 85 | args: string[], 86 | statusMsg: string, 87 | actionType: 'resume' | 'start' 88 | ) => { 89 | const commandStr = `claude ${args.join(' ')}`; 90 | setStatusMessage(statusMsg); 91 | 92 | setTimeout(() => { 93 | exit(); 94 | 95 | // Output helpful information 96 | if (actionType === 'resume') { 97 | console.log(`\nResuming conversation: ${conversation.sessionId}`); 98 | } else { 99 | console.log(`\nStarting new session in: ${conversation.projectPath}`); 100 | } 101 | console.log(`Directory: ${conversation.projectPath}`); 102 | console.log(`Executing: ${commandStr}`); 103 | console.log('---'); 104 | 105 | // Windows-specific reminder 106 | if (process.platform === 'win32') { 107 | console.log('💡 Reminder: If input doesn\'t work, press ENTER to activate.'); 108 | console.log(''); 109 | } 110 | 111 | // Spawn claude process 112 | const claude = spawn(commandStr, { 113 | stdio: 'inherit', 114 | cwd: conversation.projectPath, 115 | shell: true 116 | }); 117 | 118 | claude.on('error', (err) => { 119 | console.error(`\nFailed to ${actionType} ${actionType === 'resume' ? 'conversation' : 'new session'}:`, err.message); 120 | console.error('Make sure Claude Code is installed and available in PATH'); 121 | console.error(`Or the project directory might not exist: ${conversation.projectPath}`); 122 | 123 | // For resume action, provide clipboard fallback 124 | if (actionType === 'resume') { 125 | try { 126 | clipboardy.writeSync(conversation.sessionId); 127 | console.log(`\nSession ID copied to clipboard: ${conversation.sessionId}`); 128 | console.log(`Project directory: ${conversation.projectPath}`); 129 | console.log(`You can manually run:`); 130 | console.log(` cd "${conversation.projectPath}"`); 131 | const argsStr = claudeArgs.length > 0 ? claudeArgs.join(' ') + ' ' : ''; 132 | console.log(` claude ${argsStr}--resume ${conversation.sessionId}`); 133 | } catch (clipErr) { 134 | console.error('Failed to copy to clipboard:', clipErr instanceof Error ? clipErr.message : String(clipErr)); 135 | } 136 | } 137 | 138 | process.exit(1); 139 | }); 140 | 141 | claude.on('close', (code) => { 142 | process.exit(code || 0); 143 | }); 144 | }, EXECUTE_DELAY_MS); 145 | }; 146 | 147 | const loadConversations = async (isPaginating = false) => { 148 | try { 149 | if (isPaginating) { 150 | setPaginating(true); 151 | setConversations([]); // Clear current conversations 152 | } else { 153 | setLoading(true); 154 | } 155 | 156 | const currentDir = currentDirOnly ? process.cwd() : undefined; 157 | 158 | // Load paginated conversations 159 | const offset = currentPage * ITEMS_PER_PAGE; 160 | const { conversations: convs, total } = await getPaginatedConversations({ 161 | limit: ITEMS_PER_PAGE, 162 | offset, 163 | currentDirFilter: currentDir 164 | }); 165 | setConversations(convs); 166 | setTotalCount(total); 167 | 168 | setLoading(false); 169 | setPaginating(false); 170 | } catch (err) { 171 | setError(err instanceof Error ? err.message : 'Failed to load conversations'); 172 | setLoading(false); 173 | setPaginating(false); 174 | } 175 | }; 176 | 177 | // Track previous page for detecting page changes 178 | const [prevPage, setPrevPage] = useState(0); 179 | 180 | // Reload conversations when page changes 181 | useEffect(() => { 182 | const isPaginating = currentPage !== prevPage; 183 | setPrevPage(currentPage); 184 | loadConversations(isPaginating); 185 | }, [currentPage, currentDirOnly]); // eslint-disable-line react-hooks/exhaustive-deps 186 | 187 | useInput((input, key) => { 188 | // Don't process any input when command editor is shown 189 | if (showCommandEditor) return; 190 | 191 | if (!config) return; 192 | 193 | if (matchesKeyBinding(input, key, config.keybindings.quit)) { 194 | exit(); 195 | } 196 | 197 | // Handle full view toggle first 198 | if (matchesKeyBinding(input, key, config.keybindings.toggleFullView)) { 199 | setShowFullView(prev => !prev); 200 | // Show temporary status message 201 | setStatusMessage(showFullView ? 'Switched to normal view' : 'Switched to full view'); 202 | setTimeout(() => setStatusMessage(null), STATUS_MESSAGE_DURATION_MS); 203 | return; 204 | } 205 | 206 | // In full view, disable all navigation keys except quit and toggle 207 | if (showFullView) { 208 | return; 209 | } 210 | 211 | if (loading || conversations.length === 0) return; 212 | 213 | // Calculate pagination values 214 | const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE); 215 | 216 | if (matchesKeyBinding(input, key, config.keybindings.selectPrevious)) { 217 | if (selectedIndex === 0 && currentPage > 0) { 218 | // Auto-navigate to previous page when at first item 219 | setCurrentPage(prev => prev - 1); 220 | setSelectedIndex(ITEMS_PER_PAGE - 1); // Select last item of previous page 221 | } else { 222 | setSelectedIndex((prev) => Math.max(0, prev - 1)); 223 | } 224 | } 225 | 226 | if (matchesKeyBinding(input, key, config.keybindings.selectNext)) { 227 | const maxIndex = conversations.length - 1; 228 | const canGoNext = totalCount === -1 ? conversations.length === ITEMS_PER_PAGE : currentPage < totalPages - 1; 229 | if (selectedIndex === maxIndex && canGoNext) { 230 | // Auto-navigate to next page when at last item 231 | setCurrentPage(prev => prev + 1); 232 | setSelectedIndex(0); // Select first item of next page 233 | } else { 234 | setSelectedIndex((prev) => Math.min(maxIndex, prev + 1)); 235 | } 236 | } 237 | 238 | // Page navigation with arrow keys and n/p 239 | if (matchesKeyBinding(input, key, config.keybindings.pageNext)) { 240 | // For unknown total (-1), allow next if we got full page 241 | if (totalCount === -1 ? conversations.length === ITEMS_PER_PAGE : currentPage < totalPages - 1) { 242 | setCurrentPage(prev => prev + 1); 243 | setSelectedIndex(0); // Reset selection to first item of new page 244 | } 245 | } 246 | 247 | if (matchesKeyBinding(input, key, config.keybindings.pagePrevious) && currentPage > 0) { 248 | setCurrentPage(prev => prev - 1); 249 | setSelectedIndex(0); // Reset selection to first item of new page 250 | } 251 | 252 | 253 | if (matchesKeyBinding(input, key, config.keybindings.confirm)) { 254 | const selectedConv = conversations[selectedIndex]; 255 | if (selectedConv) { 256 | const commandArgs = [...editedArgs, '--resume', selectedConv.sessionId]; 257 | const commandStr = `claude ${commandArgs.join(' ')}`; 258 | executeClaudeCommand( 259 | selectedConv, 260 | commandArgs, 261 | `Executing: ${commandStr}`, 262 | 'resume' 263 | ); 264 | } 265 | } 266 | 267 | if (matchesKeyBinding(input, key, config.keybindings.copySessionId)) { 268 | // Copy session ID to clipboard 269 | const selectedConv = conversations[selectedIndex]; 270 | if (selectedConv) { 271 | try { 272 | clipboardy.writeSync(selectedConv.sessionId); 273 | // Show temporary status message 274 | setStatusMessage('✓ Session ID copied to clipboard!'); 275 | setTimeout(() => setStatusMessage(null), STATUS_MESSAGE_DURATION_MS); 276 | } catch { 277 | setStatusMessage('✗ Failed to copy to clipboard'); 278 | setTimeout(() => setStatusMessage(null), STATUS_MESSAGE_DURATION_MS); 279 | } 280 | } 281 | } 282 | 283 | if (matchesKeyBinding(input, key, config.keybindings.startNewSession)) { 284 | // Start new session without resuming 285 | const selectedConv = conversations[selectedIndex]; 286 | if (selectedConv) { 287 | const commandArgs = [...editedArgs]; 288 | executeClaudeCommand( 289 | selectedConv, 290 | commandArgs, 291 | `Starting new session in: ${selectedConv.projectPath}`, 292 | 'start' 293 | ); 294 | } 295 | } 296 | 297 | if (matchesKeyBinding(input, key, config.keybindings.openCommandEditor)) { 298 | setShowCommandEditor(true); 299 | } 300 | 301 | }); 302 | 303 | if (loading) { 304 | return ( 305 | 306 | Loading conversations... 307 | 308 | ); 309 | } 310 | 311 | if (error) { 312 | return ( 313 | 314 | Error: {error} 315 | 316 | ); 317 | } 318 | 319 | // Get the selected conversation 320 | const selectedConversation = conversations[selectedIndex] || null; 321 | 322 | const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE); 323 | 324 | // Calculate heights for fixed layout 325 | const headerHeight = HEADER_HEIGHT; 326 | const listMaxHeight = LIST_MAX_HEIGHT; 327 | const visibleConversations = Math.min(MAX_VISIBLE_CONVERSATIONS, conversations.length); 328 | // List height calculation: 329 | // LIST_BASE_HEIGHT includes borders (2) + title (1) 330 | const needsMoreIndicator = conversations.length > visibleConversations ? 1 : 0; 331 | const listHeight = Math.min(listMaxHeight, LIST_BASE_HEIGHT + visibleConversations + needsMoreIndicator); 332 | 333 | // Add safety margin to prevent exceeding terminal height 334 | const safetyMargin = SAFETY_MARGIN; 335 | const bottomMargin = BOTTOM_MARGIN; 336 | const totalUsedHeight = headerHeight + listHeight + bottomMargin + safetyMargin; 337 | const previewHeight = Math.max(MIN_PREVIEW_HEIGHT, dimensions.height - totalUsedHeight); 338 | 339 | if (showCommandEditor) { 340 | return ( 341 | { 344 | setEditedArgs(args); 345 | setShowCommandEditor(false); 346 | }} 347 | onCancel={() => setShowCommandEditor(false)} 348 | /> 349 | ); 350 | } 351 | 352 | if (showFullView) { 353 | return ; 354 | } 355 | 356 | return ( 357 | 358 | 359 | ccresume - Claude Code Conversation Browser 360 | 361 | 362 | {(() => { 363 | const prevKeys = config?.keybindings.pagePrevious.map(k => k === 'left' ? '←' : k).join('/') || '←'; 364 | const nextKeys = config?.keybindings.pageNext.map(k => k === 'right' ? '→' : k).join('/') || '→'; 365 | const pageHelp = `Press ${prevKeys}/${nextKeys} for pages`; 366 | 367 | return totalCount === -1 ? ( 368 | <>Page {currentPage + 1} | {pageHelp} 369 | ) : ( 370 | <>{totalCount} total | Page {currentPage + 1}/{totalPages || 1} | {pageHelp} 371 | ); 372 | })()} 373 | 374 | {editedArgs.length > 0 && ( 375 | | Options: {editedArgs.join(' ')} 376 | )} 377 | 378 | 379 | 380 | 381 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | {/* Bottom margin to absorb any overflow */} 394 | 395 | 396 | ); 397 | }; 398 | 399 | export default App; -------------------------------------------------------------------------------- /src/__tests__/CommandEditor.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'ink-testing-library'; 3 | import { CommandEditor } from '../components/CommandEditor.js'; 4 | import { jest, describe, it, expect, beforeEach } from '@jest/globals'; 5 | 6 | describe('CommandEditor', () => { 7 | const mockOnComplete = jest.fn(); 8 | const mockOnCancel = jest.fn(); 9 | 10 | beforeEach(() => { 11 | jest.clearAllMocks(); 12 | }); 13 | 14 | it('renders the command editor with initial args', () => { 15 | const { lastFrame } = render( 16 | 21 | ); 22 | 23 | expect(lastFrame()).toContain('Edit command options for Claude'); 24 | expect(lastFrame()).toContain('claude --dangerously-skip-permissions'); 25 | }); 26 | 27 | it('shows available options', () => { 28 | const { lastFrame } = render( 29 | 34 | ); 35 | 36 | expect(lastFrame()).toContain('Available Options:'); 37 | expect(lastFrame()).toContain('-p, --print'); 38 | expect(lastFrame()).toContain('-c, --continue'); 39 | expect(lastFrame()).toContain('Please refer to official docs'); 40 | }); 41 | 42 | it('displays edit instructions', () => { 43 | const { lastFrame } = render( 44 | 49 | ); 50 | 51 | expect(lastFrame()).toContain('Edit command options for Claude. Press Enter to confirm, Esc to cancel.'); 52 | expect(lastFrame()).toContain('Shortcuts: Enter=confirm, Esc=cancel, ←/→=move cursor, Tab=autocomplete'); 53 | }); 54 | 55 | it('updates command line when typing', () => { 56 | const { lastFrame, stdin } = render( 57 | 62 | ); 63 | 64 | // Type '--debug' 65 | stdin.write('--debug'); 66 | 67 | const frame = lastFrame(); 68 | expect(frame).toContain('Command: claude --debug'); 69 | }); 70 | 71 | it('calls onComplete when Enter is pressed with no suggestions', () => { 72 | const { stdin } = render( 73 | 78 | ); 79 | 80 | stdin.write('\r'); // Enter key 81 | 82 | expect(mockOnComplete).toHaveBeenCalledWith(['--debug']); 83 | expect(mockOnCancel).not.toHaveBeenCalled(); 84 | }); 85 | 86 | // Skipping escape key test due to ink-testing-library limitations 87 | 88 | // Skipping Ctrl+C test due to ink-testing-library limitations 89 | 90 | it('handles text editing with backspace', () => { 91 | const { lastFrame, stdin } = render( 92 | 97 | ); 98 | 99 | // Delete last character 100 | stdin.write('\x7F'); // Backspace 101 | 102 | expect(lastFrame()).toContain('claude --tes'); 103 | }); 104 | 105 | it('handles initial args correctly', () => { 106 | const { stdin } = render( 107 | 112 | ); 113 | 114 | // Just press enter to confirm 115 | stdin.write('\r'); 116 | 117 | expect(mockOnComplete).toHaveBeenCalledWith(['--test', '--debug']); 118 | }); 119 | 120 | it('shows cursor position correctly', () => { 121 | const { lastFrame } = render( 122 | 127 | ); 128 | 129 | // The cursor should be shown with inverse text 130 | expect(lastFrame()).toMatch(/claude --test/); 131 | }); 132 | }); -------------------------------------------------------------------------------- /src/__tests__/ConversationList.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'ink-testing-library'; 3 | import { ConversationList } from '../components/ConversationList.js'; 4 | import type { Conversation } from '../types.js'; 5 | 6 | describe('ConversationList', () => { 7 | const mockConversation: Conversation = { 8 | sessionId: '12345678-1234-1234-1234-123456789012', 9 | projectPath: '/home/user/project', 10 | projectName: 'test-project', 11 | messages: [ 12 | { 13 | type: 'user', 14 | message: { 15 | role: 'user', 16 | content: 'Test message' 17 | }, 18 | timestamp: '2024-01-01T12:00:00Z', 19 | sessionId: '12345678-1234-1234-1234-123456789012', 20 | cwd: '/home/user/project' 21 | } 22 | ], 23 | firstMessage: 'Test message', 24 | lastMessage: 'Test message', 25 | startTime: new Date('2024-01-01T12:00:00Z'), 26 | endTime: new Date('2024-01-01T12:30:00Z') 27 | }; 28 | 29 | it('renders empty state when no conversations', () => { 30 | const { lastFrame } = render( 31 | 32 | ); 33 | 34 | expect(lastFrame()).toContain('No conversations found'); 35 | }); 36 | 37 | it('renders conversation list correctly', () => { 38 | const { lastFrame } = render( 39 | 43 | ); 44 | 45 | expect(lastFrame()).toContain('Select a conversation (1 shown):'); 46 | // Session ID is no longer displayed in the list 47 | expect(lastFrame()).not.toContain('[12345678]'); 48 | expect(lastFrame()).toContain('/home/user/project'); // Full project path (shortening only works for actual home directory) 49 | expect(lastFrame()).toContain('Test message'); // Summary 50 | }); 51 | 52 | it('shows selected conversation with indicator', () => { 53 | const conversations = [ 54 | mockConversation, 55 | { ...mockConversation, sessionId: '87654321-1234-1234-1234-123456789012' } 56 | ]; 57 | 58 | const { lastFrame } = render( 59 | 63 | ); 64 | 65 | // Selected indicator is shown, but without session ID 66 | const output = lastFrame(); 67 | expect(output).toMatch(/▶.*\/home\/user\/project/); // Selected indicator with path 68 | }); 69 | 70 | it('shows more indicator when conversations exceed maxVisible', () => { 71 | const conversations = Array.from({ length: 10 }, (_, i) => ({ 72 | ...mockConversation, 73 | sessionId: `session-${i}` 74 | })); 75 | 76 | const { lastFrame } = render( 77 | 82 | ); 83 | 84 | expect(lastFrame()).toContain('↓ 7 more on this page...'); 85 | }); 86 | }); -------------------------------------------------------------------------------- /src/__tests__/ConversationPreview.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'ink-testing-library'; 3 | import { jest } from '@jest/globals'; 4 | import type { Conversation } from '../types.js'; 5 | import { defaultConfig } from '../types/config.js'; 6 | 7 | // Mock the config loader to return a consistent config synchronously 8 | jest.unstable_mockModule('../utils/configLoader.js', () => ({ 9 | loadConfig: () => defaultConfig 10 | })); 11 | 12 | // Import ConversationPreview after mocking 13 | const { ConversationPreview } = await import('../components/ConversationPreview.js'); 14 | 15 | describe('ConversationPreview', () => { 16 | const mockConversation: Conversation = { 17 | sessionId: '12345678-1234-1234-1234-123456789012', 18 | projectPath: '/home/user/project', 19 | projectName: 'test-project', 20 | messages: [ 21 | { 22 | type: 'user', 23 | message: { 24 | role: 'user', 25 | content: 'Hello, this is a test message' 26 | }, 27 | timestamp: '2024-01-01T12:00:00Z', 28 | sessionId: '12345678-1234-1234-1234-123456789012', 29 | cwd: '/home/user/project' 30 | }, 31 | { 32 | type: 'assistant', 33 | message: { 34 | role: 'assistant', 35 | content: 'This is a response' 36 | }, 37 | timestamp: '2024-01-01T12:01:00Z', 38 | sessionId: '12345678-1234-1234-1234-123456789012', 39 | cwd: '/home/user/project' 40 | } 41 | ], 42 | firstMessage: 'Hello, this is a test message', 43 | lastMessage: 'Hello, this is a test message', 44 | startTime: new Date('2024-01-01T12:00:00Z'), 45 | endTime: new Date('2024-01-01T12:30:00Z') 46 | }; 47 | 48 | it('renders empty state when no conversation selected', () => { 49 | const { lastFrame } = render( 50 | 51 | ); 52 | 53 | // Should render an empty box without text 54 | expect(lastFrame()).not.toContain('Select a conversation to preview'); 55 | }); 56 | 57 | it('renders conversation header correctly', () => { 58 | const { lastFrame } = render( 59 | 60 | ); 61 | 62 | expect(lastFrame()).toContain('Conversation History'); 63 | expect(lastFrame()).toContain('(2 messages, 30 min)'); 64 | expect(lastFrame()).toContain('Session:'); 65 | expect(lastFrame()).toContain('12345678-1234-1234-1234-123456789012'); 66 | expect(lastFrame()).toContain('Directory:'); 67 | expect(lastFrame()).toContain('/home/user/project'); 68 | }); 69 | 70 | it('renders messages correctly', () => { 71 | const { lastFrame } = render( 72 | 73 | ); 74 | 75 | expect(lastFrame()).toContain('User'); 76 | expect(lastFrame()).toContain('Hello, this is a test message'); 77 | expect(lastFrame()).toContain('Assistant'); 78 | expect(lastFrame()).toContain('This is a response'); 79 | }); 80 | 81 | it('renders scroll help text', () => { 82 | const { lastFrame } = render( 83 | 84 | ); 85 | 86 | // Check for shortcut text - could be either compact or full version 87 | const frame = lastFrame(); 88 | expect(frame).toMatch(/(?:Scroll:|kj:Scroll)/); 89 | expect(frame).toMatch(/(?:Resume:|Enter:Resume)/); 90 | expect(frame).toMatch(/(?:Quit:|q:Quit)/); 91 | }); 92 | 93 | it('shows navigation help for long conversations', () => { 94 | const longConversation = { 95 | ...mockConversation, 96 | messages: Array.from({ length: 20 }, (_, i) => ({ 97 | type: 'user' as const, 98 | message: { role: 'user' as const, content: `Message ${i}` }, 99 | timestamp: new Date(2024, 0, 1, 12, i).toISOString(), 100 | sessionId: mockConversation.sessionId, 101 | cwd: mockConversation.projectPath 102 | })) 103 | }; 104 | 105 | const { lastFrame } = render( 106 | 107 | ); 108 | 109 | // Should show navigation help (scroll indicators were removed) 110 | const frame = lastFrame(); 111 | const hasShortcuts = frame.includes('Scroll:') || frame.includes(':Scroll'); 112 | const hasLoading = frame.includes('Loading shortcuts...'); 113 | expect(hasShortcuts || hasLoading).toBe(true); 114 | }); 115 | 116 | describe('Status Messages', () => { 117 | it('displays custom status message', () => { 118 | const { lastFrame } = render( 119 | 123 | ); 124 | 125 | expect(lastFrame()).toContain('Custom status message'); 126 | expect(lastFrame()).not.toContain('Scroll: j/k'); 127 | }); 128 | 129 | it('shows default help when no status message', () => { 130 | const { lastFrame } = render( 131 | 135 | ); 136 | 137 | const frame = lastFrame(); 138 | const hasShortcuts = frame.includes('Scroll:') || frame.includes(':Scroll'); 139 | const hasLoading = frame.includes('Loading shortcuts...'); 140 | expect(hasShortcuts || hasLoading).toBe(true); 141 | }); 142 | }); 143 | 144 | describe('Scrolling', () => { 145 | const createLongConversation = () => ({ 146 | ...mockConversation, 147 | messages: Array.from({ length: 30 }, (_, i) => ({ 148 | type: (i % 2 === 0 ? 'user' : 'assistant') as const, 149 | message: { 150 | role: (i % 2 === 0 ? 'user' : 'assistant') as const, 151 | content: `Message ${i}` 152 | }, 153 | timestamp: new Date(2024, 0, 1, 12, i).toISOString(), 154 | sessionId: mockConversation.sessionId, 155 | cwd: mockConversation.projectPath 156 | })) 157 | }); 158 | 159 | it('scrolls to bottom on initial render', () => { 160 | const longConv = createLongConversation(); 161 | const { lastFrame } = render( 162 | 163 | ); 164 | 165 | // Should show recent messages (near the end) 166 | expect(lastFrame()).toContain('Message 2'); // Will see some of the latest 167 | }); 168 | 169 | it('scrolls up with k key', () => { 170 | const longConv = createLongConversation(); 171 | const { stdin, lastFrame } = render( 172 | 173 | ); 174 | 175 | // Press k to scroll up 176 | stdin.write('k'); 177 | 178 | // Frame should update after scrolling 179 | expect(lastFrame()).toBeDefined(); 180 | }); 181 | 182 | it('scrolls down with j key', () => { 183 | const longConv = createLongConversation(); 184 | const { stdin, lastFrame } = render( 185 | 186 | ); 187 | 188 | // First scroll up 189 | stdin.write('k'); 190 | stdin.write('k'); 191 | 192 | // Then scroll down 193 | stdin.write('j'); 194 | 195 | expect(lastFrame()).toBeDefined(); 196 | }); 197 | 198 | it('jumps to top with g key', () => { 199 | const longConv = createLongConversation(); 200 | const { stdin, lastFrame } = render( 201 | 202 | ); 203 | 204 | // Jump to top 205 | stdin.write('g'); 206 | 207 | // Should see first messages 208 | expect(lastFrame()).toContain('Message 0'); 209 | }); 210 | 211 | it('jumps to bottom with G key', () => { 212 | const longConv = createLongConversation(); 213 | const { stdin, lastFrame } = render( 214 | 215 | ); 216 | 217 | // First go to top 218 | stdin.write('g'); 219 | 220 | // Then jump to bottom 221 | stdin.write('G'); 222 | 223 | expect(lastFrame()).toBeDefined(); 224 | }); 225 | 226 | it('page down with d key', () => { 227 | const longConv = createLongConversation(); 228 | const { stdin, lastFrame } = render( 229 | 230 | ); 231 | 232 | // Go to top first 233 | stdin.write('g'); 234 | 235 | // Page down 236 | stdin.write('d'); 237 | 238 | expect(lastFrame()).toBeDefined(); 239 | }); 240 | 241 | it('page up with u key', () => { 242 | const longConv = createLongConversation(); 243 | const { stdin, lastFrame } = render( 244 | 245 | ); 246 | 247 | // Page up 248 | stdin.write('u'); 249 | 250 | expect(lastFrame()).toBeDefined(); 251 | }); 252 | 253 | it('handles Ctrl+D for page down', () => { 254 | const longConv = createLongConversation(); 255 | const { stdin, lastFrame } = render( 256 | 257 | ); 258 | 259 | // Ctrl+D 260 | stdin.write('\x04'); 261 | 262 | expect(lastFrame()).toBeDefined(); 263 | }); 264 | 265 | it('handles Ctrl+U for page up', () => { 266 | const longConv = createLongConversation(); 267 | const { stdin, lastFrame } = render( 268 | 269 | ); 270 | 271 | // Ctrl+U 272 | stdin.write('\x15'); 273 | 274 | expect(lastFrame()).toBeDefined(); 275 | }); 276 | 277 | it('handles Ctrl+N for line down', () => { 278 | const longConv = createLongConversation(); 279 | const { stdin, lastFrame } = render( 280 | 281 | ); 282 | 283 | // Ctrl+N 284 | stdin.write('\x0E'); 285 | 286 | expect(lastFrame()).toBeDefined(); 287 | }); 288 | 289 | it('handles Ctrl+P for line up', () => { 290 | const longConv = createLongConversation(); 291 | const { stdin, lastFrame } = render( 292 | 293 | ); 294 | 295 | // Ctrl+P 296 | stdin.write('\x10'); 297 | 298 | expect(lastFrame()).toBeDefined(); 299 | }); 300 | 301 | it('handles page up/down keys', () => { 302 | const longConv = createLongConversation(); 303 | const { stdin, lastFrame } = render( 304 | 305 | ); 306 | 307 | // PageDown 308 | stdin.write('\u001B[6~'); 309 | 310 | // PageUp 311 | stdin.write('\u001B[5~'); 312 | 313 | expect(lastFrame()).toBeDefined(); 314 | }); 315 | 316 | it('respects scroll boundaries', () => { 317 | const longConv = createLongConversation(); 318 | const { stdin, lastFrame } = render( 319 | 320 | ); 321 | 322 | // Try to scroll past top 323 | stdin.write('g'); // Go to top 324 | stdin.write('k'); // Try to go up more 325 | stdin.write('k'); // Try to go up more 326 | 327 | // Should still show first message 328 | expect(lastFrame()).toContain('Message 0'); 329 | 330 | // Try to scroll past bottom 331 | stdin.write('G'); // Go to bottom 332 | stdin.write('j'); // Try to go down more 333 | stdin.write('j'); // Try to go down more 334 | 335 | expect(lastFrame()).toBeDefined(); 336 | }); 337 | 338 | it('does not scroll when conversation is null', () => { 339 | const { stdin, lastFrame } = render( 340 | 341 | ); 342 | 343 | // Try various scroll commands 344 | stdin.write('j'); 345 | stdin.write('k'); 346 | stdin.write('g'); 347 | stdin.write('G'); 348 | 349 | // Should still show empty state (no text) 350 | expect(lastFrame()).not.toContain('Select a conversation to preview'); 351 | }); 352 | }); 353 | 354 | describe('Message Rendering', () => { 355 | it('handles messages without proper structure', () => { 356 | const malformedConv = { 357 | ...mockConversation, 358 | messages: [ 359 | null as unknown as Message, 360 | { type: 'user' }, // Missing message property 361 | mockConversation.messages[0], 362 | { message: null } as unknown as Message // Null message 363 | ] 364 | }; 365 | 366 | const { lastFrame } = render( 367 | 368 | ); 369 | 370 | // Should only render valid message 371 | expect(lastFrame()).toContain('Hello, this is a test message'); 372 | }); 373 | 374 | it('handles tool result messages', () => { 375 | const toolConv = { 376 | ...mockConversation, 377 | messages: [ 378 | ...mockConversation.messages, 379 | { 380 | type: 'assistant' as const, 381 | message: null, 382 | toolUseResult: { 383 | stdout: 'Command output here' 384 | }, 385 | timestamp: '2024-01-01T12:02:00Z', 386 | sessionId: mockConversation.sessionId, 387 | cwd: mockConversation.projectPath 388 | } as Message 389 | ] 390 | }; 391 | 392 | const { lastFrame } = render( 393 | 394 | ); 395 | 396 | expect(lastFrame()).toContain('[Tool Output]'); 397 | expect(lastFrame()).toContain('Command output here'); 398 | }); 399 | 400 | it('handles tool error messages', () => { 401 | const toolErrorConv = { 402 | ...mockConversation, 403 | messages: [ 404 | { 405 | type: 'assistant' as const, 406 | message: null, 407 | toolUseResult: { 408 | stderr: 'Error message here' 409 | }, 410 | timestamp: '2024-01-01T12:02:00Z', 411 | sessionId: mockConversation.sessionId, 412 | cwd: mockConversation.projectPath 413 | } as Message 414 | ] 415 | }; 416 | 417 | const { lastFrame } = render( 418 | 419 | ); 420 | 421 | expect(lastFrame()).toContain('[Tool Error]'); 422 | expect(lastFrame()).toContain('Error message here'); 423 | }); 424 | 425 | it('handles file list results', () => { 426 | const fileListConv = { 427 | ...mockConversation, 428 | messages: [ 429 | { 430 | type: 'assistant' as const, 431 | message: null, 432 | toolUseResult: { 433 | filenames: ['file1.txt', 'file2.js', 'file3.md'] 434 | }, 435 | timestamp: '2024-01-01T12:02:00Z', 436 | sessionId: mockConversation.sessionId, 437 | cwd: mockConversation.projectPath 438 | } as Message 439 | ] 440 | }; 441 | 442 | const { lastFrame } = render( 443 | 444 | ); 445 | 446 | expect(lastFrame()).toContain('[Files Found: 3]'); 447 | expect(lastFrame()).toContain('file1.txt'); 448 | expect(lastFrame()).toContain('file2.js'); 449 | expect(lastFrame()).toContain('file3.md'); 450 | }); 451 | 452 | it('truncates long file lists', () => { 453 | const manyFilesConv = { 454 | ...mockConversation, 455 | messages: [ 456 | { 457 | type: 'assistant' as const, 458 | message: null, 459 | toolUseResult: { 460 | filenames: Array.from({ length: 15 }, (_, i) => `file${i}.txt`) 461 | }, 462 | timestamp: '2024-01-01T12:02:00Z', 463 | sessionId: mockConversation.sessionId, 464 | cwd: mockConversation.projectPath 465 | } as Message 466 | ] 467 | }; 468 | 469 | const { lastFrame } = render( 470 | 471 | ); 472 | 473 | expect(lastFrame()).toContain('[Files Found: 15]'); 474 | expect(lastFrame()).toContain('file0.txt'); 475 | // The text is truncated due to width constraints 476 | expect(lastFrame()).toContain('...'); 477 | }); 478 | 479 | it('handles empty tool results', () => { 480 | const emptyToolConv = { 481 | ...mockConversation, 482 | messages: [ 483 | { 484 | type: 'assistant' as const, 485 | message: null, 486 | toolUseResult: {}, 487 | timestamp: '2024-01-01T12:02:00Z', 488 | sessionId: mockConversation.sessionId, 489 | cwd: mockConversation.projectPath 490 | } as Message 491 | ] 492 | }; 493 | 494 | const { lastFrame } = render( 495 | 496 | ); 497 | 498 | expect(lastFrame()).toContain('[Tool Result: No output]'); 499 | }); 500 | 501 | it('skips messages with invalid timestamps', () => { 502 | const invalidTimestampConv = { 503 | ...mockConversation, 504 | messages: [ 505 | { 506 | type: 'user' as const, 507 | message: { role: 'user' as const, content: 'Valid message' }, 508 | timestamp: '2024-01-01T12:00:00Z', 509 | sessionId: mockConversation.sessionId 510 | }, 511 | { 512 | type: 'user' as const, 513 | message: { role: 'user' as const, content: 'Invalid timestamp' }, 514 | timestamp: 'invalid-date', 515 | sessionId: mockConversation.sessionId 516 | } 517 | ] 518 | }; 519 | 520 | const { lastFrame } = render( 521 | 522 | ); 523 | 524 | expect(lastFrame()).toContain('Valid message'); 525 | expect(lastFrame()).not.toContain('Invalid timestamp'); 526 | }); 527 | 528 | it('identifies tool messages correctly', () => { 529 | const toolMessageConv = { 530 | ...mockConversation, 531 | messages: [ 532 | { 533 | type: 'assistant' as const, 534 | message: { 535 | role: 'assistant' as const, 536 | content: [ 537 | { type: 'tool_use', name: 'bash', input: { command: 'ls' } } 538 | ] 539 | }, 540 | timestamp: '2024-01-01T12:00:00Z', 541 | sessionId: mockConversation.sessionId 542 | } 543 | ] 544 | }; 545 | 546 | const { lastFrame } = render( 547 | 548 | ); 549 | 550 | expect(lastFrame()).toContain('[Tool: bash]'); 551 | }); 552 | }); 553 | 554 | describe('Conversation Changes', () => { 555 | const createLongConversation = () => ({ 556 | ...mockConversation, 557 | messages: Array.from({ length: 30 }, (_, i) => ({ 558 | type: (i % 2 === 0 ? 'user' : 'assistant') as const, 559 | message: { 560 | role: (i % 2 === 0 ? 'user' : 'assistant') as const, 561 | content: `Message ${i}` 562 | }, 563 | timestamp: new Date(2024, 0, 1, 12, i).toISOString(), 564 | sessionId: mockConversation.sessionId, 565 | cwd: mockConversation.projectPath 566 | })) 567 | }); 568 | 569 | it('resets scroll position when conversation changes', () => { 570 | const conv1 = createLongConversation(); 571 | const conv2 = { 572 | ...conv1, 573 | sessionId: 'different-session', 574 | messages: Array.from({ length: 40 }, (_, i) => ({ 575 | ...conv1.messages[0], 576 | message: { ...conv1.messages[0].message, content: `Different ${i}` } 577 | })) 578 | }; 579 | 580 | const { rerender, lastFrame, stdin } = render( 581 | 582 | ); 583 | 584 | // Scroll to top 585 | stdin.write('g'); 586 | expect(lastFrame()).toContain('Message 0'); 587 | 588 | // Change conversation 589 | rerender(); 590 | 591 | // Should be at bottom of new conversation 592 | expect(lastFrame()).toContain('Different'); 593 | }); 594 | }); 595 | }); -------------------------------------------------------------------------------- /src/__tests__/ConversationPreviewFull.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'ink-testing-library'; 3 | import { ConversationPreviewFull } from '../components/ConversationPreviewFull.js'; 4 | import type { Conversation } from '../types.js'; 5 | 6 | describe('ConversationPreviewFull', () => { 7 | const mockConversation: Conversation = { 8 | sessionId: 'test-session-123', 9 | projectPath: '/test/project', 10 | projectName: 'test-project', 11 | gitBranch: 'main', 12 | messages: [ 13 | { 14 | sessionId: 'test-session-123', 15 | timestamp: '2024-01-01T12:00:00Z', 16 | type: 'user', 17 | message: { 18 | role: 'user', 19 | content: 'Hello world' 20 | }, 21 | cwd: '/test/project' 22 | }, 23 | { 24 | sessionId: 'test-session-123', 25 | timestamp: '2024-01-01T12:00:01Z', 26 | type: 'assistant', 27 | message: { 28 | role: 'assistant', 29 | content: 'Hi there!' 30 | }, 31 | cwd: '/test/project' 32 | } 33 | ], 34 | firstMessage: 'Hello world', 35 | lastMessage: 'Hi there!', 36 | startTime: new Date('2024-01-01T12:00:00Z'), 37 | endTime: new Date('2024-01-01T12:00:01Z') 38 | }; 39 | 40 | it('should render without conversation', () => { 41 | const { lastFrame } = render( 42 | 43 | ); 44 | 45 | expect(lastFrame()).toContain('No conversation selected'); 46 | }); 47 | 48 | it('should render conversation messages', () => { 49 | const { lastFrame } = render( 50 | 51 | ); 52 | 53 | const output = lastFrame(); 54 | expect(output).toContain('[User]'); 55 | expect(output).toContain('Hello world'); 56 | expect(output).toContain('[Assistant]'); 57 | expect(output).toContain('Hi there!'); 58 | }); 59 | 60 | it('should show status message when provided', () => { 61 | const { lastFrame } = render( 62 | 66 | ); 67 | 68 | expect(lastFrame()).toContain('Test status'); 69 | }); 70 | 71 | it('should hide user messages when hideOptions includes user', () => { 72 | const { lastFrame } = render( 73 | 77 | ); 78 | 79 | const output = lastFrame(); 80 | expect(output).not.toContain('[User]'); 81 | expect(output).not.toContain('Hello world'); 82 | expect(output).toContain('[Assistant]'); 83 | expect(output).toContain('Hi there!'); 84 | }); 85 | 86 | it('should hide assistant messages when hideOptions includes assistant', () => { 87 | const { lastFrame } = render( 88 | 92 | ); 93 | 94 | const output = lastFrame(); 95 | expect(output).toContain('[User]'); 96 | expect(output).toContain('Hello world'); 97 | expect(output).not.toContain('[Assistant]'); 98 | expect(output).not.toContain('Hi there!'); 99 | }); 100 | 101 | it('should display tool use messages correctly', () => { 102 | const conversationWithTools: Conversation = { 103 | ...mockConversation, 104 | messages: [ 105 | { 106 | sessionId: 'test-session-123', 107 | timestamp: '2024-01-01T12:00:00Z', 108 | type: 'assistant', 109 | message: { 110 | role: 'assistant', 111 | content: [ 112 | { 113 | type: 'tool_use', 114 | name: 'Read', 115 | input: { file_path: '/test/file.ts' } 116 | } 117 | ] 118 | }, 119 | cwd: '/test/project' 120 | } 121 | ] 122 | }; 123 | 124 | const { lastFrame } = render( 125 | 126 | ); 127 | 128 | const output = lastFrame(); 129 | expect(output).toContain('[Tool: Read]'); 130 | expect(output).toContain('/test/file.ts'); 131 | }); 132 | 133 | it('should hide tool messages when hideOptions includes tool', () => { 134 | const conversationWithTools: Conversation = { 135 | ...mockConversation, 136 | messages: [ 137 | { 138 | sessionId: 'test-session-123', 139 | timestamp: '2024-01-01T12:00:00Z', 140 | type: 'assistant', 141 | message: { 142 | role: 'assistant', 143 | content: [ 144 | { 145 | type: 'tool_use', 146 | name: 'Read', 147 | input: { file_path: '/test/file.ts' } 148 | } 149 | ] 150 | }, 151 | cwd: '/test/project' 152 | } 153 | ] 154 | }; 155 | 156 | const { lastFrame } = render( 157 | 161 | ); 162 | 163 | const output = lastFrame(); 164 | expect(output).not.toContain('[Tool: Read]'); 165 | }); 166 | 167 | it('should display thinking messages correctly', () => { 168 | const conversationWithThinking: Conversation = { 169 | ...mockConversation, 170 | messages: [ 171 | { 172 | sessionId: 'test-session-123', 173 | timestamp: '2024-01-01T12:00:00Z', 174 | type: 'assistant', 175 | message: { 176 | role: 'assistant', 177 | content: [ 178 | { 179 | type: 'thinking', 180 | thinking: 'This is my thought process' 181 | } 182 | ] 183 | }, 184 | cwd: '/test/project' 185 | } 186 | ] 187 | }; 188 | 189 | const { lastFrame } = render( 190 | 191 | ); 192 | 193 | const output = lastFrame(); 194 | expect(output).toContain('[Thinking...]'); 195 | expect(output).toContain('This is my thought process'); 196 | }); 197 | 198 | it('should hide thinking messages when hideOptions includes thinking', () => { 199 | const conversationWithThinking: Conversation = { 200 | ...mockConversation, 201 | messages: [ 202 | { 203 | sessionId: 'test-session-123', 204 | timestamp: '2024-01-01T12:00:00Z', 205 | type: 'assistant', 206 | message: { 207 | role: 'assistant', 208 | content: [ 209 | { 210 | type: 'thinking', 211 | thinking: 'This is my thought process' 212 | } 213 | ] 214 | }, 215 | cwd: '/test/project' 216 | } 217 | ] 218 | }; 219 | 220 | const { lastFrame } = render( 221 | 225 | ); 226 | 227 | const output = lastFrame(); 228 | expect(output).not.toContain('[Thinking...]'); 229 | expect(output).not.toContain('This is my thought process'); 230 | }); 231 | 232 | it('should hide simple thinking string messages when hideOptions includes thinking', () => { 233 | const conversationWithThinking: Conversation = { 234 | ...mockConversation, 235 | messages: [ 236 | { 237 | sessionId: 'test-session-123', 238 | timestamp: '2024-01-01T12:00:00Z', 239 | type: 'assistant', 240 | message: { 241 | role: 'assistant', 242 | content: '[Thinking...]' 243 | }, 244 | cwd: '/test/project' 245 | } 246 | ] 247 | }; 248 | 249 | const { lastFrame } = render( 250 | 254 | ); 255 | 256 | const output = lastFrame(); 257 | expect(output).not.toContain('[Thinking...]'); 258 | }); 259 | }); -------------------------------------------------------------------------------- /src/__tests__/charWidth.test.ts: -------------------------------------------------------------------------------- 1 | import { getCharWidth, getStringWidth, truncateStringByWidth } from '../utils/charWidth.js'; 2 | 3 | describe('charWidth', () => { 4 | describe('getCharWidth', () => { 5 | it('returns 0 for empty string', () => { 6 | expect(getCharWidth('')).toBe(0); 7 | expect(getCharWidth(null as unknown as string)).toBe(0); 8 | }); 9 | 10 | it('returns 1 for ASCII characters', () => { 11 | expect(getCharWidth('a')).toBe(1); 12 | expect(getCharWidth('Z')).toBe(1); 13 | expect(getCharWidth('0')).toBe(1); 14 | expect(getCharWidth(' ')).toBe(1); 15 | expect(getCharWidth('!')).toBe(1); 16 | }); 17 | 18 | it('returns 2 for emoji characters', () => { 19 | expect(getCharWidth('😀')).toBe(2); 20 | expect(getCharWidth('🚀')).toBe(2); 21 | expect(getCharWidth('❤️')).toBe(2); 22 | expect(getCharWidth('🔥')).toBe(2); 23 | }); 24 | 25 | it('returns 2 for CJK characters', () => { 26 | expect(getCharWidth('中')).toBe(2); 27 | expect(getCharWidth('文')).toBe(2); 28 | expect(getCharWidth('あ')).toBe(2); // Hiragana 29 | expect(getCharWidth('ア')).toBe(2); // Katakana 30 | expect(getCharWidth('한')).toBe(2); // Korean 31 | }); 32 | 33 | it('returns 2 for full-width characters', () => { 34 | expect(getCharWidth('A')).toBe(2); // Full-width A 35 | expect(getCharWidth('1')).toBe(2); // Full-width 1 36 | expect(getCharWidth('。')).toBe(2); // CJK punctuation 37 | }); 38 | 39 | it('handles surrogate pairs correctly', () => { 40 | const emoji = '😀'; // U+1F600 41 | expect(getCharWidth(emoji)).toBe(2); 42 | 43 | // Test with explicit surrogate pair 44 | const surrogateChar = '\uD83D\uDE00'; // Same emoji 45 | expect(getCharWidth(surrogateChar)).toBe(2); 46 | }); 47 | 48 | it('handles incomplete surrogate pairs', () => { 49 | const highSurrogate = '\uD83D'; // High surrogate without low 50 | expect(getCharWidth(highSurrogate)).toBe(2); 51 | }); 52 | 53 | it('skips orphaned low surrogates', () => { 54 | const lowSurrogate = '\uDC00'; // Low surrogate without high 55 | expect(getCharWidth(lowSurrogate)).toBe(0); 56 | }); 57 | 58 | it('handles various emoji blocks', () => { 59 | expect(getCharWidth('☀')).toBe(2); // U+2600 60 | expect(getCharWidth('⌚')).toBe(2); // U+231A 61 | expect(getCharWidth('⬇')).toBe(2); // U+2B07 62 | expect(getCharWidth('⚡')).toBe(2); // U+26A1 63 | expect(getCharWidth('✅')).toBe(2); // U+2705 64 | }); 65 | 66 | it('returns 1 for other Unicode characters', () => { 67 | expect(getCharWidth('é')).toBe(1); // Latin extended 68 | expect(getCharWidth('ñ')).toBe(1); // Latin extended 69 | expect(getCharWidth('α')).toBe(1); // Greek 70 | expect(getCharWidth('б')).toBe(1); // Cyrillic 71 | }); 72 | }); 73 | 74 | describe('getStringWidth', () => { 75 | it('returns 0 for empty string', () => { 76 | expect(getStringWidth('')).toBe(0); 77 | expect(getStringWidth(null as unknown as string)).toBe(0); 78 | }); 79 | 80 | it('calculates width for ASCII strings', () => { 81 | expect(getStringWidth('hello')).toBe(5); 82 | expect(getStringWidth('Hello World!')).toBe(12); 83 | }); 84 | 85 | it('calculates width for strings with emoji', () => { 86 | expect(getStringWidth('Hello 😀')).toBe(8); // 6 + 2 87 | expect(getStringWidth('🚀🚀🚀')).toBe(6); // 2 + 2 + 2 88 | }); 89 | 90 | it('calculates width for mixed character strings', () => { 91 | expect(getStringWidth('Hello 世界')).toBe(10); // 6 + 2 + 2 92 | expect(getStringWidth('こんにちは')).toBe(10); // 2 * 5 93 | }); 94 | 95 | it('handles surrogate pairs in strings', () => { 96 | const emojiString = '👍🏼'; // Thumbs up with skin tone (multiple code points) 97 | expect(getStringWidth(emojiString)).toBe(4); // 2 for base emoji + 2 for skin tone modifier 98 | 99 | const mixedString = 'a👍b'; 100 | expect(getStringWidth(mixedString)).toBe(4); // 1 + 2 + 1 101 | }); 102 | 103 | it('handles invalid surrogate sequences', () => { 104 | const invalidSurrogate = 'a\uD83Db'; // High surrogate not followed by low 105 | expect(getStringWidth(invalidSurrogate)).toBe(4); // 1 + 2 + 1 106 | }); 107 | 108 | it('calculates width for complex strings', () => { 109 | const complex = 'User: Hello 世界! 😀 How are you?'; 110 | // U:1 s:1 e:1 r:1 ::1 space:1 H:1 e:1 l:1 l:1 o:1 space:1 世:2 界:2 !:1 space:1 😀:2 space:1 H:1 o:1 w:1 space:1 a:1 r:1 e:1 space:1 y:1 o:1 u:1 ?:1 111 | // Total: 33 (need to account for the correct width) 112 | expect(getStringWidth(complex)).toBe(33); 113 | }); 114 | }); 115 | 116 | describe('truncateStringByWidth', () => { 117 | it('returns empty string for empty input', () => { 118 | expect(truncateStringByWidth('', 10)).toBe(''); 119 | expect(truncateStringByWidth(null as unknown as string, 10)).toBe(''); 120 | }); 121 | 122 | it('returns full string if within width', () => { 123 | expect(truncateStringByWidth('hello', 10)).toBe('hello'); 124 | expect(truncateStringByWidth('hello', 8)).toBe('hello'); // 5 chars fit within 8 - 3 = 5 125 | }); 126 | 127 | it('truncates ASCII strings correctly', () => { 128 | expect(truncateStringByWidth('hello world', 8)).toBe('hello...'); 129 | expect(truncateStringByWidth('abcdefghij', 7)).toBe('abcd...'); 130 | }); 131 | 132 | it('truncates strings with emoji correctly', () => { 133 | // Width calculation: H:1 e:1 l:1 l:1 o:1 space:1 = 6 134 | // When we hit emoji at position 6, width would become 6+2=8 135 | // Since 8 + 3 (for ...) > 10, we stop before the emoji 136 | expect(truncateStringByWidth('Hello 😀 World', 10)).toBe('Hello ...'); 137 | expect(truncateStringByWidth('😀😀😀😀', 7)).toBe('😀😀...'); 138 | }); 139 | 140 | it('truncates strings with CJK characters', () => { 141 | // "Hello " = 6, "世" would make it 8, 8+3 > 10, so stop before 142 | expect(truncateStringByWidth('Hello 世界', 10)).toBe('Hello ...'); 143 | // こ:2 ん:2 に:2 ち:2 = 8, next char would exceed, so stop 144 | expect(truncateStringByWidth('こんにちは世界', 10)).toBe('こんに...'); 145 | }); 146 | 147 | it('handles edge case where character would exceed limit', () => { 148 | // Width 5 total, but next emoji would make it 7, so stop 149 | expect(truncateStringByWidth('a😀b', 5)).toBe('a...'); 150 | expect(truncateStringByWidth('世界hello', 7)).toBe('世界...'); 151 | }); 152 | 153 | it('preserves surrogate pairs when truncating', () => { 154 | const emojiString = '👍🏼👍🏼👍🏼'; // Each emoji with skin tone 155 | // The base emoji 👍 has width 2, 2 + 3 = 5, so it fits exactly 156 | expect(truncateStringByWidth(emojiString, 5)).toBe('👍...'); 157 | }); 158 | 159 | it('handles very small max widths', () => { 160 | expect(truncateStringByWidth('hello', 3)).toBe('...'); 161 | expect(truncateStringByWidth('hello', 4)).toBe('h...'); 162 | }); 163 | 164 | it('handles exact width match', () => { 165 | expect(truncateStringByWidth('hello', 8)).toBe('hello'); // 5 + 3 for ellipsis 166 | expect(truncateStringByWidth('hello world', 14)).toBe('hello world'); // 11 + 3 for ellipsis 167 | }); 168 | 169 | it('handles complex mixed content', () => { 170 | const mixed = 'User said: こんにちは! 😀 Nice to meet you'; 171 | // First 20 width chars should be: "User said: こんに..." 172 | expect(truncateStringByWidth(mixed, 20)).toBe('User said: こんに...'); 173 | }); 174 | }); 175 | }); -------------------------------------------------------------------------------- /src/__tests__/configLoader.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import { beforeEach, describe, expect, it } from '@jest/globals'; 3 | 4 | // Manual mocks 5 | const mockExistsSync = jest.fn(); 6 | const mockReadFileSync = jest.fn(); 7 | const mockHomedir = jest.fn(); 8 | 9 | jest.unstable_mockModule('fs', () => ({ 10 | existsSync: mockExistsSync, 11 | readFileSync: mockReadFileSync, 12 | })); 13 | 14 | jest.unstable_mockModule('os', () => ({ 15 | homedir: mockHomedir, 16 | })); 17 | 18 | // Dynamic imports after mocking 19 | const { getConfigPath, loadConfig } = await import('../utils/configLoader.js'); 20 | const { defaultConfig } = await import('../types/config.js'); 21 | 22 | describe('configLoader', () => { 23 | beforeEach(() => { 24 | jest.clearAllMocks(); 25 | delete process.env.XDG_CONFIG_HOME; 26 | }); 27 | 28 | describe('getConfigPath', () => { 29 | it('should use XDG_CONFIG_HOME when set', () => { 30 | process.env.XDG_CONFIG_HOME = '/custom/config'; 31 | const path = getConfigPath(); 32 | expect(path).toBe('/custom/config/ccresume/config.toml'); 33 | }); 34 | 35 | it('should use ~/.config when XDG_CONFIG_HOME is not set', () => { 36 | mockHomedir.mockReturnValue('/home/user'); 37 | const path = getConfigPath(); 38 | expect(path).toBe('/home/user/.config/ccresume/config.toml'); 39 | }); 40 | }); 41 | 42 | describe('loadConfig', () => { 43 | it('should return default config when config file does not exist', () => { 44 | mockHomedir.mockReturnValue('/home/user'); 45 | mockExistsSync.mockReturnValue(false); 46 | 47 | const config = loadConfig(); 48 | expect(config).toEqual(defaultConfig); 49 | }); 50 | 51 | it('should load and parse config file when it exists', () => { 52 | mockHomedir.mockReturnValue('/home/user'); 53 | mockExistsSync.mockReturnValue(true); 54 | 55 | const tomlContent = ` 56 | [keybindings] 57 | quit = ["q", "ctrl+c"] 58 | selectPrevious = ["up"] 59 | selectNext = ["down"] 60 | `; 61 | mockReadFileSync.mockReturnValue(tomlContent); 62 | 63 | const config = loadConfig(); 64 | expect(config.keybindings.quit).toEqual(['q', 'ctrl+c']); 65 | expect(config.keybindings.selectPrevious).toEqual(['up']); 66 | expect(config.keybindings.selectNext).toEqual(['down']); 67 | // Other keybindings should still have default values 68 | expect(config.keybindings.confirm).toEqual(defaultConfig.keybindings.confirm); 69 | }); 70 | 71 | it('should merge partial config with defaults', () => { 72 | mockHomedir.mockReturnValue('/home/user'); 73 | mockExistsSync.mockReturnValue(true); 74 | 75 | const tomlContent = ` 76 | [keybindings] 77 | quit = ["esc"] 78 | `; 79 | mockReadFileSync.mockReturnValue(tomlContent); 80 | 81 | const config = loadConfig(); 82 | expect(config.keybindings.quit).toEqual(['esc']); 83 | // All other keybindings should have default values 84 | expect(config.keybindings.selectPrevious).toEqual(defaultConfig.keybindings.selectPrevious); 85 | expect(config.keybindings.selectNext).toEqual(defaultConfig.keybindings.selectNext); 86 | }); 87 | 88 | it('should return default config on parse error', () => { 89 | mockHomedir.mockReturnValue('/home/user'); 90 | mockExistsSync.mockReturnValue(true); 91 | mockReadFileSync.mockReturnValue('invalid toml content {{{'); 92 | 93 | // Mock console.error to suppress error output in test 94 | const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); 95 | 96 | const config = loadConfig(); 97 | expect(config).toEqual(defaultConfig); 98 | expect(consoleErrorSpy).toHaveBeenCalled(); 99 | 100 | consoleErrorSpy.mockRestore(); 101 | }); 102 | 103 | it('should handle file read errors gracefully', () => { 104 | mockHomedir.mockReturnValue('/home/user'); 105 | mockExistsSync.mockReturnValue(true); 106 | mockReadFileSync.mockImplementation(() => { 107 | throw new Error('Permission denied'); 108 | }); 109 | 110 | // Mock console.error to suppress error output in test 111 | const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); 112 | 113 | const config = loadConfig(); 114 | expect(config).toEqual(defaultConfig); 115 | expect(consoleErrorSpy).toHaveBeenCalled(); 116 | 117 | consoleErrorSpy.mockRestore(); 118 | }); 119 | }); 120 | }); -------------------------------------------------------------------------------- /src/__tests__/keyBindingHelper.test.ts: -------------------------------------------------------------------------------- 1 | import { Key } from 'ink'; 2 | import { matchesKeyBinding } from '../utils/keyBindingHelper.js'; 3 | 4 | describe('keyBindingHelper', () => { 5 | describe('matchesKeyBinding', () => { 6 | it('should match single character keys', () => { 7 | const key: Key = { 8 | upArrow: false, 9 | downArrow: false, 10 | leftArrow: false, 11 | rightArrow: false, 12 | pageDown: false, 13 | pageUp: false, 14 | return: false, 15 | escape: false, 16 | ctrl: false, 17 | shift: false, 18 | tab: false, 19 | backspace: false, 20 | delete: false, 21 | meta: false, 22 | }; 23 | 24 | expect(matchesKeyBinding('q', key, ['q'])).toBe(true); 25 | expect(matchesKeyBinding('q', key, ['a'])).toBe(false); 26 | expect(matchesKeyBinding('a', key, ['a', 'b', 'c'])).toBe(true); 27 | }); 28 | 29 | it('should match arrow keys', () => { 30 | const upKey: Key = { 31 | upArrow: true, 32 | downArrow: false, 33 | leftArrow: false, 34 | rightArrow: false, 35 | pageDown: false, 36 | pageUp: false, 37 | return: false, 38 | escape: false, 39 | ctrl: false, 40 | shift: false, 41 | tab: false, 42 | backspace: false, 43 | delete: false, 44 | meta: false, 45 | }; 46 | 47 | expect(matchesKeyBinding('', upKey, ['up'])).toBe(true); 48 | expect(matchesKeyBinding('', upKey, ['uparrow'])).toBe(true); 49 | expect(matchesKeyBinding('', upKey, ['down'])).toBe(false); 50 | }); 51 | 52 | it('should match special keys', () => { 53 | const returnKey: Key = { 54 | upArrow: false, 55 | downArrow: false, 56 | leftArrow: false, 57 | rightArrow: false, 58 | pageDown: false, 59 | pageUp: false, 60 | return: true, 61 | escape: false, 62 | ctrl: false, 63 | shift: false, 64 | tab: false, 65 | backspace: false, 66 | delete: false, 67 | meta: false, 68 | }; 69 | 70 | expect(matchesKeyBinding('', returnKey, ['enter'])).toBe(true); 71 | expect(matchesKeyBinding('', returnKey, ['return'])).toBe(true); 72 | expect(matchesKeyBinding('', returnKey, ['space'])).toBe(false); 73 | }); 74 | 75 | it('should match modifier combinations', () => { 76 | const ctrlC: Key = { 77 | upArrow: false, 78 | downArrow: false, 79 | leftArrow: false, 80 | rightArrow: false, 81 | pageDown: false, 82 | pageUp: false, 83 | return: false, 84 | escape: false, 85 | ctrl: true, 86 | shift: false, 87 | tab: false, 88 | backspace: false, 89 | delete: false, 90 | meta: false, 91 | }; 92 | 93 | expect(matchesKeyBinding('c', ctrlC, ['ctrl+c'])).toBe(true); 94 | expect(matchesKeyBinding('c', ctrlC, ['c'])).toBe(false); 95 | expect(matchesKeyBinding('a', ctrlC, ['ctrl+a'])).toBe(true); 96 | }); 97 | 98 | it('should match shift combinations', () => { 99 | const shiftG: Key = { 100 | upArrow: false, 101 | downArrow: false, 102 | leftArrow: false, 103 | rightArrow: false, 104 | pageDown: false, 105 | pageUp: false, 106 | return: false, 107 | escape: false, 108 | ctrl: false, 109 | shift: true, 110 | tab: false, 111 | backspace: false, 112 | delete: false, 113 | meta: false, 114 | }; 115 | 116 | expect(matchesKeyBinding('g', shiftG, ['shift+g'])).toBe(true); 117 | expect(matchesKeyBinding('g', shiftG, ['G'])).toBe(true); 118 | expect(matchesKeyBinding('g', shiftG, ['g'])).toBe(false); 119 | }); 120 | 121 | it('should handle case insensitive matching', () => { 122 | const key: Key = { 123 | upArrow: false, 124 | downArrow: false, 125 | leftArrow: false, 126 | rightArrow: false, 127 | pageDown: false, 128 | pageUp: false, 129 | return: false, 130 | escape: false, 131 | ctrl: false, 132 | shift: false, 133 | tab: false, 134 | backspace: false, 135 | delete: false, 136 | meta: false, 137 | }; 138 | 139 | expect(matchesKeyBinding('A', key, ['a'])).toBe(true); 140 | expect(matchesKeyBinding('a', key, ['a'])).toBe(true); 141 | 142 | // 'A' binding requires shift key 143 | expect(matchesKeyBinding('a', key, ['A'])).toBe(false); 144 | }); 145 | 146 | it('should match pageup/pagedown keys', () => { 147 | const pageDownKey: Key = { 148 | upArrow: false, 149 | downArrow: false, 150 | leftArrow: false, 151 | rightArrow: false, 152 | pageDown: true, 153 | pageUp: false, 154 | return: false, 155 | escape: false, 156 | ctrl: false, 157 | shift: false, 158 | tab: false, 159 | backspace: false, 160 | delete: false, 161 | meta: false, 162 | }; 163 | 164 | expect(matchesKeyBinding('', pageDownKey, ['pagedown'])).toBe(true); 165 | expect(matchesKeyBinding('', pageDownKey, ['pageup'])).toBe(false); 166 | }); 167 | 168 | it('should match escape key', () => { 169 | const escapeKey: Key = { 170 | upArrow: false, 171 | downArrow: false, 172 | leftArrow: false, 173 | rightArrow: false, 174 | pageDown: false, 175 | pageUp: false, 176 | return: false, 177 | escape: true, 178 | ctrl: false, 179 | shift: false, 180 | tab: false, 181 | backspace: false, 182 | delete: false, 183 | meta: false, 184 | }; 185 | 186 | expect(matchesKeyBinding('', escapeKey, ['escape'])).toBe(true); 187 | expect(matchesKeyBinding('', escapeKey, ['esc'])).toBe(true); 188 | }); 189 | 190 | it('should match meta/cmd key combinations', () => { 191 | const metaQ: Key = { 192 | upArrow: false, 193 | downArrow: false, 194 | leftArrow: false, 195 | rightArrow: false, 196 | pageDown: false, 197 | pageUp: false, 198 | return: false, 199 | escape: false, 200 | ctrl: false, 201 | shift: false, 202 | tab: false, 203 | backspace: false, 204 | delete: false, 205 | meta: true, 206 | }; 207 | 208 | expect(matchesKeyBinding('q', metaQ, ['cmd+q'])).toBe(true); 209 | expect(matchesKeyBinding('q', metaQ, ['command+q'])).toBe(true); 210 | expect(matchesKeyBinding('q', metaQ, ['meta+q'])).toBe(true); 211 | expect(matchesKeyBinding('q', metaQ, ['q'])).toBe(false); 212 | }); 213 | 214 | it('should not match when no bindings are provided', () => { 215 | const key: Key = { 216 | upArrow: false, 217 | downArrow: false, 218 | leftArrow: false, 219 | rightArrow: false, 220 | pageDown: false, 221 | pageUp: false, 222 | return: false, 223 | escape: false, 224 | ctrl: false, 225 | shift: false, 226 | tab: false, 227 | backspace: false, 228 | delete: false, 229 | meta: false, 230 | }; 231 | 232 | expect(matchesKeyBinding('q', key, [])).toBe(false); 233 | }); 234 | }); 235 | }); -------------------------------------------------------------------------------- /src/__tests__/messageUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { extractMessageText } from '../utils/messageUtils.js'; 2 | 3 | describe('messageUtils', () => { 4 | describe('extractMessageText', () => { 5 | it('returns empty string for null or undefined', () => { 6 | expect(extractMessageText(null)).toBe(''); 7 | expect(extractMessageText(undefined)).toBe(''); 8 | }); 9 | 10 | it('returns string content as-is', () => { 11 | expect(extractMessageText('Hello, world!')).toBe('Hello, world!'); 12 | expect(extractMessageText('Multi\nline\nstring')).toBe('Multi\nline\nstring'); 13 | }); 14 | 15 | it('extracts text from text-type array items', () => { 16 | const content = [ 17 | { type: 'text', text: 'First part' }, 18 | { type: 'text', text: 'Second part' } 19 | ]; 20 | 21 | expect(extractMessageText(content)).toBe('First part\nSecond part'); 22 | }); 23 | 24 | it('formats tool_use messages with command', () => { 25 | const content = [ 26 | { type: 'tool_use', name: 'bash', input: { command: 'ls -la' } } 27 | ]; 28 | 29 | expect(extractMessageText(content)).toBe('[Tool: bash] ls -la'); 30 | }); 31 | 32 | it('formats tool_use messages with description', () => { 33 | const content = [ 34 | { type: 'tool_use', name: 'search', input: { description: 'Searching for files' } } 35 | ]; 36 | 37 | expect(extractMessageText(content)).toBe('[Tool: search] Searching for files'); 38 | }); 39 | 40 | it('formats tool_use messages with prompt (truncated)', () => { 41 | const longPrompt = 'This is a very long prompt that exceeds one hundred characters and should be truncated with ellipsis at the end to maintain readability'; 42 | const content = [ 43 | { type: 'tool_use', name: 'ai_model', input: { prompt: longPrompt } } 44 | ]; 45 | 46 | const result = extractMessageText(content); 47 | expect(result).toBe('[Tool: ai_model] This is a very long prompt that exceeds one hundred characters and should be truncated with ellipsis...'); 48 | expect(result.length).toBe(120); // [Tool: ai_model] + 100 + ... 49 | }); 50 | 51 | it('formats tool_use messages without input details', () => { 52 | const content = [ 53 | { type: 'tool_use', name: 'simple_tool' } 54 | ]; 55 | 56 | expect(extractMessageText(content)).toBe('[Tool: simple_tool] '); 57 | }); 58 | 59 | it('handles tool_result messages', () => { 60 | const content = [ 61 | { type: 'tool_result' } 62 | ]; 63 | 64 | expect(extractMessageText(content)).toBe('[Tool Result]'); 65 | }); 66 | 67 | it('combines different message types', () => { 68 | const content = [ 69 | { type: 'text', text: 'Let me help you with that.' }, 70 | { type: 'tool_use', name: 'bash', input: { command: 'pwd' } }, 71 | { type: 'tool_result' }, 72 | { type: 'text', text: 'The current directory is shown above.' } 73 | ]; 74 | 75 | expect(extractMessageText(content)).toBe( 76 | 'Let me help you with that.\n' + 77 | '[Tool: bash] pwd\n' + 78 | '[Tool Result]\n' + 79 | 'The current directory is shown above.' 80 | ); 81 | }); 82 | 83 | it('skips null or undefined array items', () => { 84 | const content = [ 85 | null, 86 | { type: 'text', text: 'Valid text' }, 87 | undefined, 88 | { type: 'text', text: 'More text' } 89 | ]; 90 | 91 | expect(extractMessageText(content as Array<{ type: string; text?: string }>)).toBe('Valid text\nMore text'); 92 | }); 93 | 94 | it('skips items without text property', () => { 95 | const content = [ 96 | { type: 'text' }, // Missing text property 97 | { type: 'text', text: 'Valid text' }, 98 | { type: 'text', text: '' }, // Empty text 99 | { type: 'text', text: 'More text' } 100 | ]; 101 | 102 | expect(extractMessageText(content)).toBe('Valid text\nMore text'); 103 | }); 104 | 105 | it('handles unknown message types', () => { 106 | const content = [ 107 | { type: 'unknown_type', data: 'some data' }, 108 | { type: 'text', text: 'Known type' } 109 | ]; 110 | 111 | expect(extractMessageText(content as Array<{ type: string; text?: string; data?: string }>)).toBe('Known type'); 112 | }); 113 | 114 | it('handles empty array', () => { 115 | expect(extractMessageText([])).toBe(''); 116 | }); 117 | 118 | it('handles complex nested input objects', () => { 119 | const content = [ 120 | { 121 | type: 'tool_use', 122 | name: 'complex_tool', 123 | input: { 124 | command: 'primary command', 125 | description: 'this should not be used', 126 | prompt: 'this should not be used either' 127 | } 128 | } 129 | ]; 130 | 131 | // Should prefer command over description over prompt 132 | expect(extractMessageText(content)).toBe('[Tool: complex_tool] primary command'); 133 | }); 134 | 135 | it('handles tool_use with only prompt', () => { 136 | const content = [ 137 | { 138 | type: 'tool_use', 139 | name: 'prompt_tool', 140 | input: { 141 | prompt: 'Short prompt' 142 | } 143 | } 144 | ]; 145 | 146 | expect(extractMessageText(content)).toBe('[Tool: prompt_tool] Short prompt...'); 147 | }); 148 | 149 | it('handles tool_use with empty input', () => { 150 | const content = [ 151 | { 152 | type: 'tool_use', 153 | name: 'empty_tool', 154 | input: {} 155 | } 156 | ]; 157 | 158 | expect(extractMessageText(content)).toBe('[Tool: empty_tool] '); 159 | }); 160 | 161 | it('preserves whitespace in text content', () => { 162 | const content = [ 163 | { type: 'text', text: ' Indented text ' }, 164 | { type: 'text', text: '\n\nDouble newlines\n\n' } 165 | ]; 166 | 167 | expect(extractMessageText(content)).toBe(' Indented text \n\n\nDouble newlines\n\n'); 168 | }); 169 | }); 170 | }); -------------------------------------------------------------------------------- /src/cli.tsx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import React from 'react'; 3 | import { render } from 'ink'; 4 | import { readFileSync } from 'fs'; 5 | import { fileURLToPath } from 'url'; 6 | import { dirname, join } from 'path'; 7 | import App from './App.js'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = dirname(__filename); 11 | 12 | // Get command line arguments (excluding node and script path) 13 | const args = process.argv.slice(2); 14 | 15 | // Check if '.' is present as a standalone argument - indicates current directory filtering 16 | const currentDirOnly = args.includes('.'); 17 | let filteredArgs = args.filter(arg => arg !== '.'); 18 | 19 | // Parse --hide option 20 | let hideOptions: string[] = []; 21 | const hideIndex = filteredArgs.findIndex(arg => arg === '--hide'); 22 | if (hideIndex !== -1) { 23 | // Valid hide options 24 | const validHideOptions = ['tool', 'thinking', 'user', 'assistant']; 25 | 26 | // Collect all arguments after --hide until the next option or end 27 | let i = hideIndex + 1; 28 | let argCount = 0; 29 | while (i < filteredArgs.length && !filteredArgs[i].startsWith('-')) { 30 | const arg = filteredArgs[i]; 31 | // Only add valid hide options 32 | if (validHideOptions.includes(arg)) { 33 | hideOptions.push(arg); 34 | argCount++; 35 | i++; 36 | } else { 37 | // Stop collecting if we hit an invalid hide option 38 | // This argument might be meant for claude 39 | break; 40 | } 41 | } 42 | 43 | // If no arguments provided, use default: tool and thinking 44 | if (hideOptions.length === 0) { 45 | hideOptions = ['tool', 'thinking']; 46 | } 47 | 48 | // Remove --hide and its arguments from filteredArgs 49 | filteredArgs = [ 50 | ...filteredArgs.slice(0, hideIndex), 51 | ...filteredArgs.slice(hideIndex + 1 + argCount) 52 | ]; 53 | } 54 | 55 | // Handle --help 56 | if (filteredArgs.includes('--help') || filteredArgs.includes('-h')) { 57 | console.log(`ccresume - TUI for browsing Claude Code conversations 58 | 59 | Usage: ccresume [.] [options] 60 | 61 | Options: 62 | . Filter conversations to current directory only 63 | --hide [types...] Hide specific message types (tool, thinking, user, assistant) 64 | Default: tool thinking (when no types specified) 65 | -h, --help Show this help message 66 | -v, --version Show version number 67 | 68 | All other options are passed to claude when resuming a conversation. 69 | 70 | Keyboard Controls: 71 | ↑/↓ Navigate conversations list 72 | ←/→ Navigate between pages 73 | j/k Scroll chat history 74 | Enter Resume selected conversation 75 | n Start new session in selected directory 76 | - Edit command options for Claude 77 | c Copy session ID 78 | q Quit 79 | 80 | Examples: 81 | ccresume 82 | ccresume . 83 | ccresume . --dangerously-skip-permissions 84 | ccresume --dangerously-skip-permissions 85 | 86 | Configuration: 87 | Key bindings can be customized in: ~/.config/ccresume/config.toml 88 | See example: https://github.com/sasazame/ccresume/blob/develop/config.toml.example 89 | 90 | Note: When new features are added that conflict with your custom key bindings, 91 | you'll need to either: 92 | - Add the new key binding explicitly to your config.toml 93 | - Remove/modify the conflicting custom key binding 94 | 95 | For more info: https://github.com/sasazame/ccresume`); 96 | process.exit(0); 97 | } 98 | 99 | // Handle --version 100 | if (filteredArgs.includes('--version') || filteredArgs.includes('-v')) { 101 | const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')); 102 | console.log(packageJson.version); 103 | process.exit(0); 104 | } 105 | 106 | const claudeArgs = filteredArgs; 107 | 108 | // Show Windows-specific notice at startup with pause 109 | if (process.platform === 'win32') { 110 | const { spawn } = await import('child_process'); 111 | 112 | console.log(''); 113 | console.log('📝 Notice for Windows users: If terminal doesn\'t accept input after Claude Code starts,'); 114 | console.log(' press ENTER once to activate input.'); 115 | console.log(' This is a temporary workaround for a known Windows environment issue.'); 116 | console.log(' For details, see GitHub issue: https://github.com/sasazame/ccresume/issues/32'); 117 | console.log(''); 118 | 119 | // Use spawn with inherited stdio to ensure proper pause behavior 120 | const pause = spawn('cmd.exe', ['/c', 'pause'], { stdio: 'inherit' }); 121 | 122 | // Wait for pause to complete before continuing 123 | await new Promise((resolve) => { 124 | pause.on('close', resolve); 125 | }); 126 | } 127 | 128 | // Render the app in fullscreen mode 129 | const { unmount } = render(, { 130 | exitOnCtrlC: true 131 | }); 132 | 133 | // Handle graceful exit 134 | process.on('exit', () => { 135 | unmount(); 136 | }); -------------------------------------------------------------------------------- /src/components/CommandEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Box, Text, useInput, useStdout } from 'ink'; 3 | 4 | interface CommandEditorProps { 5 | initialArgs: string[]; 6 | onComplete: (args: string[]) => void; 7 | onCancel: () => void; 8 | } 9 | 10 | interface ClaudeOption { 11 | flags: string[]; 12 | description: string; 13 | hasValue: boolean; 14 | valueDescription?: string; 15 | } 16 | 17 | const claudeOptions: ClaudeOption[] = [ 18 | { flags: ['-h', '--help'], description: 'Display help for command', hasValue: false }, 19 | { flags: ['-v', '--version'], description: 'Output the version number', hasValue: false }, 20 | { flags: ['-d', '--debug'], description: 'Enable debug mode', hasValue: false }, 21 | { flags: ['--verbose'], description: 'Override verbose mode setting from config', hasValue: false }, 22 | { flags: ['-p', '--print'], description: 'Print response and exit (useful for pipes)', hasValue: false }, 23 | { flags: ['--output-format'], description: 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)', hasValue: true, valueDescription: '' }, 24 | { flags: ['--input-format'], description: 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)', hasValue: true, valueDescription: '' }, 25 | { flags: ['--mcp-debug'], description: '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', hasValue: false }, 26 | { flags: ['--dangerously-skip-permissions'], description: 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', hasValue: false }, 27 | { flags: ['--allowedTools'], description: 'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")', hasValue: true, valueDescription: '' }, 28 | { flags: ['--disallowedTools'], description: 'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")', hasValue: true, valueDescription: '' }, 29 | { flags: ['--mcp-config'], description: 'Load MCP servers from a JSON file or string', hasValue: true, valueDescription: '' }, 30 | { flags: ['--append-system-prompt'], description: 'Append a system prompt to the default system prompt', hasValue: true, valueDescription: '' }, 31 | { flags: ['--permission-mode'], description: 'Permission mode to use for the session (choices: "acceptEdits", "bypassPermissions", "default", "plan")', hasValue: true, valueDescription: '' }, 32 | { flags: ['-c', '--continue'], description: 'Continue the most recent conversation', hasValue: false }, 33 | { flags: ['-r', '--resume'], description: 'Resume a conversation - provide a session ID or interactively select a conversation to resume', hasValue: true, valueDescription: '[sessionId]' }, 34 | { flags: ['--model'], description: 'Model for the current session. Provide an alias for the latest model (e.g. \'sonnet\' or \'opus\') or a model\'s full name (e.g. \'claude-sonnet-4-20250514\').', hasValue: true, valueDescription: '' }, 35 | { flags: ['--fallback-model'], description: 'Enable automatic fallback to specified model when default model is overloaded (only works with --print)', hasValue: true, valueDescription: '' }, 36 | { flags: ['--settings'], description: 'Path to a settings JSON file to load additional settings from', hasValue: true, valueDescription: '' }, 37 | { flags: ['--add-dir'], description: 'Additional directories to allow tool access to', hasValue: true, valueDescription: '' }, 38 | { flags: ['--ide'], description: 'Automatically connect to IDE on startup if exactly one valid IDE is available', hasValue: false }, 39 | { flags: ['--strict-mcp-config'], description: 'Only use MCP servers from --mcp-config, ignoring all other MCP configurations', hasValue: false }, 40 | { flags: ['--session-id'], description: 'Use a specific session ID for the conversation (must be a valid UUID)', hasValue: true, valueDescription: '' }, 41 | ]; 42 | 43 | const SAFETY_MARGIN = 1; 44 | 45 | // Layout constants 46 | const LAYOUT_CONSTANTS = { 47 | FIXED_ELEMENT_HEIGHT: 15, 48 | SUGGESTIONS_BASE_HEIGHT: 5, 49 | MAX_SUGGESTIONS_SHOWN: 5, 50 | MIN_OPTIONS_LIST_HEIGHT: 10, 51 | OPTIONS_LIST_MARGIN: 4, 52 | DEFAULT_TERMINAL_HEIGHT: 24 53 | } as const; 54 | 55 | export const CommandEditor: React.FC = ({ initialArgs, onComplete, onCancel }) => { 56 | const { stdout } = useStdout(); 57 | const [commandLine, setCommandLine] = useState(initialArgs.join(' ')); 58 | const [cursorPosition, setCursorPosition] = useState(commandLine.length); 59 | const [suggestions, setSuggestions] = useState([]); 60 | const [selectedSuggestion, setSelectedSuggestion] = useState(0); 61 | 62 | const terminalHeight = stdout?.rows || LAYOUT_CONSTANTS.DEFAULT_TERMINAL_HEIGHT; 63 | const totalHeight = terminalHeight - SAFETY_MARGIN; 64 | 65 | useEffect(() => { 66 | // Update suggestions based on current input 67 | const currentWord = getCurrentWord(); 68 | if (currentWord.startsWith('-')) { 69 | const matching = claudeOptions.filter(opt => 70 | opt.flags.some(flag => flag.toLowerCase().startsWith(currentWord.toLowerCase())) 71 | ); 72 | setSuggestions(matching); 73 | setSelectedSuggestion(0); 74 | } else { 75 | setSuggestions([]); 76 | } 77 | }, [commandLine, cursorPosition]); // eslint-disable-line react-hooks/exhaustive-deps 78 | 79 | const getCurrentWord = () => { 80 | const beforeCursor = commandLine.substring(0, cursorPosition); 81 | const words = beforeCursor.split(' '); 82 | return words[words.length - 1] || ''; 83 | }; 84 | 85 | const insertSuggestion = (suggestion: ClaudeOption) => { 86 | // Guard against invalid suggestions 87 | if (!suggestion || !suggestion.flags || suggestion.flags.length === 0) { 88 | return; 89 | } 90 | 91 | // Validate cursor position 92 | if (cursorPosition < 0 || cursorPosition > commandLine.length) { 93 | return; 94 | } 95 | 96 | const beforeCursor = commandLine.substring(0, cursorPosition); 97 | const afterCursor = commandLine.substring(cursorPosition); 98 | const words = beforeCursor.split(' '); 99 | const currentWord = words[words.length - 1] || ''; 100 | 101 | // Replace the current word with the suggestion 102 | const beforeWord = beforeCursor.substring(0, beforeCursor.length - currentWord.length); 103 | // Use the flag that matches the current input, or the last (long form) flag 104 | const matchingFlag = suggestion.flags.find(flag => flag.toLowerCase().startsWith(currentWord.toLowerCase())) || suggestion.flags[suggestion.flags.length - 1]; 105 | // Always add a space after the flag to prevent re-matching 106 | const newCommand = beforeWord + matchingFlag + ' ' + afterCursor; 107 | setCommandLine(newCommand); 108 | setCursorPosition(beforeWord.length + matchingFlag.length + 1); 109 | }; 110 | 111 | useInput((input, key) => { 112 | if (key.escape) { 113 | onCancel(); 114 | return; 115 | } 116 | 117 | if (key.ctrl && input === 'c') { 118 | onCancel(); 119 | return; 120 | } 121 | 122 | if (key.return) { 123 | if (suggestions.length > 0) { 124 | // If suggestions are shown, insert the selected one 125 | insertSuggestion(suggestions[selectedSuggestion]); 126 | } else { 127 | // Otherwise, complete the editing 128 | const args = commandLine.trim().split(/\s+/).filter(arg => arg.length > 0); 129 | onComplete(args); 130 | } 131 | return; 132 | } 133 | 134 | // Navigation in suggestions 135 | if (suggestions.length > 0) { 136 | if (key.upArrow) { 137 | setSelectedSuggestion(prev => Math.max(0, prev - 1)); 138 | return; 139 | } 140 | if (key.downArrow) { 141 | setSelectedSuggestion(prev => Math.min(suggestions.length - 1, prev + 1)); 142 | return; 143 | } 144 | if (key.tab) { 145 | insertSuggestion(suggestions[selectedSuggestion]); 146 | return; 147 | } 148 | } 149 | 150 | // Text editing 151 | if (key.leftArrow) { 152 | setCursorPosition(prev => Math.max(0, prev - 1)); 153 | } else if (key.rightArrow) { 154 | setCursorPosition(prev => Math.min(commandLine.length, prev + 1)); 155 | } else if (key.backspace || key.delete) { 156 | if (cursorPosition > 0) { 157 | setCommandLine(prev => 158 | prev.substring(0, cursorPosition - 1) + prev.substring(cursorPosition) 159 | ); 160 | setCursorPosition(prev => prev - 1); 161 | } 162 | } else if (input && !key.ctrl && !key.meta) { 163 | setCommandLine(prev => 164 | prev.substring(0, cursorPosition) + input + prev.substring(cursorPosition) 165 | ); 166 | setCursorPosition(prev => prev + input.length); 167 | } 168 | }); 169 | 170 | // Calculate display with cursor 171 | const displayCommand = () => { 172 | const before = commandLine.substring(0, cursorPosition); 173 | const at = commandLine[cursorPosition] || ' '; 174 | const after = commandLine.substring(cursorPosition + 1); 175 | 176 | return ( 177 | <> 178 | {before} 179 | {at} 180 | {after} 181 | 182 | ); 183 | }; 184 | 185 | // Calculate dynamic heights 186 | // Fixed elements: title (1) + help text (1) + command box (2) + disclaimer (4) + shortcuts (1) + borders (2) + padding (2) + margins (2) = 15 187 | const fixedHeight = LAYOUT_CONSTANTS.FIXED_ELEMENT_HEIGHT; 188 | 189 | // Height for suggestions if shown: title (1) + items + help (1) + borders (2) + margin (1) = 5 + items 190 | const suggestionsHeight = suggestions.length > 0 191 | ? LAYOUT_CONSTANTS.SUGGESTIONS_BASE_HEIGHT + Math.min(suggestions.length, LAYOUT_CONSTANTS.MAX_SUGGESTIONS_SHOWN) 192 | : 0; 193 | 194 | // Calculate remaining height for options list 195 | const remainingHeight = totalHeight - fixedHeight - suggestionsHeight; 196 | const optionsListHeight = Math.max( 197 | LAYOUT_CONSTANTS.MIN_OPTIONS_LIST_HEIGHT, 198 | remainingHeight - LAYOUT_CONSTANTS.OPTIONS_LIST_MARGIN 199 | ); 200 | 201 | return ( 202 | 203 | 204 | Claude Command Editor 205 | Edit command options for Claude. Press Enter to confirm, Esc to cancel. 206 | 207 | 208 | Command: 209 | claude 210 | {displayCommand()} 211 | 212 | 213 | {suggestions.length > 0 && ( 214 | 215 | Suggestions: 216 | {suggestions.slice(0, LAYOUT_CONSTANTS.MAX_SUGGESTIONS_SHOWN).map((suggestion, index) => { 217 | const flagText = suggestion.flags.join(', '); 218 | const isSelected = index === selectedSuggestion; 219 | return ( 220 | 221 | 222 | {isSelected ? '▶ ' : ' '} 223 | {flagText} 224 | {' - '} 225 | {suggestion.description} 226 | 227 | 228 | ); 229 | })} 230 | 231 | ↑↓ to navigate, Tab/Enter to select 232 | 233 | 234 | )} 235 | 236 | 237 | Available Options: 238 | 239 | {claudeOptions.map(option => { 240 | const flagDisplay = option.flags.join(', ') + (option.valueDescription ? ` ${option.valueDescription}` : ''); 241 | const paddedFlag = flagDisplay.padEnd(35); 242 | return ( 243 | 244 | {paddedFlag} 245 | {option.description} 246 | 247 | ); 248 | })} 249 | 250 | 251 | 252 | 253 | 254 | ⚠️ Note: This list is based on claude --help at a specific point in time. 255 | 256 | 257 | Please refer to official docs for the latest valid options. 258 | 259 | 260 | Options like -r, -c, -h may cause ccresume to malfunction. 261 | 262 | 263 | 264 | 265 | 266 | Shortcuts: Enter=confirm, Esc=cancel, ←/→=move cursor, Tab=autocomplete 267 | 268 | 269 | 270 | 271 | ); 272 | }; -------------------------------------------------------------------------------- /src/components/ConversationList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Text, useStdout } from 'ink'; 3 | import { format } from 'date-fns'; 4 | import type { Conversation } from '../types.js'; 5 | import { generateConversationSummary, formatProjectPath } from '../utils/conversationUtils.js'; 6 | import { getStringDisplayLength } from '../utils/stringUtils.js'; 7 | import { strictTruncateByWidth } from '../utils/strictTruncate.js'; 8 | 9 | interface ConversationListProps { 10 | conversations: Conversation[]; 11 | selectedIndex: number; 12 | maxVisible?: number; 13 | isLoading?: boolean; 14 | } 15 | 16 | export const ConversationList: React.FC = ({ 17 | conversations, 18 | selectedIndex, 19 | maxVisible = 3, 20 | isLoading = false 21 | }) => { 22 | const { stdout } = useStdout(); 23 | const terminalWidth = stdout?.columns || 80; 24 | 25 | // Calculate visible range with bounds checking 26 | const safeSelectedIndex = Math.max(0, Math.min(selectedIndex, conversations.length - 1)); 27 | 28 | // Calculate scroll window 29 | let startIndex = 0; 30 | let endIndex = conversations.length; 31 | 32 | if (conversations.length > maxVisible) { 33 | const halfWindow = Math.floor(maxVisible / 2); 34 | startIndex = Math.max(0, safeSelectedIndex - halfWindow); 35 | endIndex = Math.min(conversations.length, startIndex + maxVisible); 36 | 37 | // Adjust if we're at the end 38 | if (endIndex === conversations.length) { 39 | startIndex = Math.max(0, endIndex - maxVisible); 40 | } 41 | } 42 | 43 | const visibleConversations = conversations.slice(startIndex, endIndex); 44 | const hasMoreBelow = endIndex < conversations.length; 45 | 46 | return ( 47 | 48 | {isLoading ? 'Loading conversations...' : `Select a conversation${conversations.length > 0 ? ` (${conversations.length} shown)` : ''}:`} 49 | 50 | {isLoading ? ( 51 | 52 | 53 | ) : conversations.length === 0 ? ( 54 | No conversations found 55 | ) : ( 56 | visibleConversations.map((conv, visibleIndex) => { 57 | const actualIndex = startIndex + visibleIndex; 58 | const isSelected = actualIndex === safeSelectedIndex; 59 | 60 | const summary = generateConversationSummary(conv); 61 | const projectPath = formatProjectPath(conv.projectPath); 62 | 63 | // Calculate the fixed part length 64 | const selector = isSelected ? '▶ ' : ' '; 65 | const dateStr = format(conv.endTime, 'MMM dd HH:mm'); 66 | const fixedPart = `${selector}${dateStr} | ${projectPath}`; 67 | const fixedPartLength = getStringDisplayLength(fixedPart); 68 | 69 | // Calculate available space for summary (with separator) 70 | // Add extra buffer to prevent overflow: borders(2) + padding(2) + selector(2) + safety(10) = 16 71 | const separator = ' | '; 72 | const totalMargin = 16; 73 | const availableSpace = Math.max(20, terminalWidth - fixedPartLength - separator.length - totalMargin); 74 | const truncatedSummary = strictTruncateByWidth(summary, availableSpace); 75 | 76 | // Combine everything into one line 77 | const fullLine = truncatedSummary 78 | ? `${fixedPart}${separator}${truncatedSummary}` 79 | : fixedPart; 80 | 81 | // Final safety check: ensure the entire line fits 82 | const maxLineWidth = terminalWidth - totalMargin; 83 | const safeLine = strictTruncateByWidth(fullLine, maxLineWidth); 84 | 85 | return ( 86 | 87 | 92 | {safeLine} 93 | 94 | 95 | ); 96 | }) 97 | )} 98 | 99 | {hasMoreBelow && ( 100 | 101 | ↓ {conversations.length - endIndex} more on this page... 102 | 103 | )} 104 | 105 | ); 106 | }; -------------------------------------------------------------------------------- /src/components/ConversationPreview.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Box, Text, useInput, useStdout } from 'ink'; 3 | import { format } from 'date-fns'; 4 | import type { Conversation } from '../types.js'; 5 | import { extractMessageText } from '../utils/messageUtils.js'; 6 | import { strictTruncateByWidth } from '../utils/strictTruncate.js'; 7 | import { loadConfig } from '../utils/configLoader.js'; 8 | import { matchesKeyBinding } from '../utils/keyBindingHelper.js'; 9 | import { getShortcutText, hasKeyConflict } from '../utils/shortcutHelper.js'; 10 | import type { Config } from '../types/config.js'; 11 | 12 | interface ConversationPreviewProps { 13 | conversation: Conversation | null; 14 | statusMessage?: string | null; 15 | hideOptions?: string[]; 16 | } 17 | 18 | export const ConversationPreview: React.FC = ({ conversation, statusMessage, hideOptions = [] }) => { 19 | const { stdout } = useStdout(); 20 | const [scrollOffset, setScrollOffset] = useState(0); 21 | const terminalWidth = stdout?.columns || 80; 22 | const [config, setConfig] = useState(null); 23 | 24 | // Calculate available height for messages dynamically 25 | const [maxVisibleMessages, setMaxVisibleMessages] = useState(10); 26 | 27 | useEffect(() => { 28 | // Load config on mount 29 | const loadedConfig = loadConfig(); 30 | setConfig(loadedConfig); 31 | }, []); 32 | 33 | useEffect(() => { 34 | // Adjust visible messages based on terminal height 35 | const terminalHeight = stdout?.rows || 24; 36 | // Reserve lines for fixed parts: 37 | // - Top window: 1 (title) + 8 (conversation list with borders) 38 | // - Bottom window fixed parts: 39 | // - Border top: 1 40 | // - Header: 1 (Conversation History) 41 | // - Session info: 1 42 | // - Project info: 1 43 | // - Margin: 1 44 | // - Inner border: 2 45 | // - Scroll help: 1 46 | // - Border bottom: 1 47 | // - Margin: 1 48 | // Total fixed: 9 (top) + 10 (bottom fixed) = 19 49 | // Add extra buffer (2 lines) for multi-line text overflow 50 | const bottomMargin = 2; 51 | const calculatedHeight = terminalHeight - 19 - bottomMargin; 52 | // Minimum 5 lines, no maximum limit 53 | const availableHeight = Math.max(5, calculatedHeight); 54 | setMaxVisibleMessages(availableHeight); 55 | }, [stdout?.rows]); 56 | 57 | // Filter messages based on hideOptions 58 | const filteredMessages = conversation ? conversation.messages.filter(msg => { 59 | if (!msg || (!msg.message && !msg.toolUseResult)) { 60 | return false; 61 | } 62 | 63 | // Get content to check message type 64 | let content = ''; 65 | if (msg.message && msg.message.content) { 66 | content = extractMessageText(msg.message.content); 67 | } else if (msg.toolUseResult) { 68 | // Tool result messages are considered tool messages 69 | return !hideOptions.includes('tool'); 70 | } 71 | 72 | // Check if this is a tool message 73 | if (hideOptions.includes('tool') && content.startsWith('[Tool:')) { 74 | return false; 75 | } 76 | 77 | // Check if this is a thinking message 78 | if (hideOptions.includes('thinking') && content === '[Thinking...]') { 79 | return false; 80 | } 81 | 82 | // Check if we should hide user messages 83 | if (hideOptions.includes('user') && msg.type === 'user') { 84 | return false; 85 | } 86 | 87 | // Check if we should hide assistant messages 88 | if (hideOptions.includes('assistant') && msg.type === 'assistant') { 89 | return false; 90 | } 91 | 92 | return true; 93 | }) : []; 94 | 95 | useEffect(() => { 96 | // When conversation changes, scroll to the bottom (most recent messages) 97 | if (conversation) { 98 | const totalMessages = filteredMessages.length; 99 | const maxOffset = Math.max(0, totalMessages - maxVisibleMessages); 100 | setScrollOffset(maxOffset); 101 | } else { 102 | setScrollOffset(0); 103 | } 104 | }, [conversation?.sessionId, maxVisibleMessages, filteredMessages.length]); // eslint-disable-line react-hooks/exhaustive-deps 105 | 106 | 107 | useInput((input, key) => { 108 | if (!conversation || !config) return; 109 | 110 | const totalMessages = filteredMessages.length; 111 | const maxOffset = Math.max(0, totalMessages - maxVisibleMessages); 112 | 113 | // Top 114 | if (matchesKeyBinding(input, key, config.keybindings.scrollTop)) { 115 | setScrollOffset(0); 116 | return; 117 | } 118 | 119 | // Page scrolling 120 | if (matchesKeyBinding(input, key, config.keybindings.scrollPageDown)) { 121 | setScrollOffset(prev => Math.min(prev + Math.floor(maxVisibleMessages / 2), maxOffset)); 122 | } 123 | if (matchesKeyBinding(input, key, config.keybindings.scrollPageUp)) { 124 | setScrollOffset(prev => Math.max(prev - Math.floor(maxVisibleMessages / 2), 0)); 125 | } 126 | 127 | // Line scrolling 128 | if (matchesKeyBinding(input, key, config.keybindings.scrollDown)) { 129 | setScrollOffset(prev => Math.min(prev + 1, maxOffset)); 130 | } 131 | if (matchesKeyBinding(input, key, config.keybindings.scrollUp)) { 132 | setScrollOffset(prev => Math.max(prev - 1, 0)); 133 | } 134 | 135 | // Bottom 136 | if (matchesKeyBinding(input, key, config.keybindings.scrollBottom)) { 137 | setScrollOffset(maxOffset); 138 | } 139 | 140 | }); 141 | 142 | if (!conversation) { 143 | return ( 144 | 145 | 146 | ); 147 | } 148 | 149 | // Count valid messages (with proper structure or tool results) 150 | const messageCount = filteredMessages.length; 151 | const duration = conversation.endTime.getTime() - conversation.startTime.getTime(); 152 | const durationMinutes = Math.round(duration / 1000 / 60); 153 | 154 | const visibleMessages = filteredMessages.slice(scrollOffset, scrollOffset + maxVisibleMessages); 155 | 156 | // Calculate safe width for text wrapping 157 | // Account for borders (2) and padding (2) on each side 158 | const safeWidth = Math.max(40, terminalWidth - 4); 159 | 160 | 161 | return ( 162 | 163 | {/* Fixed header section */} 164 | 165 | 166 | Conversation History 167 | ({messageCount} messages, {durationMinutes} min) 168 | 169 | 170 | 171 | Session: 172 | {strictTruncateByWidth(conversation.sessionId, safeWidth - 10)} 173 | 174 | 175 | Directory: 176 | {strictTruncateByWidth(conversation.projectPath, safeWidth - 12)} 177 | 178 | 179 | Branch: 180 | {strictTruncateByWidth(conversation.gitBranch || '-', safeWidth - 9)} 181 | 182 | 183 | 184 | {/* Messages area with inner border */} 185 | 186 | 187 | {visibleMessages.map((msg, index) => { 188 | // Skip messages without proper structure 189 | if (!msg || (!msg.message && !msg.toolUseResult)) { 190 | return null; 191 | } 192 | 193 | const isUser = msg.type === 'user'; 194 | let content = ''; 195 | 196 | // Handle different message formats 197 | if (msg.message && msg.message.content) { 198 | content = extractMessageText(msg.message.content); 199 | } else if (msg.toolUseResult) { 200 | // Handle tool result messages 201 | const result = msg.toolUseResult; 202 | if (result.stdout) { 203 | content = `[Tool Output] ${result.stdout.replace(/\n/g, ' ').trim()}`; 204 | } else if (result.stderr) { 205 | content = `[Tool Error] ${result.stderr.replace(/\n/g, ' ').trim()}`; 206 | } else if (result.filenames && Array.isArray(result.filenames)) { 207 | const fileList = result.filenames.slice(0, 5).join(', '); 208 | const moreCount = result.filenames.length > 5 ? ` ... and ${result.filenames.length - 5} more` : ''; 209 | content = `[Files Found: ${result.filenames.length}] ${fileList}${moreCount}`; 210 | } else { 211 | content = '[Tool Result: No output]'; 212 | } 213 | } 214 | 215 | const timestamp = new Date(msg.timestamp); 216 | 217 | // Skip if timestamp is invalid 218 | if (isNaN(timestamp.getTime())) { 219 | return null; 220 | } 221 | 222 | const isToolMessage = content.startsWith('[Tool:') || 223 | content.startsWith('[Tool Output]') || 224 | content.startsWith('[Tool Error]') || 225 | content.startsWith('[Files Found:'); 226 | 227 | // Combine role and content on single line for compact display 228 | const roleText = isUser ? 'User' : 'Assistant'; 229 | const timeText = format(timestamp, 'HH:mm:ss'); 230 | const header = `[${roleText}] (${timeText})`; 231 | 232 | // Get first line of content and truncate 233 | const firstLine = content.split('\n')[0]; 234 | const headerLength = header.length + 1; // +1 for space 235 | const availableWidth = safeWidth - headerLength; 236 | const truncatedContent = strictTruncateByWidth(firstLine, availableWidth); 237 | 238 | // Use a combination of timestamp and index for unique key 239 | const uniqueKey = `${msg.timestamp}-${scrollOffset + index}`; 240 | 241 | return ( 242 | 243 | 244 | {header} 245 | {isToolMessage ? ( 246 | {truncatedContent} 247 | ) : ( 248 | {truncatedContent} 249 | )} 250 | 251 | 252 | ); 253 | }).filter(Boolean)} 254 | 255 | 256 | 257 | {/* Fixed footer */} 258 | 259 | {statusMessage ? ( 260 | {statusMessage} 261 | ) : config ? ( 262 | 263 | 264 | {getShortcutText(config, terminalWidth)} 265 | 266 | {hasKeyConflict(config) && ( 267 | <> 268 | 269 | ⚠️ Key conflict - see --help 270 | 271 | )} 272 | 273 | ) : ( 274 | Loading shortcuts... 275 | )} 276 | 277 | 278 | ); 279 | }; -------------------------------------------------------------------------------- /src/components/ConversationPreviewFull.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Box, Text, useInput } from 'ink'; 3 | import { format } from 'date-fns'; 4 | import type { Conversation } from '../types.js'; 5 | import { extractMessageText } from '../utils/messageUtils.js'; 6 | 7 | // Type definitions for tool inputs 8 | interface TodoWriteInput { 9 | todos?: Array<{ 10 | id: string; 11 | content: string; 12 | status: string; 13 | priority: string; 14 | }>; 15 | } 16 | 17 | interface EditInput { 18 | filePath?: string; 19 | file_path?: string; 20 | oldString?: string; 21 | old_string?: string; 22 | newString?: string; 23 | new_string?: string; 24 | } 25 | 26 | interface ReadInput { 27 | filePath?: string; 28 | file_path?: string; 29 | offset?: number; 30 | limit?: number; 31 | } 32 | 33 | interface BashInput { 34 | command?: string; 35 | cmd?: string; 36 | } 37 | 38 | interface GrepInput { 39 | pattern?: string; 40 | glob?: string; 41 | path?: string; 42 | } 43 | 44 | interface GlobInput { 45 | pattern?: string; 46 | } 47 | 48 | interface MultiEditInput { 49 | filePath?: string; 50 | file_path?: string; 51 | edits?: Array<{ 52 | oldString?: string; 53 | old_string?: string; 54 | newString?: string; 55 | new_string?: string; 56 | }>; 57 | } 58 | 59 | type MessageContentItem = { 60 | type: string; 61 | text?: string; 62 | name?: string; 63 | input?: unknown; 64 | tool_use_id?: string; 65 | thinking?: string; 66 | }; 67 | 68 | interface ConversationPreviewFullProps { 69 | conversation: Conversation | null; 70 | statusMessage?: string | null; 71 | hideOptions?: string[]; 72 | } 73 | 74 | export const ConversationPreviewFull: React.FC = ({ conversation, statusMessage, hideOptions = [] }) => { 75 | const [scrollOffset, setScrollOffset] = useState(0); 76 | 77 | // Filter messages based on hideOptions 78 | const filteredMessages = conversation ? conversation.messages.filter(msg => { 79 | if (!msg || (!msg.message && !msg.toolUseResult)) { 80 | return false; 81 | } 82 | 83 | // Get content to check message type 84 | let content = ''; 85 | if (msg.message && msg.message.content) { 86 | content = extractMessageText(msg.message.content); 87 | } else if (msg.toolUseResult) { 88 | // Tool result messages are considered tool messages 89 | return !hideOptions.includes('tool'); 90 | } 91 | 92 | // Check if this is a tool message 93 | if (hideOptions.includes('tool') && content.startsWith('[Tool:')) { 94 | return false; 95 | } 96 | 97 | // Check if this is a thinking message 98 | if (hideOptions.includes('thinking') && content === '[Thinking...]') { 99 | return false; 100 | } 101 | 102 | // Check if we should hide user messages 103 | if (hideOptions.includes('user') && msg.type === 'user') { 104 | return false; 105 | } 106 | 107 | // Check if we should hide assistant messages 108 | if (hideOptions.includes('assistant') && msg.type === 'assistant') { 109 | return false; 110 | } 111 | 112 | return true; 113 | }) : []; 114 | 115 | useEffect(() => { 116 | // When conversation changes, scroll to the bottom (most recent messages) 117 | if (conversation) { 118 | // Just scroll to the end 119 | setScrollOffset(Math.max(0, filteredMessages.length - 1)); 120 | } else { 121 | setScrollOffset(0); 122 | } 123 | }, [conversation?.sessionId, filteredMessages.length]); // eslint-disable-line react-hooks/exhaustive-deps 124 | 125 | // Disable all keyboard navigation in full view - only mouse scroll works 126 | useInput(() => { 127 | // Do nothing - keyboard navigation is disabled in full view 128 | }); 129 | 130 | if (!conversation) { 131 | return ( 132 | 133 | No conversation selected 134 | 135 | ); 136 | } 137 | 138 | // Show all messages from scroll offset onwards, let terminal handle overflow 139 | const visibleMessages = filteredMessages.slice(scrollOffset); 140 | 141 | return ( 142 | 143 | {visibleMessages.map((msg, index) => { 144 | // Skip messages without proper structure 145 | if (!msg || (!msg.message && !msg.toolUseResult)) { 146 | return null; 147 | } 148 | 149 | const isUser = msg.type === 'user'; 150 | let content = ''; 151 | 152 | // Handle different message formats 153 | if (msg.message && msg.message.content) { 154 | // Check if content contains tool_use 155 | const messageContent = msg.message.content; 156 | if (Array.isArray(messageContent)) { 157 | // Collect all content parts 158 | const contentParts: string[] = []; 159 | 160 | // Check for thinking content 161 | const thinkingItem = messageContent.find((item: MessageContentItem) => item.type === 'thinking'); 162 | if (thinkingItem && thinkingItem.thinking) { 163 | contentParts.push(`[Thinking...]\n${thinkingItem.thinking.trim()}`); 164 | } 165 | 166 | // Check for regular text content 167 | const textItems = messageContent.filter((item: MessageContentItem) => item.type === 'text'); 168 | textItems.forEach((item: MessageContentItem) => { 169 | if (item.text) { 170 | contentParts.push(item.text); 171 | } 172 | }); 173 | 174 | // Check for tool use 175 | const toolUse = messageContent.find((item: MessageContentItem) => item.type === 'tool_use'); 176 | if (toolUse) { 177 | // Format tool use based on tool name 178 | if (toolUse.name === 'TodoWrite') { 179 | const input = toolUse.input as TodoWriteInput; 180 | if (input?.todos) { 181 | const todos = input.todos; 182 | const todoSummary = todos.map((todo) => 183 | ` ${todo.status === 'completed' ? '✓' : todo.status === 'in_progress' ? '→' : '○'} ${todo.content}` 184 | ).join('\n'); 185 | contentParts.push(`[Tool: TodoWrite]\n${todoSummary}`); 186 | } else { 187 | contentParts.push(`[Tool: TodoWrite]`); 188 | } 189 | } else if (toolUse.name === 'Edit') { 190 | const input = toolUse.input as EditInput; 191 | const filePath = input?.filePath || input?.file_path || 'file'; 192 | const oldStr = input?.oldString || input?.old_string || ''; 193 | const newStr = input?.newString || input?.new_string || ''; 194 | contentParts.push(`[Tool: Edit] ${filePath}\nOld:\n${oldStr}\nNew:\n${newStr}`); 195 | } else if (toolUse.name === 'Read') { 196 | const input = toolUse.input as ReadInput; 197 | const filePath = input?.filePath || input?.file_path || 'file'; 198 | const lineInfo = input?.offset ? ` (lines ${input.offset}-${input.offset + (input.limit || 50)})` : ''; 199 | contentParts.push(`[Tool: Read] ${filePath}${lineInfo}`); 200 | } else if (toolUse.name === 'Bash') { 201 | const input = toolUse.input as BashInput; 202 | contentParts.push(`[Tool: Bash] ${input?.command || input?.cmd || ''}`); 203 | } else if (toolUse.name === 'Grep') { 204 | const input = toolUse.input as GrepInput; 205 | contentParts.push(`[Tool: Grep] pattern: "${input?.pattern || ''}" in ${input?.glob || input?.path || '.'}`); 206 | } else if (toolUse.name === 'Glob') { 207 | const input = toolUse.input as GlobInput; 208 | contentParts.push(`[Tool: Glob] pattern: "${input?.pattern || ''}"`); 209 | } else if (toolUse.name === 'MultiEdit') { 210 | const input = toolUse.input as MultiEditInput; 211 | const filePath = input?.filePath || input?.file_path || 'file'; 212 | const edits = input?.edits || []; 213 | const editSummary = edits.map((edit, i: number) => 214 | `Edit ${i + 1}:\nOld:\n${edit.oldString || edit.old_string || ''}\nNew:\n${edit.newString || edit.new_string || ''}` 215 | ).join('\n\n'); 216 | contentParts.push(`[Tool: MultiEdit] ${filePath}\n${editSummary}`); 217 | } else { 218 | contentParts.push(`[Tool: ${toolUse.name}] ${JSON.stringify(toolUse.input || {}).substring(0, 100)}...`); 219 | } 220 | } 221 | 222 | // Join all content parts 223 | content = contentParts.join('\n\n'); 224 | if (!content) { 225 | content = extractMessageText(messageContent); 226 | } 227 | } else { 228 | content = extractMessageText(messageContent); 229 | } 230 | } else if (msg.toolUseResult) { 231 | // Handle tool result messages 232 | const result = msg.toolUseResult; 233 | if (result.oldTodos && result.newTodos) { 234 | // TodoWrite result 235 | const changes = result.newTodos.filter((newTodo) => { 236 | const oldTodo = result.oldTodos?.find((old) => old.id === newTodo.id); 237 | return !oldTodo || oldTodo.status !== newTodo.status || oldTodo.content !== newTodo.content; 238 | }); 239 | content = `[TodoWrite Result] ${changes.length} todos updated`; 240 | } else if (result.file || result.filePath) { 241 | // Read result 242 | const filePath = result.file?.filePath || result.filePath; 243 | content = `[Read Result] ${filePath} (${result.file?.numLines || result.numLines || 0} lines)`; 244 | } else if (result.oldString && result.newString) { 245 | // Edit result 246 | content = `[Edit Result] ${result.filePath || 'file'} modified`; 247 | } else if (result.stdout) { 248 | content = `[Bash Output]\n${result.stdout.trim()}`; 249 | } else if (result.stderr) { 250 | content = `[Bash Error]\n${result.stderr.trim()}`; 251 | } else if (result.filenames && Array.isArray(result.filenames)) { 252 | const fileList = result.filenames.slice(0, 5).join('\n '); 253 | const moreCount = result.filenames.length > 5 ? `\n ... and ${result.filenames.length - 5} more` : ''; 254 | content = `[Search Results: ${result.filenames.length} files]\n ${fileList}${moreCount}`; 255 | } else { 256 | content = `[Tool Result] ${JSON.stringify(result).substring(0, 100)}...`; 257 | } 258 | } 259 | 260 | const timestamp = new Date(msg.timestamp); 261 | 262 | // Skip if timestamp is invalid 263 | if (isNaN(timestamp.getTime())) { 264 | return null; 265 | } 266 | 267 | 268 | const roleText = isUser ? 'User' : 'Assistant'; 269 | const timeText = format(timestamp, 'HH:mm:ss'); 270 | 271 | // Use a combination of timestamp and index for unique key 272 | const uniqueKey = `${msg.timestamp}-${scrollOffset + index}`; 273 | 274 | return ( 275 | 276 | 277 | [{roleText}] 278 | ({timeText}) 279 | 280 | {content.split('\n').map((line, lineIndex) => { 281 | // Check if this line is a label (starts with [ and contains ]) 282 | const isLabel = line.startsWith('[') && line.includes(']'); 283 | 284 | if (isLabel) { 285 | const labelMatch = line.match(/^(\[.*?\])(.*)/); 286 | if (labelMatch) { 287 | return ( 288 | 289 | {' '} 290 | {labelMatch[1]} 291 | {labelMatch[2]} 292 | 293 | ); 294 | } 295 | } 296 | 297 | return ( 298 | 299 | {' '} 300 | {line} 301 | 302 | ); 303 | })} 304 | 305 | 306 | ); 307 | }).filter(Boolean)} 308 | 309 | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 310 | {statusMessage ? ( 311 | {statusMessage} 312 | ) : ( 313 | 314 | Toggle: f | Quit: q | Currently supports only terminal scroll (use your mouse!) 315 | 316 | )} 317 | 318 | ); 319 | }; -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Message { 2 | sessionId: string; 3 | timestamp: string; 4 | type: 'user' | 'assistant'; 5 | message?: { 6 | role: 'user' | 'assistant'; 7 | content?: string | Array<{ type: string; text?: string; name?: string; input?: unknown; tool_use_id?: string; thinking?: string }>; 8 | }; 9 | cwd: string; 10 | toolUseResult?: { 11 | stdout?: string; 12 | stderr?: string; 13 | filenames?: string[]; 14 | durationMs?: number; 15 | interrupted?: boolean; 16 | isImage?: boolean; 17 | // TodoWrite results 18 | oldTodos?: Array<{ id: string; content: string; status: string; priority: string }>; 19 | newTodos?: Array<{ id: string; content: string; status: string; priority: string }>; 20 | // Read results 21 | file?: { 22 | filePath: string; 23 | content: string; 24 | numLines: number; 25 | startLine: number; 26 | totalLines: number; 27 | }; 28 | filePath?: string; // Fallback for different result formats 29 | numLines?: number; 30 | // Edit results 31 | oldString?: string; 32 | newString?: string; 33 | originalFile?: string; 34 | replaceAll?: boolean; 35 | structuredPatch?: unknown; 36 | userModified?: boolean; 37 | // Other tool results 38 | type?: string; 39 | content?: string; 40 | mode?: string; 41 | numFiles?: number; 42 | totalDurationMs?: number; 43 | totalTokens?: number; 44 | totalToolUseCount?: number; 45 | usage?: unknown; 46 | }; 47 | } 48 | 49 | export interface Conversation { 50 | sessionId: string; 51 | projectPath: string; 52 | projectName: string; 53 | gitBranch?: string | null; 54 | messages: Message[]; 55 | firstMessage: string; 56 | lastMessage: string; 57 | startTime: Date; 58 | endTime: Date; 59 | } -------------------------------------------------------------------------------- /src/types/config.ts: -------------------------------------------------------------------------------- 1 | export interface KeyBindings { 2 | quit: string[]; 3 | selectPrevious: string[]; 4 | selectNext: string[]; 5 | confirm: string[]; 6 | copySessionId: string[]; 7 | scrollUp: string[]; 8 | scrollDown: string[]; 9 | scrollPageUp: string[]; 10 | scrollPageDown: string[]; 11 | scrollTop: string[]; 12 | scrollBottom: string[]; 13 | pageNext: string[]; 14 | pagePrevious: string[]; 15 | startNewSession: string[]; 16 | openCommandEditor: string[]; 17 | toggleFullView: string[]; 18 | } 19 | 20 | export interface Config { 21 | keybindings: KeyBindings; 22 | } 23 | 24 | export const defaultConfig: Config = { 25 | keybindings: { 26 | quit: ['q'], 27 | selectPrevious: ['up'], 28 | selectNext: ['down'], 29 | confirm: ['enter'], 30 | copySessionId: ['c'], 31 | scrollUp: ['k'], 32 | scrollDown: ['j'], 33 | scrollPageUp: ['u', 'pageup'], 34 | scrollPageDown: ['d', 'pagedown'], 35 | scrollTop: ['g'], 36 | scrollBottom: ['G'], 37 | pageNext: ['right'], 38 | pagePrevious: ['left'], 39 | startNewSession: ['n'], 40 | openCommandEditor: ['-'], 41 | toggleFullView: ['f'], 42 | }, 43 | }; -------------------------------------------------------------------------------- /src/utils/charWidth.ts: -------------------------------------------------------------------------------- 1 | export function getCharWidth(char: string): number { 2 | if (!char || char.length === 0) return 0; 3 | 4 | const code = char.charCodeAt(0); 5 | 6 | // ASCII characters 7 | if (code <= 0x7F) { 8 | return 1; 9 | } 10 | 11 | // Check for high surrogate (emoji that use surrogate pairs) 12 | if (code >= 0xD800 && code <= 0xDBFF) { 13 | // This is a high surrogate, check if there's a low surrogate following 14 | if (char.length > 1) { 15 | const lowSurrogate = char.charCodeAt(1); 16 | if (lowSurrogate >= 0xDC00 && lowSurrogate <= 0xDFFF) { 17 | // This is a surrogate pair (emoji) 18 | return 2; 19 | } 20 | } 21 | return 2; // Treat incomplete surrogate as width 2 22 | } 23 | 24 | // Low surrogate without high surrogate (shouldn't happen in valid UTF-16) 25 | if (code >= 0xDC00 && code <= 0xDFFF) { 26 | return 0; // Skip orphaned low surrogates 27 | } 28 | 29 | // Emoji blocks (single code point emojis) 30 | if ((code >= 0x2600 && code <= 0x27BF) || // Miscellaneous Symbols and Dingbats 31 | (code >= 0x2300 && code <= 0x23FF) || // Miscellaneous Technical 32 | (code >= 0x2B00 && code <= 0x2BFF) || // Miscellaneous Symbols and Arrows 33 | (code >= 0x2100 && code <= 0x214F) || // Letterlike Symbols 34 | (code >= 0x2190 && code <= 0x21FF) || // Arrows 35 | (code >= 0x25A0 && code <= 0x25FF) || // Geometric Shapes 36 | (code >= 0x2700 && code <= 0x27BF) || // Dingbats 37 | (code >= 0x1F300 && code <= 0x1F5FF) || // Miscellaneous Symbols and Pictographs 38 | (code >= 0x1F600 && code <= 0x1F64F) || // Emoticons 39 | (code >= 0x1F680 && code <= 0x1F6FF) || // Transport and Map Symbols 40 | (code >= 0x1F700 && code <= 0x1F77F) || // Alchemical Symbols 41 | (code >= 0x1F780 && code <= 0x1F7FF) || // Geometric Shapes Extended 42 | (code >= 0x1F800 && code <= 0x1F8FF) || // Supplemental Arrows-C 43 | (code >= 0x1F900 && code <= 0x1F9FF) || // Supplemental Symbols and Pictographs 44 | (code >= 0x1FA00 && code <= 0x1FA6F) || // Chess Symbols 45 | (code >= 0x1FA70 && code <= 0x1FAFF)) { // Symbols and Pictographs Extended-A 46 | return 2; 47 | } 48 | 49 | // CJK characters and other full-width characters 50 | if ((code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified Ideographs 51 | (code >= 0x3040 && code <= 0x309F) || // Hiragana 52 | (code >= 0x30A0 && code <= 0x30FF) || // Katakana 53 | (code >= 0xAC00 && code <= 0xD7AF) || // Hangul Syllables (Korean) 54 | (code >= 0xFF00 && code <= 0xFFEF) || // Full-width forms 55 | (code >= 0x3000 && code <= 0x303F) || // CJK punctuation 56 | (code >= 0xFE30 && code <= 0xFE4F) || // CJK Compatibility Forms 57 | (code >= 0xFE50 && code <= 0xFE6F) || // Small Form Variants 58 | (code >= 0x3200 && code <= 0x32FF) || // Enclosed CJK Letters and Months 59 | (code >= 0x3300 && code <= 0x33FF) || // CJK Compatibility 60 | (code >= 0x3400 && code <= 0x4DBF) || // CJK Extension A 61 | (code >= 0x20000 && code <= 0x2A6DF) || // CJK Extension B 62 | (code >= 0x2A700 && code <= 0x2B73F) || // CJK Extension C 63 | (code >= 0x2B740 && code <= 0x2B81F) || // CJK Extension D 64 | (code >= 0x2B820 && code <= 0x2CEAF) || // CJK Extension E 65 | (code >= 0x2CEB0 && code <= 0x2EBEF) || // CJK Extension F 66 | (code >= 0x30000 && code <= 0x3134F)) { // CJK Extension G 67 | return 2; 68 | } 69 | 70 | // Default for other characters 71 | return 1; 72 | } 73 | 74 | export function getStringWidth(str: string): number { 75 | if (!str) return 0; 76 | 77 | let width = 0; 78 | let i = 0; 79 | 80 | while (i < str.length) { 81 | const code = str.charCodeAt(i); 82 | 83 | // Handle surrogate pairs 84 | if (code >= 0xD800 && code <= 0xDBFF && i + 1 < str.length) { 85 | const lowCode = str.charCodeAt(i + 1); 86 | if (lowCode >= 0xDC00 && lowCode <= 0xDFFF) { 87 | // This is a valid surrogate pair 88 | width += 2; 89 | i += 2; 90 | continue; 91 | } 92 | } 93 | 94 | // Single character 95 | width += getCharWidth(str[i]); 96 | i++; 97 | } 98 | 99 | return width; 100 | } 101 | 102 | export function truncateStringByWidth(str: string, maxWidth: number): string { 103 | if (!str) return ''; 104 | 105 | let width = 0; 106 | let result = ''; 107 | let i = 0; 108 | 109 | while (i < str.length) { 110 | const code = str.charCodeAt(i); 111 | let charSequence = ''; 112 | let charWidth = 0; 113 | 114 | // Handle surrogate pairs 115 | if (code >= 0xD800 && code <= 0xDBFF && i + 1 < str.length) { 116 | const lowCode = str.charCodeAt(i + 1); 117 | if (lowCode >= 0xDC00 && lowCode <= 0xDFFF) { 118 | // This is a valid surrogate pair 119 | charSequence = str.slice(i, i + 2); 120 | charWidth = 2; 121 | i += 2; 122 | } else { 123 | // High surrogate without valid low surrogate 124 | charSequence = str[i]; 125 | charWidth = getCharWidth(str[i]); 126 | i++; 127 | } 128 | } else { 129 | // Single character 130 | charSequence = str[i]; 131 | charWidth = getCharWidth(str[i]); 132 | i++; 133 | } 134 | 135 | if (width + charWidth > maxWidth - 3) { // Reserve space for "..." 136 | return result + '...'; 137 | } 138 | 139 | result += charSequence; 140 | width += charWidth; 141 | } 142 | 143 | return result; 144 | } -------------------------------------------------------------------------------- /src/utils/configLoader.ts: -------------------------------------------------------------------------------- 1 | import { parse } from '@iarna/toml'; 2 | import { readFileSync, existsSync } from 'fs'; 3 | import { join } from 'path'; 4 | import { homedir } from 'os'; 5 | import { Config, defaultConfig, KeyBindings } from '../types/config.js'; 6 | 7 | export function getConfigPath(): string { 8 | const xdgConfigHome = process.env.XDG_CONFIG_HOME || join(homedir(), '.config'); 9 | return join(xdgConfigHome, 'ccresume', 'config.toml'); 10 | } 11 | 12 | export function loadConfig(): Config { 13 | const configPath = getConfigPath(); 14 | 15 | if (!existsSync(configPath)) { 16 | return defaultConfig; 17 | } 18 | 19 | try { 20 | const tomlContent = readFileSync(configPath, 'utf-8'); 21 | const parsedConfig = parse(tomlContent) as Partial; 22 | 23 | // Merge with default config to ensure all keys exist 24 | const config = mergeConfigs(defaultConfig, parsedConfig); 25 | 26 | // Check for key conflicts and warn user 27 | const conflicts = checkKeyConflicts(config.keybindings); 28 | if (conflicts.length > 0) { 29 | console.error('\n⚠️ Key binding conflicts detected:'); 30 | conflicts.forEach(conflict => console.error(` - ${conflict}`)); 31 | console.error(' Please update your config.toml to resolve conflicts.\n'); 32 | } 33 | 34 | return config; 35 | } catch (error) { 36 | console.error(`Failed to load config from ${configPath}:`, error); 37 | return defaultConfig; 38 | } 39 | } 40 | 41 | function mergeConfigs(defaultConf: Config, userConf: Partial): Config { 42 | const merged: Config = JSON.parse(JSON.stringify(defaultConf)); 43 | 44 | // First, apply user configuration 45 | if (userConf.keybindings) { 46 | Object.keys(userConf.keybindings).forEach((key) => { 47 | const userBinding = userConf.keybindings![key as keyof typeof userConf.keybindings]; 48 | if (userBinding) { 49 | merged.keybindings[key as keyof typeof merged.keybindings] = userBinding; 50 | } 51 | }); 52 | } 53 | 54 | // Then migrate config with conflict detection based on the merged result 55 | return migrateConfig(merged, userConf); 56 | } 57 | 58 | function migrateConfig(config: Config, userConf: Partial): Config { 59 | // Only migrate if user hasn't explicitly configured startNewSession 60 | const userHasStartNewSession = userConf.keybindings && 'startNewSession' in userConf.keybindings; 61 | 62 | if (!userHasStartNewSession) { 63 | // Check if 'n' is already used by another keybinding 64 | const isNKeyUsed = isKeyAlreadyAssigned(config.keybindings, 'n'); 65 | 66 | if (!isNKeyUsed) { 67 | // Only assign 'n' if it's not already in use 68 | config.keybindings.startNewSession = ['n']; 69 | } else { 70 | // If 'n' is taken, don't assign any default key 71 | // User must configure it manually in config.toml 72 | config.keybindings.startNewSession = []; 73 | } 74 | } 75 | 76 | return config; 77 | } 78 | 79 | function isKeyAlreadyAssigned(keybindings: KeyBindings, key: string): boolean { 80 | // Check all existing keybindings to see if the key is already used 81 | for (const [action, keys] of Object.entries(keybindings)) { 82 | if (action === 'startNewSession') continue; // Skip the key we're trying to add 83 | 84 | if (Array.isArray(keys) && keys.includes(key)) { 85 | return true; 86 | } 87 | } 88 | 89 | return false; 90 | } 91 | 92 | function checkKeyConflicts(keybindings: KeyBindings): string[] { 93 | const conflicts: string[] = []; 94 | const keyToActions = new Map(); 95 | 96 | // Build a map of key -> [actions] 97 | for (const [action, keys] of Object.entries(keybindings)) { 98 | if (!Array.isArray(keys)) continue; 99 | 100 | for (const key of keys) { 101 | if (!keyToActions.has(key)) { 102 | keyToActions.set(key, []); 103 | } 104 | keyToActions.get(key)!.push(action); 105 | } 106 | } 107 | 108 | // Find conflicts (keys assigned to multiple actions) 109 | for (const [key, actions] of keyToActions.entries()) { 110 | if (actions.length > 1) { 111 | conflicts.push(`Key '${key}' is assigned to multiple actions: ${actions.join(', ')}`); 112 | } 113 | } 114 | 115 | return conflicts; 116 | } -------------------------------------------------------------------------------- /src/utils/conversationReader.ts: -------------------------------------------------------------------------------- 1 | import { readdir, readFile, stat } from 'fs/promises'; 2 | import { join, sep, basename } from 'path'; 3 | import { homedir } from 'os'; 4 | import type { Conversation, Message } from '../types.js'; 5 | import { extractMessageText } from './messageUtils.js'; 6 | 7 | const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects'); 8 | 9 | interface PaginationOptions { 10 | limit: number; 11 | offset: number; 12 | currentDirFilter?: string; 13 | } 14 | 15 | 16 | 17 | // Helper function to convert project path to Claude directory name 18 | function pathToClaudeDir(path: string): string { 19 | // Claude's conversion: / or \ becomes -, and . also becomes - 20 | return path.replace(/[/\\.]/g, '-'); 21 | } 22 | 23 | // Get paginated conversations with lazy loading 24 | export async function getPaginatedConversations(options: PaginationOptions): Promise<{ 25 | conversations: Conversation[]; 26 | total: number; 27 | }> { 28 | // First, just get file paths and stats without reading content 29 | const allFiles: Array<{path: string, dir: string, mtime: Date}> = []; 30 | 31 | try { 32 | const projectDirs = await readdir(CLAUDE_PROJECTS_DIR); 33 | 34 | // If filtering by directory, convert the filter path to Claude's directory name format 35 | const targetDir = options.currentDirFilter ? pathToClaudeDir(options.currentDirFilter) : null; 36 | 37 | for (const projectDir of projectDirs) { 38 | // Skip directories that don't match the filter early 39 | if (targetDir && projectDir !== targetDir) { 40 | continue; 41 | } 42 | 43 | const projectPath = join(CLAUDE_PROJECTS_DIR, projectDir); 44 | const dirFiles = await readdir(projectPath); 45 | const jsonlFiles = dirFiles.filter(f => f.endsWith('.jsonl') && 46 | /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i.test(f)); 47 | 48 | for (const file of jsonlFiles) { 49 | const filePath = join(projectPath, file); 50 | const stats = await stat(filePath); 51 | allFiles.push({ 52 | path: filePath, 53 | dir: projectDir, 54 | mtime: stats.mtime 55 | }); 56 | } 57 | } 58 | } catch (error) { 59 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 60 | return { conversations: [], total: 0 }; 61 | } 62 | throw error; 63 | } 64 | 65 | // Sort by modification time (newest first) 66 | allFiles.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); 67 | 68 | const conversations: Conversation[] = []; 69 | let skippedCount = 0; 70 | let fileIndex = 0; 71 | 72 | // Skip files based on offset 73 | while (skippedCount < options.offset && fileIndex < allFiles.length) { 74 | const file = allFiles[fileIndex]; 75 | const conversation = await readConversation(file.path, file.dir); 76 | 77 | if (conversation) { 78 | skippedCount++; 79 | } 80 | fileIndex++; 81 | } 82 | 83 | // Collect conversations for the current page 84 | while (conversations.length < options.limit && fileIndex < allFiles.length) { 85 | const file = allFiles[fileIndex]; 86 | const conversation = await readConversation(file.path, file.dir); 87 | 88 | if (conversation) { 89 | conversations.push(conversation); 90 | } 91 | fileIndex++; 92 | } 93 | 94 | // For total count, we'll return -1 to indicate unknown 95 | // The UI can handle this by not showing total pages 96 | return { conversations, total: -1 }; 97 | } 98 | 99 | export async function getAllConversations(currentDirFilter?: string): Promise { 100 | const conversations: Conversation[] = []; 101 | 102 | try { 103 | const projectDirs = await readdir(CLAUDE_PROJECTS_DIR); 104 | 105 | for (const projectDir of projectDirs) { 106 | const projectPath = join(CLAUDE_PROJECTS_DIR, projectDir); 107 | const files = await readdir(projectPath); 108 | const jsonlFiles = files.filter(f => f.endsWith('.jsonl')); 109 | 110 | for (const file of jsonlFiles) { 111 | const filePath = join(projectPath, file); 112 | const conversation = await readConversation(filePath, projectDir); 113 | if (conversation) { 114 | conversations.push(conversation); 115 | } 116 | } 117 | } 118 | 119 | // Filter by current directory if specified 120 | let filteredConversations = conversations; 121 | if (currentDirFilter) { 122 | filteredConversations = conversations.filter(conv => 123 | conv.projectPath === currentDirFilter 124 | ); 125 | } 126 | 127 | const result = filteredConversations 128 | .sort((a, b) => b.endTime.getTime() - a.endTime.getTime()); 129 | 130 | return result; 131 | } catch (error) { 132 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 133 | return []; 134 | } 135 | throw error; 136 | } 137 | } 138 | 139 | async function readConversation(filePath: string, projectDir: string): Promise { 140 | try { 141 | const content = await readFile(filePath, 'utf-8'); 142 | const lines = content.trim().split('\n').filter(line => line.trim()); 143 | 144 | if (lines.length === 0) { 145 | return null; 146 | } 147 | 148 | // Extract session ID from filename 149 | const filename = basename(filePath); 150 | const filenameSessionId = filename.replace('.jsonl', ''); 151 | 152 | const messages: Message[] = []; 153 | for (const line of lines) { 154 | try { 155 | const data = JSON.parse(line); 156 | // Only include messages with proper structure 157 | // Skip user messages that are tool results 158 | if (data && data.type && data.message && data.timestamp) { 159 | // Check if it's a user message with tool_result content 160 | if (data.type === 'user' && 161 | data.message.content && 162 | Array.isArray(data.message.content) && 163 | data.message.content.length > 0 && 164 | data.message.content[0].type === 'tool_result') { 165 | // Skip tool result messages from user 166 | continue; 167 | } 168 | messages.push(data as Message); 169 | } 170 | } catch { 171 | continue; 172 | } 173 | } 174 | 175 | if (messages.length === 0) { 176 | return null; 177 | } 178 | 179 | const userMessages = messages.filter(m => m.type === 'user'); 180 | 181 | const projectName = projectDir.replace(/^-/, '').split('-').join(sep); 182 | 183 | const startTime = new Date(messages[0].timestamp); 184 | const endTime = new Date(messages[messages.length - 1].timestamp); 185 | 186 | 187 | // Use session ID from filename as it's what Claude expects for --resume 188 | const sessionId = filenameSessionId; 189 | 190 | const projectPath = messages[0].cwd || ''; 191 | 192 | // Get gitBranch from the last line of the jsonl file 193 | // Branch info is stored in the last line by newer versions of Claude Code 194 | let gitBranch: string | null = null; 195 | try { 196 | const lastLine = lines[lines.length - 1]; 197 | const lastData = JSON.parse(lastLine); 198 | if (lastData.gitBranch !== undefined) { 199 | // Preserve null/empty string values explicitly 200 | gitBranch = lastData.gitBranch === null || lastData.gitBranch === '' ? '-' : lastData.gitBranch; 201 | } else { 202 | gitBranch = '-'; 203 | } 204 | } catch { 205 | gitBranch = '-'; 206 | } 207 | 208 | return { 209 | sessionId, 210 | projectPath, 211 | projectName, 212 | gitBranch, 213 | messages, 214 | firstMessage: userMessages.length > 0 ? extractMessageText(userMessages[0].message?.content) : '', 215 | lastMessage: userMessages.length > 0 ? extractMessageText(userMessages[userMessages.length - 1].message?.content) : '', 216 | startTime, 217 | endTime 218 | }; 219 | } catch (error) { 220 | console.error(`Error reading conversation file ${filePath}:`, error); 221 | return null; 222 | } 223 | } 224 | 225 | export function formatConversationSummary(conversation: Conversation): string { 226 | const firstMessagePreview = conversation.firstMessage 227 | .replace(/\n/g, ' ') 228 | .substring(0, 80) 229 | .trim(); 230 | 231 | return `${firstMessagePreview}${conversation.firstMessage.length > 80 ? '...' : ''}`; 232 | } -------------------------------------------------------------------------------- /src/utils/conversationUtils.ts: -------------------------------------------------------------------------------- 1 | import type { Conversation } from '../types.js'; 2 | import { extractMessageText } from './messageUtils.js'; 3 | 4 | export function generateConversationSummary(conversation: Conversation): string { 5 | // Get user messages that have actual text content (not tool results) 6 | const userMessages = conversation.messages 7 | .filter(m => { 8 | if (m.type !== 'user') return false; 9 | if (!m.message?.content) return false; 10 | 11 | // Skip tool result messages 12 | const content = extractMessageText(m.message.content); 13 | if (content.startsWith('[Tool Result]') || content.startsWith('[Tool Output]')) { 14 | return false; 15 | } 16 | 17 | return content.trim().length > 0; 18 | }); 19 | 20 | if (userMessages.length === 0) { 21 | return 'No user messages'; 22 | } 23 | 24 | // Get the first meaningful user message (not the last) 25 | const firstUserMessage = userMessages[0]; 26 | const messageText = extractMessageText(firstUserMessage.message?.content || ''); 27 | 28 | // Clean up the message - remove ALL newlines, HTML tags, and normalize spaces 29 | const cleanedMessage = messageText 30 | .replace(/[\r\n]+/g, ' ') // Replace all newlines with space 31 | .replace(/<[^>]*>/g, '') // Remove HTML tags 32 | .replace(/\s+/g, ' ') // Replace multiple spaces with single space 33 | .replace(/[`'"]/g, '') // Remove quotes that might break display 34 | .replace(/^\[.*?\]\s*/, '') // Remove [Tool: xxx] prefixes 35 | .trim(); 36 | 37 | // If message is empty after cleaning, try the next one 38 | if (!cleanedMessage && userMessages.length > 1) { 39 | const secondMessage = extractMessageText(userMessages[1].message?.content || ''); 40 | return secondMessage 41 | .replace(/[\r\n]+/g, ' ') 42 | .replace(/<[^>]*>/g, '') 43 | .replace(/\s+/g, ' ') 44 | .replace(/[`'"]/g, '') 45 | .replace(/^\[.*?\]\s*/, '') 46 | .trim() || 'No summary available'; 47 | } 48 | 49 | return cleanedMessage || 'No summary available'; 50 | } 51 | 52 | export function formatProjectPath(path: string): string { 53 | // Shorten home directory path for both Unix and Windows 54 | const home = process.env.HOME || process.env.USERPROFILE || (process.platform === 'win32' ? 'C:\\Users\\Default' : '/home'); 55 | if (path.startsWith(home)) { 56 | return '~' + path.slice(home.length); 57 | } 58 | return path; 59 | } 60 | 61 | export function formatSessionId(sessionId: string): string { 62 | // Show first 8 characters of session ID 63 | return sessionId ? sessionId.substring(0, 8) : 'unknown'; 64 | } -------------------------------------------------------------------------------- /src/utils/keyBindingHelper.ts: -------------------------------------------------------------------------------- 1 | import { Key } from 'ink'; 2 | 3 | export function matchesKeyBinding(input: string, key: Key, bindings: string[]): boolean { 4 | for (const binding of bindings) { 5 | if (matchesKey(input, key, binding)) { 6 | return true; 7 | } 8 | } 9 | return false; 10 | } 11 | 12 | function matchesKey(input: string, key: Key, binding: string): boolean { 13 | // Special case: single uppercase letter is treated as shift+lowercase 14 | if (binding.length === 1 && binding === binding.toUpperCase() && binding !== binding.toLowerCase()) { 15 | return key.shift && input.toLowerCase() === binding.toLowerCase(); 16 | } 17 | 18 | const parts = binding.toLowerCase().split('+'); 19 | const hasCtrl = parts.includes('ctrl'); 20 | const hasShift = parts.includes('shift'); 21 | const hasMeta = parts.includes('cmd') || parts.includes('command') || parts.includes('meta'); 22 | 23 | // Check modifiers must match exactly 24 | if (hasCtrl !== key.ctrl) return false; 25 | if (hasShift !== key.shift) return false; 26 | if (hasMeta !== key.meta) return false; 27 | 28 | // Get the main key (last part after removing modifiers) 29 | const mainKey = parts.filter(p => !['ctrl', 'shift', 'cmd', 'command', 'meta'].includes(p))[0]; 30 | 31 | if (!mainKey) return false; 32 | 33 | // Special key mappings 34 | switch (mainKey) { 35 | case 'up': 36 | case 'uparrow': 37 | return key.upArrow; 38 | case 'down': 39 | case 'downarrow': 40 | return key.downArrow; 41 | case 'left': 42 | case 'leftarrow': 43 | return key.leftArrow; 44 | case 'right': 45 | case 'rightarrow': 46 | return key.rightArrow; 47 | case 'enter': 48 | case 'return': 49 | return key.return; 50 | case 'pageup': 51 | return key.pageUp; 52 | case 'pagedown': 53 | return key.pageDown; 54 | case 'backspace': 55 | return key.backspace; 56 | case 'delete': 57 | return key.delete; 58 | case 'escape': 59 | case 'esc': 60 | return key.escape; 61 | case 'tab': 62 | return key.tab; 63 | default: 64 | // For regular characters 65 | if (mainKey.length === 1) { 66 | return input.toLowerCase() === mainKey; 67 | } 68 | return false; 69 | } 70 | } -------------------------------------------------------------------------------- /src/utils/messageUtils.ts: -------------------------------------------------------------------------------- 1 | export function extractMessageText(content: string | Array<{ type: string; text?: string; name?: string; input?: unknown }> | undefined | null): string { 2 | if (!content) { 3 | return ''; 4 | } 5 | 6 | if (typeof content === 'string') { 7 | return content; 8 | } 9 | 10 | if (Array.isArray(content)) { 11 | const parts: string[] = []; 12 | 13 | for (const item of content) { 14 | if (!item) continue; 15 | 16 | if (item.type === 'text' && item.text) { 17 | parts.push(item.text); 18 | } else if (item.type === 'tool_use' && item.name) { 19 | // Format tool use messages 20 | const toolName = item.name; 21 | let description = ''; 22 | 23 | if (item.input && typeof item.input === 'object') { 24 | const input = item.input as Record; 25 | if (typeof input.command === 'string') { 26 | description = input.command; 27 | } else if (typeof input.description === 'string') { 28 | description = input.description; 29 | } else if (typeof input.prompt === 'string') { 30 | description = input.prompt.substring(0, 100) + '...'; 31 | } 32 | } 33 | 34 | parts.push(`[Tool: ${toolName}] ${description}`); 35 | } else if (item.type === 'tool_result') { 36 | // Handle tool results 37 | parts.push('[Tool Result]'); 38 | } else if (item.type === 'thinking') { 39 | // Handle thinking messages 40 | parts.push('[Thinking...]'); 41 | } 42 | } 43 | 44 | return parts.join('\n'); 45 | } 46 | 47 | return ''; 48 | } -------------------------------------------------------------------------------- /src/utils/shortcutHelper.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../types/config.js'; 2 | 3 | export function getShortcutText(config: Config, width?: number): string { 4 | const shortcuts: string[] = []; 5 | 6 | // Format keybindings for display 7 | const formatKeys = (keys: string[]): string => { 8 | // Handle empty key bindings 9 | if (!keys || keys.length === 0) { 10 | return 'undefined'; 11 | } 12 | 13 | return keys.map(key => { 14 | // Convert special key names to display format 15 | if (key.includes('+')) { 16 | return key.split('+').map(part => { 17 | if (part === 'ctrl') return 'Ctrl'; 18 | if (part === 'shift') return 'Shift'; 19 | if (part === 'cmd' || part === 'command' || part === 'meta') return 'Cmd'; 20 | return part.charAt(0).toUpperCase() + part.slice(1); 21 | }).join('+'); 22 | } 23 | // Special formatting for single keys 24 | if (key === 'up') return '↑'; 25 | if (key === 'down') return '↓'; 26 | if (key === 'enter' || key === 'return') return 'Enter'; 27 | if (key === 'pageup') return 'PgUp'; 28 | if (key === 'pagedown') return 'PgDn'; 29 | return key; 30 | }).join('/'); 31 | }; 32 | 33 | // Determine which shortcuts to show based on available width 34 | const isNarrow = width && width < 120; 35 | 36 | if (isNarrow) { 37 | // Compact version for narrow terminals 38 | shortcuts.push(`↑↓:Nav`); 39 | shortcuts.push(`${formatKeys(config.keybindings.scrollUp)}${formatKeys(config.keybindings.scrollDown)}:Scroll`); 40 | shortcuts.push(`${formatKeys(config.keybindings.confirm)}:Resume`); 41 | shortcuts.push(`${formatKeys(config.keybindings.startNewSession)}:New Session`); 42 | shortcuts.push(`${formatKeys(config.keybindings.openCommandEditor)}:Edit Options`); 43 | shortcuts.push(`${formatKeys(config.keybindings.copySessionId)}:Copy`); 44 | shortcuts.push(`${formatKeys(config.keybindings.quit)}:Quit`); 45 | shortcuts.push(`${formatKeys(config.keybindings.toggleFullView)}:Full`); 46 | } else { 47 | // Full version for wider terminals - shortened where possible 48 | shortcuts.push(`Nav: ${formatKeys(config.keybindings.selectPrevious)}/${formatKeys(config.keybindings.selectNext)}`); 49 | shortcuts.push(`Scroll: ${formatKeys(config.keybindings.scrollUp)}/${formatKeys(config.keybindings.scrollDown)}`); 50 | shortcuts.push(`Page: ${formatKeys(config.keybindings.scrollPageUp)}/${formatKeys(config.keybindings.scrollPageDown)}`); 51 | shortcuts.push(`Top/Bottom: ${formatKeys(config.keybindings.scrollTop)}/${formatKeys(config.keybindings.scrollBottom)}`); 52 | shortcuts.push(`Resume: ${formatKeys(config.keybindings.confirm)}`); 53 | shortcuts.push(`New Session: ${formatKeys(config.keybindings.startNewSession)}`); 54 | shortcuts.push(`Edit Options: ${formatKeys(config.keybindings.openCommandEditor)}`); 55 | shortcuts.push(`Copy: ${formatKeys(config.keybindings.copySessionId)}`); 56 | shortcuts.push(`Quit: ${formatKeys(config.keybindings.quit)}`); 57 | shortcuts.push(`Full: ${formatKeys(config.keybindings.toggleFullView)} (experimental)`); 58 | } 59 | 60 | const shortcutText = shortcuts.join(' • '); 61 | 62 | return shortcutText; 63 | } 64 | 65 | export function hasKeyConflict(config: Config): boolean { 66 | // Check if any keybinding has 'undefined' (empty array) 67 | return Object.values(config.keybindings).some(keys => !keys || keys.length === 0); 68 | } -------------------------------------------------------------------------------- /src/utils/strictTruncate.ts: -------------------------------------------------------------------------------- 1 | import { getCharWidth } from './charWidth.js'; 2 | 3 | /** 4 | * Strictly truncate a string to fit within the specified width 5 | * This function ensures that the string never exceeds the maximum width 6 | * by cutting off characters that would overflow 7 | */ 8 | export function strictTruncateByWidth(str: string, maxWidth: number): string { 9 | if (!str || maxWidth <= 0) return ''; 10 | 11 | let width = 0; 12 | let result = ''; 13 | let i = 0; 14 | 15 | // Reserve space for ellipsis if string needs truncation 16 | const ellipsisWidth = 3; 17 | const effectiveMaxWidth = maxWidth - ellipsisWidth; 18 | 19 | while (i < str.length) { 20 | const code = str.charCodeAt(i); 21 | let charSequence = ''; 22 | let charWidth = 0; 23 | 24 | // Handle surrogate pairs 25 | if (code >= 0xD800 && code <= 0xDBFF && i + 1 < str.length) { 26 | const lowCode = str.charCodeAt(i + 1); 27 | if (lowCode >= 0xDC00 && lowCode <= 0xDFFF) { 28 | // This is a valid surrogate pair 29 | charSequence = str.slice(i, i + 2); 30 | charWidth = 2; // Emojis are typically width 2 31 | i += 2; 32 | } else { 33 | // High surrogate without valid low surrogate 34 | charSequence = str[i]; 35 | charWidth = getCharWidth(str[i]); 36 | i++; 37 | } 38 | } else { 39 | // Single character 40 | charSequence = str[i]; 41 | charWidth = getCharWidth(str[i]); 42 | i++; 43 | } 44 | 45 | // Check if adding this character would exceed the width 46 | if (width + charWidth > effectiveMaxWidth) { 47 | // Check if we've already added some characters 48 | if (result.length > 0) { 49 | return result + '...'; 50 | } else { 51 | // Even the first character is too wide, return just ellipsis 52 | return '...'; 53 | } 54 | } 55 | 56 | result += charSequence; 57 | width += charWidth; 58 | } 59 | 60 | return result; 61 | } 62 | 63 | /** 64 | * Strictly truncate each line of a multi-line string 65 | */ 66 | export function strictTruncateLines(text: string, maxWidth: number): string { 67 | const lines = text.split('\n'); 68 | return lines 69 | .map(line => strictTruncateByWidth(line, maxWidth)) 70 | .join('\n'); 71 | } -------------------------------------------------------------------------------- /src/utils/stringUtils.ts: -------------------------------------------------------------------------------- 1 | import { truncateStringByWidth, getStringWidth } from './charWidth.js'; 2 | 3 | export function truncateString(str: string, maxLength: number): string { 4 | return truncateStringByWidth(str, maxLength); 5 | } 6 | 7 | export function getStringDisplayLength(str: string): number { 8 | return getStringWidth(str); 9 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "lib": ["ES2022"], 6 | "jsx": "react", 7 | "declaration": true, 8 | "outDir": "./dist", 9 | "rootDir": "./src", 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "esModuleInterop": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "resolveJsonModule": true, 19 | "moduleResolution": "node16", 20 | "isolatedModules": true 21 | }, 22 | "include": ["src/**/*"], 23 | "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.test.tsx", "src/__tests__/**/*"] 24 | } --------------------------------------------------------------------------------