├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── build.yml │ ├── dependabot-sync.yml │ ├── goreleaser.yml │ ├── lint-sync.yml │ ├── lint.yml │ └── nightly.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── README.md ├── choose ├── choose.go ├── command.go └── options.go ├── completion ├── bash.go ├── command.go ├── fish.go └── zsh.go ├── confirm ├── command.go ├── confirm.go └── options.go ├── cursor └── cursor.go ├── default.nix ├── examples ├── .gitignore ├── README.md ├── choose.tape ├── commit.sh ├── commit.tape ├── confirm.tape ├── convert-to-gif.sh ├── customize.tape ├── demo.sh ├── demo.tape ├── diyfetch ├── fav.txt ├── file.tape ├── filter-key-value.sh ├── flavors.txt ├── format.ansi ├── git-branch-manager.sh ├── git-stage.sh ├── gum.js ├── gum.py ├── gum.rb ├── input.tape ├── kaomoji.sh ├── magic.sh ├── pager.tape ├── posix.sh ├── skate.sh ├── spin.tape ├── story.txt ├── test.sh └── write.tape ├── file ├── command.go ├── file.go └── options.go ├── filter ├── command.go ├── filter.go ├── filter_test.go └── options.go ├── flake.lock ├── flake.nix ├── format ├── README.md ├── command.go ├── formats.go └── options.go ├── go.mod ├── go.sum ├── gum.go ├── input ├── command.go ├── input.go └── options.go ├── internal ├── decode │ └── align.go ├── exit │ └── exit.go ├── files │ └── files.go ├── stdin │ └── stdin.go ├── timeout │ └── context.go └── tty │ └── tty.go ├── join ├── command.go └── options.go ├── log ├── command.go └── options.go ├── main.go ├── man └── command.go ├── pager ├── command.go ├── options.go ├── pager.go └── search.go ├── spin ├── command.go ├── options.go ├── pty.go ├── spin.go └── spinners.go ├── style ├── ascii_a.txt ├── borders.go ├── command.go ├── lipgloss.go ├── options.go └── spacing.go ├── table ├── bom.csv ├── comma.csv ├── command.go ├── example.csv ├── invalid.csv ├── options.go └── table.go ├── version ├── command.go └── options.go └── write ├── command.go ├── options.go └── write.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @charmbracelet/everyone 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "05:00" 10 | timezone: "America/New_York" 11 | labels: 12 | - "dependencies" 13 | commit-message: 14 | prefix: "chore" 15 | include: "scope" 16 | 17 | - package-ecosystem: "github-actions" 18 | directory: "/" 19 | schedule: 20 | interval: "weekly" 21 | day: "monday" 22 | time: "05:00" 23 | timezone: "America/New_York" 24 | labels: 25 | - "dependencies" 26 | commit-message: 27 | prefix: "chore" 28 | include: "scope" 29 | 30 | - package-ecosystem: "docker" 31 | directory: "/" 32 | schedule: 33 | interval: "weekly" 34 | day: "monday" 35 | time: "05:00" 36 | timezone: "America/New_York" 37 | labels: 38 | - "dependencies" 39 | commit-message: 40 | prefix: "chore" 41 | include: "scope" 42 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Fixes #... 2 | 3 | ### Changes 4 | - 5 | - 6 | - 7 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | strategy: 8 | matrix: 9 | os: [ubuntu-latest, macos-latest, windows-latest] 10 | runs-on: ${{ matrix.os }} 11 | env: 12 | GO111MODULE: "on" 13 | steps: 14 | - name: Install Go 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version: ~1.21 18 | 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Download Go modules 23 | run: go mod download 24 | 25 | - name: Build 26 | run: go build -v ./... 27 | 28 | - name: Test 29 | run: go test -v -cover -timeout=30s ./... 30 | 31 | snapshot: 32 | uses: charmbracelet/meta/.github/workflows/snapshot.yml@main 33 | secrets: 34 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 35 | 36 | dependabot: 37 | needs: [build] 38 | runs-on: ubuntu-latest 39 | permissions: 40 | pull-requests: write 41 | contents: write 42 | if: ${{ github.actor == 'dependabot[bot]' && github.event_name == 'pull_request'}} 43 | steps: 44 | - id: metadata 45 | uses: dependabot/fetch-metadata@v2 46 | with: 47 | github-token: "${{ secrets.GITHUB_TOKEN }}" 48 | - run: | 49 | gh pr review --approve "$PR_URL" 50 | gh pr merge --squash --auto "$PR_URL" 51 | env: 52 | PR_URL: ${{github.event.pull_request.html_url}} 53 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 54 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-sync.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-sync 2 | on: 3 | schedule: 4 | - cron: "0 0 * * 0" # every Sunday at midnight 5 | workflow_dispatch: # allows manual triggering 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot-sync: 13 | uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main 14 | with: 15 | repo_name: ${{ github.event.repository.name }} 16 | secrets: 17 | gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: goreleaser 4 | 5 | on: 6 | push: 7 | tags: 8 | - v*.*.* 9 | 10 | concurrency: 11 | group: goreleaser 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | goreleaser: 16 | uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main 17 | secrets: 18 | docker_username: ${{ secrets.DOCKERHUB_USERNAME }} 19 | docker_token: ${{ secrets.DOCKERHUB_TOKEN }} 20 | gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 21 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 22 | fury_token: ${{ secrets.FURY_TOKEN }} 23 | nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }} 24 | nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }} 25 | macos_sign_p12: ${{ secrets.MACOS_SIGN_P12 }} 26 | macos_sign_password: ${{ secrets.MACOS_SIGN_PASSWORD }} 27 | macos_notary_issuer_id: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} 28 | macos_notary_key_id: ${{ secrets.MACOS_NOTARY_KEY_ID }} 29 | macos_notary_key: ${{ secrets.MACOS_NOTARY_KEY }} 30 | -------------------------------------------------------------------------------- /.github/workflows/lint-sync.yml: -------------------------------------------------------------------------------- 1 | name: lint-sync 2 | on: 3 | schedule: 4 | # every Sunday at midnight 5 | - cron: "0 0 * * 0" 6 | workflow_dispatch: # allows manual triggering 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | lint: 14 | uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main 15 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | lint: 8 | uses: charmbracelet/meta/.github/workflows/lint.yml@main 9 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: nightly 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | nightly: 10 | uses: charmbracelet/meta/.github/workflows/nightly.yml@main 11 | secrets: 12 | docker_username: ${{ secrets.DOCKERHUB_USERNAME }} 13 | docker_token: ${{ secrets.DOCKERHUB_TOKEN }} 14 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 15 | macos_sign_p12: ${{ secrets.MACOS_SIGN_P12 }} 16 | macos_sign_password: ${{ secrets.MACOS_SIGN_PASSWORD }} 17 | macos_notary_issuer_id: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} 18 | macos_notary_key_id: ${{ secrets.MACOS_NOTARY_KEY_ID }} 19 | macos_notary_key: ${{ secrets.MACOS_NOTARY_KEY }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files 2 | test 3 | .DS_Store 4 | 5 | # Binaries 6 | gum 7 | dist 8 | testdata 9 | 10 | # Folders 11 | completions/ 12 | manpages/ 13 | 14 | # nix 15 | result 16 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | tests: false 4 | linters: 5 | enable: 6 | - bodyclose 7 | - exhaustive 8 | - goconst 9 | - godot 10 | - gomoddirectives 11 | - goprintffuncname 12 | - gosec 13 | - misspell 14 | - nakedret 15 | - nestif 16 | - nilerr 17 | - noctx 18 | - nolintlint 19 | - prealloc 20 | - revive 21 | - rowserrcheck 22 | - sqlclosecheck 23 | - tparallel 24 | - unconvert 25 | - unparam 26 | - whitespace 27 | - wrapcheck 28 | exclusions: 29 | generated: lax 30 | presets: 31 | - common-false-positives 32 | issues: 33 | max-issues-per-linter: 0 34 | max-same-issues: 0 35 | formatters: 36 | enable: 37 | - gofumpt 38 | - goimports 39 | exclusions: 40 | generated: lax 41 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json 2 | 3 | version: 2 4 | 5 | includes: 6 | - from_url: 7 | url: charmbracelet/meta/main/goreleaser-full.yaml 8 | 9 | variables: 10 | main: "." 11 | scoop_name: charm-gum 12 | description: "A tool for glamorous shell scripts" 13 | github_url: "https://github.com/charmbracelet/gum" 14 | maintainer: "Maas Lalani " 15 | brew_commit_author_name: "Maas Lalani" 16 | brew_commit_author_email: "maas@charm.sh" 17 | 18 | milestones: 19 | - close: true 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gcr.io/distroless/static 2 | COPY gum /usr/local/bin/gum 3 | ENTRYPOINT [ "/usr/local/bin/gum" ] 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2024 Charmbracelet, Inc 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gum 2 | 3 |

4 | Gum Image 5 |

6 | Latest Release 7 | Go Docs 8 | Build Status 9 |

10 | 11 | A tool for glamorous shell scripts. Leverage the power of 12 | [Bubbles](https://github.com/charmbracelet/bubbles) and [Lip 13 | Gloss](https://github.com/charmbracelet/lipgloss) in your scripts and aliases 14 | without writing any Go code! 15 | 16 | Shell running the ./demo.sh script 17 | 18 | The above example is running from a single shell script ([source](./examples/demo.sh)). 19 | 20 | ## Tutorial 21 | 22 | Gum provides highly configurable, ready-to-use utilities to help you write 23 | useful shell scripts and dotfiles aliases with just a few lines of code. 24 | Let's build a simple script to help you write 25 | [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) 26 | for your dotfiles. 27 | 28 | Ask for the commit type with gum choose: 29 | 30 | ```bash 31 | gum choose "fix" "feat" "docs" "style" "refactor" "test" "chore" "revert" 32 | ``` 33 | 34 | > [!NOTE] 35 | > This command itself will print to stdout which is not all that useful. To make use of the command later on you can save the stdout to a `$VARIABLE` or `file.txt`. 36 | 37 | Prompt for the scope of these changes: 38 | 39 | ```bash 40 | gum input --placeholder "scope" 41 | ``` 42 | 43 | Prompt for the summary and description of changes: 44 | 45 | ```bash 46 | gum input --value "$TYPE$SCOPE: " --placeholder "Summary of this change" 47 | gum write --placeholder "Details of this change" 48 | ``` 49 | 50 | Confirm before committing: 51 | 52 | ```bash 53 | gum confirm "Commit changes?" && git commit -m "$SUMMARY" -m "$DESCRIPTION" 54 | ``` 55 | 56 | Check out the [complete example](https://github.com/charmbracelet/gum/blob/main/examples/commit.sh) for combining these commands in a single script. 57 | 58 | Running the ./examples/commit.sh script to commit to git 59 | 60 | ## Installation 61 | 62 | Use a package manager: 63 | 64 | ```bash 65 | # macOS or Linux 66 | brew install gum 67 | 68 | # Arch Linux (btw) 69 | pacman -S gum 70 | 71 | # Nix 72 | nix-env -iA nixpkgs.gum 73 | 74 | # Flox 75 | flox install gum 76 | 77 | # Windows (via WinGet or Scoop) 78 | winget install charmbracelet.gum 79 | scoop install charm-gum 80 | ``` 81 | 82 |
83 | Debian/Ubuntu 84 | 85 | ```bash 86 | sudo mkdir -p /etc/apt/keyrings 87 | curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/charm.gpg 88 | echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list 89 | sudo apt update && sudo apt install gum 90 | ``` 91 | 92 |
93 | 94 |
95 | Fedora/RHEL/OpenSuse 96 | 97 | ```bash 98 | echo '[charm] 99 | name=Charm 100 | baseurl=https://repo.charm.sh/yum/ 101 | enabled=1 102 | gpgcheck=1 103 | gpgkey=https://repo.charm.sh/yum/gpg.key' | sudo tee /etc/yum.repos.d/charm.repo 104 | sudo rpm --import https://repo.charm.sh/yum/gpg.key 105 | 106 | # yum 107 | sudo yum install gum 108 | 109 | # zypper 110 | sudo zypper refresh 111 | sudo zypper install gum 112 | ``` 113 | 114 |
115 | 116 |
117 | FreeBSD 118 | 119 | ```bash 120 | # packages 121 | sudo pkg install gum 122 | 123 | # ports 124 | cd /usr/ports/devel/gum && sudo make install clean 125 | ``` 126 | 127 |
128 | 129 | Or download it: 130 | 131 | - [Packages][releases] are available in Debian, RPM, and Alpine formats 132 | - [Binaries][releases] are available for Linux, macOS, Windows, FreeBSD, OpenBSD, and NetBSD 133 | 134 | Or just install it with `go`: 135 | 136 | ```bash 137 | go install github.com/charmbracelet/gum@latest 138 | ``` 139 | 140 | [releases]: https://github.com/charmbracelet/gum/releases 141 | 142 | ## Commands 143 | 144 | - [`choose`](#choose): Choose an option from a list of choices 145 | - [`confirm`](#confirm): Ask a user to confirm an action 146 | - [`file`](#file): Pick a file from a folder 147 | - [`filter`](#filter): Filter items from a list 148 | - [`format`](#format): Format a string using a template 149 | - [`input`](#input): Prompt for some input 150 | - [`join`](#join): Join text vertically or horizontally 151 | - [`pager`](#pager): Scroll through a file 152 | - [`spin`](#spin): Display spinner while running a command 153 | - [`style`](#style): Apply coloring, borders, spacing to text 154 | - [`table`](#table): Render a table of data 155 | - [`write`](#write): Prompt for long-form text 156 | - [`log`](#log): Log messages to output 157 | 158 | ## Customization 159 | 160 | You can customize `gum` options and styles with `--flags` and `$ENVIRONMENT_VARIABLES`. 161 | See `gum --help` for a full view of each command's customization and configuration options. 162 | 163 | Customize with `--flags`: 164 | 165 | ```bash 166 | 167 | gum input --cursor.foreground "#FF0" \ 168 | --prompt.foreground "#0FF" \ 169 | --placeholder "What's up?" \ 170 | --prompt "* " \ 171 | --width 80 \ 172 | --value "Not much, hby?" 173 | ``` 174 | 175 | Customize with `ENVIRONMENT_VARIABLES`: 176 | 177 | ```bash 178 | export GUM_INPUT_CURSOR_FOREGROUND="#FF0" 179 | export GUM_INPUT_PROMPT_FOREGROUND="#0FF" 180 | export GUM_INPUT_PLACEHOLDER="What's up?" 181 | export GUM_INPUT_PROMPT="* " 182 | export GUM_INPUT_WIDTH=80 183 | 184 | # --flags can override values set with environment 185 | gum input 186 | ``` 187 | 188 | Gum input displaying most customization options 189 | 190 | ## Input 191 | 192 | Prompt for input with a simple command. 193 | 194 | ```bash 195 | gum input > answer.txt 196 | gum input --password > password.txt 197 | ``` 198 | 199 | Shell running gum input typing Not much, you? 200 | 201 | ## Write 202 | 203 | Prompt for some multi-line text (`ctrl+d` to complete text entry). 204 | 205 | ```bash 206 | gum write > story.txt 207 | ``` 208 | 209 | Shell running gum write typing a story 210 | 211 | ## Filter 212 | 213 | Filter a list of values with fuzzy matching: 214 | 215 | ```bash 216 | echo Strawberry >> flavors.txt 217 | echo Banana >> flavors.txt 218 | echo Cherry >> flavors.txt 219 | gum filter < flavors.txt > selection.txt 220 | ``` 221 | 222 | Shell running gum filter on different bubble gum flavors 223 | 224 | Select multiple options with the `--limit` flag or `--no-limit` flag. Use `tab` or `ctrl+space` to select, `enter` to confirm. 225 | 226 | ```bash 227 | cat flavors.txt | gum filter --limit 2 228 | cat flavors.txt | gum filter --no-limit 229 | ``` 230 | 231 | ## Choose 232 | 233 | Choose an option from a list of choices. 234 | 235 | ```bash 236 | echo "Pick a card, any card..." 237 | CARD=$(gum choose --height 15 {{A,K,Q,J},{10..2}}" "{♠,♥,♣,♦}) 238 | echo "Was your card the $CARD?" 239 | ``` 240 | 241 | You can also select multiple items with the `--limit` or `--no-limit` flag, which determines 242 | the maximum of items that can be chosen. 243 | 244 | ```bash 245 | cat songs.txt | gum choose --limit 5 246 | cat foods.txt | gum choose --no-limit --header "Grocery Shopping" 247 | ``` 248 | 249 | Shell running gum choose with numbers and gum flavors 250 | 251 | ## Confirm 252 | 253 | Confirm whether to perform an action. Exits with code `0` (affirmative) or `1` 254 | (negative) depending on selection. 255 | 256 | ```bash 257 | gum confirm && rm file.txt || echo "File not removed" 258 | ``` 259 | 260 | Shell running gum confirm 261 | 262 | ## File 263 | 264 | Prompt the user to select a file from the file tree. 265 | 266 | ```bash 267 | $EDITOR $(gum file $HOME) 268 | ``` 269 | 270 | Shell running gum file 271 | 272 | ## Pager 273 | 274 | Scroll through a long document with line numbers and a fully customizable viewport. 275 | 276 | ```bash 277 | gum pager < README.md 278 | ``` 279 | 280 | Shell running gum pager 281 | 282 | ## Spin 283 | 284 | Display a spinner while running a script or command. The spinner will 285 | automatically stop after the given command exits. 286 | 287 | To view or pipe the command's output, use the `--show-output` flag. 288 | 289 | ```bash 290 | gum spin --spinner dot --title "Buying Bubble Gum..." -- sleep 5 291 | ``` 292 | 293 | Shell running gum spin while sleeping for 5 seconds 294 | 295 | Available spinner types include: `line`, `dot`, `minidot`, `jump`, `pulse`, `points`, `globe`, `moon`, `monkey`, `meter`, `hamburger`. 296 | 297 | ## Table 298 | 299 | Select a row from some tabular data. 300 | 301 | ```bash 302 | gum table < flavors.csv | cut -d ',' -f 1 303 | ``` 304 | 305 | 306 | 307 | ## Style 308 | 309 | Pretty print any string with any layout with one command. 310 | 311 | ```bash 312 | gum style \ 313 | --foreground 212 --border-foreground 212 --border double \ 314 | --align center --width 50 --margin "1 2" --padding "2 4" \ 315 | 'Bubble Gum (1¢)' 'So sweet and so fresh!' 316 | ``` 317 | 318 | Bubble Gum, So sweet and so fresh! 319 | 320 | ## Join 321 | 322 | Combine text vertically or horizontally. Use this command with `gum style` to 323 | build layouts and pretty output. 324 | 325 | Tip: Always wrap the output of `gum style` in quotes to preserve newlines 326 | (`\n`) when using it as an argument in the `join` command. 327 | 328 | ```bash 329 | I=$(gum style --padding "1 5" --border double --border-foreground 212 "I") 330 | LOVE=$(gum style --padding "1 4" --border double --border-foreground 57 "LOVE") 331 | BUBBLE=$(gum style --padding "1 8" --border double --border-foreground 255 "Bubble") 332 | GUM=$(gum style --padding "1 5" --border double --border-foreground 240 "Gum") 333 | 334 | I_LOVE=$(gum join "$I" "$LOVE") 335 | BUBBLE_GUM=$(gum join "$BUBBLE" "$GUM") 336 | gum join --align center --vertical "$I_LOVE" "$BUBBLE_GUM" 337 | ``` 338 | 339 | I LOVE Bubble Gum written out in four boxes with double borders around them. 340 | 341 | ## Format 342 | 343 | `format` processes and formats bodies of text. `gum format` can parse markdown, 344 | template strings, and named emojis. 345 | 346 | ```bash 347 | # Format some markdown 348 | gum format -- "# Gum Formats" "- Markdown" "- Code" "- Template" "- Emoji" 349 | echo "# Gum Formats\n- Markdown\n- Code\n- Template\n- Emoji" | gum format 350 | 351 | # Syntax highlight some code 352 | cat main.go | gum format -t code 353 | 354 | # Render text any way you want with templates 355 | echo '{{ Bold "Tasty" }} {{ Italic "Bubble" }} {{ Color "99" "0" " Gum " }}' \ 356 | | gum format -t template 357 | 358 | # Display your favorite emojis! 359 | echo 'I :heart: Bubble Gum :candy:' | gum format -t emoji 360 | ``` 361 | 362 | For more information on template helpers, see the [Termenv 363 | docs](https://github.com/muesli/termenv#template-helpers). For a full list of 364 | named emojis see the [GitHub API](https://api.github.com/emojis). 365 | 366 | Running gum format for different types of formats 367 | 368 | ## Log 369 | 370 | `log` logs messages to the terminal at using different levels and styling using 371 | the [`charmbracelet/log`](https://github.com/charmbracelet/log) library. 372 | 373 | ```bash 374 | # Log some debug information. 375 | gum log --structured --level debug "Creating file..." name file.txt 376 | # DEBUG Unable to create file. name=temp.txt 377 | 378 | # Log some error. 379 | gum log --structured --level error "Unable to create file." name file.txt 380 | # ERROR Unable to create file. name=temp.txt 381 | 382 | # Include a timestamp. 383 | gum log --time rfc822 --level error "Unable to create file." 384 | ``` 385 | 386 | See the Go [`time` package](https://pkg.go.dev/time#pkg-constants) for acceptable `--time` formats. 387 | 388 | See [`charmbracelet/log`](https://github.com/charmbracelet/log) for more usage. 389 | 390 | Running gum log with debug and error levels 391 | 392 | ## Examples 393 | 394 | How to use `gum` in your daily workflows: 395 | 396 | See the [examples](./examples/) directory for more real world use cases. 397 | 398 | - Write a commit message: 399 | 400 | ```bash 401 | git commit -m "$(gum input --width 50 --placeholder "Summary of changes")" \ 402 | -m "$(gum write --width 80 --placeholder "Details of changes")" 403 | ``` 404 | 405 | - Open files in your `$EDITOR` 406 | 407 | ```bash 408 | $EDITOR $(gum filter) 409 | ``` 410 | 411 | - Connect to a `tmux` session 412 | 413 | ```bash 414 | SESSION=$(tmux list-sessions -F \#S | gum filter --placeholder "Pick session...") 415 | tmux switch-client -t "$SESSION" || tmux attach -t "$SESSION" 416 | ``` 417 | 418 | - Pick a commit hash from `git` history 419 | 420 | ```bash 421 | git log --oneline | gum filter | cut -d' ' -f1 # | copy 422 | ``` 423 | 424 | - Simple [`skate`](https://github.com/charmbracelet/skate) password selector. 425 | 426 | ``` 427 | skate list -k | gum filter | xargs skate get 428 | ``` 429 | 430 | - Uninstall packages 431 | 432 | ```bash 433 | brew list | gum choose --no-limit | xargs brew uninstall 434 | ``` 435 | 436 | - Clean up `git` branches 437 | 438 | ```bash 439 | git branch | cut -c 3- | gum choose --no-limit | xargs git branch -D 440 | ``` 441 | 442 | - Checkout GitHub pull requests with [`gh`](https://cli.github.com/) 443 | 444 | ```bash 445 | gh pr list | cut -f1,2 | gum choose | cut -f1 | xargs gh pr checkout 446 | ``` 447 | 448 | - Copy command from shell history 449 | 450 | ```bash 451 | gum filter < $HISTFILE --height 20 452 | ``` 453 | 454 | - `sudo` replacement 455 | 456 | ```bash 457 | alias please="gum input --password | sudo -nS" 458 | ``` 459 | 460 | ## Feedback 461 | 462 | We’d love to hear your thoughts on this project. Feel free to drop us a note! 463 | 464 | - [Twitter](https://twitter.com/charmcli) 465 | - [The Fediverse](https://mastodon.social/@charmcli) 466 | - [Discord](https://charm.sh/chat) 467 | 468 | ## License 469 | 470 | [MIT](https://github.com/charmbracelet/gum/raw/main/LICENSE) 471 | 472 | --- 473 | 474 | Part of [Charm](https://charm.sh). 475 | 476 | The Charm logo 477 | 478 | Charm热爱开源 • Charm loves open source 479 | -------------------------------------------------------------------------------- /choose/choose.go: -------------------------------------------------------------------------------- 1 | // Package choose provides an interface to choose one option from a given list 2 | // of options. The options can be provided as (new-line separated) stdin or a 3 | // list of arguments. 4 | // 5 | // It is different from the filter command as it does not provide a fuzzy 6 | // finding input, so it is best used for smaller lists of options. 7 | // 8 | // Let's pick from a list of gum flavors: 9 | // 10 | // $ gum choose "Strawberry" "Banana" "Cherry" 11 | package choose 12 | 13 | import ( 14 | "strings" 15 | 16 | "github.com/charmbracelet/bubbles/help" 17 | "github.com/charmbracelet/bubbles/key" 18 | "github.com/charmbracelet/bubbles/paginator" 19 | tea "github.com/charmbracelet/bubbletea" 20 | "github.com/charmbracelet/lipgloss" 21 | ) 22 | 23 | func defaultKeymap() keymap { 24 | return keymap{ 25 | Down: key.NewBinding( 26 | key.WithKeys("down", "j", "ctrl+j", "ctrl+n"), 27 | ), 28 | Up: key.NewBinding( 29 | key.WithKeys("up", "k", "ctrl+k", "ctrl+p"), 30 | ), 31 | Right: key.NewBinding( 32 | key.WithKeys("right", "l", "ctrl+f"), 33 | ), 34 | Left: key.NewBinding( 35 | key.WithKeys("left", "h", "ctrl+b"), 36 | ), 37 | Home: key.NewBinding( 38 | key.WithKeys("g", "home"), 39 | ), 40 | End: key.NewBinding( 41 | key.WithKeys("G", "end"), 42 | ), 43 | ToggleAll: key.NewBinding( 44 | key.WithKeys("a", "A", "ctrl+a"), 45 | key.WithHelp("ctrl+a", "select all"), 46 | key.WithDisabled(), 47 | ), 48 | Toggle: key.NewBinding( 49 | key.WithKeys(" ", "tab", "x", "ctrl+@"), 50 | key.WithHelp("x", "toggle"), 51 | key.WithDisabled(), 52 | ), 53 | Abort: key.NewBinding( 54 | key.WithKeys("ctrl+c"), 55 | key.WithHelp("ctrl+c", "abort"), 56 | ), 57 | Quit: key.NewBinding( 58 | key.WithKeys("esc"), 59 | key.WithHelp("esc", "quit"), 60 | ), 61 | Submit: key.NewBinding( 62 | key.WithKeys("enter", "ctrl+q"), 63 | key.WithHelp("enter", "submit"), 64 | ), 65 | } 66 | } 67 | 68 | type keymap struct { 69 | Down, 70 | Up, 71 | Right, 72 | Left, 73 | Home, 74 | End, 75 | ToggleAll, 76 | Toggle, 77 | Abort, 78 | Quit, 79 | Submit key.Binding 80 | } 81 | 82 | // FullHelp implements help.KeyMap. 83 | func (k keymap) FullHelp() [][]key.Binding { return nil } 84 | 85 | // ShortHelp implements help.KeyMap. 86 | func (k keymap) ShortHelp() []key.Binding { 87 | return []key.Binding{ 88 | k.Toggle, 89 | key.NewBinding( 90 | key.WithKeys("up", "down", "right", "left"), 91 | key.WithHelp("←↓↑→", "navigate"), 92 | ), 93 | k.Submit, 94 | k.ToggleAll, 95 | } 96 | } 97 | 98 | type model struct { 99 | height int 100 | cursor string 101 | selectedPrefix string 102 | unselectedPrefix string 103 | cursorPrefix string 104 | header string 105 | items []item 106 | quitting bool 107 | submitted bool 108 | index int 109 | limit int 110 | numSelected int 111 | currentOrder int 112 | paginator paginator.Model 113 | showHelp bool 114 | help help.Model 115 | keymap keymap 116 | 117 | // styles 118 | cursorStyle lipgloss.Style 119 | headerStyle lipgloss.Style 120 | itemStyle lipgloss.Style 121 | selectedItemStyle lipgloss.Style 122 | } 123 | 124 | type item struct { 125 | text string 126 | selected bool 127 | order int 128 | } 129 | 130 | func (m model) Init() tea.Cmd { return nil } 131 | 132 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 133 | switch msg := msg.(type) { 134 | case tea.WindowSizeMsg: 135 | return m, nil 136 | 137 | case tea.KeyMsg: 138 | start, end := m.paginator.GetSliceBounds(len(m.items)) 139 | km := m.keymap 140 | switch { 141 | case key.Matches(msg, km.Down): 142 | m.index++ 143 | if m.index >= len(m.items) { 144 | m.index = 0 145 | m.paginator.Page = 0 146 | } 147 | if m.index >= end { 148 | m.paginator.NextPage() 149 | } 150 | case key.Matches(msg, km.Up): 151 | m.index-- 152 | if m.index < 0 { 153 | m.index = len(m.items) - 1 154 | m.paginator.Page = m.paginator.TotalPages - 1 155 | } 156 | if m.index < start { 157 | m.paginator.PrevPage() 158 | } 159 | case key.Matches(msg, km.Right): 160 | m.index = clamp(m.index+m.height, 0, len(m.items)-1) 161 | m.paginator.NextPage() 162 | case key.Matches(msg, km.Left): 163 | m.index = clamp(m.index-m.height, 0, len(m.items)-1) 164 | m.paginator.PrevPage() 165 | case key.Matches(msg, km.End): 166 | m.index = len(m.items) - 1 167 | m.paginator.Page = m.paginator.TotalPages - 1 168 | case key.Matches(msg, km.Home): 169 | m.index = 0 170 | m.paginator.Page = 0 171 | case key.Matches(msg, km.ToggleAll): 172 | if m.limit <= 1 { 173 | break 174 | } 175 | if m.numSelected < len(m.items) && m.numSelected < m.limit { 176 | m = m.selectAll() 177 | } else { 178 | m = m.deselectAll() 179 | } 180 | case key.Matches(msg, km.Quit): 181 | m.quitting = true 182 | return m, tea.Quit 183 | case key.Matches(msg, km.Abort): 184 | m.quitting = true 185 | return m, tea.Interrupt 186 | case key.Matches(msg, km.Toggle): 187 | if m.limit == 1 { 188 | break // no op 189 | } 190 | 191 | if m.items[m.index].selected { 192 | m.items[m.index].selected = false 193 | m.numSelected-- 194 | } else if m.numSelected < m.limit { 195 | m.items[m.index].selected = true 196 | m.items[m.index].order = m.currentOrder 197 | m.numSelected++ 198 | m.currentOrder++ 199 | } 200 | case key.Matches(msg, km.Submit): 201 | m.quitting = true 202 | if m.limit <= 1 && m.numSelected < 1 { 203 | m.items[m.index].selected = true 204 | } 205 | m.submitted = true 206 | return m, tea.Quit 207 | } 208 | } 209 | 210 | var cmd tea.Cmd 211 | m.paginator, cmd = m.paginator.Update(msg) 212 | return m, cmd 213 | } 214 | 215 | func (m model) selectAll() model { 216 | for i := range m.items { 217 | if m.numSelected >= m.limit { 218 | break // do not exceed given limit 219 | } 220 | if m.items[i].selected { 221 | continue 222 | } 223 | m.items[i].selected = true 224 | m.items[i].order = m.currentOrder 225 | m.numSelected++ 226 | m.currentOrder++ 227 | } 228 | return m 229 | } 230 | 231 | func (m model) deselectAll() model { 232 | for i := range m.items { 233 | m.items[i].selected = false 234 | m.items[i].order = 0 235 | } 236 | m.numSelected = 0 237 | m.currentOrder = 0 238 | return m 239 | } 240 | 241 | func (m model) View() string { 242 | if m.quitting { 243 | return "" 244 | } 245 | 246 | var s strings.Builder 247 | 248 | start, end := m.paginator.GetSliceBounds(len(m.items)) 249 | for i, item := range m.items[start:end] { 250 | if i == m.index%m.height { 251 | s.WriteString(m.cursorStyle.Render(m.cursor)) 252 | } else { 253 | s.WriteString(strings.Repeat(" ", lipgloss.Width(m.cursor))) 254 | } 255 | 256 | if item.selected { 257 | s.WriteString(m.selectedItemStyle.Render(m.selectedPrefix + item.text)) 258 | } else if i == m.index%m.height { 259 | s.WriteString(m.cursorStyle.Render(m.cursorPrefix + item.text)) 260 | } else { 261 | s.WriteString(m.itemStyle.Render(m.unselectedPrefix + item.text)) 262 | } 263 | if i != m.height { 264 | s.WriteRune('\n') 265 | } 266 | } 267 | 268 | if m.paginator.TotalPages > 1 { 269 | s.WriteString(strings.Repeat("\n", m.height-m.paginator.ItemsOnPage(len(m.items))+1)) 270 | s.WriteString(" " + m.paginator.View()) 271 | } 272 | 273 | var parts []string 274 | 275 | if m.header != "" { 276 | parts = append(parts, m.headerStyle.Render(m.header)) 277 | } 278 | parts = append(parts, s.String()) 279 | if m.showHelp { 280 | parts = append(parts, "", m.help.View(m.keymap)) 281 | } 282 | 283 | return lipgloss.JoinVertical(lipgloss.Left, parts...) 284 | } 285 | 286 | func clamp(x, low, high int) int { 287 | if x < low { 288 | return low 289 | } 290 | if x > high { 291 | return high 292 | } 293 | return x 294 | } 295 | -------------------------------------------------------------------------------- /choose/command.go: -------------------------------------------------------------------------------- 1 | package choose 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "slices" 8 | "sort" 9 | "strings" 10 | 11 | "github.com/charmbracelet/bubbles/help" 12 | "github.com/charmbracelet/bubbles/paginator" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/gum/internal/stdin" 15 | "github.com/charmbracelet/gum/internal/timeout" 16 | "github.com/charmbracelet/gum/internal/tty" 17 | "github.com/charmbracelet/lipgloss" 18 | ) 19 | 20 | // Run provides a shell script interface for choosing between different through 21 | // options. 22 | func (o Options) Run() error { 23 | var ( 24 | subduedStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}) 25 | verySubduedStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}) 26 | ) 27 | 28 | input, _ := stdin.Read(stdin.StripANSI(o.StripANSI)) 29 | if len(o.Options) > 0 && len(o.Selected) == 0 { 30 | o.Selected = strings.Split(input, o.InputDelimiter) 31 | } else if len(o.Options) == 0 { 32 | if input == "" { 33 | return errors.New("no options provided, see `gum choose --help`") 34 | } 35 | o.Options = strings.Split(input, o.InputDelimiter) 36 | } 37 | 38 | // normalize options into a map 39 | options := map[string]string{} 40 | // keep the labels in the user-provided order 41 | var labels []string //nolint:prealloc 42 | for _, opt := range o.Options { 43 | if o.LabelDelimiter == "" { 44 | options[opt] = opt 45 | continue 46 | } 47 | label, value, ok := strings.Cut(opt, o.LabelDelimiter) 48 | if !ok { 49 | return fmt.Errorf("invalid option format: %q", opt) 50 | } 51 | labels = append(labels, label) 52 | options[label] = value 53 | } 54 | if o.LabelDelimiter != "" { 55 | o.Options = labels 56 | } 57 | 58 | if o.SelectIfOne && len(o.Options) == 1 { 59 | fmt.Println(options[o.Options[0]]) 60 | return nil 61 | } 62 | 63 | // We don't need to display prefixes if we are only picking one option. 64 | // Simply displaying the cursor is enough. 65 | if o.Limit == 1 && !o.NoLimit { 66 | o.SelectedPrefix = "" 67 | o.UnselectedPrefix = "" 68 | o.CursorPrefix = "" 69 | } 70 | 71 | if o.NoLimit { 72 | o.Limit = len(o.Options) + 1 73 | } 74 | 75 | if o.Ordered { 76 | slices.SortFunc(o.Options, strings.Compare) 77 | } 78 | 79 | isSelectAll := len(o.Selected) == 1 && o.Selected[0] == "*" 80 | 81 | // Keep track of the selected items. 82 | currentSelected := 0 83 | // Check if selected items should be used. 84 | hasSelectedItems := len(o.Selected) > 0 85 | startingIndex := 0 86 | currentOrder := 0 87 | items := make([]item, len(o.Options)) 88 | for i, option := range o.Options { 89 | var order int 90 | // Check if the option should be selected. 91 | isSelected := hasSelectedItems && currentSelected < o.Limit && (isSelectAll || slices.Contains(o.Selected, option)) 92 | // If the option is selected then increment the current selected count. 93 | if isSelected { 94 | if o.Limit == 1 { 95 | // When the user can choose only one option don't select the option but 96 | // start with the cursor hovering over it. 97 | startingIndex = i 98 | isSelected = false 99 | } else { 100 | currentSelected++ 101 | order = currentOrder 102 | currentOrder++ 103 | } 104 | } 105 | items[i] = item{text: option, selected: isSelected, order: order} 106 | } 107 | 108 | // Use the pagination model to display the current and total number of 109 | // pages. 110 | pager := paginator.New() 111 | pager.SetTotalPages((len(items) + o.Height - 1) / o.Height) 112 | pager.PerPage = o.Height 113 | pager.Type = paginator.Dots 114 | pager.ActiveDot = subduedStyle.Render("•") 115 | pager.InactiveDot = verySubduedStyle.Render("•") 116 | pager.KeyMap = paginator.KeyMap{} 117 | pager.Page = startingIndex / o.Height 118 | 119 | km := defaultKeymap() 120 | if o.NoLimit || o.Limit > 1 { 121 | km.Toggle.SetEnabled(true) 122 | } 123 | if o.NoLimit { 124 | km.ToggleAll.SetEnabled(true) 125 | } 126 | 127 | m := model{ 128 | index: startingIndex, 129 | currentOrder: currentOrder, 130 | height: o.Height, 131 | cursor: o.Cursor, 132 | header: o.Header, 133 | selectedPrefix: o.SelectedPrefix, 134 | unselectedPrefix: o.UnselectedPrefix, 135 | cursorPrefix: o.CursorPrefix, 136 | items: items, 137 | limit: o.Limit, 138 | paginator: pager, 139 | cursorStyle: o.CursorStyle.ToLipgloss(), 140 | headerStyle: o.HeaderStyle.ToLipgloss(), 141 | itemStyle: o.ItemStyle.ToLipgloss(), 142 | selectedItemStyle: o.SelectedItemStyle.ToLipgloss(), 143 | numSelected: currentSelected, 144 | showHelp: o.ShowHelp, 145 | help: help.New(), 146 | keymap: km, 147 | } 148 | 149 | ctx, cancel := timeout.Context(o.Timeout) 150 | defer cancel() 151 | 152 | // Disable Keybindings since we will control it ourselves. 153 | tm, err := tea.NewProgram( 154 | m, 155 | tea.WithOutput(os.Stderr), 156 | tea.WithContext(ctx), 157 | ).Run() 158 | if err != nil { 159 | return fmt.Errorf("unable to pick selection: %w", err) 160 | } 161 | m = tm.(model) 162 | if !m.submitted { 163 | return errors.New("nothing selected") 164 | } 165 | if o.Ordered && o.Limit > 1 { 166 | sort.Slice(m.items, func(i, j int) bool { 167 | return m.items[i].order < m.items[j].order 168 | }) 169 | } 170 | 171 | var out []string 172 | for _, item := range m.items { 173 | if item.selected { 174 | out = append(out, options[item.text]) 175 | } 176 | } 177 | tty.Println(strings.Join(out, o.OutputDelimiter)) 178 | return nil 179 | } 180 | -------------------------------------------------------------------------------- /choose/options.go: -------------------------------------------------------------------------------- 1 | package choose 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/gum/style" 7 | ) 8 | 9 | // Options is the customization options for the choose command. 10 | type Options struct { 11 | Options []string `arg:"" optional:"" help:"Options to choose from."` 12 | Limit int `help:"Maximum number of options to pick" default:"1" group:"Selection"` 13 | NoLimit bool `help:"Pick unlimited number of options (ignores limit)" group:"Selection"` 14 | Ordered bool `help:"Maintain the order of the selected options" env:"GUM_CHOOSE_ORDERED"` 15 | Height int `help:"Height of the list" default:"10" env:"GUM_CHOOSE_HEIGHT"` 16 | Cursor string `help:"Prefix to show on item that corresponds to the cursor position" default:"> " env:"GUM_CHOOSE_CURSOR"` 17 | ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_CHOOSE_SHOW_HELP"` 18 | Timeout time.Duration `help:"Timeout until choose returns selected element" default:"0s" env:"GUM_CCHOOSE_TIMEOUT"` // including timeout command options [Timeout,...] 19 | Header string `help:"Header value" default:"Choose:" env:"GUM_CHOOSE_HEADER"` 20 | CursorPrefix string `help:"Prefix to show on the cursor item (hidden if limit is 1)" default:"• " env:"GUM_CHOOSE_CURSOR_PREFIX"` 21 | SelectedPrefix string `help:"Prefix to show on selected items (hidden if limit is 1)" default:"✓ " env:"GUM_CHOOSE_SELECTED_PREFIX"` 22 | UnselectedPrefix string `help:"Prefix to show on unselected items (hidden if limit is 1)" default:"• " env:"GUM_CHOOSE_UNSELECTED_PREFIX"` 23 | Selected []string `help:"Options that should start as selected (selects all if given *)" default:"" env:"GUM_CHOOSE_SELECTED"` 24 | SelectIfOne bool `help:"Select the given option if there is only one" group:"Selection"` 25 | InputDelimiter string `help:"Option delimiter when reading from STDIN" default:"\n" env:"GUM_CHOOSE_INPUT_DELIMITER"` 26 | OutputDelimiter string `help:"Option delimiter when writing to STDOUT" default:"\n" env:"GUM_CHOOSE_OUTPUT_DELIMITER"` 27 | LabelDelimiter string `help:"Allows to set a delimiter, so options can be set as label:value" default:"" env:"GUM_CHOOSE_LABEL_DELIMITER"` 28 | StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_CHOOSE_STRIP_ANSI"` 29 | 30 | CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_CURSOR_"` 31 | HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_CHOOSE_HEADER_"` 32 | ItemStyle style.Styles `embed:"" prefix:"item." hidden:"" envprefix:"GUM_CHOOSE_ITEM_"` 33 | SelectedItemStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_CHOOSE_SELECTED_"` 34 | } 35 | -------------------------------------------------------------------------------- /completion/command.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/alecthomas/kong" 10 | ) 11 | 12 | // Completion command. 13 | type Completion struct { 14 | Bash Bash `cmd:"" help:"Generate the autocompletion script for bash"` 15 | Zsh Zsh `cmd:"" help:"Generate the autocompletion script for zsh"` 16 | Fish Fish `cmd:"" help:"Generate the autocompletion script for fish"` 17 | } 18 | 19 | func commandName(cmd *kong.Node) string { 20 | commandName := cmd.FullPath() 21 | commandName = strings.ReplaceAll(commandName, " ", "_") 22 | commandName = strings.ReplaceAll(commandName, ":", "__") 23 | return commandName 24 | } 25 | 26 | func hasCommands(cmd *kong.Node) bool { 27 | for _, c := range cmd.Children { 28 | if !c.Hidden { 29 | return true 30 | } 31 | } 32 | return false 33 | } 34 | 35 | //nolint:deadcode,unused 36 | func isArgument(cmd *kong.Node) bool { 37 | return cmd.Type == kong.ArgumentNode 38 | } 39 | 40 | // writeString writes a string into a buffer, and checks if the error is not nil. 41 | func writeString(b io.StringWriter, s string) { 42 | if _, err := b.WriteString(s); err != nil { 43 | fmt.Fprintln(os.Stderr, "Error:", err) 44 | os.Exit(1) 45 | } 46 | } 47 | 48 | func nonCompletableFlag(flag *kong.Flag) bool { 49 | return flag.Hidden 50 | } 51 | 52 | func flagPossibleValues(flag *kong.Flag) []string { 53 | values := make([]string, 0) 54 | for _, enum := range flag.EnumSlice() { 55 | if strings.TrimSpace(enum) != "" { 56 | values = append(values, enum) 57 | } 58 | } 59 | return values 60 | } 61 | -------------------------------------------------------------------------------- /completion/fish.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/alecthomas/kong" 9 | ) 10 | 11 | // Fish is a fish shell completion generator. 12 | type Fish struct{} 13 | 14 | // Run generates fish completion script. 15 | func (f Fish) Run(ctx *kong.Context) error { 16 | var buf strings.Builder 17 | buf.WriteString(`# Fish shell completion for gum 18 | # Generated by gum completion 19 | 20 | # disable file completion unless explicitly enabled 21 | complete -c gum -f 22 | 23 | `) 24 | node := ctx.Model.Node 25 | f.gen(&buf, node) 26 | _, err := fmt.Fprint(ctx.Stdout, buf.String()) 27 | if err != nil { 28 | return fmt.Errorf("unable to generate fish completion: %w", err) 29 | } 30 | return nil 31 | } 32 | 33 | func (f Fish) gen(buf io.StringWriter, cmd *kong.Node) { 34 | root := cmd 35 | for root.Parent != nil { 36 | root = root.Parent 37 | } 38 | rootName := root.Name 39 | if cmd.Parent == nil { 40 | _, _ = buf.WriteString(fmt.Sprintf("# %s\n", rootName)) 41 | } else { 42 | _, _ = buf.WriteString(fmt.Sprintf("# %s\n", cmd.Path())) 43 | _, _ = buf.WriteString( 44 | fmt.Sprintf("complete -c %s -f -n '__fish_use_subcommand' -a %s -d '%s'\n", 45 | rootName, 46 | cmd.Name, 47 | cmd.Help, 48 | ), 49 | ) 50 | } 51 | 52 | for _, f := range cmd.Flags { 53 | if f.Hidden { 54 | continue 55 | } 56 | if cmd.Parent == nil { 57 | _, _ = buf.WriteString( 58 | fmt.Sprintf("complete -c %s -f", 59 | rootName, 60 | ), 61 | ) 62 | } else { 63 | _, _ = buf.WriteString( 64 | fmt.Sprintf("complete -c %s -f -n '__fish_seen_subcommand_from %s'", 65 | rootName, 66 | cmd.Name, 67 | ), 68 | ) 69 | } 70 | if !f.IsBool() { 71 | enums := flagPossibleValues(f) 72 | if len(enums) > 0 { 73 | _, _ = buf.WriteString(fmt.Sprintf(" -xa '%s'", strings.Join(enums, " "))) 74 | } else { 75 | _, _ = buf.WriteString(" -x") 76 | } 77 | } 78 | if f.Short != 0 { 79 | _, _ = buf.WriteString(fmt.Sprintf(" -s %c", f.Short)) 80 | } 81 | _, _ = buf.WriteString(fmt.Sprintf(" -l %s", f.Name)) 82 | _, _ = buf.WriteString(fmt.Sprintf(" -d \"%s\"", f.Help)) 83 | _, _ = buf.WriteString("\n") 84 | } 85 | _, _ = buf.WriteString("\n") 86 | 87 | for _, c := range cmd.Children { 88 | if c == nil || c.Hidden { 89 | continue 90 | } 91 | f.gen(buf, c) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /completion/zsh.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/alecthomas/kong" 9 | ) 10 | 11 | // Zsh is zsh completion generator. 12 | type Zsh struct{} 13 | 14 | // Run generates zsh completion script. 15 | func (z Zsh) Run(ctx *kong.Context) error { 16 | var out strings.Builder 17 | format := `#compdef %[1]s 18 | # zsh completion for %[1]s 19 | # generated by gum completion 20 | 21 | ` 22 | fmt.Fprintf(&out, format, ctx.Model.Name) 23 | z.gen(&out, ctx.Model.Node) 24 | _, err := fmt.Fprint(ctx.Stdout, out.String()) 25 | if err != nil { 26 | return fmt.Errorf("unable to generate zsh completion: %w", err) 27 | } 28 | return nil 29 | } 30 | 31 | func (z Zsh) writeFlag(buf io.StringWriter, f *kong.Flag) { 32 | var str strings.Builder 33 | str.WriteString(" ") 34 | if f.Short != 0 { 35 | str.WriteString("'(") 36 | str.WriteString(fmt.Sprintf("-%c --%s", f.Short, f.Name)) 37 | if !f.IsBool() { 38 | str.WriteString("=") 39 | } 40 | str.WriteString(")'") 41 | str.WriteString("{") 42 | str.WriteString(fmt.Sprintf("-%c,--%s", f.Short, f.Name)) 43 | if !f.IsBool() { 44 | str.WriteString("=") 45 | } 46 | str.WriteString("}") 47 | str.WriteString("\"") 48 | } else { 49 | str.WriteString("\"") 50 | str.WriteString(fmt.Sprintf("--%s", f.Name)) 51 | if !f.IsBool() { 52 | str.WriteString("=") 53 | } 54 | } 55 | str.WriteString(fmt.Sprintf("[%s]", f.Help)) 56 | if !f.IsBool() { 57 | str.WriteString(":") 58 | str.WriteString(strings.ToLower(f.Help)) 59 | str.WriteString(":") 60 | } 61 | values := flagPossibleValues(f) 62 | if len(values) > 0 { 63 | str.WriteString("(") 64 | for i, v := range f.EnumSlice() { 65 | str.WriteString(v) 66 | if i < len(values)-1 { 67 | str.WriteString(" ") 68 | } 69 | } 70 | str.WriteString(")") 71 | } 72 | str.WriteString("\"") 73 | writeString(buf, str.String()) 74 | } 75 | 76 | func (z Zsh) writeFlags(buf io.StringWriter, cmd *kong.Node) { 77 | for i, f := range cmd.Flags { 78 | if f.Hidden { 79 | continue 80 | } 81 | z.writeFlag(buf, f) 82 | if i < len(cmd.Flags)-1 { 83 | writeString(buf, " \\\n") 84 | } 85 | } 86 | } 87 | 88 | func (z Zsh) writeCommand(buf io.StringWriter, c *kong.Node) { 89 | writeString(buf, fmt.Sprintf(" \"%s[%s]\"", c.Name, c.Help)) 90 | } 91 | 92 | func (z Zsh) writeCommands(buf io.StringWriter, cmd *kong.Node) { 93 | for i, c := range cmd.Children { 94 | if c == nil || c.Hidden { 95 | continue 96 | } 97 | z.writeCommand(buf, c) 98 | if i < len(cmd.Children)-1 { 99 | _, _ = buf.WriteString(" \\") 100 | } 101 | writeString(buf, "\n") 102 | } 103 | } 104 | 105 | func (z Zsh) gen(buf io.StringWriter, cmd *kong.Node) { 106 | for _, c := range cmd.Children { 107 | if c == nil || c.Hidden { 108 | continue 109 | } 110 | z.gen(buf, c) 111 | } 112 | cmdName := commandName(cmd) 113 | 114 | writeString(buf, fmt.Sprintf("_%s() {\n", cmdName)) 115 | if hasCommands(cmd) { 116 | writeString(buf, " local line state\n") 117 | } 118 | writeString(buf, " _arguments -C \\\n") 119 | z.writeFlags(buf, cmd) 120 | if hasCommands(cmd) { 121 | writeString(buf, " \\\n") 122 | writeString(buf, " \"1: :->cmds\" \\\n") 123 | writeString(buf, " \"*::arg:->args\"\n") 124 | writeString(buf, " case \"$state\" in\n") 125 | writeString(buf, " cmds)\n") 126 | writeString(buf, fmt.Sprintf(" _values \"%s command\" \\\n", cmdName)) 127 | z.writeCommands(buf, cmd) 128 | writeString(buf, " ;;\n") 129 | writeString(buf, " args)\n") 130 | writeString(buf, " case \"$line[1]\" in\n") 131 | for _, c := range cmd.Children { 132 | if c == nil || c.Hidden { 133 | continue 134 | } 135 | writeString(buf, fmt.Sprintf(" %s)\n", c.Name)) 136 | writeString(buf, fmt.Sprintf(" _%s\n", commandName(c))) 137 | writeString(buf, " ;;\n") 138 | } 139 | writeString(buf, " esac\n") 140 | writeString(buf, " ;;\n") 141 | writeString(buf, " esac\n") 142 | } 143 | // writeArgAliases(buf, cmd) 144 | writeString(buf, "\n") 145 | writeString(buf, "}\n\n") 146 | } 147 | -------------------------------------------------------------------------------- /confirm/command.go: -------------------------------------------------------------------------------- 1 | package confirm 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/charmbracelet/bubbles/help" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/gum/internal/exit" 11 | "github.com/charmbracelet/gum/internal/stdin" 12 | "github.com/charmbracelet/gum/internal/timeout" 13 | ) 14 | 15 | // Run provides a shell script interface for prompting a user to confirm an 16 | // action with an affirmative or negative answer. 17 | func (o Options) Run() error { 18 | line, err := stdin.Read(stdin.SingleLine(true)) 19 | if err == nil { 20 | switch line { 21 | case "yes", "y": 22 | return nil 23 | default: 24 | return exit.ErrExit(1) 25 | } 26 | } 27 | 28 | ctx, cancel := timeout.Context(o.Timeout) 29 | defer cancel() 30 | 31 | m := model{ 32 | affirmative: o.Affirmative, 33 | negative: o.Negative, 34 | showOutput: o.ShowOutput, 35 | confirmation: o.Default, 36 | defaultSelection: o.Default, 37 | keys: defaultKeymap(o.Affirmative, o.Negative), 38 | help: help.New(), 39 | showHelp: o.ShowHelp, 40 | prompt: o.Prompt, 41 | selectedStyle: o.SelectedStyle.ToLipgloss(), 42 | unselectedStyle: o.UnselectedStyle.ToLipgloss(), 43 | promptStyle: o.PromptStyle.ToLipgloss(), 44 | } 45 | tm, err := tea.NewProgram( 46 | m, 47 | tea.WithOutput(os.Stderr), 48 | tea.WithContext(ctx), 49 | ).Run() 50 | if err != nil && ctx.Err() != context.DeadlineExceeded { 51 | return fmt.Errorf("unable to confirm: %w", err) 52 | } 53 | m = tm.(model) 54 | 55 | if o.ShowOutput { 56 | confirmationText := m.negative 57 | if m.confirmation { 58 | confirmationText = m.affirmative 59 | } 60 | fmt.Println(m.prompt, confirmationText) 61 | } 62 | 63 | if m.confirmation { 64 | return nil 65 | } 66 | 67 | return exit.ErrExit(1) 68 | } 69 | -------------------------------------------------------------------------------- /confirm/confirm.go: -------------------------------------------------------------------------------- 1 | // Package confirm provides an interface to ask a user to confirm an action. 2 | // The user is provided with an interface to choose an affirmative or negative 3 | // answer, which is then reflected in the exit code for use in scripting. 4 | // 5 | // If the user selects the affirmative answer, the program exits with 0. If the 6 | // user selects the negative answer, the program exits with 1. 7 | // 8 | // I.e. confirm if the user wants to delete a file 9 | // 10 | // $ gum confirm "Are you sure?" && rm file.txt 11 | package confirm 12 | 13 | import ( 14 | "github.com/charmbracelet/bubbles/help" 15 | "github.com/charmbracelet/bubbles/key" 16 | 17 | tea "github.com/charmbracelet/bubbletea" 18 | "github.com/charmbracelet/lipgloss" 19 | ) 20 | 21 | func defaultKeymap(affirmative, negative string) keymap { 22 | return keymap{ 23 | Abort: key.NewBinding( 24 | key.WithKeys("ctrl+c"), 25 | key.WithHelp("ctrl+c", "cancel"), 26 | ), 27 | Quit: key.NewBinding( 28 | key.WithKeys("esc"), 29 | key.WithHelp("esc", "quit"), 30 | ), 31 | Negative: key.NewBinding( 32 | key.WithKeys("n", "N", "q"), 33 | key.WithHelp("n", negative), 34 | ), 35 | Affirmative: key.NewBinding( 36 | key.WithKeys("y", "Y"), 37 | key.WithHelp("y", affirmative), 38 | ), 39 | Toggle: key.NewBinding( 40 | key.WithKeys( 41 | "left", 42 | "h", 43 | "ctrl+n", 44 | "shift+tab", 45 | "right", 46 | "l", 47 | "ctrl+p", 48 | "tab", 49 | ), 50 | key.WithHelp("←→", "toggle"), 51 | ), 52 | Submit: key.NewBinding( 53 | key.WithKeys("enter"), 54 | key.WithHelp("enter", "submit"), 55 | ), 56 | } 57 | } 58 | 59 | type keymap struct { 60 | Abort key.Binding 61 | Quit key.Binding 62 | Negative key.Binding 63 | Affirmative key.Binding 64 | Toggle key.Binding 65 | Submit key.Binding 66 | } 67 | 68 | // FullHelp implements help.KeyMap. 69 | func (k keymap) FullHelp() [][]key.Binding { return nil } 70 | 71 | // ShortHelp implements help.KeyMap. 72 | func (k keymap) ShortHelp() []key.Binding { 73 | return []key.Binding{k.Toggle, k.Submit, k.Affirmative, k.Negative} 74 | } 75 | 76 | type model struct { 77 | prompt string 78 | affirmative string 79 | negative string 80 | quitting bool 81 | showHelp bool 82 | help help.Model 83 | keys keymap 84 | 85 | showOutput bool 86 | confirmation bool 87 | 88 | defaultSelection bool 89 | 90 | // styles 91 | promptStyle lipgloss.Style 92 | selectedStyle lipgloss.Style 93 | unselectedStyle lipgloss.Style 94 | } 95 | 96 | func (m model) Init() tea.Cmd { return nil } 97 | 98 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 99 | switch msg := msg.(type) { 100 | case tea.WindowSizeMsg: 101 | return m, nil 102 | case tea.KeyMsg: 103 | switch { 104 | case key.Matches(msg, m.keys.Abort): 105 | m.confirmation = false 106 | return m, tea.Interrupt 107 | case key.Matches(msg, m.keys.Quit): 108 | m.confirmation = false 109 | m.quitting = true 110 | return m, tea.Quit 111 | case key.Matches(msg, m.keys.Negative): 112 | m.confirmation = false 113 | m.quitting = true 114 | return m, tea.Quit 115 | case key.Matches(msg, m.keys.Toggle): 116 | if m.negative == "" { 117 | break 118 | } 119 | m.confirmation = !m.confirmation 120 | case key.Matches(msg, m.keys.Submit): 121 | m.quitting = true 122 | return m, tea.Quit 123 | case key.Matches(msg, m.keys.Affirmative): 124 | m.quitting = true 125 | m.confirmation = true 126 | return m, tea.Quit 127 | } 128 | } 129 | return m, nil 130 | } 131 | 132 | func (m model) View() string { 133 | if m.quitting { 134 | return "" 135 | } 136 | 137 | var aff, neg string 138 | 139 | if m.confirmation { 140 | aff = m.selectedStyle.Render(m.affirmative) 141 | neg = m.unselectedStyle.Render(m.negative) 142 | } else { 143 | aff = m.unselectedStyle.Render(m.affirmative) 144 | neg = m.selectedStyle.Render(m.negative) 145 | } 146 | 147 | // If the option is intentionally empty, do not show it. 148 | if m.negative == "" { 149 | neg = "" 150 | } 151 | 152 | if m.showHelp { 153 | return lipgloss.JoinVertical( 154 | lipgloss.Left, 155 | m.promptStyle.Render(m.prompt)+"\n", 156 | lipgloss.JoinHorizontal(lipgloss.Left, aff, neg), 157 | "\n"+m.help.View(m.keys), 158 | ) 159 | } 160 | 161 | return lipgloss.JoinVertical( 162 | lipgloss.Left, 163 | m.promptStyle.Render(m.prompt)+"\n", 164 | lipgloss.JoinHorizontal(lipgloss.Left, aff, neg), 165 | ) 166 | } 167 | -------------------------------------------------------------------------------- /confirm/options.go: -------------------------------------------------------------------------------- 1 | package confirm 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/gum/style" 7 | ) 8 | 9 | // Options is the customization options for the confirm command. 10 | type Options struct { 11 | Default bool `help:"Default confirmation action" default:"true"` 12 | ShowOutput bool `help:"Print prompt and chosen action to output" default:"false"` 13 | Affirmative string `help:"The title of the affirmative action" default:"Yes"` 14 | Negative string `help:"The title of the negative action" default:"No"` 15 | Prompt string `arg:"" help:"Prompt to display." default:"Are you sure?"` 16 | //nolint:staticcheck 17 | PromptStyle style.Styles `embed:"" prefix:"prompt." help:"The style of the prompt" set:"defaultMargin=0 0 0 1" set:"defaultForeground=#7571F9" set:"defaultBold=true" envprefix:"GUM_CONFIRM_PROMPT_"` 18 | //nolint:staticcheck 19 | SelectedStyle style.Styles `embed:"" prefix:"selected." help:"The style of the selected action" set:"defaultBackground=212" set:"defaultForeground=230" set:"defaultPadding=0 3" set:"defaultMargin=0 1" envprefix:"GUM_CONFIRM_SELECTED_"` 20 | //nolint:staticcheck 21 | UnselectedStyle style.Styles `embed:"" prefix:"unselected." help:"The style of the unselected action" set:"defaultBackground=235" set:"defaultForeground=254" set:"defaultPadding=0 3" set:"defaultMargin=0 1" envprefix:"GUM_CONFIRM_UNSELECTED_"` 22 | ShowHelp bool `help:"Show help key binds" negatable:"" default:"true" env:"GUM_CONFIRM_SHOW_HELP"` 23 | Timeout time.Duration `help:"Timeout until confirm returns selected value or default if provided" default:"0s" env:"GUM_CONFIRM_TIMEOUT"` 24 | } 25 | -------------------------------------------------------------------------------- /cursor/cursor.go: -------------------------------------------------------------------------------- 1 | // Package cursor provides cursor modes. 2 | package cursor 3 | 4 | import ( 5 | "github.com/charmbracelet/bubbles/cursor" 6 | ) 7 | 8 | // Modes maps strings to cursor modes. 9 | var Modes = map[string]cursor.Mode{ 10 | "blink": cursor.CursorBlink, 11 | "hide": cursor.CursorHide, 12 | "static": cursor.CursorStatic, 13 | } 14 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { pkgs }: 2 | 3 | pkgs.buildGoModule rec { 4 | pname = "gum"; 5 | version = "0.15.2"; 6 | 7 | src = ./.; 8 | 9 | vendorHash = "sha256-TK2Fc4bTkiSpyYrg4dJOzamEnii03P7kyHZdah9izqY="; 10 | 11 | ldflags = [ "-s" "-w" "-X=main.Version=${version}" ]; 12 | } 13 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | *.gif 2 | *.png 3 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Glamour 2 | 3 | A casual introduction. 你好世界! 4 | 5 | ## Let's talk about artichokes 6 | 7 | The artichoke is mentioned as a garden 8 | plant in the 8th century BC by Homer 9 | and Hesiod. The naturally occurring 10 | variant of the artichoke, the cardoon, 11 | which is native to the Mediterranean 12 | area, also has records of use as a 13 | food among the ancient Greeks and 14 | Romans. Pliny the Elder mentioned 15 | growing of 'carduus' in Carthage 16 | and Cordoba. 17 | 18 | He holds him with his skinny hand, 19 | There was ship,' quoth he. 20 | 'Hold off! unhand me, grey-beard loon!' 21 | An artichoke dropt he. 22 | 23 | ## Other foods worth mentioning 24 | 25 | 1. Carrots 26 | 2. Celery 27 | 3. Tacos 28 | • Soft 29 | • Hard 30 | 4. Cucumber 31 | 32 | ## Things to eat today 33 | 34 | * Carrots 35 | * Ramen 36 | * Currywurst 37 | -------------------------------------------------------------------------------- /examples/choose.tape: -------------------------------------------------------------------------------- 1 | Output choose.gif 2 | 3 | Set Width 1000 4 | Set Height 430 5 | Set Shell bash 6 | 7 | Type "gum choose {1..5}" 8 | Sleep 500ms 9 | Enter 10 | Sleep 500ms 11 | Down@250ms 3 12 | Sleep 500ms 13 | Up@250ms 2 14 | Enter 15 | Sleep 1.5s 16 | Ctrl+L 17 | Sleep 500ms 18 | Type "gum choose --limit 2 Banana Cherry Orange" 19 | Sleep 500ms 20 | Enter 21 | Sleep 500ms 22 | Type@250ms "jxjxk" 23 | Sleep 1s 24 | Enter 25 | Sleep 2s 26 | 27 | -------------------------------------------------------------------------------- /examples/commit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # This script is used to write a conventional commit message. 4 | # It prompts the user to choose the type of commit as specified in the 5 | # conventional commit spec. And then prompts for the summary and detailed 6 | # description of the message and uses the values provided. as the summary and 7 | # details of the message. 8 | # 9 | # If you want to add a simpler version of this script to your dotfiles, use: 10 | # 11 | # alias gcm='git commit -m "$(gum input)" -m "$(gum write)"' 12 | 13 | # if [ -z "$(git status -s -uno | grep -v '^ ' | awk '{print $2}')" ]; then 14 | # gum confirm "Stage all?" && git add . 15 | # fi 16 | 17 | TYPE=$(gum choose "fix" "feat" "docs" "style" "refactor" "test" "chore" "revert") 18 | SCOPE=$(gum input --placeholder "scope") 19 | 20 | # Since the scope is optional, wrap it in parentheses if it has a value. 21 | test -n "$SCOPE" && SCOPE="($SCOPE)" 22 | 23 | # Pre-populate the input with the type(scope): so that the user may change it 24 | SUMMARY=$(gum input --value "$TYPE$SCOPE: " --placeholder "Summary of this change") 25 | DESCRIPTION=$(gum write --placeholder "Details of this change") 26 | 27 | # Commit these changes if user confirms 28 | gum confirm "Commit changes?" && git commit -m "$SUMMARY" -m "$DESCRIPTION" 29 | -------------------------------------------------------------------------------- /examples/commit.tape: -------------------------------------------------------------------------------- 1 | Output commit.gif 2 | 3 | Set Shell "bash" 4 | Set FontSize 32 5 | Set Width 1200 6 | Set Height 600 7 | 8 | Type "./commit.sh" Sleep 500ms Enter 9 | 10 | Sleep 1s 11 | Down@250ms 2 12 | Sleep 500ms 13 | Enter 14 | 15 | Sleep 500ms 16 | 17 | Type "gum" 18 | 19 | Sleep 500ms 20 | Enter 21 | 22 | Sleep 1s 23 | 24 | Type "Gum is sooo tasty" 25 | Sleep 500ms 26 | 27 | Enter 28 | 29 | Sleep 1s 30 | 31 | Type@65ms "I love bubble gum." 32 | Sleep 500ms 33 | Alt+Enter 34 | Sleep 500ms 35 | Alt+Enter 36 | Sleep 500ms 37 | Type "This commit shows how much I love chewing bubble gum!!!" 38 | Sleep 500ms 39 | Enter 40 | 41 | Sleep 1s 42 | 43 | Left@400ms 3 44 | 45 | Sleep 1s 46 | -------------------------------------------------------------------------------- /examples/confirm.tape: -------------------------------------------------------------------------------- 1 | Output confirm.gif 2 | 3 | Set Width 1000 4 | Set Height 350 5 | Set Shell bash 6 | 7 | Sleep 500ms 8 | Type "gum confirm && echo 'Me too!' || echo 'Me neither.'" 9 | Sleep 1s 10 | Enter 11 | Sleep 1s 12 | Right 13 | Sleep 500ms 14 | Left 15 | Sleep 500ms 16 | Enter 17 | Sleep 1.5s 18 | Ctrl+L 19 | Type "gum confirm && echo 'Me too!' || echo 'Me neither.'" 20 | Sleep 500ms 21 | Enter 22 | Sleep 500ms 23 | Right 24 | Sleep 500ms 25 | Enter 26 | Sleep 1s 27 | -------------------------------------------------------------------------------- /examples/convert-to-gif.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script converts some video to a GIF. It prompts the user to select an 4 | # video file with `gum filter` Set the frame rate, desired width, and max 5 | # colors to use Then, converts the video to a GIF. 6 | 7 | INPUT=$(gum filter --placeholder "Input file") 8 | FRAMERATE=$(gum input --prompt "Frame rate: " --placeholder "Frame Rate" --prompt.foreground 240 --value "50") 9 | WIDTH=$(gum input --prompt "Width: " --placeholder "Width" --prompt.foreground 240 --value "1200") 10 | MAXCOLORS=$(gum input --prompt "Max Colors: " --placeholder "Max Colors" --prompt.foreground 240 --value "256") 11 | 12 | BASENAME=$(basename "$INPUT") 13 | BASENAME="${BASENAME%%.*}" 14 | 15 | gum spin --title "Converting to GIF" -- ffmpeg -i "$INPUT" -vf "fps=$FRAMERATE,scale=$WIDTH:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=$MAXCOLORS[p];[s1][p]paletteuse" "$BASENAME.gif" 16 | -------------------------------------------------------------------------------- /examples/customize.tape: -------------------------------------------------------------------------------- 1 | Output customize.gif 2 | 3 | Set Width 1000 4 | Set Height 350 5 | Set Shell bash 6 | 7 | Sleep 1s 8 | Type `gum input --cursor.foreground "#F4AC45" \` Enter 9 | Type `--prompt.foreground "#04B575" --prompt "What's up? " \` Enter 10 | Type `--placeholder "Not much, you?" --value "Not much, you?" \` Enter 11 | Type `--width 80` Enter 12 | Sleep 1s 13 | Ctrl+A 14 | Sleep 1s 15 | Ctrl+E 16 | Sleep 1s 17 | Ctrl+U 18 | Sleep 1s 19 | 20 | -------------------------------------------------------------------------------- /examples/demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "Hello, there! Welcome to $(gum style --foreground 212 'Gum')." 4 | NAME=$(gum input --placeholder "What is your name?") 5 | 6 | echo -e "Well, it is nice to meet you, $(gum style --foreground 212 "$NAME")." 7 | 8 | sleep 1; clear 9 | 10 | echo -e "Can you tell me a $(gum style --italic --foreground 99 'secret')?\n" 11 | 12 | gum write --placeholder "I'll keep it to myself, I promise!" > /dev/null # we keep the secret to ourselves 13 | 14 | clear; echo "What should I do with this information?"; sleep 1 15 | 16 | READ="Read"; THINK="Think"; DISCARD="Discard" 17 | ACTIONS=$(gum choose --no-limit "$READ" "$THINK" "$DISCARD") 18 | 19 | clear; echo "One moment, please." 20 | 21 | grep -q "$READ" <<< "$ACTIONS" && gum spin -s line --title "Reading the secret..." -- sleep 1 22 | grep -q "$THINK" <<< "$ACTIONS" && gum spin -s pulse --title "Thinking about your secret..." -- sleep 1 23 | grep -q "$DISCARD" <<< "$ACTIONS" && gum spin -s monkey --title " Discarding your secret..." -- sleep 2 24 | 25 | sleep 1; clear 26 | 27 | GUM=$(echo -e "Cherry\nGrape\nLime\nOrange" | gum filter --placeholder "Favorite flavor?") 28 | echo "I'll keep that in mind!" 29 | 30 | sleep 1; clear 31 | 32 | echo "Do you like $(gum style --foreground "#04B575" "Bubble Gum?")" 33 | sleep 1 34 | 35 | CHOICE=$(gum choose --item.foreground 250 "Yes" "No" "It's complicated") 36 | 37 | [[ "$CHOICE" == "Yes" ]] && echo "I thought so, $(gum style --bold "Bubble Gum") is the best." || echo "I'm sorry to hear that." 38 | 39 | sleep 1 40 | 41 | gum spin --title "Chewing some $(gum style --foreground "#04B575" "$GUM") bubble gum..." -- sleep 2.5 42 | 43 | clear 44 | 45 | NICE_MEETING_YOU=$(gum style --height 5 --width 20 --padding '1 3' --border double --border-foreground 57 "Nice meeting you, $(gum style --foreground 212 "$NAME"). See you soon!") 46 | CHEW_BUBBLE_GUM=$(gum style --width 17 --padding '1 3' --border double --border-foreground 212 "Go chew some $(gum style --foreground "#04B575" "$GUM") bubble gum.") 47 | gum join --horizontal "$NICE_MEETING_YOU" "$CHEW_BUBBLE_GUM" 48 | -------------------------------------------------------------------------------- /examples/demo.tape: -------------------------------------------------------------------------------- 1 | Output ./demo.gif 2 | 3 | Set Shell bash 4 | 5 | Set FontSize 22 6 | Set Width 800 7 | Set Height 450 8 | 9 | Type "./demo.sh" 10 | Enter 11 | Sleep 1s 12 | Type "Walter" 13 | Sleep 500ms 14 | Enter 15 | 16 | Sleep 2s 17 | 18 | Type "Nope, sorry!" 19 | Sleep 500ms 20 | Alt+Enter 21 | Sleep 200ms 22 | Alt+Enter 23 | Sleep 500ms 24 | Type "I don't trust you." 25 | Sleep 1s 26 | Enter 27 | 28 | Sleep 2s 29 | 30 | Type "x" Sleep 250ms Type "j" Sleep 250ms 31 | Type "x" Sleep 250ms Type "j" Sleep 250ms 32 | Type "x" Sleep 1s 33 | 34 | Enter 35 | 36 | Sleep 6s 37 | 38 | Type "li" 39 | Sleep 1s 40 | Enter 41 | 42 | Sleep 3s 43 | Down@500ms 2 44 | Up@500ms 2 45 | Sleep 1s 46 | Enter 47 | 48 | 49 | Sleep 6s 50 | -------------------------------------------------------------------------------- /examples/diyfetch: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # ____ _____ ____ _ _ 4 | # | _ \_ _\ \ / / _| ___| |_ ___| |__ 5 | # | | | | | \ V / |_ / _ \ __/ __| '_ \ 6 | # | |_| | | | || _| __/ || (__| | | | 7 | # |____/___| |_||_| \___|\__\___|_| |_| 8 | # 9 | # About: 10 | # DIYfetch it the shell script template for writing fetch tool 11 | # utilizing `gum join` command (https://github.com/charmbracelet/gum#join). 12 | # 13 | # This script is written in POSIX-shell for portability 14 | # feel free to switch it to any scripting language that you prefer. 15 | # 16 | # Note: 17 | # When copy ANSI string from random script make sure to replace "\033" and "\e" to "" 18 | # or wrap it in `$(printf '%b' "")`. 19 | # 20 | # URL: https://github.com/info-mono/diyfetch 21 | 22 | 23 | # Prepare ------------------------------------------------------------------------------------------ 24 | 25 | # You can lookup the color codes on https://wikipedia.org/wiki/ANSI_escape_code#8-bit 26 | main_color=4 27 | 28 | # You can add some eye candy icons with Emoji of use Nerd Fonts (https://www.nerdfonts.com). 29 | info=$(gum style "[1;38;5;${main_color}m${USER}@[1;38;5;${main_color}m$(hostname) 30 | ---------------- 31 | [1;38;5;${main_color}mOS:  32 | [1;38;5;${main_color}mKERNEL: $(uname -sr) 33 | [1;38;5;${main_color}mUPTIME: $(uptime -p | cut -c 4-) 34 | [1;38;5;${main_color}mSHELL: $(basename "${SHELL}") 35 | [1;38;5;${main_color}mEDITOR: $(basename "${EDITOR:-}") 36 | [1;38;5;${main_color}mDE:  37 | [1;38;5;${main_color}mWM:  38 | [1;38;5;${main_color}mTERMINAL: ") 39 | 40 | # You can get OS arts on https://github.com/info-mono/os-ansi 41 | # copy the raw data of the .ansi file then paste it down below. 42 | art=$(gum style ' ___ 43 | (.. | 44 | (<> | 45 | / __ \ 46 | ( / \/ | 47 | _/\ __)/_) 48 | \/-____\/') 49 | 50 | # You can generate colorstrip using https://github.com/info-mono/colorstrip 51 | color=$(gum style '████████████████████████ 52 | ████████████████████████') 53 | 54 | 55 | # Display ------------------------------------------------------------------------------------------ 56 | 57 | # The code in this section is to display the fetch adaptively to the terminal's size. 58 | # If you just want a static fetch display, you can just use something like this: 59 | # 60 | # group_info_color=$(gum join --vertical "${info}" '' "${color}") 61 | # gum join --horizontal --align center ' ' "${art}" ' ' "${group_info_color}" 62 | 63 | terminal_size=$(stty size) 64 | terminal_height=${terminal_size% *} 65 | terminal_width=${terminal_size#* } 66 | 67 | # Acknowledge of how high the shell prompt is so the prompt don't push the fetch out. 68 | prompt_height=${PROMPT_HEIGHT:-1} 69 | 70 | print_test() { 71 | no_color=$(printf '%b' "${1}" | sed -e 's/\x1B\[[0-9;]*[JKmsu]//g') 72 | 73 | [ "$(printf '%s' "${no_color}" | wc --lines)" -gt $(( terminal_height - prompt_height )) ] && return 1 74 | [ "$(printf '%s' "${no_color}" | wc --max-line-length)" -gt "${terminal_width}" ] && return 1 75 | 76 | gum style --align center --width="${terminal_width}" "${1}" '' 77 | printf '%b' "\033[A" 78 | 79 | exit 0 80 | } 81 | 82 | 83 | # Paper layout 84 | print_test "$(gum join --vertical --align center "${art}" '' "${info}" '' "${color}")" 85 | 86 | # Classic layout 87 | group_info_color=$(gum join --vertical "${info}" '' "${color}") 88 | print_test "$(gum join --horizontal --align center "${art}" ' ' "${group_info_color}")" 89 | 90 | # Hybrid layout 91 | group_art_info=$(gum join --horizontal --align center "${art}" ' ' "${info}") 92 | print_test "$(gum join --vertical --align center "${group_art_info}" '' "${color}")" 93 | 94 | # Other layout 95 | print_test "$(gum join --vertical --align center "${art}" '' "${info}")" 96 | print_test "${group_art_info}" 97 | print_test "${group_info_color}" 98 | print_test "${info}" 99 | 100 | exit 1 -------------------------------------------------------------------------------- /examples/fav.txt: -------------------------------------------------------------------------------- 1 | Banana 2 | -------------------------------------------------------------------------------- /examples/file.tape: -------------------------------------------------------------------------------- 1 | Output file.gif 2 | Set Width 800 3 | Set Height 525 4 | Set Shell bash 5 | 6 | Type "gum file .." 7 | Enter 8 | Sleep 1s 9 | Down@150ms 6 10 | Sleep 1s 11 | Enter 12 | Sleep 1s 13 | Type "j" 14 | Sleep 1s 15 | 16 | -------------------------------------------------------------------------------- /examples/filter-key-value.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export LIST=$(cat < gum format -t code < main.go 2 | 3 | 4 |    package main 5 |     6 |    import "fmt" 7 |     8 |    func main() { 9 |     fmt.Println("Charm_™ Gum") 10 |    } 11 |     12 |  13 | -------------------------------------------------------------------------------- /examples/git-branch-manager.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | # This script is used to manage git branches such as delete, update, and rebase 4 | # them. It prompts the user to choose the branches and the action they want to 5 | # perform. 6 | # 7 | # For an explanation on the script and tutorial on how to create it, watch: 8 | # https://www.youtube.com/watch?v=tnikefEuArQ 9 | 10 | GIT_COLOR="#f14e32" 11 | 12 | git_color_text () { 13 | gum style --foreground "$GIT_COLOR" "$1" 14 | } 15 | 16 | get_branches () { 17 | if [ ${1+x} ]; then 18 | gum choose --selected.foreground="$GIT_COLOR" --limit="$1" $(git branch --format="%(refname:short)") 19 | else 20 | gum choose --selected.foreground="$GIT_COLOR" --no-limit $(git branch --format="%(refname:short)") 21 | fi 22 | } 23 | 24 | git rev-parse --git-dir > /dev/null 2>&1 25 | 26 | if [ $? -ne 0 ]; 27 | then 28 | echo "$(git_color_text "!!") Must be run in a $(git_color_text "git") repo" 29 | exit 1 30 | fi 31 | 32 | gum style \ 33 | --border normal \ 34 | --margin "1" \ 35 | --padding "1" \ 36 | --border-foreground "$GIT_COLOR" \ 37 | "$(git_color_text ' Git') Branch Manager" 38 | 39 | echo "Choose $(git_color_text 'branches') to operate on:" 40 | branches=$(get_branches) 41 | 42 | echo "" 43 | echo "Choose a $(git_color_text "command"):" 44 | command=$(gum choose --cursor.foreground="$GIT_COLOR" rebase delete update) 45 | echo "" 46 | 47 | echo $branches | tr " " "\n" | while read -r branch 48 | do 49 | case $command in 50 | rebase) 51 | base_branch=$(get_branches 1) 52 | git fetch origin 53 | git checkout "$branch" 54 | git rebase "origin/$base_branch" 55 | ;; 56 | delete) 57 | git branch -D "$branch" 58 | ;; 59 | update) 60 | git checkout "$branch" 61 | git pull --ff-only 62 | ;; 63 | esac 64 | done 65 | -------------------------------------------------------------------------------- /examples/git-stage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ADD="Add" 4 | RESET="Reset" 5 | 6 | ACTION=$(gum choose "$ADD" "$RESET") 7 | 8 | if [ "$ACTION" == "$ADD" ]; then 9 | git status --short | cut -c 4- | gum choose --no-limit | xargs git add 10 | else 11 | git status --short | cut -c 4- | gum choose --no-limit | xargs git restore 12 | fi 13 | -------------------------------------------------------------------------------- /examples/gum.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require("child_process"); 2 | 3 | const activities = ["walking", "running", "cycling", "driving", "transport"]; 4 | 5 | console.log("What's your favorite activity?") 6 | const gum = spawn("gum", ["choose", ...activities]); 7 | 8 | gum.stderr.pipe(process.stderr); 9 | 10 | gum.stdout.on("data", data => { 11 | const activity = data.toString().trim(); 12 | console.log(`I like ${activity} too!`); 13 | }); 14 | -------------------------------------------------------------------------------- /examples/gum.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | print("What's your favorite language?") 4 | 5 | result = subprocess.run(["gum", "choose", "Go", "Python"], stdout=subprocess.PIPE, text=True) 6 | 7 | print(f"I like {result.stdout.strip()}, too!") 8 | -------------------------------------------------------------------------------- /examples/gum.rb: -------------------------------------------------------------------------------- 1 | puts 'What is your name?' 2 | name = `gum input --placeholder "Your name"`.chomp 3 | 4 | puts "Hello #{name}!" 5 | 6 | puts 'Pick your 2 favorite colors' 7 | 8 | COLORS = { 9 | 'Red' => '#FF0000', 10 | 'Blue' => '#0000FF', 11 | 'Green' => '#00FF00', 12 | 'Yellow' => '#FFFF00', 13 | 'Orange' => '#FFA500', 14 | 'Purple' => '#800080', 15 | 'Pink' => '#FF00FF' 16 | }.freeze 17 | 18 | colors = `gum choose #{COLORS.keys.join(' ')} --limit 2`.chomp.split("\n") 19 | 20 | if colors.length == 2 21 | first = `gum style --foreground '#{COLORS[colors[0]]}' '#{colors[0]}'`.chomp 22 | second = `gum style --foreground '#{COLORS[colors[1]]}' '#{colors[1]}'`.chomp 23 | puts "You chose #{first} and #{second}." 24 | elsif colors.length == 1 25 | first = `gum style --foreground '#{COLORS[colors[0]]}' '#{colors[0]}'`.chomp 26 | puts "You chose #{first}." 27 | else 28 | puts "You didn't pick any colors!" 29 | end 30 | -------------------------------------------------------------------------------- /examples/input.tape: -------------------------------------------------------------------------------- 1 | Output input.gif 2 | 3 | Set Width 800 4 | Set Height 250 5 | Set Shell bash 6 | 7 | Sleep 1s 8 | Type `gum input --placeholder "What's up?"` 9 | Sleep 1s 10 | Enter 11 | Sleep 1s 12 | Type "Not much, you?" 13 | Sleep 1s 14 | Enter 15 | Sleep 1s 16 | 17 | -------------------------------------------------------------------------------- /examples/kaomoji.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # If the user passes '-h', '--help', or 'help' print out a little bit of help. 4 | # text. 5 | case "$1" in 6 | "-h" | "--help" | "help") 7 | printf 'Generate kaomojis on request.\n\n' 8 | printf 'Usage: %s [kind]\n' "$(basename "$0")" 9 | exit 1 10 | ;; 11 | esac 12 | 13 | # The user can pass an argument like "bear" or "angry" to specify the general 14 | # kind of Kaomoji produced. 15 | sentiment="" 16 | if [[ $1 != "" ]]; then 17 | sentiment=" $1" 18 | fi 19 | 20 | # Ask mods to generate Kaomojis. Save the output in a variable. 21 | kaomoji="$(mods "generate 10${sentiment} kaomojis. number them and put each one on its own line.")" 22 | if [[ $kaomoji == "" ]]; then 23 | exit 1 24 | fi 25 | 26 | # Pipe mods output to gum so the user can choose the perfect kaomoji. Save that 27 | # choice in a variable. Also note that we're using cut to drop the item number 28 | # in front of the Kaomoji. 29 | choice="$(echo "$kaomoji" | gum choose | cut -d ' ' -f 2)" 30 | if [[ $choice == "" ]]; then 31 | exit 1 32 | fi 33 | 34 | # If xsel (X11) or pbcopy (macOS) exists, copy to the clipboard. If not, just 35 | # print the Kaomoji. 36 | if command -v xsel &> /dev/null; then 37 | printf '%s' "$choice" | xclip -sel clip # X11 38 | elif command -v pbcopy &> /dev/null; then 39 | printf '%s' "$choice" | pbcopy # macOS 40 | else 41 | # We can't copy, so just print it out. 42 | printf 'Here you go: %s\n' "$choice" 43 | exit 0 44 | fi 45 | 46 | # We're done! 47 | printf 'Copied %s to the clipboard\n' "$choice" 48 | -------------------------------------------------------------------------------- /examples/magic.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Always ask for permission! 4 | echo "Do you want to see a magic trick?" 5 | 6 | YES="Yes, please!" 7 | NO="No, thank you!" 8 | 9 | CHOICE=$(gum choose "$YES" "$NO") 10 | 11 | if [ "$CHOICE" != "$YES" ]; then 12 | echo "Alright, then. Have a nice day!" 13 | exit 1 14 | fi 15 | 16 | 17 | # Let the magic begin. 18 | echo "Alright, then. Let's begin!" 19 | gum style --foreground 212 "Pick a card, any card..." 20 | 21 | CARD=$(gum choose "Ace (A)" "Two (2)" "Three (3)" "Four (4)" "Five (5)" "Six (6)" "Seven (7)" "Eight (8)" "Nine (9)" "Ten (10)" "Jack (J)" "Queen (Q)" "King (K)") 22 | SUIT=$(gum choose "Hearts (♥)" "Diamonds (♦)" "Clubs (♣)" "Spades (♠)") 23 | 24 | gum style --foreground 212 "You picked the $CARD of $SUIT." 25 | 26 | SHORT_CARD=$(echo $CARD | cut -d' ' -f2 | tr -d '()') 27 | SHORT_SUIT=$(echo $SUIT | cut -d' ' -f2 | tr -d '()') 28 | 29 | TOP_LEFT=$(gum join --vertical "$SHORT_CARD" "$SHORT_SUIT") 30 | BOTTOM_RIGHT=$(gum join --vertical "$SHORT_SUIT" "$SHORT_CARD") 31 | 32 | TOP_LEFT=$(gum style --width 10 --height 5 --align left "$TOP_LEFT") 33 | BOTTOM_RIGHT=$(gum style --width 10 --align right "$BOTTOM_RIGHT") 34 | 35 | if [[ "$SHORT_SUIT" == "♥" || "$SHORT_SUIT" == "♦" ]]; then 36 | CARD_COLOR="1" # Red 37 | else 38 | CARD_COLOR="7" # Black 39 | fi 40 | 41 | gum style --border rounded --padding "0 1" --margin 2 --border-foreground "$CARD_COLOR" --foreground "$CARD_COLOR" "$(gum join --vertical "$TOP_LEFT" "$BOTTOM_RIGHT")" 42 | 43 | echo "Is this your card?" 44 | 45 | gum choose "Omg, yes!" "Nope, sorry!" 46 | -------------------------------------------------------------------------------- /examples/pager.tape: -------------------------------------------------------------------------------- 1 | Output pager.gif 2 | 3 | Set Shell bash 4 | Set Width 900 5 | Set Height 750 6 | 7 | Sleep 1s 8 | Type "gum pager < README.md" 9 | Enter 10 | Sleep 1.5s 11 | Down@100ms 25 12 | Sleep 1s 13 | Up@100ms 25 14 | Sleep 3s 15 | 16 | -------------------------------------------------------------------------------- /examples/posix.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "What's your favorite shell?" 4 | 5 | gum choose "Posix" "Bash" "Zsh" "Fish" "Elvish" 6 | -------------------------------------------------------------------------------- /examples/skate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Building a simple `skate` TUI with gum to allow you to select a database and 4 | # pick a value from skate. 5 | 6 | DATABASE=$(skate list-dbs | gum choose) 7 | skate list --keys-only "$DATABASE" | gum filter | xargs -I {} skate get {}"$DATABASE" 8 | -------------------------------------------------------------------------------- /examples/spin.tape: -------------------------------------------------------------------------------- 1 | Output spin.gif 2 | 3 | Set Shell bash 4 | Set Width 1200 5 | Set Height 300 6 | Set FontSize 36 7 | 8 | Sleep 500ms 9 | Type `gum spin --title "Buying Gum..." -- sleep 5` 10 | Sleep 1s 11 | Enter 12 | Sleep 4s 13 | 14 | -------------------------------------------------------------------------------- /examples/story.txt: -------------------------------------------------------------------------------- 1 | Once upon a time 2 | In a land far, far away.... 3 | -------------------------------------------------------------------------------- /examples/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Choose 4 | gum choose Foo Bar Baz 5 | gum choose Choose One Item --cursor "* " --cursor.foreground 99 --selected.foreground 99 6 | gum choose Pick Two Items Maximum --limit 2 --cursor "* " --cursor-prefix "(•) " --selected-prefix "(x) " --unselected-prefix "( ) " --cursor.foreground 99 --selected.foreground 99 7 | gum choose Unlimited Choice Of Items --no-limit --cursor "* " --cursor-prefix "(•) " --selected-prefix "(x) " --unselected-prefix "( ) " --cursor.foreground 99 --selected.foreground 99 8 | 9 | # Confirm 10 | gum confirm "Testing?" 11 | gum confirm "No?" --default=false --affirmative "Okay." --negative "Cancel." 12 | 13 | # Filter 14 | gum filter 15 | echo {1..500} | sed 's/ /\n/g' | gum filter 16 | echo {1..500} | sed 's/ /\n/g' | gum filter --indicator ">" --placeholder "Pick a number..." --indicator.foreground 1 --text.foreground 2 --match.foreground 3 --prompt.foreground 4 --height 5 17 | 18 | # Format 19 | echo "# Header\nBody" | gum format 20 | echo 'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("Hello, Gum!")\n}\n' | gum format -t code 21 | echo ":candy:" | gum format -t emoji 22 | echo '{{ Bold "Bold" }}' | gum format -t template 23 | 24 | # Input 25 | gum input 26 | gum input --prompt "Email: " --placeholder "john@doe.com" --prompt.foreground 99 --cursor.foreground 99 --width 50 27 | gum input --password --prompt "Password: " --placeholder "hunter2" --prompt.foreground 99 --cursor.foreground 99 --width 50 28 | 29 | # Join 30 | gum join "Horizontal" "Join" 31 | gum join --vertical "Vertical" "Join" 32 | 33 | # Spin 34 | gum spin -- sleep 1 35 | gum spin --spinner minidot --title "Loading..." --title.foreground 99 -- sleep 1 36 | gum spin --show-output --spinner monkey --title "Loading..." --title.foreground 99 -- sh -c 'sleep 1; echo "Hello, Gum!"' 37 | 38 | # Style 39 | gum style --foreground 99 --border double --border-foreground 99 --padding "1 2" --margin 1 "Hello, Gum." 40 | 41 | # Write 42 | gum write 43 | gum write --width 40 --height 6 --placeholder "Type whatever you want" --prompt "| " --show-cursor-line --show-line-numbers --value "Something..." --base.padding 1 --cursor.foreground 99 --prompt.foreground 99 44 | 45 | # Table 46 | gum table < table/example.csv 47 | 48 | # Pager 49 | gum pager < README.md 50 | 51 | # File 52 | gum file 53 | -------------------------------------------------------------------------------- /examples/write.tape: -------------------------------------------------------------------------------- 1 | Output write.gif 2 | 3 | Set Width 800 4 | Set Height 350 5 | Set Shell bash 6 | 7 | Sleep 500ms 8 | Type "gum write > story.txt" 9 | Enter 10 | Sleep 1s 11 | Type "Once upon a time" 12 | Sleep 1s 13 | Alt+Enter 14 | Type "In a land far, far away...." 15 | Sleep 500ms 16 | Enter 17 | Sleep 1s 18 | Type "cat story.txt" 19 | Enter 20 | Sleep 2s 21 | 22 | -------------------------------------------------------------------------------- /file/command.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/charmbracelet/bubbles/filepicker" 10 | "github.com/charmbracelet/bubbles/help" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/gum/internal/timeout" 13 | ) 14 | 15 | // Run is the interface to picking a file. 16 | func (o Options) Run() error { 17 | if !o.File && !o.Directory { 18 | return errors.New("at least one between --file and --directory must be set") 19 | } 20 | 21 | if o.Path == "" { 22 | o.Path = "." 23 | } 24 | 25 | path, err := filepath.Abs(o.Path) 26 | if err != nil { 27 | return fmt.Errorf("file not found: %w", err) 28 | } 29 | 30 | fp := filepicker.New() 31 | fp.CurrentDirectory = path 32 | fp.Path = path 33 | fp.SetHeight(o.Height) 34 | fp.AutoHeight = o.Height == 0 35 | fp.Cursor = o.Cursor 36 | fp.DirAllowed = o.Directory 37 | fp.FileAllowed = o.File 38 | fp.ShowPermissions = o.Permissions 39 | fp.ShowSize = o.Size 40 | fp.ShowHidden = o.All 41 | fp.Styles = filepicker.DefaultStyles() 42 | fp.Styles.Cursor = o.CursorStyle.ToLipgloss() 43 | fp.Styles.Symlink = o.SymlinkStyle.ToLipgloss() 44 | fp.Styles.Directory = o.DirectoryStyle.ToLipgloss() 45 | fp.Styles.File = o.FileStyle.ToLipgloss() 46 | fp.Styles.Permission = o.PermissionsStyle.ToLipgloss() 47 | fp.Styles.Selected = o.SelectedStyle.ToLipgloss() 48 | fp.Styles.FileSize = o.FileSizeStyle.ToLipgloss() 49 | m := model{ 50 | filepicker: fp, 51 | showHelp: o.ShowHelp, 52 | help: help.New(), 53 | keymap: defaultKeymap(), 54 | headerStyle: o.HeaderStyle.ToLipgloss(), 55 | header: o.Header, 56 | } 57 | 58 | ctx, cancel := timeout.Context(o.Timeout) 59 | defer cancel() 60 | 61 | tm, err := tea.NewProgram( 62 | &m, 63 | tea.WithOutput(os.Stderr), 64 | tea.WithContext(ctx), 65 | ).Run() 66 | if err != nil { 67 | return fmt.Errorf("unable to pick selection: %w", err) 68 | } 69 | m = tm.(model) 70 | if m.selectedPath == "" { 71 | return errors.New("no file selected") 72 | } 73 | 74 | fmt.Println(m.selectedPath) 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /file/file.go: -------------------------------------------------------------------------------- 1 | // Package file provides an interface to pick a file from a folder (tree). 2 | // The user is provided a file manager-like interface to navigate, to 3 | // select a file. 4 | // 5 | // Let's pick a file from the current directory: 6 | // 7 | // $ gum file 8 | // $ gum file . 9 | // 10 | // Let's pick a file from the home directory: 11 | // 12 | // $ gum file $HOME 13 | package file 14 | 15 | import ( 16 | "github.com/charmbracelet/bubbles/filepicker" 17 | "github.com/charmbracelet/bubbles/help" 18 | "github.com/charmbracelet/bubbles/key" 19 | tea "github.com/charmbracelet/bubbletea" 20 | "github.com/charmbracelet/lipgloss" 21 | ) 22 | 23 | type keymap filepicker.KeyMap 24 | 25 | var keyQuit = key.NewBinding( 26 | key.WithKeys("esc", "q"), 27 | key.WithHelp("esc", "close"), 28 | ) 29 | 30 | var keyAbort = key.NewBinding( 31 | key.WithKeys("ctrl+c"), 32 | key.WithHelp("ctrl+c", "abort"), 33 | ) 34 | 35 | func defaultKeymap() keymap { 36 | km := filepicker.DefaultKeyMap() 37 | return keymap(km) 38 | } 39 | 40 | // FullHelp implements help.KeyMap. 41 | func (k keymap) FullHelp() [][]key.Binding { return nil } 42 | 43 | // ShortHelp implements help.KeyMap. 44 | func (k keymap) ShortHelp() []key.Binding { 45 | return []key.Binding{ 46 | key.NewBinding( 47 | key.WithKeys("up", "down"), 48 | key.WithHelp("↓↑", "navigate"), 49 | ), 50 | keyQuit, 51 | k.Select, 52 | } 53 | } 54 | 55 | type model struct { 56 | header string 57 | headerStyle lipgloss.Style 58 | filepicker filepicker.Model 59 | selectedPath string 60 | quitting bool 61 | showHelp bool 62 | help help.Model 63 | keymap keymap 64 | } 65 | 66 | func (m model) Init() tea.Cmd { return m.filepicker.Init() } 67 | 68 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 69 | switch msg := msg.(type) { 70 | case tea.WindowSizeMsg: 71 | if m.showHelp { 72 | m.filepicker.Height -= lipgloss.Height(m.helpView()) //nolint:staticcheck 73 | } 74 | case tea.KeyMsg: 75 | switch { 76 | case key.Matches(msg, keyAbort): 77 | m.quitting = true 78 | return m, tea.Interrupt 79 | case key.Matches(msg, keyQuit): 80 | m.quitting = true 81 | return m, tea.Quit 82 | } 83 | } 84 | var cmd tea.Cmd 85 | m.filepicker, cmd = m.filepicker.Update(msg) 86 | if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect { 87 | m.selectedPath = path 88 | m.quitting = true 89 | return m, tea.Quit 90 | } 91 | return m, cmd 92 | } 93 | 94 | func (m model) View() string { 95 | if m.quitting { 96 | return "" 97 | } 98 | var parts []string 99 | if m.header != "" { 100 | parts = append(parts, m.headerStyle.Render(m.header)) 101 | } 102 | parts = append(parts, m.filepicker.View()) 103 | if m.showHelp { 104 | parts = append(parts, m.helpView()) 105 | } 106 | return lipgloss.JoinVertical(lipgloss.Left, parts...) 107 | } 108 | 109 | func (m model) helpView() string { 110 | return m.help.View(m.keymap) 111 | } 112 | -------------------------------------------------------------------------------- /file/options.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/gum/style" 7 | ) 8 | 9 | // Options are the options for the file command. 10 | type Options struct { 11 | // Path is the path to the folder / directory to begin traversing. 12 | Path string `arg:"" optional:"" name:"path" help:"The path to the folder to begin traversing" env:"GUM_FILE_PATH"` 13 | // Cursor is the character to display in front of the current selected items. 14 | Cursor string `short:"c" help:"The cursor character" default:">" env:"GUM_FILE_CURSOR"` 15 | All bool `short:"a" help:"Show hidden and 'dot' files" default:"false" env:"GUM_FILE_ALL"` 16 | Permissions bool `short:"p" help:"Show file permissions" default:"true" negatable:"" env:"GUM_FILE_PERMISSION"` 17 | Size bool `short:"s" help:"Show file size" default:"true" negatable:"" env:"GUM_FILE_SIZE"` 18 | File bool `help:"Allow files selection" default:"true" env:"GUM_FILE_FILE"` 19 | Directory bool `help:"Allow directories selection" default:"false" env:"GUM_FILE_DIRECTORY"` 20 | ShowHelp bool `help:"Show help key binds" negatable:"" default:"true" env:"GUM_FILE_SHOW_HELP"` 21 | Timeout time.Duration `help:"Timeout until command aborts without a selection" default:"0s" env:"GUM_FILE_TIMEOUT"` 22 | Header string `help:"Header value" default:"" env:"GUM_FILE_HEADER"` 23 | Height int `help:"Maximum number of files to display" default:"10" env:"GUM_FILE_HEIGHT"` 24 | 25 | CursorStyle style.Styles `embed:"" prefix:"cursor." help:"The cursor style" set:"defaultForeground=212" envprefix:"GUM_FILE_CURSOR_"` 26 | SymlinkStyle style.Styles `embed:"" prefix:"symlink." help:"The style to use for symlinks" set:"defaultForeground=36" envprefix:"GUM_FILE_SYMLINK_"` 27 | DirectoryStyle style.Styles `embed:"" prefix:"directory." help:"The style to use for directories" set:"defaultForeground=99" envprefix:"GUM_FILE_DIRECTORY_"` 28 | FileStyle style.Styles `embed:"" prefix:"file." help:"The style to use for files" envprefix:"GUM_FILE_FILE_"` 29 | PermissionsStyle style.Styles `embed:"" prefix:"permissions." help:"The style to use for permissions" set:"defaultForeground=244" envprefix:"GUM_FILE_PERMISSIONS_"` 30 | SelectedStyle style.Styles `embed:"" prefix:"selected." help:"The style to use for the selected item" set:"defaultBold=true" set:"defaultForeground=212" envprefix:"GUM_FILE_SELECTED_"` //nolint:staticcheck 31 | FileSizeStyle style.Styles `embed:"" prefix:"file-size." help:"The style to use for file sizes" set:"defaultWidth=8" set:"defaultAlign=right" set:"defaultForeground=240" envprefix:"GUM_FILE_FILE_SIZE_"` //nolint:staticcheck 32 | HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_FILE_HEADER_"` 33 | } 34 | -------------------------------------------------------------------------------- /filter/command.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "slices" 8 | "strings" 9 | 10 | "github.com/charmbracelet/bubbles/help" 11 | "github.com/charmbracelet/bubbles/textinput" 12 | "github.com/charmbracelet/bubbles/viewport" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/gum/internal/files" 15 | "github.com/charmbracelet/gum/internal/stdin" 16 | "github.com/charmbracelet/gum/internal/timeout" 17 | "github.com/charmbracelet/gum/internal/tty" 18 | "github.com/charmbracelet/x/ansi" 19 | "github.com/sahilm/fuzzy" 20 | ) 21 | 22 | // Run provides a shell script interface for filtering through options, powered 23 | // by the textinput bubble. 24 | func (o Options) Run() error { 25 | i := textinput.New() 26 | i.Focus() 27 | 28 | i.Prompt = o.Prompt 29 | i.PromptStyle = o.PromptStyle.ToLipgloss() 30 | i.PlaceholderStyle = o.PlaceholderStyle.ToLipgloss() 31 | i.Placeholder = o.Placeholder 32 | i.Width = o.Width 33 | 34 | v := viewport.New(o.Width, o.Height) 35 | 36 | if len(o.Options) == 0 { 37 | if input, _ := stdin.Read(stdin.StripANSI(o.StripANSI)); input != "" { 38 | o.Options = strings.Split(input, o.InputDelimiter) 39 | } else { 40 | o.Options = files.List() 41 | } 42 | } 43 | 44 | if len(o.Options) == 0 { 45 | return errors.New("no options provided, see `gum filter --help`") 46 | } 47 | 48 | ctx, cancel := timeout.Context(o.Timeout) 49 | defer cancel() 50 | 51 | options := []tea.ProgramOption{ 52 | tea.WithOutput(os.Stderr), 53 | tea.WithReportFocus(), 54 | tea.WithContext(ctx), 55 | } 56 | if o.Height == 0 { 57 | options = append(options, tea.WithAltScreen()) 58 | } 59 | 60 | var matches []fuzzy.Match 61 | if o.Value != "" { 62 | i.SetValue(o.Value) 63 | } 64 | 65 | choices := map[string]string{} 66 | filteringChoices := []string{} 67 | for _, opt := range o.Options { 68 | s := ansi.Strip(opt) 69 | choices[s] = opt 70 | filteringChoices = append(filteringChoices, s) 71 | } 72 | switch { 73 | case o.Value != "" && o.Fuzzy: 74 | matches = fuzzy.Find(o.Value, filteringChoices) 75 | case o.Value != "" && !o.Fuzzy: 76 | matches = exactMatches(o.Value, filteringChoices) 77 | default: 78 | matches = matchAll(filteringChoices) 79 | } 80 | 81 | if o.NoLimit { 82 | o.Limit = len(o.Options) 83 | } 84 | 85 | if o.SelectIfOne && len(matches) == 1 { 86 | tty.Println(matches[0].Str) 87 | return nil 88 | } 89 | 90 | km := defaultKeymap() 91 | if o.NoLimit || o.Limit > 1 { 92 | km.Toggle.SetEnabled(true) 93 | km.ToggleAndPrevious.SetEnabled(true) 94 | km.ToggleAndNext.SetEnabled(true) 95 | km.ToggleAll.SetEnabled(true) 96 | } 97 | 98 | m := model{ 99 | choices: choices, 100 | filteringChoices: filteringChoices, 101 | indicator: o.Indicator, 102 | matches: matches, 103 | header: o.Header, 104 | textinput: i, 105 | viewport: &v, 106 | indicatorStyle: o.IndicatorStyle.ToLipgloss(), 107 | selectedPrefixStyle: o.SelectedPrefixStyle.ToLipgloss(), 108 | selectedPrefix: o.SelectedPrefix, 109 | unselectedPrefixStyle: o.UnselectedPrefixStyle.ToLipgloss(), 110 | unselectedPrefix: o.UnselectedPrefix, 111 | matchStyle: o.MatchStyle.ToLipgloss(), 112 | headerStyle: o.HeaderStyle.ToLipgloss(), 113 | textStyle: o.TextStyle.ToLipgloss(), 114 | cursorTextStyle: o.CursorTextStyle.ToLipgloss(), 115 | height: o.Height, 116 | selected: make(map[string]struct{}), 117 | limit: o.Limit, 118 | reverse: o.Reverse, 119 | fuzzy: o.Fuzzy, 120 | sort: o.Sort && o.FuzzySort, 121 | strict: o.Strict, 122 | showHelp: o.ShowHelp, 123 | keymap: km, 124 | help: help.New(), 125 | } 126 | 127 | isSelectAll := len(o.Selected) == 1 && o.Selected[0] == "*" 128 | currentSelected := 0 129 | if len(o.Selected) > 0 { 130 | for i, option := range matches { 131 | if currentSelected >= o.Limit || (!isSelectAll && !slices.Contains(o.Selected, option.Str)) { 132 | continue 133 | } 134 | if o.Limit == 1 { 135 | m.cursor = i 136 | m.selected[option.Str] = struct{}{} 137 | } else { 138 | currentSelected++ 139 | m.selected[option.Str] = struct{}{} 140 | } 141 | } 142 | } 143 | 144 | tm, err := tea.NewProgram(m, options...).Run() 145 | if err != nil { 146 | return fmt.Errorf("unable to run filter: %w", err) 147 | } 148 | 149 | m = tm.(model) 150 | if !m.submitted { 151 | return errors.New("nothing selected") 152 | } 153 | 154 | // allSelections contains values only if limit is greater 155 | // than 1 or if flag --no-limit is passed, hence there is 156 | // no need to further checks 157 | if len(m.selected) > 0 { 158 | o.checkSelected(m) 159 | } else if len(m.matches) > m.cursor && m.cursor >= 0 { 160 | tty.Println(m.matches[m.cursor].Str) 161 | } 162 | 163 | return nil 164 | } 165 | 166 | func (o Options) checkSelected(m model) { 167 | out := []string{} 168 | for k := range m.selected { 169 | out = append(out, k) 170 | } 171 | tty.Println(strings.Join(out, o.OutputDelimiter)) 172 | } 173 | -------------------------------------------------------------------------------- /filter/filter_test.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/charmbracelet/x/ansi" 8 | ) 9 | 10 | func TestMatchedRanges(t *testing.T) { 11 | for name, tt := range map[string]struct { 12 | in []int 13 | out [][2]int 14 | }{ 15 | "empty": { 16 | in: []int{}, 17 | out: [][2]int{}, 18 | }, 19 | "one char": { 20 | in: []int{1}, 21 | out: [][2]int{{1, 1}}, 22 | }, 23 | "2 char range": { 24 | in: []int{1, 2}, 25 | out: [][2]int{{1, 2}}, 26 | }, 27 | "multiple char range": { 28 | in: []int{1, 2, 3, 4, 5, 6}, 29 | out: [][2]int{{1, 6}}, 30 | }, 31 | "multiple char ranges": { 32 | in: []int{1, 2, 3, 5, 6, 10, 11, 12, 13, 23, 24, 40, 42, 43, 45, 52}, 33 | out: [][2]int{{1, 3}, {5, 6}, {10, 13}, {23, 24}, {40, 40}, {42, 43}, {45, 45}, {52, 52}}, 34 | }, 35 | } { 36 | t.Run(name, func(t *testing.T) { 37 | match := matchedRanges(tt.in) 38 | if !reflect.DeepEqual(match, tt.out) { 39 | t.Errorf("expected %v, got %v", tt.out, match) 40 | } 41 | }) 42 | } 43 | } 44 | 45 | func TestByteToChar(t *testing.T) { 46 | stStr := "\x1b[90m\ue615\x1b[39m \x1b[3m\x1b[32mDow\x1b[0m\x1b[90m\x1b[39m\x1b[3wnloads" 47 | str := " Downloads" 48 | rng := [2]int{4, 7} 49 | expect := "Dow" 50 | 51 | if got := str[rng[0]:rng[1]]; got != expect { 52 | t.Errorf("expected %q, got %q", expect, got) 53 | } 54 | 55 | start, stop := bytePosToVisibleCharPos(str, rng) 56 | if got := ansi.Strip(ansi.Cut(stStr, start, stop)); got != expect { 57 | t.Errorf("expected %+q, got %+q", expect, got) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /filter/options.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/gum/style" 7 | ) 8 | 9 | // Options is the customization options for the filter command. 10 | type Options struct { 11 | Options []string `arg:"" optional:"" help:"Options to filter."` 12 | 13 | Indicator string `help:"Character for selection" default:"•" env:"GUM_FILTER_INDICATOR"` 14 | IndicatorStyle style.Styles `embed:"" prefix:"indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_INDICATOR_"` 15 | Limit int `help:"Maximum number of options to pick" default:"1" group:"Selection"` 16 | NoLimit bool `help:"Pick unlimited number of options (ignores limit)" group:"Selection"` 17 | SelectIfOne bool `help:"Select the given option if there is only one" group:"Selection"` 18 | Selected []string `help:"Options that should start as selected (selects all if given *)" default:"" env:"GUM_FILTER_SELECTED"` 19 | ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_FILTER_SHOW_HELP"` 20 | Strict bool `help:"Only returns if anything matched. Otherwise return Filter" negatable:"" default:"true" group:"Selection"` 21 | SelectedPrefix string `help:"Character to indicate selected items (hidden if limit is 1)" default:" ◉ " env:"GUM_FILTER_SELECTED_PREFIX"` 22 | SelectedPrefixStyle style.Styles `embed:"" prefix:"selected-indicator." set:"defaultForeground=212" envprefix:"GUM_FILTER_SELECTED_PREFIX_"` 23 | UnselectedPrefix string `help:"Character to indicate unselected items (hidden if limit is 1)" default:" ○ " env:"GUM_FILTER_UNSELECTED_PREFIX"` 24 | UnselectedPrefixStyle style.Styles `embed:"" prefix:"unselected-prefix." set:"defaultForeground=240" envprefix:"GUM_FILTER_UNSELECTED_PREFIX_"` 25 | HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=99" envprefix:"GUM_FILTER_HEADER_"` 26 | Header string `help:"Header value" default:"" env:"GUM_FILTER_HEADER"` 27 | TextStyle style.Styles `embed:"" prefix:"text." envprefix:"GUM_FILTER_TEXT_"` 28 | CursorTextStyle style.Styles `embed:"" prefix:"cursor-text." envprefix:"GUM_FILTER_CURSOR_TEXT_"` 29 | MatchStyle style.Styles `embed:"" prefix:"match." set:"defaultForeground=212" envprefix:"GUM_FILTER_MATCH_"` 30 | Placeholder string `help:"Placeholder value" default:"Filter..." env:"GUM_FILTER_PLACEHOLDER"` 31 | Prompt string `help:"Prompt to display" default:"> " env:"GUM_FILTER_PROMPT"` 32 | PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=240" envprefix:"GUM_FILTER_PROMPT_"` 33 | PlaceholderStyle style.Styles `embed:"" prefix:"placeholder." set:"defaultForeground=240" envprefix:"GUM_FILTER_PLACEHOLDER_"` 34 | Width int `help:"Input width" default:"0" env:"GUM_FILTER_WIDTH"` 35 | Height int `help:"Input height" default:"0" env:"GUM_FILTER_HEIGHT"` 36 | Value string `help:"Initial filter value" default:"" env:"GUM_FILTER_VALUE"` 37 | Reverse bool `help:"Display from the bottom of the screen" env:"GUM_FILTER_REVERSE"` 38 | Fuzzy bool `help:"Enable fuzzy matching; otherwise match from start of word" default:"true" env:"GUM_FILTER_FUZZY" negatable:""` 39 | FuzzySort bool `help:"Sort fuzzy results by their scores" default:"true" env:"GUM_FILTER_FUZZY_SORT" negatable:""` 40 | Timeout time.Duration `help:"Timeout until filter command aborts" default:"0s" env:"GUM_FILTER_TIMEOUT"` 41 | InputDelimiter string `help:"Option delimiter when reading from STDIN" default:"\n" env:"GUM_FILTER_INPUT_DELIMITER"` 42 | OutputDelimiter string `help:"Option delimiter when writing to STDOUT" default:"\n" env:"GUM_FILTER_OUTPUT_DELIMITER"` 43 | StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_FILTER_STRIP_ANSI"` 44 | 45 | // Deprecated: use [FuzzySort]. This will be removed at some point. 46 | Sort bool `help:"Sort fuzzy results by their scores" default:"true" env:"GUM_FILTER_FUZZY_SORT" negatable:"" hidden:""` 47 | } 48 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1737062831, 24 | "narHash": "sha256-Tbk1MZbtV2s5aG+iM99U8FqwxU/YNArMcWAv6clcsBc=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "5df43628fdf08d642be8ba5b3625a6c70731c19c", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A tool for glamorous shell scripts"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, flake-utils }: 10 | flake-utils.lib.eachDefaultSystem (system: 11 | let pkgs = import nixpkgs { inherit system; }; in 12 | rec { 13 | packages.default = import ./default.nix { inherit pkgs; }; 14 | }) // { 15 | overlays.default = final: prev: { 16 | gum = import ./default.nix { pkgs = final; }; 17 | }; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /format/README.md: -------------------------------------------------------------------------------- 1 | # Gum Format 2 | 3 | Gum format allows you to format different text into human readable output. 4 | 5 | Four different parse-able formats exist: 6 | 7 | 1. [Markdown](#markdown) 8 | 2. [Code](#code) 9 | 3. [Template](#template) 10 | 4. [Emoji](#emoji) 11 | 12 | ## Markdown 13 | 14 | Render any input as markdown text. This uses 15 | [Glamour](https://github.com/charmbracelet/glamour) behind the scenes. 16 | 17 | You can pass input as lines directly as arguments to the command invocation or 18 | pass markdown over `stdin`. 19 | 20 | ```bash 21 | gum format --type markdown < README.md 22 | # Or, directly as arguments (useful for quick lists) 23 | gum format --type markdown -- "# Gum Formats" "- Markdown" "- Code" "- Template" "- Emoji" 24 | ``` 25 | 26 | ## Code 27 | 28 | Render any code snippet with syntax highlighting. 29 | [Glamour](https://github.com/charmbracelet/glamour), which uses 30 | [Chroma](https://github.com/alecthomas/chroma) under the hood, handles styling. 31 | 32 | Similarly to the `markdown` format, `code` can take input over `stdin`. 33 | 34 | ```bash 35 | cat options.go | gum format --type code 36 | ``` 37 | 38 | ## Template 39 | 40 | Render styled input from a string template. Templating is handled by 41 | [Termenv](https://github.com/muesli/termenv). 42 | 43 | ```bash 44 | gum format --type template '{{ Bold "Tasty" }} {{ Italic "Bubble" }} {{ Color "99" "0" " Gum " }}' 45 | # Or, via stdin 46 | echo '{{ Bold "Tasty" }} {{ Italic "Bubble" }} {{ Color "99" "0" " Gum " }}' | gum format --type template 47 | ``` 48 | 49 | ## Emoji 50 | 51 | Parse and render emojis from their matching `:name:`s. Powered by 52 | [Glamour](https://github.com/charmbracelet/glamour) and [Goldmark 53 | Emoji](https://github.com/yuin/goldmark-emoji) 54 | 55 | ```bash 56 | gum format --type emoji 'I :heart: Bubble Gum :candy:' 57 | # You know the drill, also via stdin 58 | echo 'I :heart: Bubble Gum :candy:' | gum format --type emoji 59 | ``` 60 | 61 | ## Tables 62 | 63 | Tables are rendered using [Glamour](https://github.com/charmbracelet/glamour). 64 | 65 | | Bubble Gum Flavor | Price | 66 | | ----------------- | ----- | 67 | | Strawberry | $0.99 | 68 | | Cherry | $0.50 | 69 | | Banana | $0.75 | 70 | | Orange | $0.25 | 71 | | Lemon | $0.50 | 72 | | Lime | $0.50 | 73 | | Grape | $0.50 | 74 | | Watermelon | $0.50 | 75 | | Pineapple | $0.50 | 76 | | Blueberry | $0.50 | 77 | | Raspberry | $0.50 | 78 | | Cranberry | $0.50 | 79 | | Peach | $0.50 | 80 | | Apple | $0.50 | 81 | | Mango | $0.50 | 82 | | Pomegranate | $0.50 | 83 | | Coconut | $0.50 | 84 | | Cinnamon | $0.50 | 85 | -------------------------------------------------------------------------------- /format/command.go: -------------------------------------------------------------------------------- 1 | // Package format allows you to render formatted text from the command line. 2 | // 3 | // It supports the following types: 4 | // 5 | // 1. Markdown 6 | // 2. Code 7 | // 3. Emoji 8 | // 4. Template 9 | // 10 | // For more information, see the format/README.md file. 11 | package format 12 | 13 | import ( 14 | "fmt" 15 | "strings" 16 | 17 | "github.com/charmbracelet/gum/internal/stdin" 18 | ) 19 | 20 | // Run runs the format command. 21 | func (o Options) Run() error { 22 | var input, output string 23 | var err error 24 | if len(o.Template) > 0 { 25 | input = strings.Join(o.Template, "\n") 26 | } else { 27 | input, _ = stdin.Read(stdin.StripANSI(o.StripANSI)) 28 | } 29 | 30 | switch o.Type { 31 | case "code": 32 | output, err = code(input, o.Language) 33 | case "emoji": 34 | output, err = emoji(input) 35 | case "template": 36 | output, err = template(input) 37 | default: 38 | output, err = markdown(input, o.Theme) 39 | } 40 | if err != nil { 41 | return err 42 | } 43 | 44 | fmt.Print(output) 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /format/formats.go: -------------------------------------------------------------------------------- 1 | package format 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | tpl "text/template" 7 | 8 | "github.com/charmbracelet/glamour" 9 | "github.com/muesli/termenv" 10 | ) 11 | 12 | func code(input, language string) (string, error) { 13 | renderer, err := glamour.NewTermRenderer( 14 | glamour.WithAutoStyle(), 15 | glamour.WithWordWrap(0), 16 | ) 17 | if err != nil { 18 | return "", fmt.Errorf("unable to create renderer: %w", err) 19 | } 20 | output, err := renderer.Render(fmt.Sprintf("```%s\n%s\n```", language, input)) 21 | if err != nil { 22 | return "", fmt.Errorf("unable to render: %w", err) 23 | } 24 | return output, nil 25 | } 26 | 27 | func emoji(input string) (string, error) { 28 | renderer, err := glamour.NewTermRenderer( 29 | glamour.WithEmoji(), 30 | ) 31 | if err != nil { 32 | return "", fmt.Errorf("unable to create renderer: %w", err) 33 | } 34 | output, err := renderer.Render(input) 35 | if err != nil { 36 | return "", fmt.Errorf("unable to render: %w", err) 37 | } 38 | return output, nil 39 | } 40 | 41 | func markdown(input string, theme string) (string, error) { 42 | renderer, err := glamour.NewTermRenderer( 43 | glamour.WithStylePath(theme), 44 | glamour.WithWordWrap(0), 45 | ) 46 | if err != nil { 47 | return "", fmt.Errorf("unable to render: %w", err) 48 | } 49 | output, err := renderer.Render(input) 50 | if err != nil { 51 | return "", fmt.Errorf("unable to render: %w", err) 52 | } 53 | return output, nil 54 | } 55 | 56 | func template(input string) (string, error) { 57 | f := termenv.TemplateFuncs(termenv.ANSI256) 58 | t, err := tpl.New("tpl").Funcs(f).Parse(input) 59 | if err != nil { 60 | return "", fmt.Errorf("unable to parse template: %w", err) 61 | } 62 | 63 | var buf bytes.Buffer 64 | err = t.Execute(&buf, nil) 65 | return buf.String(), err 66 | } 67 | -------------------------------------------------------------------------------- /format/options.go: -------------------------------------------------------------------------------- 1 | package format 2 | 3 | // Options is customization options for the format command. 4 | type Options struct { 5 | Template []string `arg:"" optional:"" help:"Template string to format (can also be provided via stdin)"` 6 | Theme string `help:"Glamour theme to use for markdown formatting" default:"pink" env:"GUM_FORMAT_THEME"` 7 | Language string `help:"Programming language to parse code" short:"l" default:"" env:"GUM_FORMAT_LANGUAGE"` 8 | 9 | StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_FORMAT_STRIP_ANSI"` 10 | 11 | Type string `help:"Format to use (markdown,template,code,emoji)" enum:"markdown,template,code,emoji" short:"t" default:"markdown" env:"GUM_FORMAT_TYPE"` 12 | } 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/charmbracelet/gum 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/Masterminds/semver/v3 v3.3.1 9 | github.com/alecthomas/kong v1.11.0 10 | github.com/alecthomas/mango-kong v0.1.0 11 | github.com/charmbracelet/bubbles v0.21.0 12 | github.com/charmbracelet/bubbletea v1.3.5 13 | github.com/charmbracelet/glamour v0.10.0 14 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 15 | github.com/charmbracelet/log v0.4.2 16 | github.com/charmbracelet/x/ansi v0.9.2 17 | github.com/charmbracelet/x/editor v0.1.0 18 | github.com/charmbracelet/x/term v0.2.1 19 | github.com/charmbracelet/x/xpty v0.1.2 20 | github.com/muesli/roff v0.1.0 21 | github.com/muesli/termenv v0.16.0 22 | github.com/rivo/uniseg v0.4.7 23 | github.com/sahilm/fuzzy v0.1.1 24 | golang.org/x/text v0.25.0 25 | ) 26 | 27 | require ( 28 | github.com/alecthomas/chroma/v2 v2.14.0 // indirect 29 | github.com/atotto/clipboard v0.1.4 // indirect 30 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 31 | github.com/aymerick/douceur v0.2.0 // indirect 32 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 33 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 34 | github.com/charmbracelet/x/conpty v0.1.0 // indirect 35 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect 36 | github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect 37 | github.com/charmbracelet/x/termios v0.1.1 // indirect 38 | github.com/creack/pty v1.1.24 // indirect 39 | github.com/dlclark/regexp2 v1.11.0 // indirect 40 | github.com/dustin/go-humanize v1.0.1 // indirect 41 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 42 | github.com/go-logfmt/logfmt v0.6.0 // indirect 43 | github.com/gorilla/css v1.0.1 // indirect 44 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 45 | github.com/mattn/go-isatty v0.0.20 // indirect 46 | github.com/mattn/go-localereader v0.0.1 // indirect 47 | github.com/mattn/go-runewidth v0.0.16 // indirect 48 | github.com/microcosm-cc/bluemonday v1.0.27 // indirect 49 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 50 | github.com/muesli/cancelreader v0.2.2 // indirect 51 | github.com/muesli/mango v0.2.0 // indirect 52 | github.com/muesli/reflow v0.3.0 // indirect 53 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 54 | github.com/yuin/goldmark v1.7.8 // indirect 55 | github.com/yuin/goldmark-emoji v1.0.5 // indirect 56 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect 57 | golang.org/x/net v0.40.0 // indirect 58 | golang.org/x/sync v0.14.0 // indirect 59 | golang.org/x/sys v0.33.0 // indirect 60 | golang.org/x/term v0.32.0 // indirect 61 | ) 62 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 2 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 3 | github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= 4 | github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 5 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 6 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 7 | github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= 8 | github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= 9 | github.com/alecthomas/kong v1.11.0 h1:y++1gI7jf8O7G7l4LZo5ASFhrhJvzc+WgF/arranEmM= 10 | github.com/alecthomas/kong v1.11.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= 11 | github.com/alecthomas/mango-kong v0.1.0 h1:iFVfP1k1K4qpml3JUQmD5I8MCQYfIvsD9mRdrw7jJC4= 12 | github.com/alecthomas/mango-kong v0.1.0/go.mod h1:t+TYVdsONUolf/BwVcm+15eqcdAj15h4Qe9MMFAwwT4= 13 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 14 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 15 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 16 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 18 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 19 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 20 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 21 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 22 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 23 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 24 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 25 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= 26 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= 27 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 28 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 29 | github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= 30 | github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= 31 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= 32 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= 33 | github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 34 | github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 35 | github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY= 36 | github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 37 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 38 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 39 | github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= 40 | github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= 41 | github.com/charmbracelet/x/editor v0.1.0 h1:p69/dpvlwRTs9uYiPeAWruwsHqTFzHhTvQOd/WVSX98= 42 | github.com/charmbracelet/x/editor v0.1.0/go.mod h1:oivrEbcP/AYt/Hpvk5pwDXXrQ933gQS6UzL6fxqAGSA= 43 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= 44 | github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 45 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 46 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 47 | github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= 48 | github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= 49 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 50 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 51 | github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= 52 | github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 53 | github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= 54 | github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= 55 | github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 56 | github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 57 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 58 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 59 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 60 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 61 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 62 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 63 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 64 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 65 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 66 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 67 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 68 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 69 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 70 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 71 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 72 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 73 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 74 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 75 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 76 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 77 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 78 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 79 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 80 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 81 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 82 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 83 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 84 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 85 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 86 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 87 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 88 | github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= 89 | github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= 90 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 91 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 92 | github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= 93 | github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 94 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 95 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 96 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 97 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 98 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 99 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 100 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 101 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 102 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 103 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 104 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 105 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 106 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 107 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 108 | github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 109 | github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= 110 | github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 111 | github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= 112 | github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 113 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= 114 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= 115 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 116 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 117 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 118 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 119 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 120 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 121 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 122 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 123 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 124 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 125 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 126 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 127 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 128 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 129 | -------------------------------------------------------------------------------- /gum.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/alecthomas/kong" 5 | 6 | "github.com/charmbracelet/gum/choose" 7 | "github.com/charmbracelet/gum/completion" 8 | "github.com/charmbracelet/gum/confirm" 9 | "github.com/charmbracelet/gum/file" 10 | "github.com/charmbracelet/gum/filter" 11 | "github.com/charmbracelet/gum/format" 12 | "github.com/charmbracelet/gum/input" 13 | "github.com/charmbracelet/gum/join" 14 | "github.com/charmbracelet/gum/log" 15 | "github.com/charmbracelet/gum/man" 16 | "github.com/charmbracelet/gum/pager" 17 | "github.com/charmbracelet/gum/spin" 18 | "github.com/charmbracelet/gum/style" 19 | "github.com/charmbracelet/gum/table" 20 | "github.com/charmbracelet/gum/version" 21 | "github.com/charmbracelet/gum/write" 22 | ) 23 | 24 | // Gum is the command-line interface for Gum. 25 | type Gum struct { 26 | // Version is a flag that can be used to display the version number. 27 | Version kong.VersionFlag `short:"v" help:"Print the version number"` 28 | 29 | // Completion generates Gum shell completion scripts. 30 | Completion completion.Completion `cmd:"" hidden:"" help:"Request shell completion"` 31 | 32 | // Man is a hidden command that generates Gum man pages. 33 | Man man.Man `cmd:"" hidden:"" help:"Generate man pages"` 34 | 35 | // Choose provides an interface to choose one option from a given list of 36 | // options. The options can be provided as (new-line separated) stdin or a 37 | // list of arguments. 38 | // 39 | // It is different from the filter command as it does not provide a fuzzy 40 | // finding input, so it is best used for smaller lists of options. 41 | // 42 | // Let's pick from a list of gum flavors: 43 | // 44 | // $ gum choose "Strawberry" "Banana" "Cherry" 45 | // 46 | Choose choose.Options `cmd:"" help:"Choose an option from a list of choices"` 47 | 48 | // Confirm provides an interface to ask a user to confirm an action. 49 | // The user is provided with an interface to choose an affirmative or 50 | // negative answer, which is then reflected in the exit code for use in 51 | // scripting. 52 | // 53 | // If the user selects the affirmative answer, the program exits with 0. 54 | // If the user selects the negative answer, the program exits with 1. 55 | // 56 | // I.e. confirm if the user wants to delete a file 57 | // 58 | // $ gum confirm "Are you sure?" && rm file.txt 59 | // 60 | Confirm confirm.Options `cmd:"" help:"Ask a user to confirm an action"` 61 | 62 | // File provides an interface to pick a file from a folder (tree). 63 | // The user is provided a file manager-like interface to navigate, to 64 | // select a file. 65 | // 66 | // Let's pick a file from the current directory: 67 | // 68 | // $ gum file 69 | // $ gum file . 70 | // 71 | // Let's pick a file from the home directory: 72 | // 73 | // $ gum file $HOME 74 | File file.Options `cmd:"" help:"Pick a file from a folder"` 75 | 76 | // Filter provides a fuzzy searching text input to allow filtering a list of 77 | // options to select one option. 78 | // 79 | // By default it will list all the files (recursively) in the current directory 80 | // for the user to choose one, but the script (or user) can provide different 81 | // new-line separated options to choose from. 82 | // 83 | // I.e. let's pick from a list of gum flavors: 84 | // 85 | // $ cat flavors.text | gum filter 86 | // 87 | Filter filter.Options `cmd:"" help:"Filter items from a list"` 88 | 89 | // Format allows you to render styled text from `markdown`, `code`, 90 | // `template` strings, or embedded `emoji` strings. 91 | // For more information see the format/README.md file. 92 | Format format.Options `cmd:"" help:"Format a string using a template"` 93 | 94 | // Input provides a shell script interface for the text input bubble. 95 | // https://github.com/charmbracelet/bubbles/tree/master/textinput 96 | // 97 | // It can be used to prompt the user for some input. The text the user 98 | // entered will be sent to stdout. 99 | // 100 | // $ gum input --placeholder "What's your favorite gum?" > answer.text 101 | // 102 | Input input.Options `cmd:"" help:"Prompt for some input"` 103 | 104 | // Join provides a shell script interface for the lipgloss JoinHorizontal 105 | // and JoinVertical commands. It allows you to join multi-line text to 106 | // build different layouts. 107 | // 108 | // For example, you can place two bordered boxes next to each other: 109 | // Note: We wrap the variable in quotes to ensure the new lines are part of a 110 | // single argument. Otherwise, the command won't work as expected. 111 | // 112 | // $ gum join --horizontal "$BUBBLE_BOX" "$GUM_BOX" 113 | // 114 | // ╔══════════════════════╗╔═════════════╗ 115 | // ║ ║║ ║ 116 | // ║ Bubble ║║ Gum ║ 117 | // ║ ║║ ║ 118 | // ╚══════════════════════╝╚═════════════╝ 119 | // 120 | Join join.Options `cmd:"" help:"Join text vertically or horizontally"` 121 | 122 | // Pager provides a shell script interface for the viewport bubble. 123 | // https://github.com/charmbracelet/bubbles/tree/master/viewport 124 | // 125 | // It allows the user to scroll through content like a pager. 126 | // 127 | // ╭────────────────────────────────────────────────╮ 128 | // │ 1 │ Gum Pager │ 129 | // │ 2 │ ========= │ 130 | // │ 3 │ │ 131 | // │ 4 │ ``` │ 132 | // │ 5 │ gum pager --height 10 --width 25 < text │ 133 | // │ 6 │ ``` │ 134 | // │ 7 │ │ 135 | // │ 8 │ │ 136 | // ╰────────────────────────────────────────────────╯ 137 | // ↓↑: navigate • q: quit 138 | // 139 | Pager pager.Options `cmd:"" help:"Scroll through a file"` 140 | 141 | // Spin provides a shell script interface for the spinner bubble. 142 | // https://github.com/charmbracelet/bubbles/tree/master/spinner 143 | // 144 | // It is useful for displaying that some task is running in the background 145 | // while consuming it's output so that it is not shown to the user. 146 | // 147 | // For example, let's do a long running task: $ sleep 5 148 | // 149 | // We can simply prepend a spinner to this task to show it to the user, 150 | // while performing the task / command in the background. 151 | // 152 | // $ gum spin -t "Taking a nap..." -- sleep 5 153 | // 154 | // The spinner will automatically exit when the task is complete. 155 | // 156 | Spin spin.Options `cmd:"" help:"Display spinner while running a command"` 157 | 158 | // Style provides a shell script interface for Lip Gloss. 159 | // https://github.com/charmbracelet/lipgloss 160 | // 161 | // It allows you to use Lip Gloss to style text without needing to use Go. 162 | // All of the styling options are available as flags. 163 | // 164 | // Let's make some text glamorous using bash: 165 | // 166 | // $ gum style \ 167 | // --foreground 212 --border double --align center \ 168 | // --width 50 --margin 2 --padding "2 4" \ 169 | // "Bubble Gum (1¢)" "So sweet and so fresh\!" 170 | // 171 | // 172 | // ╔══════════════════════════════════════════════════╗ 173 | // ║ ║ 174 | // ║ ║ 175 | // ║ Bubble Gum (1¢) ║ 176 | // ║ So sweet and so fresh! ║ 177 | // ║ ║ 178 | // ║ ║ 179 | // ╚══════════════════════════════════════════════════╝ 180 | // 181 | Style style.Options `cmd:"" help:"Apply coloring, borders, spacing to text"` 182 | 183 | // Table provides a shell script interface for the table bubble. 184 | // https://github.com/charmbracelet/bubbles/tree/master/table 185 | // 186 | // It is useful to render tabular (CSV) data in a terminal and allows 187 | // the user to select a row from the table. 188 | // 189 | // Let's render a table of gum flavors: 190 | // 191 | // $ gum table <<< "Flavor,Price\nStrawberry,$0.50\nBanana,$0.99\nCherry,$0.75" 192 | // 193 | // Flavor Price 194 | // Strawberry $0.50 195 | // Banana $0.99 196 | // Cherry $0.75 197 | // 198 | Table table.Options `cmd:"" help:"Render a table of data"` 199 | 200 | // Write provides a shell script interface for the text area bubble. 201 | // https://github.com/charmbracelet/bubbles/tree/master/textarea 202 | // 203 | // It can be used to ask the user to write some long form of text 204 | // (multi-line) input. The text the user entered will be sent to stdout. 205 | // 206 | // $ gum write > output.text 207 | // 208 | Write write.Options `cmd:"" help:"Prompt for long-form text"` 209 | 210 | // Log provides a shell script interface for logging using Log. 211 | // https://github.com/charmbracelet/log 212 | // 213 | // It can be used to log messages to output. 214 | // 215 | // $ gum log --level info "Hello, world!" 216 | // 217 | Log log.Options `cmd:"" help:"Log messages to output"` 218 | 219 | // VersionCheck provides a command that checks if the current gum version 220 | // matches a given semantic version constraint. 221 | // 222 | // It can be used to check that a minimum gum version is installed in a 223 | // script. 224 | // 225 | // $ gum version-check '~> 0.15' 226 | // 227 | VersionCheck version.Options `cmd:"" help:"Semver check current gum version"` 228 | } 229 | -------------------------------------------------------------------------------- /input/command.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/charmbracelet/bubbles/help" 9 | "github.com/charmbracelet/bubbles/textinput" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/gum/cursor" 12 | "github.com/charmbracelet/gum/internal/stdin" 13 | "github.com/charmbracelet/gum/internal/timeout" 14 | ) 15 | 16 | // Run provides a shell script interface for the text input bubble. 17 | // https://github.com/charmbracelet/bubbles/textinput 18 | func (o Options) Run() error { 19 | if o.Value == "" { 20 | if in, _ := stdin.Read(stdin.StripANSI(o.StripANSI)); in != "" { 21 | o.Value = in 22 | } 23 | } 24 | 25 | i := textinput.New() 26 | if o.Value != "" { 27 | i.SetValue(o.Value) 28 | } else if in, _ := stdin.Read(stdin.StripANSI(o.StripANSI)); in != "" { 29 | i.SetValue(in) 30 | } 31 | i.Focus() 32 | i.Prompt = o.Prompt 33 | i.Placeholder = o.Placeholder 34 | i.Width = o.Width 35 | i.PromptStyle = o.PromptStyle.ToLipgloss() 36 | i.PlaceholderStyle = o.PlaceholderStyle.ToLipgloss() 37 | i.Cursor.Style = o.CursorStyle.ToLipgloss() 38 | i.Cursor.SetMode(cursor.Modes[o.CursorMode]) 39 | i.CharLimit = o.CharLimit 40 | 41 | if o.Password { 42 | i.EchoMode = textinput.EchoPassword 43 | i.EchoCharacter = '•' 44 | } 45 | 46 | m := model{ 47 | textinput: i, 48 | header: o.Header, 49 | headerStyle: o.HeaderStyle.ToLipgloss(), 50 | autoWidth: o.Width < 1, 51 | showHelp: o.ShowHelp, 52 | help: help.New(), 53 | keymap: defaultKeymap(), 54 | } 55 | 56 | ctx, cancel := timeout.Context(o.Timeout) 57 | defer cancel() 58 | 59 | p := tea.NewProgram( 60 | m, 61 | tea.WithOutput(os.Stderr), 62 | tea.WithReportFocus(), 63 | tea.WithContext(ctx), 64 | ) 65 | tm, err := p.Run() 66 | if err != nil { 67 | return fmt.Errorf("failed to run input: %w", err) 68 | } 69 | 70 | m = tm.(model) 71 | if !m.submitted { 72 | return errors.New("not submitted") 73 | } 74 | fmt.Println(m.textinput.Value()) 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /input/input.go: -------------------------------------------------------------------------------- 1 | // Package input provides a shell script interface for the text input bubble. 2 | // https://github.com/charmbracelet/bubbles/tree/master/textinput 3 | // 4 | // It can be used to prompt the user for some input. The text the user entered 5 | // will be sent to stdout. 6 | // 7 | // $ gum input --placeholder "What's your favorite gum?" > answer.text 8 | package input 9 | 10 | import ( 11 | "github.com/charmbracelet/bubbles/help" 12 | "github.com/charmbracelet/bubbles/key" 13 | "github.com/charmbracelet/bubbles/textinput" 14 | tea "github.com/charmbracelet/bubbletea" 15 | "github.com/charmbracelet/lipgloss" 16 | ) 17 | 18 | type keymap textinput.KeyMap 19 | 20 | func defaultKeymap() keymap { 21 | k := textinput.DefaultKeyMap 22 | return keymap(k) 23 | } 24 | 25 | // FullHelp implements help.KeyMap. 26 | func (k keymap) FullHelp() [][]key.Binding { return nil } 27 | 28 | // ShortHelp implements help.KeyMap. 29 | func (k keymap) ShortHelp() []key.Binding { 30 | return []key.Binding{ 31 | key.NewBinding( 32 | key.WithKeys("enter"), 33 | key.WithHelp("enter", "submit"), 34 | ), 35 | } 36 | } 37 | 38 | type model struct { 39 | autoWidth bool 40 | header string 41 | headerStyle lipgloss.Style 42 | textinput textinput.Model 43 | quitting bool 44 | submitted bool 45 | showHelp bool 46 | help help.Model 47 | keymap keymap 48 | } 49 | 50 | func (m model) Init() tea.Cmd { return textinput.Blink } 51 | 52 | func (m model) View() string { 53 | if m.quitting { 54 | return "" 55 | } 56 | if m.header != "" { 57 | header := m.headerStyle.Render(m.header) 58 | return lipgloss.JoinVertical(lipgloss.Left, header, m.textinput.View()) 59 | } 60 | 61 | if !m.showHelp { 62 | return m.textinput.View() 63 | } 64 | return lipgloss.JoinVertical( 65 | lipgloss.Top, 66 | m.textinput.View(), 67 | "", 68 | m.help.View(m.keymap), 69 | ) 70 | } 71 | 72 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 73 | switch msg := msg.(type) { 74 | case tea.WindowSizeMsg: 75 | if m.autoWidth { 76 | m.textinput.Width = msg.Width - lipgloss.Width(m.textinput.Prompt) - 1 77 | } 78 | case tea.KeyMsg: 79 | switch msg.String() { 80 | case "ctrl+c": 81 | m.quitting = true 82 | return m, tea.Interrupt 83 | case "esc": 84 | m.quitting = true 85 | return m, tea.Quit 86 | case "enter": 87 | m.quitting = true 88 | m.submitted = true 89 | return m, tea.Quit 90 | } 91 | } 92 | 93 | var cmd tea.Cmd 94 | m.textinput, cmd = m.textinput.Update(msg) 95 | return m, cmd 96 | } 97 | -------------------------------------------------------------------------------- /input/options.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/gum/style" 7 | ) 8 | 9 | // Options are the customization options for the input. 10 | type Options struct { 11 | Placeholder string `help:"Placeholder value" default:"Type something..." env:"GUM_INPUT_PLACEHOLDER"` 12 | Prompt string `help:"Prompt to display" default:"> " env:"GUM_INPUT_PROMPT"` 13 | PromptStyle style.Styles `embed:"" prefix:"prompt." envprefix:"GUM_INPUT_PROMPT_"` 14 | PlaceholderStyle style.Styles `embed:"" prefix:"placeholder." set:"defaultForeground=240" envprefix:"GUM_INPUT_PLACEHOLDER_"` 15 | CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_INPUT_CURSOR_"` 16 | CursorMode string `prefix:"cursor." name:"mode" help:"Cursor mode" default:"blink" enum:"blink,hide,static" env:"GUM_INPUT_CURSOR_MODE"` 17 | Value string `help:"Initial value (can also be passed via stdin)" default:""` 18 | CharLimit int `help:"Maximum value length (0 for no limit)" default:"400"` 19 | Width int `help:"Input width (0 for terminal width)" default:"0" env:"GUM_INPUT_WIDTH"` 20 | Password bool `help:"Mask input characters" default:"false"` 21 | ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_INPUT_SHOW_HELP"` 22 | Header string `help:"Header value" default:"" env:"GUM_INPUT_HEADER"` 23 | HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_INPUT_HEADER_"` 24 | Timeout time.Duration `help:"Timeout until input aborts" default:"0s" env:"GUM_INPUT_TIMEOUT"` 25 | StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_INPUT_STRIP_ANSI"` 26 | } 27 | -------------------------------------------------------------------------------- /internal/decode/align.go: -------------------------------------------------------------------------------- 1 | // Package decode position strings to lipgloss. 2 | package decode 3 | 4 | import "github.com/charmbracelet/lipgloss" 5 | 6 | // Align maps strings to `lipgloss.Position`s. 7 | var Align = map[string]lipgloss.Position{ 8 | "center": lipgloss.Center, 9 | "left": lipgloss.Left, 10 | "top": lipgloss.Top, 11 | "bottom": lipgloss.Bottom, 12 | "right": lipgloss.Right, 13 | } 14 | -------------------------------------------------------------------------------- /internal/exit/exit.go: -------------------------------------------------------------------------------- 1 | // Package exit code implementation. 2 | package exit 3 | 4 | import "strconv" 5 | 6 | // StatusTimeout is the exit code for timed out commands. 7 | const StatusTimeout = 124 8 | 9 | // StatusAborted is the exit code for aborted commands. 10 | const StatusAborted = 130 11 | 12 | // ErrExit is a custom exit error. 13 | type ErrExit int 14 | 15 | // Error implements error. 16 | func (e ErrExit) Error() string { return "exit " + strconv.Itoa(int(e)) } 17 | -------------------------------------------------------------------------------- /internal/files/files.go: -------------------------------------------------------------------------------- 1 | // Package files handles files. 2 | package files 3 | 4 | import ( 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | // List returns a list of all files in the current directory. 11 | // It ignores the .git directory. 12 | func List() []string { 13 | var files []string 14 | err := filepath.Walk(".", 15 | func(path string, info os.FileInfo, err error) error { 16 | if shouldIgnore(path) || info.IsDir() || err != nil { 17 | return nil //nolint:nilerr 18 | } 19 | files = append(files, path) 20 | return nil 21 | }) 22 | if err != nil { 23 | return []string{} 24 | } 25 | return files 26 | } 27 | 28 | var defaultIgnorePatterns = []string{"node_modules", ".git", "."} 29 | 30 | func shouldIgnore(path string) bool { 31 | for _, prefix := range defaultIgnorePatterns { 32 | if strings.HasPrefix(path, prefix) { 33 | return true 34 | } 35 | } 36 | return false 37 | } 38 | -------------------------------------------------------------------------------- /internal/stdin/stdin.go: -------------------------------------------------------------------------------- 1 | // Package stdin handles processing input from stdin. 2 | package stdin 3 | 4 | import ( 5 | "bufio" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | 11 | "github.com/charmbracelet/x/ansi" 12 | ) 13 | 14 | type options struct { 15 | ansiStrip bool 16 | singleLine bool 17 | } 18 | 19 | // Option is a read option. 20 | type Option func(*options) 21 | 22 | // StripANSI optionally strips ansi sequences. 23 | func StripANSI(b bool) Option { 24 | return func(o *options) { 25 | o.ansiStrip = b 26 | } 27 | } 28 | 29 | // SingleLine reads a single line. 30 | func SingleLine(b bool) Option { 31 | return func(o *options) { 32 | o.singleLine = b 33 | } 34 | } 35 | 36 | // Read reads input from an stdin pipe. 37 | func Read(opts ...Option) (string, error) { 38 | if IsEmpty() { 39 | return "", fmt.Errorf("stdin is empty") 40 | } 41 | 42 | options := options{} 43 | for _, opt := range opts { 44 | opt(&options) 45 | } 46 | 47 | reader := bufio.NewReader(os.Stdin) 48 | var b strings.Builder 49 | 50 | if options.singleLine { 51 | line, _, err := reader.ReadLine() 52 | if err != nil { 53 | return "", fmt.Errorf("failed to read line: %w", err) 54 | } 55 | _, err = b.Write(line) 56 | if err != nil { 57 | return "", fmt.Errorf("failed to write: %w", err) 58 | } 59 | } 60 | 61 | for !options.singleLine { 62 | r, _, err := reader.ReadRune() 63 | if err != nil && err == io.EOF { 64 | break 65 | } 66 | _, err = b.WriteRune(r) 67 | if err != nil { 68 | return "", fmt.Errorf("failed to write rune: %w", err) 69 | } 70 | } 71 | 72 | s := strings.TrimSpace(b.String()) 73 | if options.ansiStrip { 74 | return ansi.Strip(s), nil 75 | } 76 | return s, nil 77 | } 78 | 79 | // IsEmpty returns whether stdin is empty. 80 | func IsEmpty() bool { 81 | stat, err := os.Stdin.Stat() 82 | if err != nil { 83 | return true 84 | } 85 | 86 | if stat.Mode()&os.ModeNamedPipe == 0 && stat.Size() == 0 { 87 | return true 88 | } 89 | 90 | return false 91 | } 92 | -------------------------------------------------------------------------------- /internal/timeout/context.go: -------------------------------------------------------------------------------- 1 | // Package timeout handles context timeouts. 2 | package timeout 3 | 4 | import ( 5 | "context" 6 | "time" 7 | ) 8 | 9 | // Context setup a new context that times out if the given timeout is > 0. 10 | func Context(timeout time.Duration) (context.Context, context.CancelFunc) { 11 | ctx := context.Background() 12 | if timeout == 0 { 13 | return ctx, func() {} 14 | } 15 | return context.WithTimeout(ctx, timeout) 16 | } 17 | -------------------------------------------------------------------------------- /internal/tty/tty.go: -------------------------------------------------------------------------------- 1 | // Package tty provides tty-aware printing. 2 | package tty 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "sync" 8 | 9 | "github.com/charmbracelet/x/ansi" 10 | "github.com/charmbracelet/x/term" 11 | ) 12 | 13 | var isTTY = sync.OnceValue(func() bool { 14 | return term.IsTerminal(os.Stdout.Fd()) 15 | }) 16 | 17 | // Println handles println, striping ansi sequences if stdout is not a tty. 18 | func Println(s string) { 19 | if isTTY() { 20 | fmt.Println(s) 21 | return 22 | } 23 | fmt.Println(ansi.Strip(s)) 24 | } 25 | -------------------------------------------------------------------------------- /join/command.go: -------------------------------------------------------------------------------- 1 | // Package join provides a shell script interface for the lipgloss 2 | // JoinHorizontal and JoinVertical commands. It allows you to join multi-line 3 | // text to build different layouts. 4 | // 5 | // For example, you can place two bordered boxes next to each other: Note: We 6 | // wrap the variable in quotes to ensure the new lines are part of a single 7 | // argument. Otherwise, the command won't work as expected. 8 | // 9 | // $ gum join --horizontal "$BUBBLE_BOX" "$GUM_BOX" 10 | // 11 | // ╔══════════════════════╗╔═════════════╗ 12 | // ║ ║║ ║ 13 | // ║ Bubble ║║ Gum ║ 14 | // ║ ║║ ║ 15 | // ╚══════════════════════╝╚═════════════╝ 16 | package join 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/charmbracelet/lipgloss" 22 | 23 | "github.com/charmbracelet/gum/internal/decode" 24 | ) 25 | 26 | // Run is the command-line interface for the joining strings through lipgloss. 27 | func (o Options) Run() error { 28 | join := lipgloss.JoinHorizontal 29 | if o.Vertical { 30 | join = lipgloss.JoinVertical 31 | } 32 | fmt.Println(join(decode.Align[o.Align], o.Text...)) 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /join/options.go: -------------------------------------------------------------------------------- 1 | package join 2 | 3 | // Options is the set of options that can configure a join. 4 | type Options struct { 5 | Text []string `arg:"" help:"Text to join."` 6 | 7 | Align string `help:"Text alignment" enum:"left,center,right,bottom,middle,top" default:"left"` 8 | Horizontal bool `help:"Join (potentially multi-line) strings horizontally"` 9 | Vertical bool `help:"Join (potentially multi-line) strings vertically"` 10 | } 11 | -------------------------------------------------------------------------------- /log/command.go: -------------------------------------------------------------------------------- 1 | // Package log the log command. 2 | package log 3 | 4 | import ( 5 | "fmt" 6 | "math" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/charmbracelet/log" 13 | ) 14 | 15 | // Run is the command-line interface for logging text. 16 | func (o Options) Run() error { 17 | l := log.New(os.Stderr) 18 | 19 | if o.File != "" { 20 | f, err := os.OpenFile(o.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, os.ModePerm) //nolint:gosec 21 | if err != nil { 22 | return fmt.Errorf("error opening file: %w", err) 23 | } 24 | 25 | defer f.Close() //nolint:errcheck 26 | l.SetOutput(f) 27 | } 28 | 29 | l.SetPrefix(o.Prefix) 30 | l.SetLevel(-math.MaxInt32) // log all levels 31 | l.SetReportTimestamp(o.Time != "") 32 | if o.MinLevel != "" { 33 | lvl, err := log.ParseLevel(o.MinLevel) 34 | if err != nil { 35 | return err //nolint:wrapcheck 36 | } 37 | l.SetLevel(lvl) 38 | } 39 | 40 | timeFormats := map[string]string{ 41 | "layout": time.Layout, 42 | "ansic": time.ANSIC, 43 | "unixdate": time.UnixDate, 44 | "rubydate": time.RubyDate, 45 | "rfc822": time.RFC822, 46 | "rfc822z": time.RFC822Z, 47 | "rfc850": time.RFC850, 48 | "rfc1123": time.RFC1123, 49 | "rfc1123z": time.RFC1123Z, 50 | "rfc3339": time.RFC3339, 51 | "rfc3339nano": time.RFC3339Nano, 52 | "kitchen": time.Kitchen, 53 | "stamp": time.Stamp, 54 | "stampmilli": time.StampMilli, 55 | "stampmicro": time.StampMicro, 56 | "stampnano": time.StampNano, 57 | "datetime": time.DateTime, 58 | "dateonly": time.DateOnly, 59 | "timeonly": time.TimeOnly, 60 | } 61 | 62 | tf, ok := timeFormats[strings.ToLower(o.Time)] 63 | if ok { 64 | l.SetTimeFormat(tf) 65 | } else { 66 | l.SetTimeFormat(o.Time) 67 | } 68 | 69 | st := log.DefaultStyles() 70 | lvl := levelToLog[o.Level] 71 | lvlStyle := o.LevelStyle.ToLipgloss() 72 | if lvlStyle.GetForeground() == lipgloss.Color("") { 73 | lvlStyle = lvlStyle.Foreground(st.Levels[lvl].GetForeground()) 74 | } 75 | 76 | st.Levels[lvl] = lvlStyle. 77 | SetString(strings.ToUpper(lvl.String())). 78 | Inline(true) 79 | 80 | st.Timestamp = o.TimeStyle.ToLipgloss(). 81 | Inline(true) 82 | st.Prefix = o.PrefixStyle.ToLipgloss(). 83 | Inline(true) 84 | st.Message = o.MessageStyle.ToLipgloss(). 85 | Inline(true) 86 | st.Key = o.KeyStyle.ToLipgloss(). 87 | Inline(true) 88 | st.Value = o.ValueStyle.ToLipgloss(). 89 | Inline(true) 90 | st.Separator = o.SeparatorStyle.ToLipgloss(). 91 | Inline(true) 92 | 93 | l.SetStyles(st) 94 | 95 | switch o.Formatter { 96 | case "json": 97 | l.SetFormatter(log.JSONFormatter) 98 | case "logfmt": 99 | l.SetFormatter(log.LogfmtFormatter) 100 | case "text": 101 | l.SetFormatter(log.TextFormatter) 102 | } 103 | 104 | var arg0 string 105 | var args []interface{} 106 | if len(o.Text) > 0 { 107 | arg0 = o.Text[0] 108 | } 109 | 110 | if len(o.Text) > 1 { 111 | args = make([]interface{}, len(o.Text[1:])) 112 | for i, arg := range o.Text[1:] { 113 | args[i] = arg 114 | } 115 | } 116 | 117 | logger := map[string]logger{ 118 | "none": {printf: l.Printf, print: l.Print}, 119 | "debug": {printf: l.Debugf, print: l.Debug}, 120 | "info": {printf: l.Infof, print: l.Info}, 121 | "warn": {printf: l.Warnf, print: l.Warn}, 122 | "error": {printf: l.Errorf, print: l.Error}, 123 | "fatal": {printf: l.Fatalf, print: l.Fatal}, 124 | }[o.Level] 125 | 126 | if o.Format { 127 | logger.printf(arg0, args...) 128 | } else if o.Structured { 129 | logger.print(arg0, args...) 130 | } else { 131 | logger.print(strings.Join(o.Text, " ")) 132 | } 133 | 134 | return nil 135 | } 136 | 137 | type logger struct { 138 | printf func(string, ...interface{}) 139 | print func(interface{}, ...interface{}) 140 | } 141 | 142 | var levelToLog = map[string]log.Level{ 143 | "none": log.Level(math.MaxInt32), 144 | "debug": log.DebugLevel, 145 | "info": log.InfoLevel, 146 | "warn": log.WarnLevel, 147 | "error": log.ErrorLevel, 148 | "fatal": log.FatalLevel, 149 | } 150 | -------------------------------------------------------------------------------- /log/options.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "github.com/charmbracelet/gum/style" 5 | ) 6 | 7 | // Options is the set of options that can configure a join. 8 | type Options struct { 9 | Text []string `arg:"" help:"Text to log"` 10 | 11 | File string `short:"o" help:"Log to file"` 12 | Format bool `short:"f" help:"Format message using printf" xor:"format,structured"` 13 | Formatter string `help:"The log formatter to use" enum:"json,logfmt,text" default:"text"` 14 | Level string `short:"l" help:"The log level to use" enum:"none,debug,info,warn,error,fatal" default:"none"` 15 | Prefix string `help:"Prefix to print before the message"` 16 | Structured bool `short:"s" help:"Use structured logging" xor:"format,structured"` 17 | Time string `short:"t" help:"The time format to use (kitchen, layout, ansic, rfc822, etc...)" default:""` 18 | 19 | MinLevel string `help:"Minimal level to show" default:"" env:"GUM_LOG_LEVEL"` 20 | 21 | LevelStyle style.Styles `embed:"" prefix:"level." help:"The style of the level being used" set:"defaultBold=true" envprefix:"GUM_LOG_LEVEL_"` 22 | TimeStyle style.Styles `embed:"" prefix:"time." help:"The style of the time" envprefix:"GUM_LOG_TIME_"` 23 | PrefixStyle style.Styles `embed:"" prefix:"prefix." help:"The style of the prefix" set:"defaultBold=true" set:"defaultFaint=true" envprefix:"GUM_LOG_PREFIX_"` //nolint:staticcheck 24 | MessageStyle style.Styles `embed:"" prefix:"message." help:"The style of the message" envprefix:"GUM_LOG_MESSAGE_"` 25 | KeyStyle style.Styles `embed:"" prefix:"key." help:"The style of the key" set:"defaultFaint=true" envprefix:"GUM_LOG_KEY_"` 26 | ValueStyle style.Styles `embed:"" prefix:"value." help:"The style of the value" envprefix:"GUM_LOG_VALUE_"` 27 | SeparatorStyle style.Styles `embed:"" prefix:"separator." help:"The style of the separator" set:"defaultFaint=true" envprefix:"GUM_LOG_SEPARATOR_"` 28 | } 29 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package main is Gum: a tool for glamorous shell scripts. 2 | package main 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "os" 8 | "runtime/debug" 9 | 10 | "github.com/alecthomas/kong" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/gum/internal/exit" 13 | "github.com/charmbracelet/lipgloss" 14 | "github.com/muesli/termenv" 15 | ) 16 | 17 | const shaLen = 7 18 | 19 | var ( 20 | // Version contains the application version number. It's set via ldflags 21 | // when building. 22 | Version = "" 23 | 24 | // CommitSHA contains the SHA of the commit that this application was built 25 | // against. It's set via ldflags when building. 26 | CommitSHA = "" 27 | ) 28 | 29 | var bubbleGumPink = lipgloss.NewStyle().Foreground(lipgloss.Color("212")) 30 | 31 | func main() { 32 | lipgloss.SetColorProfile(termenv.NewOutput(os.Stderr).Profile) 33 | 34 | if Version == "" { 35 | if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" { 36 | Version = info.Main.Version 37 | } else { 38 | Version = "unknown (built from source)" 39 | } 40 | } 41 | version := fmt.Sprintf("gum version %s", Version) 42 | if len(CommitSHA) >= shaLen { 43 | version += " (" + CommitSHA[:shaLen] + ")" 44 | } 45 | 46 | gum := &Gum{} 47 | ctx := kong.Parse( 48 | gum, 49 | kong.Description(fmt.Sprintf("A tool for %s shell scripts.", bubbleGumPink.Render("glamorous"))), 50 | kong.UsageOnError(), 51 | kong.ConfigureHelp(kong.HelpOptions{ 52 | Compact: true, 53 | Summary: false, 54 | NoExpandSubcommands: true, 55 | }), 56 | kong.Vars{ 57 | "version": version, 58 | "versionNumber": Version, 59 | "defaultHeight": "0", 60 | "defaultWidth": "0", 61 | "defaultAlign": "left", 62 | "defaultBorder": "none", 63 | "defaultBorderForeground": "", 64 | "defaultBorderBackground": "", 65 | "defaultBackground": "", 66 | "defaultForeground": "", 67 | "defaultMargin": "0 0", 68 | "defaultPadding": "0 0", 69 | "defaultUnderline": "false", 70 | "defaultBold": "false", 71 | "defaultFaint": "false", 72 | "defaultItalic": "false", 73 | "defaultStrikethrough": "false", 74 | }, 75 | ) 76 | if err := ctx.Run(); err != nil { 77 | var ex exit.ErrExit 78 | if errors.As(err, &ex) { 79 | os.Exit(int(ex)) 80 | } 81 | if errors.Is(err, tea.ErrProgramKilled) { 82 | fmt.Fprintln(os.Stderr, "timed out") 83 | os.Exit(exit.StatusTimeout) 84 | } 85 | if errors.Is(err, tea.ErrInterrupted) { 86 | os.Exit(exit.StatusAborted) 87 | } 88 | fmt.Fprintln(os.Stderr, err) 89 | os.Exit(1) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /man/command.go: -------------------------------------------------------------------------------- 1 | // Package man the man command. 2 | package man 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/alecthomas/kong" 8 | mangokong "github.com/alecthomas/mango-kong" 9 | "github.com/muesli/roff" 10 | ) 11 | 12 | // Man is a gum sub-command that generates man pages. 13 | type Man struct{} 14 | 15 | // BeforeApply implements Kong BeforeApply hook. 16 | func (m Man) BeforeApply(ctx *kong.Context) error { 17 | // Set the correct man pages description without color escape sequences. 18 | ctx.Model.Help = "A tool for glamorous shell scripts." 19 | man := mangokong.NewManPage(1, ctx.Model) 20 | man = man.WithSection("Copyright", "(c) 2022-2024 Charmbracelet, Inc.\n"+ 21 | "Released under MIT license.") 22 | _, _ = fmt.Fprint(ctx.Stdout, man.Build(roff.NewDocument())) 23 | ctx.Exit(0) 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /pager/command.go: -------------------------------------------------------------------------------- 1 | package pager 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | "github.com/charmbracelet/bubbles/help" 8 | "github.com/charmbracelet/bubbles/viewport" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/gum/internal/stdin" 11 | "github.com/charmbracelet/gum/internal/timeout" 12 | ) 13 | 14 | // Run provides a shell script interface for the viewport bubble. 15 | // https://github.com/charmbracelet/bubbles/viewport 16 | func (o Options) Run() error { 17 | vp := viewport.New(o.Style.Width, o.Style.Height) 18 | vp.Style = o.Style.ToLipgloss() 19 | 20 | if o.Content == "" { 21 | stdin, err := stdin.Read() 22 | if err != nil { 23 | return fmt.Errorf("unable to read stdin") 24 | } 25 | if stdin != "" { 26 | // Sanitize the input from stdin by removing backspace sequences. 27 | backspace := regexp.MustCompile(".\x08") 28 | o.Content = backspace.ReplaceAllString(stdin, "") 29 | } else { 30 | return fmt.Errorf("provide some content to display") 31 | } 32 | } 33 | 34 | m := model{ 35 | viewport: vp, 36 | help: help.New(), 37 | content: o.Content, 38 | origContent: o.Content, 39 | showLineNumbers: o.ShowLineNumbers, 40 | lineNumberStyle: o.LineNumberStyle.ToLipgloss(), 41 | softWrap: o.SoftWrap, 42 | matchStyle: o.MatchStyle.ToLipgloss(), 43 | matchHighlightStyle: o.MatchHighlightStyle.ToLipgloss(), 44 | keymap: defaultKeymap(), 45 | } 46 | 47 | ctx, cancel := timeout.Context(o.Timeout) 48 | defer cancel() 49 | 50 | _, err := tea.NewProgram( 51 | m, 52 | tea.WithAltScreen(), 53 | tea.WithReportFocus(), 54 | tea.WithContext(ctx), 55 | ).Run() 56 | if err != nil { 57 | return fmt.Errorf("unable to start program: %w", err) 58 | } 59 | 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /pager/options.go: -------------------------------------------------------------------------------- 1 | package pager 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/gum/style" 7 | ) 8 | 9 | // Options are the options for the pager. 10 | type Options struct { 11 | //nolint:staticcheck 12 | Style style.Styles `embed:"" help:"Style the pager" set:"defaultBorder=rounded" set:"defaultPadding=0 1" set:"defaultBorderForeground=212" envprefix:"GUM_PAGER_"` 13 | Content string `arg:"" optional:"" help:"Display content to scroll"` 14 | ShowLineNumbers bool `help:"Show line numbers" default:"true"` 15 | LineNumberStyle style.Styles `embed:"" prefix:"line-number." help:"Style the line numbers" set:"defaultForeground=237" envprefix:"GUM_PAGER_LINE_NUMBER_"` 16 | SoftWrap bool `help:"Soft wrap lines" default:"true" negatable:""` 17 | MatchStyle style.Styles `embed:"" prefix:"match." help:"Style the matched text" set:"defaultForeground=212" set:"defaultBold=true" envprefix:"GUM_PAGER_MATCH_"` //nolint:staticcheck 18 | MatchHighlightStyle style.Styles `embed:"" prefix:"match-highlight." help:"Style the matched highlight text" set:"defaultForeground=235" set:"defaultBackground=225" set:"defaultBold=true" envprefix:"GUM_PAGER_MATCH_HIGH_"` //nolint:staticcheck 19 | Timeout time.Duration `help:"Timeout until command exits" default:"0s" env:"GUM_PAGER_TIMEOUT"` 20 | 21 | // Deprecated: this has no effect anymore. 22 | HelpStyle style.Styles `embed:"" prefix:"help." help:"Style the help text" set:"defaultForeground=241" envprefix:"GUM_PAGER_HELP_" hidden:""` 23 | } 24 | -------------------------------------------------------------------------------- /pager/pager.go: -------------------------------------------------------------------------------- 1 | // Package pager provides a pager (similar to less) for the terminal. 2 | // 3 | // $ cat file.txt | gum pager 4 | package pager 5 | 6 | import ( 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/charmbracelet/bubbles/help" 11 | "github.com/charmbracelet/bubbles/key" 12 | "github.com/charmbracelet/bubbles/textinput" 13 | "github.com/charmbracelet/bubbles/viewport" 14 | tea "github.com/charmbracelet/bubbletea" 15 | "github.com/charmbracelet/lipgloss" 16 | "github.com/charmbracelet/x/ansi" 17 | ) 18 | 19 | type keymap struct { 20 | Home, 21 | End, 22 | Search, 23 | NextMatch, 24 | PrevMatch, 25 | Abort, 26 | Quit, 27 | ConfirmSearch, 28 | CancelSearch key.Binding 29 | } 30 | 31 | // FullHelp implements help.KeyMap. 32 | func (k keymap) FullHelp() [][]key.Binding { 33 | return nil 34 | } 35 | 36 | // ShortHelp implements help.KeyMap. 37 | func (k keymap) ShortHelp() []key.Binding { 38 | return []key.Binding{ 39 | key.NewBinding( 40 | key.WithKeys("up", "down"), 41 | key.WithHelp("↓↑", "navigate"), 42 | ), 43 | k.Quit, 44 | k.Search, 45 | k.NextMatch, 46 | k.PrevMatch, 47 | } 48 | } 49 | 50 | func defaultKeymap() keymap { 51 | return keymap{ 52 | Home: key.NewBinding( 53 | key.WithKeys("g", "home"), 54 | key.WithHelp("h", "home"), 55 | ), 56 | End: key.NewBinding( 57 | key.WithKeys("G", "end"), 58 | key.WithHelp("G", "end"), 59 | ), 60 | Search: key.NewBinding( 61 | key.WithKeys("/"), 62 | key.WithHelp("/", "search"), 63 | ), 64 | PrevMatch: key.NewBinding( 65 | key.WithKeys("p", "N"), 66 | key.WithHelp("N", "previous match"), 67 | ), 68 | NextMatch: key.NewBinding( 69 | key.WithKeys("n"), 70 | key.WithHelp("n", "next match"), 71 | ), 72 | Abort: key.NewBinding( 73 | key.WithKeys("ctrl+c"), 74 | key.WithHelp("ctrl+c", "abort"), 75 | ), 76 | Quit: key.NewBinding( 77 | key.WithKeys("q", "esc"), 78 | key.WithHelp("esc", "quit"), 79 | ), 80 | ConfirmSearch: key.NewBinding( 81 | key.WithKeys("enter"), 82 | key.WithHelp("enter", "confirm"), 83 | ), 84 | CancelSearch: key.NewBinding( 85 | key.WithKeys("ctrl+c", "ctrl+d", "esc"), 86 | key.WithHelp("ctrl+c", "cancel"), 87 | ), 88 | } 89 | } 90 | 91 | type model struct { 92 | content string 93 | origContent string 94 | viewport viewport.Model 95 | help help.Model 96 | showLineNumbers bool 97 | lineNumberStyle lipgloss.Style 98 | softWrap bool 99 | search search 100 | matchStyle lipgloss.Style 101 | matchHighlightStyle lipgloss.Style 102 | maxWidth int 103 | keymap keymap 104 | } 105 | 106 | func (m model) Init() tea.Cmd { return nil } 107 | 108 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 109 | switch msg := msg.(type) { 110 | case tea.WindowSizeMsg: 111 | m.processText(msg) 112 | case tea.KeyMsg: 113 | return m.keyHandler(msg) 114 | } 115 | 116 | m.keymap.PrevMatch.SetEnabled(m.search.query != nil) 117 | m.keymap.NextMatch.SetEnabled(m.search.query != nil) 118 | 119 | var cmd tea.Cmd 120 | m.search.input, cmd = m.search.input.Update(msg) 121 | return m, cmd 122 | } 123 | 124 | func (m *model) helpView() string { 125 | return m.help.View(m.keymap) 126 | } 127 | 128 | func (m *model) processText(msg tea.WindowSizeMsg) { 129 | m.viewport.Height = msg.Height - lipgloss.Height(m.helpView()) 130 | m.viewport.Width = msg.Width 131 | textStyle := lipgloss.NewStyle().Width(m.viewport.Width) 132 | var text strings.Builder 133 | 134 | // Determine max width of a line. 135 | m.maxWidth = m.viewport.Width 136 | if m.softWrap { 137 | vpStyle := m.viewport.Style 138 | m.maxWidth -= vpStyle.GetHorizontalBorderSize() + vpStyle.GetHorizontalMargins() + vpStyle.GetHorizontalPadding() 139 | if m.showLineNumbers { 140 | m.maxWidth -= lipgloss.Width(" │ ") 141 | } 142 | } 143 | 144 | for i, line := range strings.Split(m.content, "\n") { 145 | line = strings.ReplaceAll(line, "\t", " ") 146 | if m.showLineNumbers { 147 | text.WriteString(m.lineNumberStyle.Render(fmt.Sprintf("%4d │ ", i+1))) 148 | } 149 | idx := 0 150 | if w := ansi.StringWidth(line); m.softWrap && w > m.maxWidth { 151 | for w > idx { 152 | if m.showLineNumbers && idx != 0 { 153 | text.WriteString(m.lineNumberStyle.Render(" │ ")) 154 | } 155 | truncatedLine := ansi.Cut(line, idx, m.maxWidth+idx) 156 | idx += m.maxWidth 157 | text.WriteString(textStyle.Render(truncatedLine)) 158 | text.WriteString("\n") 159 | } 160 | } else { 161 | text.WriteString(textStyle.Render(line)) 162 | text.WriteString("\n") 163 | } 164 | } 165 | 166 | diffHeight := m.viewport.Height - lipgloss.Height(text.String()) 167 | if diffHeight > 0 && m.showLineNumbers { 168 | remainingLines := " ~ │ " + strings.Repeat("\n ~ │ ", diffHeight-1) 169 | text.WriteString(m.lineNumberStyle.Render(remainingLines)) 170 | } 171 | m.viewport.SetContent(text.String()) 172 | } 173 | 174 | const heightOffset = 2 175 | 176 | func (m model) keyHandler(msg tea.KeyMsg) (model, tea.Cmd) { 177 | km := m.keymap 178 | var cmd tea.Cmd 179 | if m.search.active { 180 | switch { 181 | case key.Matches(msg, km.ConfirmSearch): 182 | if m.search.input.Value() != "" { 183 | m.content = m.origContent 184 | m.search.Execute(&m) 185 | 186 | // Trigger a view update to highlight the found matches. 187 | m.search.NextMatch(&m) 188 | m.processText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width}) 189 | } else { 190 | m.search.Done() 191 | } 192 | case key.Matches(msg, km.CancelSearch): 193 | m.search.Done() 194 | default: 195 | m.search.input, cmd = m.search.input.Update(msg) 196 | } 197 | } else { 198 | switch { 199 | case key.Matches(msg, km.Home): 200 | m.viewport.GotoTop() 201 | case key.Matches(msg, km.End): 202 | m.viewport.GotoBottom() 203 | case key.Matches(msg, km.Search): 204 | m.search.Begin() 205 | return m, textinput.Blink 206 | case key.Matches(msg, km.PrevMatch): 207 | m.search.PrevMatch(&m) 208 | m.processText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width}) 209 | case key.Matches(msg, km.NextMatch): 210 | m.search.NextMatch(&m) 211 | m.processText(tea.WindowSizeMsg{Height: m.viewport.Height + heightOffset, Width: m.viewport.Width}) 212 | case key.Matches(msg, km.Quit): 213 | return m, tea.Quit 214 | case key.Matches(msg, km.Abort): 215 | return m, tea.Interrupt 216 | } 217 | m.viewport, cmd = m.viewport.Update(msg) 218 | } 219 | 220 | return m, cmd 221 | } 222 | 223 | func (m model) View() string { 224 | if m.search.active { 225 | return m.viewport.View() + "\n " + m.search.input.View() 226 | } 227 | 228 | return m.viewport.View() + "\n" + m.helpView() 229 | } 230 | -------------------------------------------------------------------------------- /pager/search.go: -------------------------------------------------------------------------------- 1 | package pager 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/textinput" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/charmbracelet/x/ansi" 11 | ) 12 | 13 | type search struct { 14 | active bool 15 | input textinput.Model 16 | query *regexp.Regexp 17 | matchIndex int 18 | matchLipglossStr string 19 | matchString string 20 | } 21 | 22 | func (s *search) new() { 23 | input := textinput.New() 24 | input.Placeholder = "search" 25 | input.Prompt = "/" 26 | input.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) 27 | s.input = input 28 | } 29 | 30 | func (s *search) Begin() { 31 | s.new() 32 | s.active = true 33 | s.input.Focus() 34 | } 35 | 36 | // Execute find all lines in the model with a match. 37 | func (s *search) Execute(m *model) { 38 | defer s.Done() 39 | if s.input.Value() == "" { 40 | s.query = nil 41 | return 42 | } 43 | 44 | var err error 45 | s.query, err = regexp.Compile(s.input.Value()) 46 | if err != nil { 47 | s.query = nil 48 | return 49 | } 50 | query := regexp.MustCompile(fmt.Sprintf("(%s)", s.query.String())) 51 | m.content = query.ReplaceAllString(m.content, m.matchStyle.Render("$1")) 52 | 53 | // Recompile the regex to match the an replace the highlights. 54 | leftPad, _ := lipglossPadding(m.matchStyle) 55 | matchingString := regexp.QuoteMeta(m.matchStyle.Render()[:leftPad]) + s.query.String() + regexp.QuoteMeta(m.matchStyle.Render()[leftPad:]) 56 | s.query, err = regexp.Compile(matchingString) 57 | if err != nil { 58 | s.query = nil 59 | } 60 | } 61 | 62 | func (s *search) Done() { 63 | s.active = false 64 | 65 | // To account for the first match is always executed. 66 | s.matchIndex = -1 67 | } 68 | 69 | func (s *search) NextMatch(m *model) { 70 | // Check that we are within bounds. 71 | if s.query == nil { 72 | return 73 | } 74 | 75 | // Remove previous highlight. 76 | m.content = strings.Replace(m.content, s.matchLipglossStr, s.matchString, 1) 77 | 78 | // Highlight the next match. 79 | allMatches := s.query.FindAllStringIndex(m.content, -1) 80 | if len(allMatches) == 0 { 81 | return 82 | } 83 | 84 | leftPad, rightPad := lipglossPadding(m.matchStyle) 85 | s.matchIndex = (s.matchIndex + 1) % len(allMatches) 86 | match := allMatches[s.matchIndex] 87 | lhs := m.content[:match[0]] 88 | rhs := m.content[match[0]:] 89 | s.matchString = m.content[match[0]:match[1]] 90 | s.matchLipglossStr = m.matchHighlightStyle.Render(s.matchString[leftPad : len(s.matchString)-rightPad]) 91 | m.content = lhs + strings.Replace(rhs, m.content[match[0]:match[1]], s.matchLipglossStr, 1) 92 | 93 | // Update the viewport position. 94 | var line int 95 | formatStr := softWrapEm(m.content, m.maxWidth, m.softWrap) 96 | index := strings.Index(formatStr, s.matchLipglossStr) 97 | if index != -1 { 98 | line = strings.Count(formatStr[:index], "\n") 99 | } 100 | 101 | // Only update if the match is not within the viewport. 102 | if index != -1 && (line > m.viewport.YOffset-1+m.viewport.VisibleLineCount()-1 || line < m.viewport.YOffset) { 103 | m.viewport.SetYOffset(line) 104 | } 105 | } 106 | 107 | func (s *search) PrevMatch(m *model) { 108 | // Check that we are within bounds. 109 | if s.query == nil { 110 | return 111 | } 112 | 113 | // Remove previous highlight. 114 | m.content = strings.Replace(m.content, s.matchLipglossStr, s.matchString, 1) 115 | 116 | // Highlight the previous match. 117 | allMatches := s.query.FindAllStringIndex(m.content, -1) 118 | if len(allMatches) == 0 { 119 | return 120 | } 121 | 122 | s.matchIndex = (s.matchIndex - 1) % len(allMatches) 123 | if s.matchIndex < 0 { 124 | s.matchIndex = len(allMatches) - 1 125 | } 126 | 127 | leftPad, rightPad := lipglossPadding(m.matchStyle) 128 | match := allMatches[s.matchIndex] 129 | lhs := m.content[:match[0]] 130 | rhs := m.content[match[0]:] 131 | s.matchString = m.content[match[0]:match[1]] 132 | s.matchLipglossStr = m.matchHighlightStyle.Render(s.matchString[leftPad : len(s.matchString)-rightPad]) 133 | m.content = lhs + strings.Replace(rhs, m.content[match[0]:match[1]], s.matchLipglossStr, 1) 134 | 135 | // Update the viewport position. 136 | var line int 137 | formatStr := softWrapEm(m.content, m.maxWidth, m.softWrap) 138 | index := strings.Index(formatStr, s.matchLipglossStr) 139 | if index != -1 { 140 | line = strings.Count(formatStr[:index], "\n") 141 | } 142 | 143 | // Only update if the match is not within the viewport. 144 | if index != -1 && (line > m.viewport.YOffset-1+m.viewport.VisibleLineCount()-1 || line < m.viewport.YOffset) { 145 | m.viewport.SetYOffset(line) 146 | } 147 | } 148 | 149 | func softWrapEm(str string, maxWidth int, softWrap bool) string { 150 | var text strings.Builder 151 | for _, line := range strings.Split(str, "\n") { 152 | idx := 0 153 | if w := ansi.StringWidth(line); softWrap && w > maxWidth { 154 | for w > idx { 155 | truncatedLine := ansi.Cut(line, idx, maxWidth+idx) 156 | idx += maxWidth 157 | text.WriteString(truncatedLine) 158 | text.WriteString("\n") 159 | } 160 | } else { 161 | text.WriteString(line) 162 | text.WriteString("\n") 163 | } 164 | } 165 | 166 | return text.String() 167 | } 168 | 169 | // lipglossPadding calculates how much padding a string is given by a style. 170 | func lipglossPadding(style lipgloss.Style) (int, int) { 171 | render := style.Render(" ") 172 | before := strings.Index(render, " ") 173 | after := len(render) - len(" ") - before 174 | return before, after 175 | } 176 | -------------------------------------------------------------------------------- /spin/command.go: -------------------------------------------------------------------------------- 1 | package spin 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/charmbracelet/bubbles/spinner" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/gum/internal/exit" 10 | "github.com/charmbracelet/gum/internal/timeout" 11 | "github.com/charmbracelet/x/term" 12 | ) 13 | 14 | // Run provides a shell script interface for the spinner bubble. 15 | // https://github.com/charmbracelet/bubbles/spinner 16 | func (o Options) Run() error { 17 | isOutTTY := term.IsTerminal(os.Stdout.Fd()) 18 | isErrTTY := term.IsTerminal(os.Stderr.Fd()) 19 | 20 | s := spinner.New() 21 | s.Style = o.SpinnerStyle.ToLipgloss() 22 | s.Spinner = spinnerMap[o.Spinner] 23 | m := model{ 24 | spinner: s, 25 | title: o.TitleStyle.ToLipgloss().Render(o.Title), 26 | command: o.Command, 27 | align: o.Align, 28 | showStdout: (o.ShowOutput || o.ShowStdout) && isOutTTY, 29 | showStderr: (o.ShowOutput || o.ShowStderr) && isErrTTY, 30 | showError: o.ShowError, 31 | isTTY: isErrTTY, 32 | } 33 | 34 | ctx, cancel := timeout.Context(o.Timeout) 35 | defer cancel() 36 | 37 | tm, err := tea.NewProgram( 38 | m, 39 | tea.WithOutput(os.Stderr), 40 | tea.WithContext(ctx), 41 | tea.WithInput(nil), 42 | ).Run() 43 | if err != nil { 44 | return fmt.Errorf("unable to run action: %w", err) 45 | } 46 | 47 | m = tm.(model) 48 | // If the command succeeds, and we are printing output and we are in a TTY then push the STDOUT we got to the actual 49 | // STDOUT for piping or other things. 50 | //nolint:nestif 51 | if m.err != nil { 52 | if _, err := fmt.Fprintf(os.Stderr, "%s\n", m.err.Error()); err != nil { 53 | return fmt.Errorf("failed to write to stdout: %w", err) 54 | } 55 | return exit.ErrExit(1) 56 | } else if m.status == 0 { 57 | var output string 58 | if o.ShowOutput || (o.ShowStdout && o.ShowStderr) { 59 | output = m.output 60 | } else if o.ShowStdout { 61 | output = m.stdout 62 | } else if o.ShowStderr { 63 | output = m.stderr 64 | } 65 | if output != "" { 66 | if _, err := os.Stdout.WriteString(output); err != nil { 67 | return fmt.Errorf("failed to write to stdout: %w", err) 68 | } 69 | } 70 | } else if o.ShowError { 71 | // Otherwise if we are showing errors and the command did not exit with a 0 status code then push all of the command 72 | // output to the terminal. This way failed commands can be debugged. 73 | if _, err := os.Stdout.WriteString(m.output); err != nil { 74 | return fmt.Errorf("failed to write to stdout: %w", err) 75 | } 76 | } 77 | 78 | return exit.ErrExit(m.status) 79 | } 80 | -------------------------------------------------------------------------------- /spin/options.go: -------------------------------------------------------------------------------- 1 | package spin 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/gum/style" 7 | ) 8 | 9 | // Options is the customization options for the spin command. 10 | type Options struct { 11 | Command []string `arg:"" help:"Command to run"` 12 | 13 | ShowOutput bool `help:"Show or pipe output of command during execution (shows both STDOUT and STDERR)" default:"false" env:"GUM_SPIN_SHOW_OUTPUT"` 14 | ShowError bool `help:"Show output of command only if the command fails" default:"false" env:"GUM_SPIN_SHOW_ERROR"` 15 | ShowStdout bool `help:"Show STDOUT output" default:"false" env:"GUM_SPIN_SHOW_STDOUT"` 16 | ShowStderr bool `help:"Show STDERR errput" default:"false" env:"GUM_SPIN_SHOW_STDERR"` 17 | Spinner string `help:"Spinner type" short:"s" type:"spinner" enum:"line,dot,minidot,jump,pulse,points,globe,moon,monkey,meter,hamburger" default:"dot" env:"GUM_SPIN_SPINNER"` 18 | SpinnerStyle style.Styles `embed:"" prefix:"spinner." set:"defaultForeground=212" envprefix:"GUM_SPIN_SPINNER_"` 19 | Title string `help:"Text to display to user while spinning" default:"Loading..." env:"GUM_SPIN_TITLE"` 20 | TitleStyle style.Styles `embed:"" prefix:"title." envprefix:"GUM_SPIN_TITLE_"` 21 | Align string `help:"Alignment of spinner with regard to the title" short:"a" type:"align" enum:"left,right" default:"left" env:"GUM_SPIN_ALIGN"` 22 | Timeout time.Duration `help:"Timeout until spin command aborts" default:"0s" env:"GUM_SPIN_TIMEOUT"` 23 | } 24 | -------------------------------------------------------------------------------- /spin/pty.go: -------------------------------------------------------------------------------- 1 | package spin 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/charmbracelet/x/term" 7 | "github.com/charmbracelet/x/xpty" 8 | ) 9 | 10 | func openPty(f *os.File) (pty xpty.Pty, err error) { 11 | width, height, err := term.GetSize(f.Fd()) 12 | if err != nil { 13 | return nil, err //nolint:wrapcheck 14 | } 15 | 16 | pty, err = xpty.NewPty(width, height) 17 | if err != nil { 18 | return nil, err //nolint:wrapcheck 19 | } 20 | 21 | return pty, nil 22 | } 23 | -------------------------------------------------------------------------------- /spin/spin.go: -------------------------------------------------------------------------------- 1 | // Package spin provides a shell script interface for the spinner bubble. 2 | // https://github.com/charmbracelet/bubbles/tree/master/spinner 3 | // 4 | // It is useful for displaying that some task is running in the background 5 | // while consuming it's output so that it is not shown to the user. 6 | // 7 | // For example, let's do a long running task: $ sleep 5 8 | // 9 | // We can simply prepend a spinner to this task to show it to the user, while 10 | // performing the task / command in the background. 11 | // 12 | // $ gum spin -t "Taking a nap..." -- sleep 5 13 | // 14 | // The spinner will automatically exit when the task is complete. 15 | package spin 16 | 17 | import ( 18 | "bytes" 19 | "context" 20 | "io" 21 | "os" 22 | "os/exec" 23 | "runtime" 24 | "syscall" 25 | 26 | "github.com/charmbracelet/bubbles/spinner" 27 | tea "github.com/charmbracelet/bubbletea" 28 | "github.com/charmbracelet/x/term" 29 | "github.com/charmbracelet/x/xpty" 30 | ) 31 | 32 | type model struct { 33 | spinner spinner.Model 34 | title string 35 | align string 36 | command []string 37 | quitting bool 38 | isTTY bool 39 | status int 40 | stdout string 41 | stderr string 42 | output string 43 | showStdout bool 44 | showStderr bool 45 | showError bool 46 | err error 47 | } 48 | 49 | var ( 50 | bothbuf bytes.Buffer 51 | outbuf bytes.Buffer 52 | errbuf bytes.Buffer 53 | 54 | executing *exec.Cmd 55 | ) 56 | 57 | type errorMsg error 58 | 59 | type finishCommandMsg struct { 60 | stdout string 61 | stderr string 62 | output string 63 | status int 64 | } 65 | 66 | func commandStart(command []string) tea.Cmd { 67 | return func() tea.Msg { 68 | var args []string 69 | if len(command) > 1 { 70 | args = command[1:] 71 | } 72 | 73 | executing = exec.Command(command[0], args...) //nolint:gosec 74 | executing.Stdin = os.Stdin 75 | 76 | isTerminal := term.IsTerminal(os.Stdout.Fd()) 77 | 78 | // NOTE(@andreynering): We had issues with Git Bash on Windows 79 | // when it comes to handling PTYs, so we're falling back to 80 | // to redirecting stdout/stderr as usual to avoid issues. 81 | //nolint:nestif 82 | if isTerminal && runtime.GOOS == "windows" { 83 | executing.Stdout = io.MultiWriter(&bothbuf, &outbuf) 84 | executing.Stderr = io.MultiWriter(&bothbuf, &errbuf) 85 | _ = executing.Run() 86 | } else if isTerminal { 87 | stdoutPty, err := openPty(os.Stdout) 88 | if err != nil { 89 | return errorMsg(err) 90 | } 91 | defer stdoutPty.Close() //nolint:errcheck 92 | 93 | stderrPty, err := openPty(os.Stderr) 94 | if err != nil { 95 | return errorMsg(err) 96 | } 97 | defer stderrPty.Close() //nolint:errcheck 98 | 99 | if outUnixPty, isOutUnixPty := stdoutPty.(*xpty.UnixPty); isOutUnixPty { 100 | executing.Stdout = outUnixPty.Slave() 101 | } 102 | if errUnixPty, isErrUnixPty := stderrPty.(*xpty.UnixPty); isErrUnixPty { 103 | executing.Stderr = errUnixPty.Slave() 104 | } 105 | 106 | go io.Copy(io.MultiWriter(&bothbuf, &outbuf), stdoutPty) //nolint:errcheck 107 | go io.Copy(io.MultiWriter(&bothbuf, &errbuf), stderrPty) //nolint:errcheck 108 | 109 | if err = stdoutPty.Start(executing); err != nil { 110 | return errorMsg(err) 111 | } 112 | _ = xpty.WaitProcess(context.Background(), executing) 113 | } else { 114 | executing.Stdout = os.Stdout 115 | executing.Stderr = os.Stderr 116 | _ = executing.Run() 117 | } 118 | 119 | status := executing.ProcessState.ExitCode() 120 | if status == -1 { 121 | status = 1 122 | } 123 | 124 | return finishCommandMsg{ 125 | stdout: outbuf.String(), 126 | stderr: errbuf.String(), 127 | output: bothbuf.String(), 128 | status: status, 129 | } 130 | } 131 | } 132 | 133 | func commandAbort() tea.Msg { 134 | if executing != nil && executing.Process != nil { 135 | _ = executing.Process.Signal(syscall.SIGINT) 136 | } 137 | return tea.InterruptMsg{} 138 | } 139 | 140 | func (m model) Init() tea.Cmd { 141 | return tea.Batch( 142 | m.spinner.Tick, 143 | commandStart(m.command), 144 | ) 145 | } 146 | 147 | func (m model) View() string { 148 | if m.quitting { 149 | return "" 150 | } 151 | 152 | var out string 153 | if m.showStderr { 154 | out += errbuf.String() 155 | } 156 | if m.showStdout { 157 | out += outbuf.String() 158 | } 159 | 160 | if !m.isTTY { 161 | return m.title 162 | } 163 | 164 | var header string 165 | if m.align == "left" { 166 | header = m.spinner.View() + " " + m.title 167 | } else { 168 | header = m.title + " " + m.spinner.View() 169 | } 170 | return header + "\n" + out 171 | } 172 | 173 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 174 | switch msg := msg.(type) { 175 | case finishCommandMsg: 176 | m.stdout = msg.stdout 177 | m.stderr = msg.stderr 178 | m.output = msg.output 179 | m.status = msg.status 180 | m.quitting = true 181 | return m, tea.Quit 182 | case tea.KeyMsg: 183 | switch msg.String() { 184 | case "ctrl+c": 185 | return m, commandAbort 186 | } 187 | case errorMsg: 188 | m.err = msg 189 | m.quitting = true 190 | return m, tea.Quit 191 | } 192 | 193 | var cmd tea.Cmd 194 | m.spinner, cmd = m.spinner.Update(msg) 195 | return m, cmd 196 | } 197 | -------------------------------------------------------------------------------- /spin/spinners.go: -------------------------------------------------------------------------------- 1 | package spin 2 | 3 | import "github.com/charmbracelet/bubbles/spinner" 4 | 5 | var spinnerMap = map[string]spinner.Spinner{ 6 | "line": spinner.Line, 7 | "dot": spinner.Dot, 8 | "minidot": spinner.MiniDot, 9 | "jump": spinner.Jump, 10 | "pulse": spinner.Pulse, 11 | "points": spinner.Points, 12 | "globe": spinner.Globe, 13 | "moon": spinner.Moon, 14 | "monkey": spinner.Monkey, 15 | "meter": spinner.Meter, 16 | "hamburger": spinner.Hamburger, 17 | } 18 | -------------------------------------------------------------------------------- /style/ascii_a.txt: -------------------------------------------------------------------------------- 1 | # 2 | # # 3 | # # 4 | # # 5 | ####### 6 | # # 7 | # # 8 | -------------------------------------------------------------------------------- /style/borders.go: -------------------------------------------------------------------------------- 1 | package style 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | // Border maps strings to `lipgloss.Border`s. 6 | var Border map[string]lipgloss.Border = map[string]lipgloss.Border{ 7 | "double": lipgloss.DoubleBorder(), 8 | "hidden": lipgloss.HiddenBorder(), 9 | "none": {}, 10 | "normal": lipgloss.NormalBorder(), 11 | "rounded": lipgloss.RoundedBorder(), 12 | "thick": lipgloss.ThickBorder(), 13 | } 14 | -------------------------------------------------------------------------------- /style/command.go: -------------------------------------------------------------------------------- 1 | // Package style provides a shell script interface for Lip Gloss. 2 | // https://github.com/charmbracelet/lipgloss 3 | // 4 | // It allows you to use Lip Gloss to style text without needing to use Go. All 5 | // of the styling options are available as flags. 6 | package style 7 | 8 | import ( 9 | "errors" 10 | "fmt" 11 | "strings" 12 | 13 | "github.com/charmbracelet/gum/internal/stdin" 14 | ) 15 | 16 | // Run provides a shell script interface for the Lip Gloss styling. 17 | // https://github.com/charmbracelet/lipgloss 18 | func (o Options) Run() error { 19 | var text string 20 | if len(o.Text) > 0 { 21 | text = strings.Join(o.Text, "\n") 22 | } else { 23 | text, _ = stdin.Read(stdin.StripANSI(o.StripANSI)) 24 | if text == "" { 25 | return errors.New("no input provided, see `gum style --help`") 26 | } 27 | } 28 | if o.Trim { 29 | var lines []string 30 | for _, line := range strings.Split(text, "\n") { 31 | lines = append(lines, strings.TrimSpace(line)) 32 | } 33 | text = strings.Join(lines, "\n") 34 | } 35 | fmt.Println(o.Style.ToLipgloss().Render(text)) 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /style/lipgloss.go: -------------------------------------------------------------------------------- 1 | package style 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | 6 | "github.com/charmbracelet/gum/internal/decode" 7 | ) 8 | 9 | // ToLipgloss takes a Styles flag set and returns the corresponding 10 | // lipgloss.Style. 11 | func (s Styles) ToLipgloss() lipgloss.Style { 12 | return lipgloss.NewStyle(). 13 | Background(lipgloss.Color(s.Background)). 14 | Foreground(lipgloss.Color(s.Foreground)). 15 | BorderBackground(lipgloss.Color(s.BorderBackground)). 16 | BorderForeground(lipgloss.Color(s.BorderForeground)). 17 | Align(decode.Align[s.Align]). 18 | Border(Border[s.Border]). 19 | Height(s.Height). 20 | Width(s.Width). 21 | Margin(parseMargin(s.Margin)). 22 | Padding(parsePadding(s.Padding)). 23 | Bold(s.Bold). 24 | Faint(s.Faint). 25 | Italic(s.Italic). 26 | Strikethrough(s.Strikethrough). 27 | Underline(s.Underline) 28 | } 29 | 30 | // ToLipgloss takes a Styles flag set and returns the corresponding 31 | // lipgloss.Style. 32 | func (s StylesNotHidden) ToLipgloss() lipgloss.Style { 33 | return lipgloss.NewStyle(). 34 | Background(lipgloss.Color(s.Background)). 35 | Foreground(lipgloss.Color(s.Foreground)). 36 | BorderBackground(lipgloss.Color(s.BorderBackground)). 37 | BorderForeground(lipgloss.Color(s.BorderForeground)). 38 | Align(decode.Align[s.Align]). 39 | Border(Border[s.Border]). 40 | Height(s.Height). 41 | Width(s.Width). 42 | Margin(parseMargin(s.Margin)). 43 | Padding(parsePadding(s.Padding)). 44 | Bold(s.Bold). 45 | Faint(s.Faint). 46 | Italic(s.Italic). 47 | Strikethrough(s.Strikethrough). 48 | Underline(s.Underline) 49 | } 50 | -------------------------------------------------------------------------------- /style/options.go: -------------------------------------------------------------------------------- 1 | package style 2 | 3 | // Options is the customization options for the style command. 4 | type Options struct { 5 | Text []string `arg:"" optional:"" help:"Text to which to apply the style"` 6 | Trim bool `help:"Trim whitespaces on every input line" default:"false"` 7 | StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_STYLE_STRIP_ANSI"` 8 | Style StylesNotHidden `embed:""` 9 | } 10 | 11 | // Styles is a flag set of possible styles. 12 | // 13 | // It corresponds to the available options in the lipgloss.Style struct. 14 | // 15 | // This flag set is used in other parts of the application to embed styles for 16 | // components, through embedding and prefixing. 17 | type Styles struct { 18 | // Colors 19 | Foreground string `help:"Foreground Color" default:"${defaultForeground}" group:"Style Flags" env:"FOREGROUND"` 20 | Background string `help:"Background Color" default:"${defaultBackground}" group:"Style Flags" env:"BACKGROUND"` 21 | 22 | // Border 23 | Border string `help:"Border Style" enum:"none,hidden,normal,rounded,thick,double" default:"${defaultBorder}" group:"Style Flags" env:"BORDER" hidden:"true"` 24 | BorderBackground string `help:"Border Background Color" group:"Style Flags" default:"${defaultBorderBackground}" env:"BORDER_BACKGROUND" hidden:"true"` 25 | BorderForeground string `help:"Border Foreground Color" group:"Style Flags" default:"${defaultBorderForeground}" env:"BORDER_FOREGROUND" hidden:"true"` 26 | 27 | // Layout 28 | Align string `help:"Text Alignment" enum:"left,center,right,bottom,middle,top" default:"${defaultAlign}" group:"Style Flags" env:"ALIGN" hidden:"true"` 29 | Height int `help:"Text height" default:"${defaultHeight}" group:"Style Flags" env:"HEIGHT" hidden:"true"` 30 | Width int `help:"Text width" default:"${defaultWidth}" group:"Style Flags" env:"WIDTH" hidden:"true"` 31 | Margin string `help:"Text margin" default:"${defaultMargin}" group:"Style Flags" env:"MARGIN" hidden:"true"` 32 | Padding string `help:"Text padding" default:"${defaultPadding}" group:"Style Flags" env:"PADDING" hidden:"true"` 33 | 34 | // Format 35 | Bold bool `help:"Bold text" default:"${defaultBold}" group:"Style Flags" env:"BOLD" hidden:"true"` 36 | Faint bool `help:"Faint text" default:"${defaultFaint}" group:"Style Flags" env:"FAINT" hidden:"true"` 37 | Italic bool `help:"Italicize text" default:"${defaultItalic}" group:"Style Flags" env:"ITALIC" hidden:"true"` 38 | Strikethrough bool `help:"Strikethrough text" default:"${defaultStrikethrough}" group:"Style Flags" env:"STRIKETHROUGH" hidden:"true"` 39 | Underline bool `help:"Underline text" default:"${defaultUnderline}" group:"Style Flags" env:"UNDERLINE" hidden:"true"` 40 | } 41 | 42 | // StylesNotHidden allows the style struct to display full help when not-embedded. 43 | // 44 | // NB: We must duplicate this struct to ensure that `gum style` does not hide 45 | // flags when an error pops up. Ideally, we can dynamically hide or show flags 46 | // based on the command run: https://github.com/alecthomas/kong/issues/316 47 | type StylesNotHidden struct { 48 | // Colors 49 | Foreground string `help:"Foreground Color" default:"${defaultForeground}" group:"Style Flags" env:"FOREGROUND"` 50 | Background string `help:"Background Color" default:"${defaultBackground}" group:"Style Flags" env:"BACKGROUND"` 51 | 52 | // Border 53 | Border string `help:"Border Style" enum:"none,hidden,normal,rounded,thick,double" default:"${defaultBorder}" group:"Style Flags" env:"BORDER"` 54 | BorderBackground string `help:"Border Background Color" group:"Style Flags" default:"${defaultBorderBackground}" env:"BORDER_BACKGROUND"` 55 | BorderForeground string `help:"Border Foreground Color" group:"Style Flags" default:"${defaultBorderForeground}" env:"BORDER_FOREGROUND"` 56 | 57 | // Layout 58 | Align string `help:"Text Alignment" enum:"left,center,right,bottom,middle,top" default:"${defaultAlign}" group:"Style Flags" env:"ALIGN"` 59 | Height int `help:"Text height" default:"${defaultHeight}" group:"Style Flags" env:"HEIGHT"` 60 | Width int `help:"Text width" default:"${defaultWidth}" group:"Style Flags" env:"WIDTH"` 61 | Margin string `help:"Text margin" default:"${defaultMargin}" group:"Style Flags" env:"MARGIN"` 62 | Padding string `help:"Text padding" default:"${defaultPadding}" group:"Style Flags" env:"PADDING"` 63 | 64 | // Format 65 | Bold bool `help:"Bold text" default:"${defaultBold}" group:"Style Flags" env:"BOLD"` 66 | Faint bool `help:"Faint text" default:"${defaultFaint}" group:"Style Flags" env:"FAINT"` 67 | Italic bool `help:"Italicize text" default:"${defaultItalic}" group:"Style Flags" env:"ITALIC"` 68 | Strikethrough bool `help:"Strikethrough text" default:"${defaultStrikethrough}" group:"Style Flags" env:"STRIKETHROUGH"` 69 | Underline bool `help:"Underline text" default:"${defaultUnderline}" group:"Style Flags" env:"UNDERLINE"` 70 | } 71 | -------------------------------------------------------------------------------- /style/spacing.go: -------------------------------------------------------------------------------- 1 | package style 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | minTokens = 1 10 | halfTokens = 2 11 | maxTokens = 4 12 | ) 13 | 14 | // parsePadding parses 1 - 4 integers from a string and returns them in a top, 15 | // right, bottom, left order for use in the lipgloss.Padding() method. 16 | func parsePadding(s string) (int, int, int, int) { 17 | var ints [maxTokens]int 18 | 19 | tokens := strings.Split(s, " ") 20 | 21 | if len(tokens) > maxTokens { 22 | return 0, 0, 0, 0 23 | } 24 | 25 | // All tokens must be an integer 26 | for i, token := range tokens { 27 | parsed, err := strconv.Atoi(token) 28 | if err != nil { 29 | return 0, 0, 0, 0 30 | } 31 | ints[i] = parsed 32 | } 33 | 34 | if len(tokens) == minTokens { 35 | return ints[0], ints[0], ints[0], ints[0] 36 | } 37 | 38 | if len(tokens) == halfTokens { 39 | return ints[0], ints[1], ints[0], ints[1] 40 | } 41 | 42 | if len(tokens) == maxTokens { 43 | return ints[0], ints[1], ints[2], ints[3] 44 | } 45 | 46 | return 0, 0, 0, 0 47 | } 48 | 49 | // parseMargin is an alias for parsePadding since they involve the same logic 50 | // to parse integers to the same format. 51 | var parseMargin = parsePadding 52 | -------------------------------------------------------------------------------- /table/bom.csv: -------------------------------------------------------------------------------- 1 | "first_name","last_name","username" 2 | "Rob","Pike",rob 3 | Ken,Thompson,ken 4 | "Robert","Griesemer","gri" 5 | -------------------------------------------------------------------------------- /table/comma.csv: -------------------------------------------------------------------------------- 1 | Bubble Gum,Price,Ingredients 2 | Strawberry,$0.88,"Water,Sugar" 3 | Guava,$1.00,"Guava Flavoring,Food Coloring,Xanthan Gum" 4 | Orange,$0.99,"Sugar,Dextrose,Glucose" 5 | Cinnamon,$0.50,"Cin""na""mon" -------------------------------------------------------------------------------- /table/command.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/charmbracelet/bubbles/help" 9 | "github.com/charmbracelet/bubbles/table" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/gum/internal/stdin" 12 | "github.com/charmbracelet/gum/internal/timeout" 13 | "github.com/charmbracelet/gum/style" 14 | "github.com/charmbracelet/lipgloss" 15 | ltable "github.com/charmbracelet/lipgloss/table" 16 | "golang.org/x/text/encoding" 17 | "golang.org/x/text/encoding/unicode" 18 | "golang.org/x/text/transform" 19 | ) 20 | 21 | // Run provides a shell script interface for rendering tabular data (CSV). 22 | func (o Options) Run() error { 23 | var input *os.File 24 | if o.File != "" { 25 | var err error 26 | input, err = os.Open(o.File) 27 | if err != nil { 28 | return fmt.Errorf("could not render file: %w", err) 29 | } 30 | } else { 31 | if stdin.IsEmpty() { 32 | return fmt.Errorf("no data provided") 33 | } 34 | input = os.Stdin 35 | } 36 | defer input.Close() //nolint: errcheck 37 | 38 | transformer := unicode.BOMOverride(encoding.Nop.NewDecoder()) 39 | reader := csv.NewReader(transform.NewReader(input, transformer)) 40 | reader.LazyQuotes = o.LazyQuotes 41 | reader.FieldsPerRecord = o.FieldsPerRecord 42 | separatorRunes := []rune(o.Separator) 43 | if len(separatorRunes) != 1 { 44 | return fmt.Errorf("separator must be single character") 45 | } 46 | reader.Comma = separatorRunes[0] 47 | 48 | writer := csv.NewWriter(os.Stdout) 49 | writer.Comma = separatorRunes[0] 50 | 51 | var columnNames []string 52 | var err error 53 | // If no columns are provided we'll use the first row of the CSV as the 54 | // column names. 55 | if len(o.Columns) <= 0 { 56 | columnNames, err = reader.Read() 57 | if err != nil { 58 | return fmt.Errorf("unable to parse columns") 59 | } 60 | } else { 61 | columnNames = o.Columns 62 | } 63 | 64 | data, err := reader.ReadAll() 65 | if err != nil { 66 | return fmt.Errorf("invalid data provided") 67 | } 68 | columns := make([]table.Column, 0, len(columnNames)) 69 | 70 | for i, title := range columnNames { 71 | width := lipgloss.Width(title) 72 | if len(o.Widths) > i { 73 | width = o.Widths[i] 74 | } 75 | columns = append(columns, table.Column{ 76 | Title: title, 77 | Width: width, 78 | }) 79 | } 80 | 81 | defaultStyles := table.DefaultStyles() 82 | 83 | styles := table.Styles{ 84 | Cell: defaultStyles.Cell.Inherit(o.CellStyle.ToLipgloss()), 85 | Header: defaultStyles.Header.Inherit(o.HeaderStyle.ToLipgloss()), 86 | Selected: o.SelectedStyle.ToLipgloss(), 87 | } 88 | 89 | rows := make([]table.Row, 0, len(data)) 90 | for row := range data { 91 | if len(data[row]) > len(columns) { 92 | return fmt.Errorf("invalid number of columns") 93 | } 94 | 95 | // fixes the data in case we have more columns than rows: 96 | for len(data[row]) < len(columns) { 97 | data[row] = append(data[row], "") 98 | } 99 | 100 | for i, col := range data[row] { 101 | if len(o.Widths) == 0 { 102 | width := lipgloss.Width(col) 103 | if width > columns[i].Width { 104 | columns[i].Width = width 105 | } 106 | } 107 | } 108 | 109 | rows = append(rows, table.Row(data[row])) 110 | } 111 | 112 | if o.Print { 113 | table := ltable.New(). 114 | Headers(columnNames...). 115 | Rows(data...). 116 | BorderStyle(o.BorderStyle.ToLipgloss()). 117 | Border(style.Border[o.Border]). 118 | StyleFunc(func(row, _ int) lipgloss.Style { 119 | if row == 0 { 120 | return styles.Header 121 | } 122 | return styles.Cell 123 | }) 124 | 125 | fmt.Println(table.Render()) 126 | return nil 127 | } 128 | 129 | opts := []table.Option{ 130 | table.WithColumns(columns), 131 | table.WithFocused(true), 132 | table.WithRows(rows), 133 | table.WithStyles(styles), 134 | } 135 | if o.Height > 0 { 136 | opts = append(opts, table.WithHeight(o.Height)) 137 | } 138 | 139 | table := table.New(opts...) 140 | 141 | ctx, cancel := timeout.Context(o.Timeout) 142 | defer cancel() 143 | 144 | m := model{ 145 | table: table, 146 | showHelp: o.ShowHelp, 147 | hideCount: o.HideCount, 148 | help: help.New(), 149 | keymap: defaultKeymap(), 150 | } 151 | tm, err := tea.NewProgram( 152 | m, 153 | tea.WithOutput(os.Stderr), 154 | tea.WithContext(ctx), 155 | ).Run() 156 | if err != nil { 157 | return fmt.Errorf("failed to start tea program: %w", err) 158 | } 159 | 160 | if tm == nil { 161 | return fmt.Errorf("failed to get selection") 162 | } 163 | 164 | m = tm.(model) 165 | if o.ReturnColumn > 0 && o.ReturnColumn <= len(m.selected) { 166 | if err = writer.Write([]string{m.selected[o.ReturnColumn-1]}); err != nil { 167 | return fmt.Errorf("failed to write col %d of selected row: %w", o.ReturnColumn, err) 168 | } 169 | } else { 170 | if err = writer.Write([]string(m.selected)); err != nil { 171 | return fmt.Errorf("failed to write selected row: %w", err) 172 | } 173 | } 174 | 175 | writer.Flush() 176 | 177 | return nil 178 | } 179 | -------------------------------------------------------------------------------- /table/example.csv: -------------------------------------------------------------------------------- 1 | Bubble Gum Flavor,Price 2 | Strawberry,$0.99 3 | Cherry,$0.50 4 | Banana,$0.75 5 | Orange,$0.25 6 | Lemon,$0.50 7 | Lime,$0.50 8 | Grape,$0.50 9 | Watermelon,$0.50 10 | Pineapple,$0.50 11 | Blueberry,$0.50 12 | Raspberry,$0.50 13 | Cranberry,$0.50 14 | Peach,$0.50 15 | Apple,$0.50 16 | Mango,$0.50 17 | Pomegranate,$0.50 18 | Coconut,$0.50 19 | Cinnamon,$0.50 20 | -------------------------------------------------------------------------------- /table/invalid.csv: -------------------------------------------------------------------------------- 1 | Bubble Gum Flavor 2 | Strawberry,$0.99 3 | Cherry,$0.50 4 | Banana,$0.75 5 | Orange 6 | Lemon,$0.50 7 | Lime,$0.50 8 | Grape,$0.50 9 | Watermelon,$0.50 10 | Pineapple,$0.50 11 | Blueberry,$0.50 12 | Raspberry,$0.50 13 | Cranberry,$0.50 14 | Peach,$0.50 15 | Apple,$0.50 16 | Mango,$0.50 17 | Pomegranate,$0.50 18 | Coconut,$0.50 19 | Cinnamon,$0.50 20 | -------------------------------------------------------------------------------- /table/options.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/gum/style" 7 | ) 8 | 9 | // Options is the customization options for the table command. 10 | type Options struct { 11 | Separator string `short:"s" help:"Row separator" default:","` 12 | Columns []string `short:"c" help:"Column names"` 13 | Widths []int `short:"w" help:"Column widths"` 14 | Height int `help:"Table height" default:"0"` 15 | Print bool `short:"p" help:"static print" default:"false"` 16 | File string `short:"f" help:"file path" default:""` 17 | Border string `short:"b" help:"border style" default:"rounded" enum:"rounded,thick,normal,hidden,double,none"` 18 | ShowHelp bool `help:"Show help keybinds" default:"true" negatable:"" env:"GUM_TABLE_SHOW_HELP"` 19 | HideCount bool `help:"Hide item count on help keybinds" default:"false" negatable:"" env:"GUM_TABLE_HIDE_COUNT"` 20 | LazyQuotes bool `help:"If LazyQuotes is true, a quote may appear in an unquoted field and a non-doubled quote may appear in a quoted field" default:"false" env:"GUM_TABLE_LAZY_QUOTES"` 21 | FieldsPerRecord int `help:"Sets the number of expected fields per record" default:"0" env:"GUM_TABLE_FIELDS_PER_RECORD"` 22 | 23 | BorderStyle style.Styles `embed:"" prefix:"border." envprefix:"GUM_TABLE_BORDER_"` 24 | CellStyle style.Styles `embed:"" prefix:"cell." envprefix:"GUM_TABLE_CELL_"` 25 | HeaderStyle style.Styles `embed:"" prefix:"header." envprefix:"GUM_TABLE_HEADER_"` 26 | SelectedStyle style.Styles `embed:"" prefix:"selected." set:"defaultForeground=212" envprefix:"GUM_TABLE_SELECTED_"` 27 | ReturnColumn int `short:"r" help:"Which column number should be returned instead of whole row as string. Default=0 returns whole Row" default:"0"` 28 | Timeout time.Duration `help:"Timeout until choose returns selected element" default:"0s" env:"GUM_TABLE_TIMEOUT"` 29 | } 30 | -------------------------------------------------------------------------------- /table/table.go: -------------------------------------------------------------------------------- 1 | // Package table provides a shell script interface for the table bubble. 2 | // https://github.com/charmbracelet/bubbles/tree/master/table 3 | // 4 | // It is useful to render tabular (CSV) data in a terminal and allows 5 | // the user to select a row from the table. 6 | // 7 | // Let's render a table of gum flavors: 8 | // 9 | // $ gum table <<< "Flavor,Price\nStrawberry,$0.50\nBanana,$0.99\nCherry,$0.75" 10 | // 11 | // Flavor Price 12 | // Strawberry $0.50 13 | // Banana $0.99 14 | // Cherry $0.75 15 | package table 16 | 17 | import ( 18 | "fmt" 19 | "strconv" 20 | 21 | "github.com/charmbracelet/bubbles/help" 22 | "github.com/charmbracelet/bubbles/key" 23 | "github.com/charmbracelet/bubbles/table" 24 | tea "github.com/charmbracelet/bubbletea" 25 | ) 26 | 27 | type keymap struct { 28 | Navigate, 29 | Select, 30 | Quit, 31 | Abort key.Binding 32 | } 33 | 34 | // FullHelp implements help.KeyMap. 35 | func (k keymap) FullHelp() [][]key.Binding { return nil } 36 | 37 | // ShortHelp implements help.KeyMap. 38 | func (k keymap) ShortHelp() []key.Binding { 39 | return []key.Binding{ 40 | k.Navigate, 41 | k.Select, 42 | k.Quit, 43 | } 44 | } 45 | 46 | func defaultKeymap() keymap { 47 | return keymap{ 48 | Navigate: key.NewBinding( 49 | key.WithKeys("up", "down"), 50 | key.WithHelp("↓↑", "navigate"), 51 | ), 52 | Select: key.NewBinding( 53 | key.WithKeys("enter"), 54 | key.WithHelp("enter", "select"), 55 | ), 56 | Quit: key.NewBinding( 57 | key.WithKeys("esc", "ctrl+q", "q"), 58 | key.WithHelp("esc", "quit"), 59 | ), 60 | Abort: key.NewBinding( 61 | key.WithKeys("ctrl+c"), 62 | key.WithHelp("ctrl+c", "abort"), 63 | ), 64 | } 65 | } 66 | 67 | type model struct { 68 | table table.Model 69 | selected table.Row 70 | quitting bool 71 | showHelp bool 72 | hideCount bool 73 | help help.Model 74 | keymap keymap 75 | } 76 | 77 | func (m model) Init() tea.Cmd { return nil } 78 | 79 | func (m model) countView() string { 80 | if m.hideCount { 81 | return "" 82 | } 83 | 84 | padding := strconv.Itoa(numLen(len(m.table.Rows()))) 85 | return m.help.Styles.FullDesc.Render(fmt.Sprintf( 86 | "%"+padding+"d/%d%s", 87 | m.table.Cursor()+1, 88 | len(m.table.Rows()), 89 | m.help.ShortSeparator, 90 | )) 91 | } 92 | 93 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 94 | var cmd tea.Cmd 95 | 96 | switch msg := msg.(type) { 97 | case tea.KeyMsg: 98 | km := m.keymap 99 | switch { 100 | case key.Matches(msg, km.Select): 101 | m.selected = m.table.SelectedRow() 102 | m.quitting = true 103 | return m, tea.Quit 104 | case key.Matches(msg, km.Quit): 105 | m.quitting = true 106 | return m, tea.Quit 107 | case key.Matches(msg, km.Abort): 108 | m.quitting = true 109 | return m, tea.Interrupt 110 | } 111 | } 112 | 113 | m.table, cmd = m.table.Update(msg) 114 | return m, cmd 115 | } 116 | 117 | func (m model) View() string { 118 | if m.quitting { 119 | return "" 120 | } 121 | s := m.table.View() 122 | if m.showHelp { 123 | s += "\n" + m.countView() + m.help.View(m.keymap) 124 | } 125 | return s 126 | } 127 | 128 | func numLen(i int) int { 129 | if i == 0 { 130 | return 1 131 | } 132 | count := 0 133 | for i != 0 { 134 | i /= 10 135 | count++ 136 | } 137 | return count 138 | } 139 | -------------------------------------------------------------------------------- /version/command.go: -------------------------------------------------------------------------------- 1 | // Package version the version command. 2 | package version 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/Masterminds/semver/v3" 8 | "github.com/alecthomas/kong" 9 | ) 10 | 11 | // Run check that a given version matches a semantic version constraint. 12 | func (o Options) Run(ctx *kong.Context) error { 13 | c, err := semver.NewConstraint(o.Constraint) 14 | if err != nil { 15 | return fmt.Errorf("could not parse range %s: %w", o.Constraint, err) 16 | } 17 | current := ctx.Model.Vars()["versionNumber"] 18 | v, err := semver.NewVersion(current) 19 | if err != nil { 20 | return fmt.Errorf("could not parse version %s: %w", current, err) 21 | } 22 | if !c.Check(v) { 23 | return fmt.Errorf("gum version %q is not within given range %q", current, o.Constraint) 24 | } 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /version/options.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | // Options is the set of options that can be used with version. 4 | type Options struct { 5 | Constraint string `arg:"" help:"Semantic version constraint"` 6 | } 7 | -------------------------------------------------------------------------------- /write/command.go: -------------------------------------------------------------------------------- 1 | package write 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/charmbracelet/bubbles/help" 10 | "github.com/charmbracelet/bubbles/textarea" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/gum/cursor" 13 | "github.com/charmbracelet/gum/internal/stdin" 14 | "github.com/charmbracelet/gum/internal/timeout" 15 | ) 16 | 17 | // Run provides a shell script interface for the text area bubble. 18 | // https://github.com/charmbracelet/bubbles/textarea 19 | func (o Options) Run() error { 20 | in, _ := stdin.Read(stdin.StripANSI(o.StripANSI)) 21 | if in != "" && o.Value == "" { 22 | o.Value = strings.ReplaceAll(in, "\r", "") 23 | } 24 | 25 | a := textarea.New() 26 | a.Focus() 27 | 28 | a.Prompt = o.Prompt 29 | a.Placeholder = o.Placeholder 30 | a.ShowLineNumbers = o.ShowLineNumbers 31 | a.CharLimit = o.CharLimit 32 | a.MaxHeight = o.MaxLines 33 | 34 | style := textarea.Style{ 35 | Base: o.BaseStyle.ToLipgloss(), 36 | Placeholder: o.PlaceholderStyle.ToLipgloss(), 37 | CursorLine: o.CursorLineStyle.ToLipgloss(), 38 | CursorLineNumber: o.CursorLineNumberStyle.ToLipgloss(), 39 | EndOfBuffer: o.EndOfBufferStyle.ToLipgloss(), 40 | LineNumber: o.LineNumberStyle.ToLipgloss(), 41 | Prompt: o.PromptStyle.ToLipgloss(), 42 | } 43 | 44 | a.BlurredStyle = style 45 | a.FocusedStyle = style 46 | a.Cursor.Style = o.CursorStyle.ToLipgloss() 47 | a.Cursor.SetMode(cursor.Modes[o.CursorMode]) 48 | 49 | a.SetWidth(o.Width) 50 | a.SetHeight(o.Height) 51 | a.SetValue(o.Value) 52 | 53 | m := model{ 54 | textarea: a, 55 | header: o.Header, 56 | headerStyle: o.HeaderStyle.ToLipgloss(), 57 | autoWidth: o.Width < 1, 58 | help: help.New(), 59 | showHelp: o.ShowHelp, 60 | keymap: defaultKeymap(), 61 | } 62 | 63 | m.textarea.KeyMap.InsertNewline = m.keymap.InsertNewline 64 | 65 | ctx, cancel := timeout.Context(o.Timeout) 66 | defer cancel() 67 | 68 | p := tea.NewProgram( 69 | m, 70 | tea.WithOutput(os.Stderr), 71 | tea.WithReportFocus(), 72 | tea.WithContext(ctx), 73 | ) 74 | tm, err := p.Run() 75 | if err != nil { 76 | return fmt.Errorf("failed to run write: %w", err) 77 | } 78 | m = tm.(model) 79 | if !m.submitted { 80 | return errors.New("not submitted") 81 | } 82 | fmt.Println(m.textarea.Value()) 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /write/options.go: -------------------------------------------------------------------------------- 1 | package write 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/gum/style" 7 | ) 8 | 9 | // Options are the customization options for the textarea. 10 | type Options struct { 11 | Width int `help:"Text area width (0 for terminal width)" default:"0" env:"GUM_WRITE_WIDTH"` 12 | Height int `help:"Text area height" default:"5" env:"GUM_WRITE_HEIGHT"` 13 | Header string `help:"Header value" default:"" env:"GUM_WRITE_HEADER"` 14 | Placeholder string `help:"Placeholder value" default:"Write something..." env:"GUM_WRITE_PLACEHOLDER"` 15 | Prompt string `help:"Prompt to display" default:"┃ " env:"GUM_WRITE_PROMPT"` 16 | ShowCursorLine bool `help:"Show cursor line" default:"false" env:"GUM_WRITE_SHOW_CURSOR_LINE"` 17 | ShowLineNumbers bool `help:"Show line numbers" default:"false" env:"GUM_WRITE_SHOW_LINE_NUMBERS"` 18 | Value string `help:"Initial value (can be passed via stdin)" default:"" env:"GUM_WRITE_VALUE"` 19 | CharLimit int `help:"Maximum value length (0 for no limit)" default:"0"` 20 | MaxLines int `help:"Maximum number of lines (0 for no limit)" default:"0"` 21 | ShowHelp bool `help:"Show help key binds" negatable:"" default:"true" env:"GUM_WRITE_SHOW_HELP"` 22 | CursorMode string `prefix:"cursor." name:"mode" help:"Cursor mode" default:"blink" enum:"blink,hide,static" env:"GUM_WRITE_CURSOR_MODE"` 23 | Timeout time.Duration `help:"Timeout until choose returns selected element" default:"0s" env:"GUM_WRITE_TIMEOUT"` 24 | StripANSI bool `help:"Strip ANSI sequences when reading from STDIN" default:"true" negatable:"" env:"GUM_WRITE_STRIP_ANSI"` 25 | 26 | BaseStyle style.Styles `embed:"" prefix:"base." envprefix:"GUM_WRITE_BASE_"` 27 | CursorLineNumberStyle style.Styles `embed:"" prefix:"cursor-line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_CURSOR_LINE_NUMBER_"` 28 | CursorLineStyle style.Styles `embed:"" prefix:"cursor-line." envprefix:"GUM_WRITE_CURSOR_LINE_"` 29 | CursorStyle style.Styles `embed:"" prefix:"cursor." set:"defaultForeground=212" envprefix:"GUM_WRITE_CURSOR_"` 30 | EndOfBufferStyle style.Styles `embed:"" prefix:"end-of-buffer." set:"defaultForeground=0" envprefix:"GUM_WRITE_END_OF_BUFFER_"` 31 | LineNumberStyle style.Styles `embed:"" prefix:"line-number." set:"defaultForeground=7" envprefix:"GUM_WRITE_LINE_NUMBER_"` 32 | HeaderStyle style.Styles `embed:"" prefix:"header." set:"defaultForeground=240" envprefix:"GUM_WRITE_HEADER_"` 33 | PlaceholderStyle style.Styles `embed:"" prefix:"placeholder." set:"defaultForeground=240" envprefix:"GUM_WRITE_PLACEHOLDER_"` 34 | PromptStyle style.Styles `embed:"" prefix:"prompt." set:"defaultForeground=7" envprefix:"GUM_WRITE_PROMPT_"` 35 | } 36 | -------------------------------------------------------------------------------- /write/write.go: -------------------------------------------------------------------------------- 1 | // Package write provides a shell script interface for the text area bubble. 2 | // https://github.com/charmbracelet/bubbles/tree/master/textarea 3 | // 4 | // It can be used to ask the user to write some long form of text (multi-line) 5 | // input. The text the user entered will be sent to stdout. 6 | // Text entry is completed with CTRL+D and aborted with CTRL+C or Escape. 7 | // 8 | // $ gum write > output.text 9 | package write 10 | 11 | import ( 12 | "io" 13 | "os" 14 | 15 | "github.com/charmbracelet/bubbles/help" 16 | "github.com/charmbracelet/bubbles/key" 17 | "github.com/charmbracelet/bubbles/textarea" 18 | tea "github.com/charmbracelet/bubbletea" 19 | "github.com/charmbracelet/lipgloss" 20 | "github.com/charmbracelet/x/editor" 21 | ) 22 | 23 | type keymap struct { 24 | textarea.KeyMap 25 | Submit key.Binding 26 | Quit key.Binding 27 | Abort key.Binding 28 | OpenInEditor key.Binding 29 | } 30 | 31 | // FullHelp implements help.KeyMap. 32 | func (k keymap) FullHelp() [][]key.Binding { return nil } 33 | 34 | // ShortHelp implements help.KeyMap. 35 | func (k keymap) ShortHelp() []key.Binding { 36 | return []key.Binding{ 37 | k.InsertNewline, 38 | k.OpenInEditor, 39 | k.Submit, 40 | } 41 | } 42 | 43 | func defaultKeymap() keymap { 44 | km := textarea.DefaultKeyMap 45 | km.InsertNewline = key.NewBinding( 46 | key.WithKeys("ctrl+j"), 47 | key.WithHelp("ctrl+j", "insert newline"), 48 | ) 49 | return keymap{ 50 | KeyMap: km, 51 | Quit: key.NewBinding( 52 | key.WithKeys("esc"), 53 | key.WithHelp("esc", "quit"), 54 | ), 55 | Abort: key.NewBinding( 56 | key.WithKeys("ctrl+c"), 57 | key.WithHelp("ctrl+c", "cancel"), 58 | ), 59 | OpenInEditor: key.NewBinding( 60 | key.WithKeys("ctrl+e"), 61 | key.WithHelp("ctrl+e", "open editor"), 62 | ), 63 | Submit: key.NewBinding( 64 | key.WithKeys("enter"), 65 | key.WithHelp("enter", "submit"), 66 | ), 67 | } 68 | } 69 | 70 | type model struct { 71 | autoWidth bool 72 | header string 73 | headerStyle lipgloss.Style 74 | quitting bool 75 | submitted bool 76 | textarea textarea.Model 77 | showHelp bool 78 | help help.Model 79 | keymap keymap 80 | } 81 | 82 | func (m model) Init() tea.Cmd { return textarea.Blink } 83 | 84 | func (m model) View() string { 85 | if m.quitting { 86 | return "" 87 | } 88 | 89 | var parts []string 90 | 91 | // Display the header above the text area if it is not empty. 92 | if m.header != "" { 93 | parts = append(parts, m.headerStyle.Render(m.header)) 94 | } 95 | parts = append(parts, m.textarea.View()) 96 | if m.showHelp { 97 | parts = append(parts, "", m.help.View(m.keymap)) 98 | } 99 | return lipgloss.JoinVertical(lipgloss.Left, parts...) 100 | } 101 | 102 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 103 | switch msg := msg.(type) { 104 | case tea.WindowSizeMsg: 105 | if m.autoWidth { 106 | m.textarea.SetWidth(msg.Width) 107 | } 108 | case tea.FocusMsg, tea.BlurMsg: 109 | var cmd tea.Cmd 110 | m.textarea, cmd = m.textarea.Update(msg) 111 | return m, cmd 112 | case startEditorMsg: 113 | return m, openEditor(msg.path, msg.lineno) 114 | case editorFinishedMsg: 115 | if msg.err != nil { 116 | m.quitting = true 117 | return m, tea.Interrupt 118 | } 119 | m.textarea.SetValue(msg.content) 120 | case tea.KeyMsg: 121 | km := m.keymap 122 | switch { 123 | case key.Matches(msg, km.Abort): 124 | m.quitting = true 125 | return m, tea.Interrupt 126 | case key.Matches(msg, km.Quit): 127 | m.quitting = true 128 | return m, tea.Quit 129 | case key.Matches(msg, km.Submit): 130 | m.quitting = true 131 | m.submitted = true 132 | return m, tea.Quit 133 | case key.Matches(msg, km.OpenInEditor): 134 | //nolint: gosec 135 | return m, createTempFile(m.textarea.Value(), uint(m.textarea.Line())+1) 136 | } 137 | } 138 | 139 | var cmd tea.Cmd 140 | m.textarea, cmd = m.textarea.Update(msg) 141 | return m, cmd 142 | } 143 | 144 | type startEditorMsg struct { 145 | path string 146 | lineno uint 147 | } 148 | 149 | type editorFinishedMsg struct { 150 | content string 151 | err error 152 | } 153 | 154 | func createTempFile(content string, lineno uint) tea.Cmd { 155 | return func() tea.Msg { 156 | f, err := os.CreateTemp("", "gum.*.md") 157 | if err != nil { 158 | return editorFinishedMsg{err: err} 159 | } 160 | _, err = io.WriteString(f, content) 161 | if err != nil { 162 | return editorFinishedMsg{err: err} 163 | } 164 | _ = f.Close() 165 | return startEditorMsg{ 166 | path: f.Name(), 167 | lineno: lineno, 168 | } 169 | } 170 | } 171 | 172 | func openEditor(path string, lineno uint) tea.Cmd { 173 | cb := func(err error) tea.Msg { 174 | if err != nil { 175 | return editorFinishedMsg{ 176 | err: err, 177 | } 178 | } 179 | bts, err := os.ReadFile(path) 180 | if err != nil { 181 | return editorFinishedMsg{err: err} 182 | } 183 | return editorFinishedMsg{ 184 | content: string(bts), 185 | } 186 | } 187 | cmd, err := editor.Cmd( 188 | "Gum", 189 | path, 190 | editor.LineNumber(lineno), 191 | editor.EndOfLine(), 192 | ) 193 | if err != nil { 194 | return func() tea.Msg { return cb(err) } 195 | } 196 | return tea.ExecProcess(cmd, cb) 197 | } 198 | --------------------------------------------------------------------------------