From 7bdbc5267a6463b48868e83880d80806fe63faf2 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:59:15 -0400 Subject: [PATCH 01/10] fix(tui): disable bubbletea hard scroll optimization unconditionally Vendors a patched charm.land/bubbletea/v2 (via a local go.mod replace) with hard scroll optimization (using terminal scroll-region sequences to shift existing lines instead of repainting) disabled unconditionally, instead of only on GOOS=="windows". That GOOS check only catches a Windows terminal driving the program directly. It misses a Linux process running inside a multipass VM whose output is ultimately displayed by Windows Terminal over a remote shell, which corrupts rendering badly (garbled/overlapping text, worse on resize) the same way the check exists to prevent. There's no reliable way to detect that remote hop from inside the VM, so scroll optimization is now off in all cases. --- go.mod | 7 + .../bubbletea-v2-patched/.gitattributes | 1 + .../.github/ISSUE_TEMPLATE/bug.yml | 61 + .../.github/ISSUE_TEMPLATE/bug_report.md | 37 + .../.github/ISSUE_TEMPLATE/config.yml | 5 + .../.github/ISSUE_TEMPLATE/feature_request.md | 20 + .../.github/dependabot.yml | 91 ++ .../.github/workflows/build.yml | 20 + .../.github/workflows/coverage.yml | 29 + .../.github/workflows/dependabot-sync.yml | 17 + .../.github/workflows/examples.yml | 38 + .../.github/workflows/lint-sync.yml | 14 + .../.github/workflows/lint.yml | 12 + .../.github/workflows/release.yml | 29 + third_party/bubbletea-v2-patched/.gitignore | 23 + .../bubbletea-v2-patched/.golangci.yml | 47 + .../bubbletea-v2-patched/.goreleaser.yml | 5 + third_party/bubbletea-v2-patched/LICENSE | 21 + third_party/bubbletea-v2-patched/README.md | 402 +++++ .../bubbletea-v2-patched/Taskfile.yaml | 38 + .../bubbletea-v2-patched/UPGRADE_GUIDE_V2.md | 573 +++++++ third_party/bubbletea-v2-patched/clipboard.go | 70 + third_party/bubbletea-v2-patched/color.go | 91 ++ third_party/bubbletea-v2-patched/commands.go | 175 ++ .../bubbletea-v2-patched/commands_test.go | 67 + .../bubbletea-v2-patched/cursed_renderer.go | 868 ++++++++++ .../cursed_renderer_test.go | 80 + third_party/bubbletea-v2-patched/cursor.go | 28 + third_party/bubbletea-v2-patched/environ.go | 34 + third_party/bubbletea-v2-patched/exec.go | 129 ++ third_party/bubbletea-v2-patched/exec_test.go | 137 ++ third_party/bubbletea-v2-patched/focus.go | 9 + third_party/bubbletea-v2-patched/go.mod | 28 + third_party/bubbletea-v2-patched/go.sum | 36 + third_party/bubbletea-v2-patched/input.go | 54 + third_party/bubbletea-v2-patched/key.go | 371 +++++ third_party/bubbletea-v2-patched/keyboard.go | 59 + third_party/bubbletea-v2-patched/logging.go | 53 + .../bubbletea-v2-patched/logging_test.go | 29 + third_party/bubbletea-v2-patched/mod.go | 27 + third_party/bubbletea-v2-patched/mouse.go | 144 ++ .../bubbletea-v2-patched/nil_renderer.go | 53 + third_party/bubbletea-v2-patched/options.go | 168 ++ .../bubbletea-v2-patched/options_test.go | 106 ++ third_party/bubbletea-v2-patched/paste.go | 20 + third_party/bubbletea-v2-patched/profile.go | 15 + third_party/bubbletea-v2-patched/raw.go | 37 + third_party/bubbletea-v2-patched/renderer.go | 104 ++ third_party/bubbletea-v2-patched/screen.go | 68 + .../bubbletea-v2-patched/screen_test.go | 207 +++ .../bubbletea-v2-patched/signals_unix.go | 33 + .../bubbletea-v2-patched/signals_windows.go | 10 + third_party/bubbletea-v2-patched/tea.go | 1439 +++++++++++++++++ third_party/bubbletea-v2-patched/tea_test.go | 671 ++++++++ third_party/bubbletea-v2-patched/termcap.go | 48 + .../bubbletea-v2-patched/termios_bsd.go | 13 + .../bubbletea-v2-patched/termios_other.go | 8 + .../bubbletea-v2-patched/termios_unix.go | 14 + .../bubbletea-v2-patched/termios_windows.go | 11 + .../TestClearMsg/bg_fg_cur_color.golden | 1 + .../testdata/TestClearMsg/clear_screen.golden | 1 + .../TestClearMsg/read_set_clipboard.golden | 1 + .../testdata/TestViewModel/altscreen.golden | 1 + .../TestViewModel/altscreen_autoexit.golden | 1 + .../TestViewModel/bg_set_color.golden | 1 + .../TestViewModel/bp_stop_start.golden | 1 + .../testdata/TestViewModel/cursor_hide.golden | 1 + .../TestViewModel/cursor_hideshow.golden | 1 + .../kitty_stop_startreleases.golden | 1 + .../TestViewModel/mouse_allmotion.golden | 1 + .../TestViewModel/mouse_cellmotion.golden | 1 + .../TestViewModel/mouse_disable.golden | 1 + third_party/bubbletea-v2-patched/tty.go | 136 ++ third_party/bubbletea-v2-patched/tty_unix.go | 47 + .../bubbletea-v2-patched/tty_windows.go | 64 + third_party/bubbletea-v2-patched/xterm.go | 22 + 76 files changed, 7286 insertions(+) create mode 100644 third_party/bubbletea-v2-patched/.gitattributes create mode 100644 third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug.yml create mode 100644 third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug_report.md create mode 100644 third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/config.yml create mode 100644 third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/feature_request.md create mode 100644 third_party/bubbletea-v2-patched/.github/dependabot.yml create mode 100644 third_party/bubbletea-v2-patched/.github/workflows/build.yml create mode 100644 third_party/bubbletea-v2-patched/.github/workflows/coverage.yml create mode 100644 third_party/bubbletea-v2-patched/.github/workflows/dependabot-sync.yml create mode 100644 third_party/bubbletea-v2-patched/.github/workflows/examples.yml create mode 100644 third_party/bubbletea-v2-patched/.github/workflows/lint-sync.yml create mode 100644 third_party/bubbletea-v2-patched/.github/workflows/lint.yml create mode 100644 third_party/bubbletea-v2-patched/.github/workflows/release.yml create mode 100644 third_party/bubbletea-v2-patched/.gitignore create mode 100644 third_party/bubbletea-v2-patched/.golangci.yml create mode 100644 third_party/bubbletea-v2-patched/.goreleaser.yml create mode 100644 third_party/bubbletea-v2-patched/LICENSE create mode 100644 third_party/bubbletea-v2-patched/README.md create mode 100644 third_party/bubbletea-v2-patched/Taskfile.yaml create mode 100644 third_party/bubbletea-v2-patched/UPGRADE_GUIDE_V2.md create mode 100644 third_party/bubbletea-v2-patched/clipboard.go create mode 100644 third_party/bubbletea-v2-patched/color.go create mode 100644 third_party/bubbletea-v2-patched/commands.go create mode 100644 third_party/bubbletea-v2-patched/commands_test.go create mode 100644 third_party/bubbletea-v2-patched/cursed_renderer.go create mode 100644 third_party/bubbletea-v2-patched/cursed_renderer_test.go create mode 100644 third_party/bubbletea-v2-patched/cursor.go create mode 100644 third_party/bubbletea-v2-patched/environ.go create mode 100644 third_party/bubbletea-v2-patched/exec.go create mode 100644 third_party/bubbletea-v2-patched/exec_test.go create mode 100644 third_party/bubbletea-v2-patched/focus.go create mode 100644 third_party/bubbletea-v2-patched/go.mod create mode 100644 third_party/bubbletea-v2-patched/go.sum create mode 100644 third_party/bubbletea-v2-patched/input.go create mode 100644 third_party/bubbletea-v2-patched/key.go create mode 100644 third_party/bubbletea-v2-patched/keyboard.go create mode 100644 third_party/bubbletea-v2-patched/logging.go create mode 100644 third_party/bubbletea-v2-patched/logging_test.go create mode 100644 third_party/bubbletea-v2-patched/mod.go create mode 100644 third_party/bubbletea-v2-patched/mouse.go create mode 100644 third_party/bubbletea-v2-patched/nil_renderer.go create mode 100644 third_party/bubbletea-v2-patched/options.go create mode 100644 third_party/bubbletea-v2-patched/options_test.go create mode 100644 third_party/bubbletea-v2-patched/paste.go create mode 100644 third_party/bubbletea-v2-patched/profile.go create mode 100644 third_party/bubbletea-v2-patched/raw.go create mode 100644 third_party/bubbletea-v2-patched/renderer.go create mode 100644 third_party/bubbletea-v2-patched/screen.go create mode 100644 third_party/bubbletea-v2-patched/screen_test.go create mode 100644 third_party/bubbletea-v2-patched/signals_unix.go create mode 100644 third_party/bubbletea-v2-patched/signals_windows.go create mode 100644 third_party/bubbletea-v2-patched/tea.go create mode 100644 third_party/bubbletea-v2-patched/tea_test.go create mode 100644 third_party/bubbletea-v2-patched/termcap.go create mode 100644 third_party/bubbletea-v2-patched/termios_bsd.go create mode 100644 third_party/bubbletea-v2-patched/termios_other.go create mode 100644 third_party/bubbletea-v2-patched/termios_unix.go create mode 100644 third_party/bubbletea-v2-patched/termios_windows.go create mode 100644 third_party/bubbletea-v2-patched/testdata/TestClearMsg/bg_fg_cur_color.golden create mode 100644 third_party/bubbletea-v2-patched/testdata/TestClearMsg/clear_screen.golden create mode 100644 third_party/bubbletea-v2-patched/testdata/TestClearMsg/read_set_clipboard.golden create mode 100644 third_party/bubbletea-v2-patched/testdata/TestViewModel/altscreen.golden create mode 100644 third_party/bubbletea-v2-patched/testdata/TestViewModel/altscreen_autoexit.golden create mode 100644 third_party/bubbletea-v2-patched/testdata/TestViewModel/bg_set_color.golden create mode 100644 third_party/bubbletea-v2-patched/testdata/TestViewModel/bp_stop_start.golden create mode 100644 third_party/bubbletea-v2-patched/testdata/TestViewModel/cursor_hide.golden create mode 100644 third_party/bubbletea-v2-patched/testdata/TestViewModel/cursor_hideshow.golden create mode 100644 third_party/bubbletea-v2-patched/testdata/TestViewModel/kitty_stop_startreleases.golden create mode 100644 third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_allmotion.golden create mode 100644 third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_cellmotion.golden create mode 100644 third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_disable.golden create mode 100644 third_party/bubbletea-v2-patched/tty.go create mode 100644 third_party/bubbletea-v2-patched/tty_unix.go create mode 100644 third_party/bubbletea-v2-patched/tty_windows.go create mode 100644 third_party/bubbletea-v2-patched/xterm.go diff --git a/go.mod b/go.mod index d415c4d35..74097a8a3 100644 --- a/go.mod +++ b/go.mod @@ -36,3 +36,10 @@ require ( golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect golang.org/x/sync v0.22.0 // indirect ) + +// Vendored fork of bubbletea/v2 with hard scroll optimization disabled +// unconditionally (see third_party/bubbletea-v2-patched/cursed_renderer.go): +// it corrupts rendering when zero's output is displayed by Windows Terminal +// over a remote shell (e.g. running inside a multipass VM), a case the +// upstream GOOS=="windows" check doesn't catch. +replace charm.land/bubbletea/v2 => ./third_party/bubbletea-v2-patched diff --git a/third_party/bubbletea-v2-patched/.gitattributes b/third_party/bubbletea-v2-patched/.gitattributes new file mode 100644 index 000000000..6c929d480 --- /dev/null +++ b/third_party/bubbletea-v2-patched/.gitattributes @@ -0,0 +1 @@ +*.golden -text diff --git a/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug.yml b/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 000000000..a9b077005 --- /dev/null +++ b/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,61 @@ +name: Bug Report +description: File a bug report +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! Please fill the form below. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + validations: + required: true + - type: textarea + id: reproducible + attributes: + label: How can we reproduce this? + description: | + Please share a code snippet, gist, or public repository that reproduces the issue. + Make sure to make the reproducible as concise as possible, + with only the minimum required code to reproduce the issue. + validations: + required: true + - type: textarea + id: version + attributes: + label: Which version of bubbletea are you using? + description: '' + render: bash + validations: + required: true + - type: textarea + id: terminaal + attributes: + label: Which terminals did you reproduce this with? + description: | + Other helpful information: + was it over SSH? + On tmux? + Which version of said terminal? + validations: + required: true + - type: checkboxes + id: search + attributes: + label: Search + options: + - label: | + I searched for other open and closed issues and pull requests before opening this, + and didn't find anything that seems related. + required: true + - type: textarea + id: ctx + attributes: + label: Additional context + description: Anything else you would like to add + validations: + required: false + diff --git a/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug_report.md b/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..0e7a6cb54 --- /dev/null +++ b/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,37 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Setup** +Please complete the following information along with version numbers, if applicable. + - OS [e.g. Ubuntu, macOS] + - Shell [e.g. zsh, fish] + - Terminal Emulator [e.g. kitty, iterm] + - Terminal Multiplexer [e.g. tmux] + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Source Code** +Please include source code if needed to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +Add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/config.yml b/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..897a394e0 --- /dev/null +++ b/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: +- name: Discord + url: https://charm.sh/discord + about: Chat on our Discord. diff --git a/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/feature_request.md b/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..11fc491ef --- /dev/null +++ b/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/third_party/bubbletea-v2-patched/.github/dependabot.yml b/third_party/bubbletea-v2-patched/.github/dependabot.yml new file mode 100644 index 000000000..46cd07a8d --- /dev/null +++ b/third_party/bubbletea-v2-patched/.github/dependabot.yml @@ -0,0 +1,91 @@ +version: 2 + +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "05:00" + timezone: "America/New_York" + labels: + - "dependencies" + commit-message: + prefix: "chore" + include: "scope" + groups: + all: + patterns: + - "*" + ignore: + - dependency-name: github.com/charmbracelet/bubbletea/v2 + versions: + - v2.0.0-beta1 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "05:00" + timezone: "America/New_York" + labels: + - "dependencies" + commit-message: + prefix: "chore" + include: "scope" + groups: + all: + patterns: + - "*" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "05:00" + timezone: "America/New_York" + labels: + - "dependencies" + commit-message: + prefix: "chore" + include: "scope" + groups: + all: + patterns: + - "*" + + - package-ecosystem: "gomod" + directory: "/examples" + schedule: + interval: "weekly" + day: "monday" + time: "05:00" + timezone: "America/New_York" + labels: + - "dependencies" + commit-message: + prefix: "chore" + include: "scope" + groups: + all: + patterns: + - "*" + + - package-ecosystem: "gomod" + directory: "/tutorials" + schedule: + interval: "weekly" + day: "monday" + time: "05:00" + timezone: "America/New_York" + labels: + - "dependencies" + commit-message: + prefix: "chore" + include: "scope" + groups: + all: + patterns: + - "*" diff --git a/third_party/bubbletea-v2-patched/.github/workflows/build.yml b/third_party/bubbletea-v2-patched/.github/workflows/build.yml new file mode 100644 index 000000000..b3fbbad04 --- /dev/null +++ b/third_party/bubbletea-v2-patched/.github/workflows/build.yml @@ -0,0 +1,20 @@ +name: build +on: [push, pull_request] + +jobs: + build: + uses: charmbracelet/meta/.github/workflows/build.yml@main + + build-go-mod: + uses: charmbracelet/meta/.github/workflows/build.yml@main + with: + go-version: "" + go-version-file: ./go.mod + + build-examples: + if: github.actor != 'dependabot[bot]' + uses: charmbracelet/meta/.github/workflows/build.yml@main + with: + go-version: "" + go-version-file: ./examples/go.mod + working-directory: ./examples diff --git a/third_party/bubbletea-v2-patched/.github/workflows/coverage.yml b/third_party/bubbletea-v2-patched/.github/workflows/coverage.yml new file mode 100644 index 000000000..4a218734d --- /dev/null +++ b/third_party/bubbletea-v2-patched/.github/workflows/coverage.yml @@ -0,0 +1,29 @@ +name: coverage +on: [push, pull_request] + +jobs: + coverage: + strategy: + matrix: + go-version: [^1] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + env: + GO111MODULE: "on" + steps: + - name: Install Go + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + + - name: Checkout code + uses: actions/checkout@v6 + + - name: Coverage + run: | + go test -race -covermode=atomic -coverprofile=coverage.txt ./... + + - uses: codecov/codecov-action@v6 + with: + file: ./coverage.txt + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/third_party/bubbletea-v2-patched/.github/workflows/dependabot-sync.yml b/third_party/bubbletea-v2-patched/.github/workflows/dependabot-sync.yml new file mode 100644 index 000000000..9b082590b --- /dev/null +++ b/third_party/bubbletea-v2-patched/.github/workflows/dependabot-sync.yml @@ -0,0 +1,17 @@ +name: dependabot-sync +on: + schedule: + - cron: "0 0 * * 0" # every Sunday at midnight + workflow_dispatch: # allows manual triggering + +permissions: + contents: write + pull-requests: write + +jobs: + dependabot-sync: + uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main + with: + repo_name: ${{ github.event.repository.name }} + secrets: + gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} diff --git a/third_party/bubbletea-v2-patched/.github/workflows/examples.yml b/third_party/bubbletea-v2-patched/.github/workflows/examples.yml new file mode 100644 index 000000000..d5f402b87 --- /dev/null +++ b/third_party/bubbletea-v2-patched/.github/workflows/examples.yml @@ -0,0 +1,38 @@ +name: examples + +on: + push: + branches: + - 'master' + paths: + - '.github/workflows/examples.yml' + - './examples/go.mod' + - './examples/go.sum' + - './tutorials/go.mod' + - './tutorials/go.sum' + - './go.mod' + - './go.sum' + workflow_dispatch: {} + +jobs: + tidy: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: '^1' + cache: true + - shell: bash + run: | + (cd ./examples && go mod tidy) + (cd ./tutorials && go mod tidy) + - uses: stefanzweifel/git-auto-commit-action@v7 + with: + commit_message: "chore: go mod tidy tutorials and examples" + branch: master + commit_user_name: actions-user + commit_user_email: actions@github.com + diff --git a/third_party/bubbletea-v2-patched/.github/workflows/lint-sync.yml b/third_party/bubbletea-v2-patched/.github/workflows/lint-sync.yml new file mode 100644 index 000000000..ecf858024 --- /dev/null +++ b/third_party/bubbletea-v2-patched/.github/workflows/lint-sync.yml @@ -0,0 +1,14 @@ +name: lint-sync +on: + schedule: + # every Sunday at midnight + - cron: "0 0 * * 0" + workflow_dispatch: # allows manual triggering + +permissions: + contents: write + pull-requests: write + +jobs: + lint: + uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main diff --git a/third_party/bubbletea-v2-patched/.github/workflows/lint.yml b/third_party/bubbletea-v2-patched/.github/workflows/lint.yml new file mode 100644 index 000000000..d71849463 --- /dev/null +++ b/third_party/bubbletea-v2-patched/.github/workflows/lint.yml @@ -0,0 +1,12 @@ +name: lint +on: + push: + pull_request: + +jobs: + lint: + uses: charmbracelet/meta/.github/workflows/lint.yml@main + with: + golangci_path: .golangci.yml + golangci_version: v2.9 + timeout: 10m diff --git a/third_party/bubbletea-v2-patched/.github/workflows/release.yml b/third_party/bubbletea-v2-patched/.github/workflows/release.yml new file mode 100644 index 000000000..f649dfded --- /dev/null +++ b/third_party/bubbletea-v2-patched/.github/workflows/release.yml @@ -0,0 +1,29 @@ +name: goreleaser + +on: + push: + tags: + - v*.*.* + +concurrency: + group: goreleaser + cancel-in-progress: true + +jobs: + goreleaser: + uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main + secrets: + docker_username: ${{ secrets.DOCKERHUB_USERNAME }} + docker_token: ${{ secrets.DOCKERHUB_TOKEN }} + gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + goreleaser_key: ${{ secrets.GORELEASER_KEY }} + twitter_consumer_key: ${{ secrets.TWITTER_CONSUMER_KEY }} + twitter_consumer_secret: ${{ secrets.TWITTER_CONSUMER_SECRET }} + twitter_access_token: ${{ secrets.TWITTER_ACCESS_TOKEN }} + twitter_access_token_secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} + mastodon_client_id: ${{ secrets.MASTODON_CLIENT_ID }} + mastodon_client_secret: ${{ secrets.MASTODON_CLIENT_SECRET }} + mastodon_access_token: ${{ secrets.MASTODON_ACCESS_TOKEN }} + discord_webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }} + discord_webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }} +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json diff --git a/third_party/bubbletea-v2-patched/.gitignore b/third_party/bubbletea-v2-patched/.gitignore new file mode 100644 index 000000000..abd7c0612 --- /dev/null +++ b/third_party/bubbletea-v2-patched/.gitignore @@ -0,0 +1,23 @@ +.DS_Store +.envrc + +examples/fullscreen/fullscreen +examples/help/help +examples/http/http +examples/list-default/list-default +examples/list-fancy/list-fancy +examples/list-simple/list-simple +examples/mouse/mouse +examples/pager/pager +examples/progress-download/color_vortex.blend +examples/progress-download/progress-download +examples/simple/simple +examples/spinner/spinner +examples/textinput/textinput +examples/textinputs/textinputs +examples/views/views +tutorials/basics/basics +tutorials/commands/commands +.idea +coverage.txt +dist/ diff --git a/third_party/bubbletea-v2-patched/.golangci.yml b/third_party/bubbletea-v2-patched/.golangci.yml new file mode 100644 index 000000000..c90f03161 --- /dev/null +++ b/third_party/bubbletea-v2-patched/.golangci.yml @@ -0,0 +1,47 @@ +version: "2" +run: + tests: false +linters: + enable: + - bodyclose + - exhaustive + - goconst + - godot + - gomoddirectives + - goprintffuncname + - gosec + - misspell + - nakedret + - nestif + - nilerr + - noctx + - nolintlint + - prealloc + - revive + - rowserrcheck + - sqlclosecheck + - tparallel + - unconvert + - unparam + - whitespace + - wrapcheck + exclusions: + rules: + - text: '(slog|log)\.\w+' + linters: + - noctx + generated: lax + presets: + - common-false-positives + settings: + exhaustive: + default-signifies-exhaustive: true +issues: + max-issues-per-linter: 0 + max-same-issues: 0 +formatters: + enable: + - gofumpt + - goimports + exclusions: + generated: lax diff --git a/third_party/bubbletea-v2-patched/.goreleaser.yml b/third_party/bubbletea-v2-patched/.goreleaser.yml new file mode 100644 index 000000000..3353d0202 --- /dev/null +++ b/third_party/bubbletea-v2-patched/.goreleaser.yml @@ -0,0 +1,5 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json +version: 2 +includes: + - from_url: + url: charmbracelet/meta/main/goreleaser-lib.yaml diff --git a/third_party/bubbletea-v2-patched/LICENSE b/third_party/bubbletea-v2-patched/LICENSE new file mode 100644 index 000000000..01d14e6ae --- /dev/null +++ b/third_party/bubbletea-v2-patched/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2026 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/bubbletea-v2-patched/README.md b/third_party/bubbletea-v2-patched/README.md new file mode 100644 index 000000000..4d08d355b --- /dev/null +++ b/third_party/bubbletea-v2-patched/README.md @@ -0,0 +1,402 @@ +# Bubble Tea + +

+
+ Latest Release + GoDoc + Build Status +

+ +The fun, functional and stateful way to build terminal apps. A Go framework +based on [The Elm Architecture][elm]. Bubble Tea is well-suited for simple and +complex terminal applications, either inline, full-window, or a mix of both. + +

+ Bubble Tea Example +

+ +Bubble Tea is in use in production and includes a number of features and +performance optimizations we’ve added along the way. Among those is +a high-performance cell-based renderer, built-in color downsampling, +declarative views, high-fidelity keyboard and mouse handling, native clipboard +support, and more. + +To get started, see the tutorial below, the [examples][examples], the +[docs][docs], and some common [resources](#libraries-we-use-with-bubble-tea). + +> [!TIP] +> +> Upgrading from v1? Check out the [upgrade guide](./UPGRADE_GUIDE_V2.md), or +> point your LLM at it and let it go to town. + +## By the way + +Be sure to check out [Bubbles][bubbles], a library of common UI components for Bubble Tea. + +

+ Bubbles Badge   + Text Input Example from Bubbles +

+ +--- + +## Tutorial + +Bubble Tea is based on the functional design paradigms of [The Elm +Architecture][elm], which happens to work nicely with Go. It's a delightful way +to build applications. + +This tutorial assumes you have a working knowledge of Go. + +By the way, the non-annotated source code for this program is available +[on GitHub][tut-source]. + +[elm]: https://guide.elm-lang.org/architecture/ +[tut-source]: https://github.com/charmbracelet/bubbletea/tree/main/tutorials/basics + +### Enough! Let's get to it. + +For this tutorial, we're making a shopping list. + +To start we'll define our package and import some libraries. Our only external +import will be the Bubble Tea library, which we'll call `tea` for short. + +```go +package main + +// These imports will be used later in the tutorial. If you save the file +// now, Go might complain they are unused, but that's fine. +// You may also need to run `go mod tidy` to download bubbletea and its +// dependencies. +import ( + "fmt" + "os" + + tea "charm.land/bubbletea/v2" +) +``` + +Bubble Tea programs are comprised of a **model** that describes the application +state and three simple methods on that model: + +- **Init**, a function that returns an initial command for the application to run. +- **Update**, a function that handles incoming events and updates the model accordingly. +- **View**, a function that renders the UI based on the data in the model. + +### The Model + +So let's start by defining our model which will store our application's state. +It can be any type, but a `struct` usually makes the most sense. + +```go +type model struct { + choices []string // items on the to-do list + cursor int // which to-do list item our cursor is pointing at + selected map[int]struct{} // which to-do items are selected +} +``` + +## Initialization + +Next, we’ll define our application’s initial state. `Init` can return a `Cmd` +that could perform some initial I/O. For now, we don’t need to do any I/O, so +for the command, we’ll just return `nil`, which translates to “no command.” + +```go +func initialModel() model { + return model{ + // Our to-do list is a grocery list + choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"}, + + // A map which indicates which choices are selected. We're using + // the map like a mathematical set. The keys refer to the indexes + // of the `choices` slice, above. + selected: make(map[int]struct{}), + } +} +``` + +After that, we’ll define our application’s initial state in the `Init` method. `Init` +can return a `Cmd` that could perform some initial I/O. For now, we don't need +to do any I/O, so for the command, we'll just return `nil`, which translates to +"no command." + +```go +func (m model) Init() tea.Cmd { + // Just return `nil`, which means "no I/O right now, please." + return nil +} +``` + +### The Update Method + +Next up is the update method. The update function is called when “things +happen.” Its job is to look at what has happened and return an updated model in +response. It can also return a `Cmd` to make more things happen, but for now +don't worry about that part. + +In our case, when a user presses the down arrow, `Update`’s job is to notice +that the down arrow was pressed and move the cursor accordingly (or not). + +The “something happened” comes in the form of a `Msg`, which can be any type. +Messages are the result of some I/O that took place, such as a keypress, timer +tick, or a response from a server. + +We usually figure out which type of `Msg` we received with a type switch, but +you could also use a type assertion. + +For now, we'll just deal with `tea.KeyPressMsg` messages, which are +automatically sent to the update function when keys are pressed. + +```go +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + // Is it a key press? + case tea.KeyPressMsg: + + // Cool, what was the actual key pressed? + switch msg.String() { + + // These keys should exit the program. + case "ctrl+c", "q": + return m, tea.Quit + + // The "up" and "k" keys move the cursor up + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + + // The "down" and "j" keys move the cursor down + case "down", "j": + if m.cursor < len(m.choices)-1 { + m.cursor++ + } + + // The "enter" key and the space bar toggle the selected state + // for the item that the cursor is pointing at. + case "enter", "space": + _, ok := m.selected[m.cursor] + if ok { + delete(m.selected, m.cursor) + } else { + m.selected[m.cursor] = struct{}{} + } + } + } + + // Return the updated model to the Bubble Tea runtime for processing. + // Note that we're not returning a command. + return m, nil +} +``` + +You may have noticed that ctrl+c and q above return +a `tea.Quit` command with the model. That’s a special command which instructs +the Bubble Tea runtime to quit, exiting the program. + +### The View Method + +At last, it’s time to render our UI. Of all the methods, the view is the +simplest. We look at the model in its current state and use it to build a +`tea.View`. The view declares our UI content and, optionally, terminal features +like alt screen mode, mouse tracking, cursor position, and more. + +Because the view describes the entire UI of your application, you don’t have to +worry about redrawing logic and stuff like that. Bubble Tea takes care of it +for you. + +```go +func (m model) View() tea.View { + // The header + s := "What should we buy at the market?\n\n" + + // Iterate over our choices + for i, choice := range m.choices { + + // Is the cursor pointing at this choice? + cursor := " " // no cursor + if m.cursor == i { + cursor = ">" // cursor! + } + + // Is this choice selected? + checked := " " // not selected + if _, ok := m.selected[i]; ok { + checked = "x" // selected! + } + + // Render the row + s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) + } + + // The footer + s += "\nPress q to quit.\n" + + // Send the UI for rendering + return tea.NewView(s) +} +``` + +### All Together Now + +The last step is to simply run our program. We pass our initial model to +`tea.NewProgram` and let it rip: + +```go +func main() { + p := tea.NewProgram(initialModel()) + if _, err := p.Run(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } +} +``` + +## What’s Next? + +This tutorial covers the basics of building an interactive terminal UI, but +in the real world you'll also need to perform I/O. To learn about that have a +look at the [Command Tutorial][cmd]. It's pretty simple. + +There are also several [Bubble Tea examples][examples] available and, of course, +there are [Go Docs][docs]. + +[cmd]: https://github.com/charmbracelet/bubbletea/tree/main/tutorials/commands/ +[examples]: https://github.com/charmbracelet/bubbletea/tree/main/examples +[docs]: https://pkg.go.dev/charm.land/bubbletea/v2?tab=doc + +## Debugging + +### Debugging with Delve + +Since Bubble Tea apps assume control of stdin and stdout, you’ll need to run +delve in headless mode and then connect to it: + +```bash +# Start the debugger +$ dlv debug --headless --api-version=2 --listen=127.0.0.1:43000 . +API server listening at: 127.0.0.1:43000 + +# Connect to it from another terminal +$ dlv connect 127.0.0.1:43000 +``` + +If you do not explicitly supply the `--listen` flag, the port used will vary +per run, so passing this in makes the debugger easier to use from a script +or your IDE of choice. + +Additionally, we pass in `--api-version=2` because delve defaults to version 1 +for backwards compatibility reasons. However, delve recommends using version 2 +for all new development and some clients may no longer work with version 1. +For more information, see the [Delve documentation](https://github.com/go-delve/delve/tree/master/Documentation/api). + +### Logging Stuff + +You can’t really log to stdout with Bubble Tea because your TUI is busy +occupying that! You can, however, log to a file by including something like +the following prior to starting your Bubble Tea program: + +```go +if len(os.Getenv("DEBUG")) > 0 { + f, err := tea.LogToFile("debug.log", "debug") + if err != nil { + fmt.Println("fatal:", err) + os.Exit(1) + } + defer f.Close() +} +``` + +To see what’s being logged in real time, run `tail -f debug.log` while you run +your program in another window. + +## Libraries we use with Bubble Tea + +- [Bubbles][bubbles]: Common Bubble Tea components such as text inputs, viewports, spinners and so on +- [Lip Gloss][lipgloss]: Style, format and layout tools for terminal applications +- [Harmonica][harmonica]: A spring animation library for smooth, natural motion +- [BubbleZone][bubblezone]: Easy mouse event tracking for Bubble Tea components +- [ntcharts][ntcharts]: A terminal charting library built for Bubble Tea and [Lip Gloss][lipgloss] + +[bubbles]: https://github.com/charmbracelet/bubbles +[lipgloss]: https://github.com/charmbracelet/lipgloss +[harmonica]: https://github.com/charmbracelet/harmonica +[bubblezone]: https://github.com/lrstanley/bubblezone +[ntcharts]: https://github.com/NimbleMarkets/ntcharts + +## Bubble Tea in the Wild + +There are over [18,000 applications](https://github.com/charmbracelet/bubbletea/network/dependents) built with Bubble Tea! Here are a handful of ’em. + +### Staff favourites + +- [chezmoi](https://github.com/twpayne/chezmoi): securely manage your dotfiles across multiple machines +- [circumflex](https://github.com/bensadeh/circumflex): read Hacker News in the terminal +- [gh-dash](https://www.github.com/dlvhdr/gh-dash): a GitHub CLI extension for PRs and issues +- [Tetrigo](https://github.com/Broderick-Westrope/tetrigo): Tetris in the terminal +- [Signls](https://github.com/emprcl/signls): a generative midi sequencer designed for composition and live performance +- [Superfile](https://github.com/yorukot/superfile): a super file manager + +### In Industry + +- Microsoft Azure – [Aztify](https://github.com/Azure/aztfy): bring Microsoft Azure resources under Terraform +- Daytona – [Daytona](https://github.com/daytonaio/daytona): an AI infrastructure platform +- Cockroach Labs – [CockroachDB](https://github.com/cockroachdb/cockroach): a cloud-native, high-availability distributed SQL database +- Truffle Security Co. – [Trufflehog](https://github.com/trufflesecurity/trufflehog): find leaked credentials +- NVIDIA – [container-canary](https://github.com/NVIDIA/container-canary): a container validator +- AWS – [eks-node-viewer](https://github.com/awslabs/eks-node-viewer): a tool for visualizing dynamic node usage within an EKS cluster +- MinIO – [mc](https://github.com/minio/mc): the official [MinIO](https://min.io) client +- Ubuntu – [Authd](https://github.com/ubuntu/authd): an authentication daemon for cloud-based identity providers + +### Charm stuff + +- [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser, and online markdown stash +- [Huh?](https://github.com/charmbracelet/huh): an interactive prompt and form toolkit +- [Mods](https://github.com/charmbracelet/mods): AI on the CLI, built for pipelines +- [Wishlist](https://github.com/charmbracelet/wishlist): an SSH directory (and bastion!) + +### There’s so much more where that came from + +For more applications built with Bubble Tea see [Charm & Friends][community]. +Is there something cool you made with Bubble Tea you want to share? [PRs][community] are +welcome! + +## Contributing + +See [contributing][contribute]. + +[contribute]: https://github.com/charmbracelet/bubbletea/contribute + +## Feedback + +We’d love to hear your thoughts on this project. Feel free to drop us a note! + +- [Twitter](https://twitter.com/charmcli) +- [The Fediverse](https://mastodon.social/@charmcli) +- [Discord](https://charm.sh/chat) + +## Acknowledgments + +Bubble Tea is based on the paradigms of [The Elm Architecture][elm] by Evan +Czaplicki et alia and the excellent [go-tea][gotea] by TJ Holowaychuk. It’s +inspired by the many great [_Zeichenorientierte Benutzerschnittstellen_][zb] +of days past. + +[elm]: https://guide.elm-lang.org/architecture/ +[gotea]: https://github.com/tj/go-tea +[zb]: https://de.wikipedia.org/wiki/Zeichenorientierte_Benutzerschnittstelle +[community]: https://github.com/charm-and-friends/charm-in-the-wild + +## License + +[MIT](https://github.com/charmbracelet/bubbletea/raw/main/LICENSE) + +--- + +Part of [Charm](https://charm.sh). + +The Charm logo + +Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة diff --git a/third_party/bubbletea-v2-patched/Taskfile.yaml b/third_party/bubbletea-v2-patched/Taskfile.yaml new file mode 100644 index 000000000..10e47f306 --- /dev/null +++ b/third_party/bubbletea-v2-patched/Taskfile.yaml @@ -0,0 +1,38 @@ +# https://taskfile.dev + +version: "3" + +tasks: + lint: + desc: Run lint + cmds: + - golangci-lint run + + test: + desc: Run tests + cmds: + - go test -race -count 4 -cpu 1,4 ./... {{.CLI_ARGS}} + + release: + desc: Create and push a new tag following semver + vars: + NEXT: + sh: svu next --always || go run github.com/caarlos0/svu/v3@latest next --always + prompt: "This will release {{.NEXT}}. Continue?" + preconditions: + - sh: '[ $(git symbolic-ref --short HEAD) = "main" ]' + msg: Not on main branch + - sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]" + msg: "Git is dirty" + - sh: 'gh run list --workflow build.yml --commit $(git rev-parse HEAD) --status success --json conclusion -q ".[0].conclusion" | grep -q success' + msg: "Test build for this commit failed or not present" + cmds: + - task: fetch-tags + - git commit --allow-empty -m "{{.NEXT}}" + - git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}} + - echo "Pushing {{.NEXT}}..." + - git push origin main --follow-tags + + fetch-tags: + cmds: + - git fetch --tags diff --git a/third_party/bubbletea-v2-patched/UPGRADE_GUIDE_V2.md b/third_party/bubbletea-v2-patched/UPGRADE_GUIDE_V2.md new file mode 100644 index 000000000..2ca7b0a08 --- /dev/null +++ b/third_party/bubbletea-v2-patched/UPGRADE_GUIDE_V2.md @@ -0,0 +1,573 @@ +# Bubble Tea v2 Upgrade Guide + +This guide covers everything you need to change when upgrading from Bubble Tea v1 to v2. For a tour of all the exciting new features, check out the [What's New](https://github.com/charmbracelet/bubbletea/releases/tag/v2.0.0) doc. + +> [!NOTE] +> We don't take API changes lightly and strive to make the upgrade process as simple as possible. If something feels way off, let us know. + +## Migration Checklist + +Here's the short version — a checklist you can follow top to bottom. Each item links to the relevant section below. + +- [ ] [Update import paths](#import-paths) +- [ ] [Change `View() string` to `View() tea.View`](#view-returns-a-teaview-now) +- [ ] [Replace `tea.KeyMsg` with `tea.KeyPressMsg`](#key-messages) +- [ ] [Update key fields: `msg.Type` / `msg.Runes` / `msg.Alt`](#key-messages) +- [ ] [Replace `case " ":` with `case "space":`](#key-messages) +- [ ] [Update mouse message usage](#mouse-messages) +- [ ] [Rename mouse button constants](#mouse-messages) +- [ ] [Remove old program options → use View fields](#removed-program-options) +- [ ] [Remove imperative commands → use View fields](#removed-commands) +- [ ] [Remove old program methods](#removed-program-methods) +- [ ] [Rename `tea.WindowSize()` → `tea.RequestWindowSize`](#renamed-apis) +- [ ] [Replace `tea.Sequentially(...)` → `tea.Sequence(...)`](#renamed-apis) + +## Import Paths + +The module path changed to a vanity domain. Lip Gloss moved too. + +```go +// Before +import tea "github.com/charmbracelet/bubbletea" +import "github.com/charmbracelet/lipgloss" + +// After +import tea "charm.land/bubbletea/v2" +import "charm.land/lipgloss/v2" +``` + +## The Big Idea: Declarative Views + +The single biggest change in v2 is the shift from **imperative commands** to **declarative View fields**. In v1, you'd use program options like `tea.WithAltScreen()` and commands like `tea.EnterAltScreen` to toggle terminal features on and off. In v2, you just set fields on the `tea.View` struct in your `View()` method and Bubble Tea handles the rest. + +This means: no more startup option flags, no more toggle commands, no more fighting over state. Just declare what you want and Bubble Tea will make it so. + +```go +// v1: imperative — scattered across NewProgram, Init, and Update +p := tea.NewProgram(model{}, tea.WithAltScreen(), tea.WithMouseCellMotion()) + +// v2: declarative — everything lives in View() +func (m model) View() tea.View { + v := tea.NewView("Hello!") + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v +} +``` + +Keep this in mind as you go through the rest of the guide — most of the "removed" things simply moved into View fields. + +## View Returns a `tea.View` Now + +The `View()` method no longer returns a `string`. It returns a `tea.View` struct. + +```go +// Before: +func (m model) View() string { + return "Hello, world!" +} + +// After: +func (m model) View() tea.View { + return tea.NewView("Hello, world!") +} +``` + +You can also use the longer form if you need to set additional fields: + +```go +func (m model) View() tea.View { + var v tea.View + v.SetContent("Hello, world!") + v.AltScreen = true + return v +} +``` + +The `tea.View` struct has fields for everything that used to be controlled by options and commands: + +| View Field | What It Does | +|---|---| +| `Content` | The rendered string (set via `SetContent()` or `NewView()`) | +| `AltScreen` | Enter/exit the alternate screen buffer | +| `MouseMode` | `MouseModeNone`, `MouseModeCellMotion`, or `MouseModeAllMotion` | +| `ReportFocus` | Enable focus/blur event reporting | +| `DisableBracketedPasteMode` | Disable bracketed paste | +| `WindowTitle` | Set the terminal window title | +| `Cursor` | Control cursor position, shape, color, and blink | +| `ForegroundColor` | Set the terminal foreground color | +| `BackgroundColor` | Set the terminal background color | +| `ProgressBar` | Show a native terminal progress bar | +| `KeyboardEnhancements` | Request keyboard enhancement features | +| `OnMouse` | Intercept mouse messages based on view content | + +## Key Messages + +Key messages got a major overhaul. Here's the quick rundown: + +### `tea.KeyMsg` is now an interface + +In v1, `tea.KeyMsg` was a struct you'd match on for key presses. In v2, it's an **interface** that covers both key presses and releases. For most code, you want `tea.KeyPressMsg`: + +```go +// Before: +case tea.KeyMsg: + switch msg.String() { + case "q": + return m, tea.Quit + } + +// After: +case tea.KeyPressMsg: + switch msg.String() { + case "q": + return m, tea.Quit + } +``` + +If you want to handle both presses and releases, use `tea.KeyMsg` and type-switch inside: + +```go +case tea.KeyMsg: + switch key := msg.(type) { + case tea.KeyPressMsg: + // key press + case tea.KeyReleaseMsg: + // key release + } +``` + +### Key fields changed + +| v1 | v2 | Notes | +|---|---|---| +| `msg.Type` | `msg.Code` | A `rune` — can be `tea.KeyEnter`, `'a'`, etc. | +| `msg.Runes` | `msg.Text` | Now a `string`, not `[]rune` | +| `msg.Alt` | `msg.Mod` | `msg.Mod.Contains(tea.ModAlt)` for alt, etc. | +| `tea.KeyRune` | — | Check `len(msg.Text) > 0` instead | +| `tea.KeyCtrlC` | — | Use `msg.String() == "ctrl+c"` or check `msg.Code` + `msg.Mod` | + +### Space bar changed + +Space bar now returns `"space"` instead of `" "` when using `msg.String()`: + +```go +// Before: +case " ": + +// After: +case "space": +``` + +`key.Code` is still `' '` and `key.Text` is still `" "`, but `String()` returns `"space"`. + +### Ctrl+key matching + +```go +// Before: +case tea.KeyCtrlC: + // ctrl+c + +// After (option A — string matching): +case tea.KeyPressMsg: + switch msg.String() { + case "ctrl+c": + // ctrl+c + } + +// After (option B — field matching): +case tea.KeyPressMsg: + if msg.Code == 'c' && msg.Mod == tea.ModCtrl { + // ctrl+c + } +``` + +### New Key fields + +These are new in v2 and don't have v1 equivalents: + +- **`key.ShiftedCode`** — the shifted key code (e.g., `'B'` when pressing shift+b) +- **`key.BaseCode`** — the key on a US PC-101 layout (handy for international keyboards) +- **`key.IsRepeat`** — whether the key is auto-repeating (Kitty protocol / Windows Console only) +- **`key.Keystroke()`** — like `String()` but always includes modifier info + +## Paste Messages + +Paste events no longer come in as `tea.KeyMsg` with a `Paste` flag. They're now their own message types: + +```go +// Before: +case tea.KeyMsg: + if msg.Paste { + m.text += string(msg.Runes) + } + +// After: +case tea.PasteMsg: + m.text += msg.Content +case tea.PasteStartMsg: + // paste started +case tea.PasteEndMsg: + // paste ended +``` + +## Mouse Messages + +### `tea.MouseMsg` is now an interface + +In v1, `tea.MouseMsg` was a struct with `X`, `Y`, `Button`, etc. In v2, it's an **interface**. You get the coordinates by calling `msg.Mouse()`: + +```go +// Before: +case tea.MouseMsg: + x, y := msg.X, msg.Y + +// After: +case tea.MouseMsg: + mouse := msg.Mouse() + x, y := mouse.X, mouse.Y +``` + +### Mouse events are split by type + +Instead of checking `msg.Action`, match on specific message types: + +```go +// Before: +case tea.MouseMsg: + if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { + // left click + } + +// After: +case tea.MouseClickMsg: + if msg.Button == tea.MouseLeft { + // left click + } +case tea.MouseReleaseMsg: + // release +case tea.MouseWheelMsg: + // scroll +case tea.MouseMotionMsg: + // movement +``` + +### Button constants renamed + +| v1 | v2 | +|---|---| +| `tea.MouseButtonLeft` | `tea.MouseLeft` | +| `tea.MouseButtonRight` | `tea.MouseRight` | +| `tea.MouseButtonMiddle` | `tea.MouseMiddle` | +| `tea.MouseButtonWheelUp` | `tea.MouseWheelUp` | +| `tea.MouseButtonWheelDown` | `tea.MouseWheelDown` | +| `tea.MouseButtonWheelLeft` | `tea.MouseWheelLeft` | +| `tea.MouseButtonWheelRight` | `tea.MouseWheelRight` | + +### `tea.MouseEvent` → `tea.Mouse` + +The `MouseEvent` struct is gone. The new `Mouse` struct has `X`, `Y`, `Button`, and `Mod` fields. + +### Mouse mode is now a View field + +```go +// Before: +p := tea.NewProgram(model{}, tea.WithMouseCellMotion()) + +// After: +func (m model) View() tea.View { + v := tea.NewView("...") + v.MouseMode = tea.MouseModeCellMotion + return v +} +``` + +## Removed Program Options + +These options no longer exist. They all moved to View fields. + +| Removed Option | Do This Instead | +|---|---| +| `tea.WithAltScreen()` | `view.AltScreen = true` | +| `tea.WithMouseCellMotion()` | `view.MouseMode = tea.MouseModeCellMotion` | +| `tea.WithMouseAllMotion()` | `view.MouseMode = tea.MouseModeAllMotion` | +| `tea.WithReportFocus()` | `view.ReportFocus = true` | +| `tea.WithoutBracketedPaste()` | `view.DisableBracketedPasteMode = true` | +| `tea.WithInputTTY()` | Just remove it — v2 always opens the TTY for input automatically | +| `tea.WithANSICompressor()` | Just remove it — the new renderer handles optimization automatically | + +## Removed Commands + +These commands no longer exist. Set the corresponding View field instead. + +| Removed Command | Do This Instead | +|---|---| +| `tea.EnterAltScreen` | `view.AltScreen = true` | +| `tea.ExitAltScreen` | `view.AltScreen = false` | +| `tea.EnableMouseCellMotion` | `view.MouseMode = tea.MouseModeCellMotion` | +| `tea.EnableMouseAllMotion` | `view.MouseMode = tea.MouseModeAllMotion` | +| `tea.DisableMouse` | `view.MouseMode = tea.MouseModeNone` | +| `tea.HideCursor` | `view.Cursor = nil` | +| `tea.ShowCursor` | `view.Cursor = &tea.Cursor{...}` or `tea.NewCursor(x, y)` | +| `tea.EnableBracketedPaste` | `view.DisableBracketedPasteMode = false` | +| `tea.DisableBracketedPaste` | `view.DisableBracketedPasteMode = true` | +| `tea.EnableReportFocus` | `view.ReportFocus = true` | +| `tea.DisableReportFocus` | `view.ReportFocus = false` | +| `tea.SetWindowTitle("...")` | `view.WindowTitle = "..."` | + +## Removed Program Methods + +These methods on `*Program` are gone. + +| Removed Method | Do This Instead | +|---|---| +| `p.Start()` | `p.Run()` | +| `p.StartReturningModel()` | `p.Run()` | +| `p.EnterAltScreen()` | `view.AltScreen = true` in `View()` | +| `p.ExitAltScreen()` | `view.AltScreen = false` in `View()` | +| `p.EnableMouseCellMotion()` | `view.MouseMode` in `View()` | +| `p.DisableMouseCellMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` | +| `p.EnableMouseAllMotion()` | `view.MouseMode` in `View()` | +| `p.DisableMouseAllMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` | +| `p.SetWindowTitle(...)` | `view.WindowTitle` in `View()` | + +## Renamed APIs + +| v1 | v2 | Notes | +|---|---|---| +| `tea.Sequentially(...)` | `tea.Sequence(...)` | `Sequentially` was already deprecated in v1 | +| `tea.WindowSize()` | `tea.RequestWindowSize` | Now returns `Msg` directly, not a `Cmd` | + +## New Program Options + +These are new in v2: + +| Option | What It Does | +|---|---| +| `tea.WithColorProfile(p)` | Force a specific color profile (great for testing) | +| `tea.WithWindowSize(w, h)` | Set initial terminal size (great for testing) | + +## Complete Before & After + +Here's a minimal but complete program showing the most common migration patterns side by side. + +**v1:** + +```go +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" +) + +type model struct { + count int +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case " ": + m.count++ + } + case tea.MouseMsg: + if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { + m.count++ + } + } + return m, nil +} + +func (m model) View() string { + return fmt.Sprintf("Count: %d\n\nSpace or click to increment. q to quit.\n", m.count) +} + +func main() { + p := tea.NewProgram(model{}, tea.WithAltScreen(), tea.WithMouseCellMotion()) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +``` + +**v2:** + +```go +package main + +import ( + "fmt" + "os" + + tea "charm.land/bubbletea/v2" +) + +type model struct { + count int +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "space": + m.count++ + } + case tea.MouseClickMsg: + if msg.Button == tea.MouseLeft { + m.count++ + } + } + return m, nil +} + +func (m model) View() tea.View { + v := tea.NewView(fmt.Sprintf("Count: %d\n\nSpace or click to increment. q to quit.\n", m.count)) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + return v +} + +func main() { + p := tea.NewProgram(model{}) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +``` + +Notice how the `NewProgram` call got simpler? All the terminal feature flags moved into `View()` where they belong. + +## Quick Reference + +A flat old → new lookup table. Handy for search-and-replace and LLM-assisted migration. + +### Import Paths + +| v1 | v2 | +|---|---| +| `github.com/charmbracelet/bubbletea` | `charm.land/bubbletea/v2` | +| `github.com/charmbracelet/lipgloss` | `charm.land/lipgloss/v2` | + +### Model Interface + +| v1 | v2 | +|---|---| +| `View() string` | `View() tea.View` | + +### Key Events + +| v1 | v2 | +|---|---| +| `tea.KeyMsg` (struct) | `tea.KeyPressMsg` for presses, `tea.KeyMsg` (interface) for both | +| `msg.Type` | `msg.Code` | +| `msg.Runes` | `msg.Text` (string, not `[]rune`) | +| `msg.Alt` | `msg.Mod.Contains(tea.ModAlt)` | +| `tea.KeyRune` | check `len(msg.Text) > 0` | +| `tea.KeyCtrlC` | `msg.Code == 'c' && msg.Mod == tea.ModCtrl` or `msg.String() == "ctrl+c"` | +| `case " ":` (space) | `case "space":` | + +### Mouse Events + +| v1 | v2 | +|---|---| +| `tea.MouseMsg` (struct) | `tea.MouseMsg` (interface) — call `.Mouse()` for the data | +| `tea.MouseEvent` | `tea.Mouse` | +| `tea.MouseButtonLeft` | `tea.MouseLeft` | +| `tea.MouseButtonRight` | `tea.MouseRight` | +| `tea.MouseButtonMiddle` | `tea.MouseMiddle` | +| `tea.MouseButtonWheelUp` | `tea.MouseWheelUp` | +| `tea.MouseButtonWheelDown` | `tea.MouseWheelDown` | +| `msg.X`, `msg.Y` (direct) | `msg.Mouse().X`, `msg.Mouse().Y` | + +### Options → View Fields + +| v1 Option | v2 View Field | +|---|---| +| `tea.WithAltScreen()` | `view.AltScreen = true` | +| `tea.WithMouseCellMotion()` | `view.MouseMode = tea.MouseModeCellMotion` | +| `tea.WithMouseAllMotion()` | `view.MouseMode = tea.MouseModeAllMotion` | +| `tea.WithReportFocus()` | `view.ReportFocus = true` | +| `tea.WithoutBracketedPaste()` | `view.DisableBracketedPasteMode = true` | + +### Commands → View Fields + +| v1 Command | v2 View Field | +|---|---| +| `tea.EnterAltScreen` / `tea.ExitAltScreen` | `view.AltScreen = true/false` | +| `tea.EnableMouseCellMotion` | `view.MouseMode = tea.MouseModeCellMotion` | +| `tea.EnableMouseAllMotion` | `view.MouseMode = tea.MouseModeAllMotion` | +| `tea.DisableMouse` | `view.MouseMode = tea.MouseModeNone` | +| `tea.HideCursor` / `tea.ShowCursor` | `view.Cursor = nil` / `view.Cursor = &tea.Cursor{...}` | +| `tea.EnableBracketedPaste` / `tea.DisableBracketedPaste` | `view.DisableBracketedPasteMode = false/true` | +| `tea.EnableReportFocus` / `tea.DisableReportFocus` | `view.ReportFocus = true/false` | +| `tea.SetWindowTitle("...")` | `view.WindowTitle = "..."` | + +### Removed Options (No Replacement Needed) + +| v1 Option | What Happened | +|---|---| +| `tea.WithInputTTY()` | v2 always opens the TTY for input automatically | +| `tea.WithANSICompressor()` | The new renderer handles optimization automatically | + +### Removed Program Methods + +| v1 Method | v2 Replacement | +|---|---| +| `p.Start()` | `p.Run()` | +| `p.StartReturningModel()` | `p.Run()` | +| `p.EnterAltScreen()` | `view.AltScreen = true` in `View()` | +| `p.ExitAltScreen()` | `view.AltScreen = false` in `View()` | +| `p.EnableMouseCellMotion()` | `view.MouseMode` in `View()` | +| `p.DisableMouseCellMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` | +| `p.EnableMouseAllMotion()` | `view.MouseMode` in `View()` | +| `p.DisableMouseAllMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` | +| `p.SetWindowTitle(...)` | `view.WindowTitle` in `View()` | + +### Other Renames + +| v1 | v2 | +|---|---| +| `tea.Sequentially(...)` | `tea.Sequence(...)` | +| `tea.WindowSize()` | `tea.RequestWindowSize` (now returns `Msg`, not `Cmd`) | + +### New Program Options + +| Option | Description | +|---|---| +| `tea.WithColorProfile(p)` | Force a specific color profile | +| `tea.WithWindowSize(w, h)` | Set initial window size (great for testing) | + +## Feedback + +Have thoughts on the v2 upgrade? We'd _love_ to hear about it. Let us know on… + +- [Discord](https://charm.land/chat) +- [Matrix](https://charm.land/matrix) +- [Email](mailto:vt100@charm.land) + +--- + +Part of [Charm](https://charm.land). + +The Charm logo + +Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة diff --git a/third_party/bubbletea-v2-patched/clipboard.go b/third_party/bubbletea-v2-patched/clipboard.go new file mode 100644 index 000000000..237a589ed --- /dev/null +++ b/third_party/bubbletea-v2-patched/clipboard.go @@ -0,0 +1,70 @@ +package tea + +// ClipboardMsg is a clipboard read message event. This message is emitted when +// a terminal receives an OSC52 clipboard read message event. +type ClipboardMsg struct { + Content string + Selection byte +} + +// Clipboard returns the clipboard selection type. This will be one of the +// following values: +// +// - c: System clipboard. +// - p: Primary clipboard (X11/Wayland only). +func (e ClipboardMsg) Clipboard() byte { + return e.Selection +} + +// String returns the string representation of the clipboard message. +func (e ClipboardMsg) String() string { + return e.Content +} + +// setClipboardMsg is an internal message used to set the system clipboard +// using OSC52. +type setClipboardMsg string + +// SetClipboard produces a command that sets the system clipboard using OSC52. +// Note that OSC52 is not supported in all terminals. +func SetClipboard(s string) Cmd { + return func() Msg { + return setClipboardMsg(s) + } +} + +// readClipboardMsg is an internal message used to read the system clipboard +// using OSC52. +type readClipboardMsg struct{} + +// ReadClipboard produces a command that reads the system clipboard using OSC52. +// Note that OSC52 is not supported in all terminals. +func ReadClipboard() Msg { + return readClipboardMsg{} +} + +// setPrimaryClipboardMsg is an internal message used to set the primary +// clipboard using OSC52. +type setPrimaryClipboardMsg string + +// SetPrimaryClipboard produces a command that sets the primary clipboard using +// OSC52. Primary clipboard selection is a feature present in X11 and Wayland +// only. +// Note that OSC52 is not supported in all terminals. +func SetPrimaryClipboard(s string) Cmd { + return func() Msg { + return setPrimaryClipboardMsg(s) + } +} + +// readPrimaryClipboardMsg is an internal message used to read the primary +// clipboard using OSC52. +type readPrimaryClipboardMsg struct{} + +// ReadPrimaryClipboard produces a command that reads the primary clipboard +// using OSC52. Primary clipboard selection is a feature present in X11 and +// Wayland only. +// Note that OSC52 is not supported in all terminals. +func ReadPrimaryClipboard() Msg { + return readPrimaryClipboardMsg{} +} diff --git a/third_party/bubbletea-v2-patched/color.go b/third_party/bubbletea-v2-patched/color.go new file mode 100644 index 000000000..54514e4b3 --- /dev/null +++ b/third_party/bubbletea-v2-patched/color.go @@ -0,0 +1,91 @@ +package tea + +import ( + "image/color" + + uv "github.com/charmbracelet/ultraviolet" +) + +// backgroundColorMsg is a message that requests the terminal background color. +type backgroundColorMsg struct{} + +// RequestBackgroundColor is a command that requests the terminal background color. +func RequestBackgroundColor() Msg { + return backgroundColorMsg{} +} + +// foregroundColorMsg is a message that requests the terminal foreground color. +type foregroundColorMsg struct{} + +// RequestForegroundColor is a command that requests the terminal foreground color. +func RequestForegroundColor() Msg { + return foregroundColorMsg{} +} + +// cursorColorMsg is a message that requests the terminal cursor color. +type cursorColorMsg struct{} + +// RequestCursorColor is a command that requests the terminal cursor color. +func RequestCursorColor() Msg { + return cursorColorMsg{} +} + +// ForegroundColorMsg represents a foreground color message. This message is +// emitted when the program requests the terminal foreground color with the +// [RequestForegroundColor] Cmd. +type ForegroundColorMsg struct{ color.Color } + +// String returns the hex representation of the color. +func (e ForegroundColorMsg) String() string { + return uv.ForegroundColorEvent(e).String() +} + +// IsDark returns whether the color is dark. +func (e ForegroundColorMsg) IsDark() bool { + return uv.ForegroundColorEvent(e).IsDark() +} + +// BackgroundColorMsg represents a background color message. This message is +// emitted when the program requests the terminal background color with the +// [RequestBackgroundColor] Cmd. +// +// This is commonly used in [Update.Init] to get the terminal background color +// for style definitions. For that you'll want to call +// [BackgroundColorMsg.IsDark] to determine if the color is dark or light. For +// example: +// +// func (m Model) Init() (Model, Cmd) { +// return m, RequestBackgroundColor() +// } +// +// func (m Model) Update(msg Msg) (Model, Cmd) { +// switch msg := msg.(type) { +// case BackgroundColorMsg: +// m.styles = newStyles(msg.IsDark()) +// } +// } +type BackgroundColorMsg struct{ color.Color } + +// String returns the hex representation of the color. +func (e BackgroundColorMsg) String() string { + return uv.BackgroundColorEvent(e).String() +} + +// IsDark returns whether the color is dark. +func (e BackgroundColorMsg) IsDark() bool { + return uv.BackgroundColorEvent(e).IsDark() +} + +// CursorColorMsg represents a cursor color change message. This message is +// emitted when the program requests the terminal cursor color. +type CursorColorMsg struct{ color.Color } + +// String returns the hex representation of the color. +func (e CursorColorMsg) String() string { + return uv.CursorColorEvent(e).String() +} + +// IsDark returns whether the color is dark. +func (e CursorColorMsg) IsDark() bool { + return uv.CursorColorEvent(e).IsDark() +} diff --git a/third_party/bubbletea-v2-patched/commands.go b/third_party/bubbletea-v2-patched/commands.go new file mode 100644 index 000000000..4497c3a38 --- /dev/null +++ b/third_party/bubbletea-v2-patched/commands.go @@ -0,0 +1,175 @@ +package tea + +import ( + "time" +) + +// Batch performs a bunch of commands concurrently with no ordering guarantees +// about the results. Use a Batch to return several commands. +// +// Example: +// +// func (m model) Init() (Model, Cmd) { +// return m, tea.Batch(someCommand, someOtherCommand) +// } +func Batch(cmds ...Cmd) Cmd { + return compactCmds[BatchMsg](cmds) +} + +// BatchMsg is a message used to perform a bunch of commands concurrently with +// no ordering guarantees. You can send a BatchMsg with Batch. +type BatchMsg []Cmd + +// Sequence runs the given commands one at a time, in order. Contrast this with +// Batch, which runs commands concurrently. +func Sequence(cmds ...Cmd) Cmd { + return compactCmds[sequenceMsg](cmds) +} + +// sequenceMsg is used internally to run the given commands in order. +type sequenceMsg []Cmd + +// compactCmds ignores any nil commands in cmds, and returns the most direct +// command possible. That is, considering the non-nil commands, if there are +// none it returns nil, if there is exactly one it returns that command +// directly, else it returns the non-nil commands as type T. +func compactCmds[T ~[]Cmd](cmds []Cmd) Cmd { + var validCmds []Cmd + for _, c := range cmds { + if c == nil { + continue + } + validCmds = append(validCmds, c) + } + switch len(validCmds) { + case 0: + return nil + case 1: + return validCmds[0] + default: + return func() Msg { + return T(validCmds) + } + } +} + +// Every is a command that ticks in sync with the system clock. So, if you +// wanted to tick with the system clock every second, minute or hour you +// could use this. It's also handy for having different things tick in sync. +// +// Because we're ticking with the system clock the tick will likely not run for +// the entire specified duration. For example, if we're ticking for one minute +// and the clock is at 12:34:20 then the next tick will happen at 12:35:00, 40 +// seconds later. +// +// To produce the command, pass a duration and a function which returns +// a message containing the time at which the tick occurred. +// +// type TickMsg time.Time +// +// cmd := Every(time.Second, func(t time.Time) Msg { +// return TickMsg(t) +// }) +// +// Beginners' note: Every sends a single message and won't automatically +// dispatch messages at an interval. To do that, you'll want to return another +// Every command after receiving your tick message. For example: +// +// type TickMsg time.Time +// +// // Send a message every second. +// func tickEvery() Cmd { +// return Every(time.Second, func(t time.Time) Msg { +// return TickMsg(t) +// }) +// } +// +// func (m model) Init() (Model, Cmd) { +// // Start ticking. +// return m, tickEvery() +// } +// +// func (m model) Update(msg Msg) (Model, Cmd) { +// switch msg.(type) { +// case TickMsg: +// // Return your Every command again to loop. +// return m, tickEvery() +// } +// return m, nil +// } +// +// Every is analogous to Tick in the Elm Architecture. +func Every(duration time.Duration, fn func(time.Time) Msg) Cmd { + n := time.Now() + d := n.Truncate(duration).Add(duration).Sub(n) + t := time.NewTimer(d) + return func() Msg { + ts := <-t.C + t.Stop() + for len(t.C) > 0 { + <-t.C + } + return fn(ts) + } +} + +// Tick produces a command at an interval independent of the system clock at +// the given duration. That is, the timer begins precisely when invoked, +// and runs for its entire duration. +// +// To produce the command, pass a duration and a function which returns +// a message containing the time at which the tick occurred. +// +// type TickMsg time.Time +// +// cmd := Tick(time.Second, func(t time.Time) Msg { +// return TickMsg(t) +// }) +// +// Beginners' note: Tick sends a single message and won't automatically +// dispatch messages at an interval. To do that, you'll want to return another +// Tick command after receiving your tick message. For example: +// +// type TickMsg time.Time +// +// func doTick() Cmd { +// return Tick(time.Second, func(t time.Time) Msg { +// return TickMsg(t) +// }) +// } +// +// func (m model) Init() (Model, Cmd) { +// // Start ticking. +// return m, doTick() +// } +// +// func (m model) Update(msg Msg) (Model, Cmd) { +// switch msg.(type) { +// case TickMsg: +// // Return your Tick command again to loop. +// return m, doTick() +// } +// return m, nil +// } +func Tick(d time.Duration, fn func(time.Time) Msg) Cmd { + t := time.NewTimer(d) + return func() Msg { + ts := <-t.C + t.Stop() + for len(t.C) > 0 { + <-t.C + } + return fn(ts) + } +} + +type windowSizeMsg struct{} + +// RequestWindowSize is a command that queries the terminal for its current +// size. It delivers the results to Update via a [WindowSizeMsg]. Keep in mind +// that WindowSizeMsgs will automatically be delivered to Update when the +// [Program] starts and when the window dimensions change so in many cases you +// will not need to explicitly invoke this command. +func RequestWindowSize() Msg { + return windowSizeMsg{} +} diff --git a/third_party/bubbletea-v2-patched/commands_test.go b/third_party/bubbletea-v2-patched/commands_test.go new file mode 100644 index 000000000..d647ea9ec --- /dev/null +++ b/third_party/bubbletea-v2-patched/commands_test.go @@ -0,0 +1,67 @@ +package tea + +import ( + "testing" + "time" +) + +func TestEvery(t *testing.T) { + t.Parallel() + expected := "every ms" + msg := Every(time.Millisecond, func(t time.Time) Msg { + return expected + })() + if expected != msg { + t.Fatalf("expected a msg %v but got %v", expected, msg) + } +} + +func TestTick(t *testing.T) { + t.Parallel() + expected := "tick" + msg := Tick(time.Millisecond, func(t time.Time) Msg { + return expected + })() + if expected != msg { + t.Fatalf("expected a msg %v but got %v", expected, msg) + } +} + +func TestBatch(t *testing.T) { + t.Parallel() + testMultipleCommands[BatchMsg](t, Batch) +} + +func TestSequence(t *testing.T) { + t.Parallel() + testMultipleCommands[sequenceMsg](t, Sequence) +} + +func testMultipleCommands[T ~[]Cmd](t *testing.T, createFn func(cmd ...Cmd) Cmd) { + t.Run("nil cmd", func(t *testing.T) { + t.Parallel() + if b := createFn(nil); b != nil { + t.Fatalf("expected nil, got %+v", b) + } + }) + t.Run("empty cmd", func(t *testing.T) { + t.Parallel() + if b := createFn(); b != nil { + t.Fatalf("expected nil, got %+v", b) + } + }) + t.Run("single cmd", func(t *testing.T) { + t.Parallel() + b := createFn(Quit)() + if _, ok := b.(QuitMsg); !ok { + t.Fatalf("expected a QuitMsg, got %T", b) + } + }) + t.Run("mixed nil cmds", func(t *testing.T) { + t.Parallel() + b := createFn(nil, Quit, nil, Quit, nil, nil)() + if l := len(b.(T)); l != 2 { + t.Fatalf("expected a []Cmd with len 2, got %d", l) + } + }) +} diff --git a/third_party/bubbletea-v2-patched/cursed_renderer.go b/third_party/bubbletea-v2-patched/cursed_renderer.go new file mode 100644 index 000000000..5e4982040 --- /dev/null +++ b/third_party/bubbletea-v2-patched/cursed_renderer.go @@ -0,0 +1,868 @@ +package tea + +import ( + "bytes" + "fmt" + "image/color" + "io" + "strings" + "sync" + + "github.com/charmbracelet/colorprofile" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" + "github.com/lucasb-eyer/go-colorful" +) + +type cursedRenderer struct { + w io.Writer + buf bytes.Buffer // updates buffer to be flushed to [w] + scr *uv.TerminalRenderer + cellbuf uv.ScreenBuffer + lastView *View + env []string + term string // the terminal type $TERM + width, height int + mu sync.Mutex + profile colorprofile.Profile + logger uv.Logger + view View + hardTabs bool // whether to use hard tabs to optimize cursor movements + backspace bool // whether to use backspace to optimize cursor movements + mapnl bool + syncdUpdates bool // whether to use synchronized output mode for updates + starting bool // indicates whether the renderer is starting after being stopped +} + +var _ renderer = &cursedRenderer{} + +func newCursedRenderer(w io.Writer, env []string, width, height int) (s *cursedRenderer) { + s = new(cursedRenderer) + s.w = w + s.env = env + s.term = uv.Environ(env).Getenv("TERM") + s.width, s.height = width, height // This needs to happen before [cursedRenderer.reset]. + s.cellbuf = uv.NewScreenBuffer(s.width, s.height) + reset(s) + return +} + +// setLogger sets the logger for the renderer. +func (s *cursedRenderer) setLogger(logger uv.Logger) { + s.mu.Lock() + s.logger = logger + s.mu.Unlock() +} + +// setOptimizations sets the cursor movement optimizations. +func (s *cursedRenderer) setOptimizations(hardTabs, backspace, mapnl bool) { + s.mu.Lock() + s.hardTabs = hardTabs + s.backspace = backspace + s.mapnl = mapnl + if s.hardTabs { + s.scr.SetTabStops(s.width) + } else { + s.scr.SetTabStops(-1) + } + s.scr.SetBackspace(s.backspace) + s.scr.SetMapNewline(s.mapnl) + s.mu.Unlock() +} + +// start implements renderer. +func (s *cursedRenderer) start() { + s.mu.Lock() + defer s.mu.Unlock() + + // Mark that we're starting. This is used to restore some state when + // starting the renderer again after it was stopped. + s.starting = true + + if s.lastView == nil { + return + } + + if s.lastView.AltScreen { + enableAltScreen(s, true, true) + } + enableTextCursor(s, s.lastView.Cursor != nil) + if s.lastView.Cursor != nil { + if s.lastView.Cursor.Color != nil { + col, ok := colorful.MakeColor(s.lastView.Cursor.Color) + if ok { + _, _ = s.scr.WriteString(ansi.SetCursorColor(col.Hex())) + } + } + curStyle := encodeCursorStyle(s.lastView.Cursor.Shape, s.lastView.Cursor.Blink) + if curStyle != 0 && curStyle != 1 { + _, _ = s.scr.WriteString(ansi.SetCursorStyle(curStyle)) + } + } + if s.lastView.ForegroundColor != nil { + col, ok := colorful.MakeColor(s.lastView.ForegroundColor) + if ok { + _, _ = s.scr.WriteString(ansi.SetForegroundColor(col.Hex())) + } + } + if s.lastView.BackgroundColor != nil { + col, ok := colorful.MakeColor(s.lastView.BackgroundColor) + if ok { + _, _ = s.scr.WriteString(ansi.SetBackgroundColor(col.Hex())) + } + } + if !s.lastView.DisableBracketedPasteMode { + _, _ = s.scr.WriteString(ansi.SetModeBracketedPaste) + } + if s.lastView.ReportFocus { + _, _ = s.scr.WriteString(ansi.SetModeFocusEvent) + } + switch s.lastView.MouseMode { + case MouseModeNone: + case MouseModeCellMotion: + _, _ = s.scr.WriteString(ansi.SetModeMouseButtonEvent + ansi.SetModeMouseExtSgr) + case MouseModeAllMotion: + _, _ = s.scr.WriteString(ansi.SetModeMouseAnyEvent + ansi.SetModeMouseExtSgr) + } + if s.lastView.WindowTitle != "" { + _, _ = s.scr.WriteString(ansi.SetWindowTitle(s.lastView.WindowTitle)) + } + if s.lastView.ProgressBar != nil { + setProgressBar(s, s.lastView.ProgressBar) + } + // Enable modifyOtherKeys and Kitty keyboard protocol. + // Both can coexist; terminals ignore what they don't support. + _, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2) + + kittyFlags := keyboardEnhancementsFlags(s.lastView.KeyboardEnhancements) + _, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1)) +} + +// close implements renderer. +func (s *cursedRenderer) close() (err error) { + s.mu.Lock() + defer s.mu.Unlock() + + // Exit the altScreen and show cursor before closing. It's important that + // we don't change the [cursedRenderer] altScreen and cursorHidden states + // so that we can restore them when we start the renderer again. This is + // used when the user suspends the program and then resumes it. + if lv := s.lastView; lv != nil { //nolint:nestif + // NOTE: The Kitty keyboard specs specify that the terminal should have + // two registries for the main and alt screens. We disable keyboard + // enhancements whenever we enter/exit alt screen mode in + // [cursedRenderer.flush]. + // Here, we reset the keyboard protocol of the last screen used + // assuming the other screen is already reset when we switched screens. + _, _ = s.buf.WriteString(ansi.ResetModifyOtherKeys) + _, _ = s.buf.WriteString(ansi.KittyKeyboard(0, 1)) + + // Go to the bottom of the screen. + // We need to go to the bottom of the screen regardless of whether + // we're in alt screen mode or not to avoid leaving the cursor in the + // middle in terminals that don't support alt screen mode. + s.scr.MoveTo(0, s.cellbuf.Height()-1) + _ = s.scr.Flush() // we need to flush to write the cursor movement + if lv.AltScreen { + enableAltScreen(s, false, true) + } else { + _, _ = s.scr.WriteString(ansi.EraseScreenBelow) + } + if lv.Cursor == nil { + enableTextCursor(s, true) + } + if !lv.DisableBracketedPasteMode { + _, _ = s.scr.WriteString(ansi.ResetModeBracketedPaste) + } + if lv.ReportFocus { + _, _ = s.scr.WriteString(ansi.ResetModeFocusEvent) + } + switch lv.MouseMode { + case MouseModeNone: + case MouseModeCellMotion, MouseModeAllMotion: + _, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent + + ansi.ResetModeMouseAnyEvent + + ansi.ResetModeMouseExtSgr) + } + + if lv.WindowTitle != "" { + // Clear the window title if it was set. + _, _ = s.scr.WriteString(ansi.SetWindowTitle("")) + } + if lc := lv.Cursor; lc != nil { + curShape := encodeCursorStyle(lc.Shape, lc.Blink) + if curShape != 0 && curShape != 1 { + // Reset the cursor style to default if it was set to something other + // blinking block. + _, _ = s.scr.WriteString(ansi.SetCursorStyle(0)) + } + + if lc.Color != nil { + _, _ = s.scr.WriteString(ansi.ResetCursorColor) + } + } + + if lv.BackgroundColor != nil { + _, _ = s.scr.WriteString(ansi.ResetBackgroundColor) + } + if lv.ForegroundColor != nil { + _, _ = s.scr.WriteString(ansi.ResetForegroundColor) + } + if lv.ProgressBar != nil && lv.ProgressBar.State != ProgressBarNone { + _, _ = s.scr.WriteString(ansi.ResetProgressBar) + } + } + + if s.cellbuf.Method == ansi.GraphemeWidth { + // Make sure to turn off Unicode mode (2027) + _, _ = s.scr.WriteString(ansi.ResetModeUnicodeCore) + } + + if err := s.scr.Flush(); err != nil { + return fmt.Errorf("bubbletea: error closing screen writer: %w", err) + } + + if s.buf.Len() > 0 { + if s.logger != nil { + s.logger.Printf("output: %q", s.buf.String()) + } + if _, err := io.Copy(s.w, &s.buf); err != nil { + return fmt.Errorf("bubbletea: error writing to screen: %w", err) + } + s.buf.Reset() + } + + x, y := s.scr.Position() + + // We want to clear the renderer state but not the cursor position. This is + // because we might be putting the tea process in the background, run some + // other process, and then return to the tea process. We want to keep the + // cursor position so that we can continue where we left off. + reset(s) + s.scr.SetPosition(x, y) + + return nil +} + +// writeString implements renderer. +func (s *cursedRenderer) writeString(str string) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + return s.scr.WriteString(str) //nolint:wrapcheck +} + +// flush implements renderer. +func (s *cursedRenderer) flush(closing bool) error { + s.mu.Lock() + defer s.mu.Unlock() + + view := s.view + frameArea := uv.Rect(0, 0, s.width, s.height) + if len(view.Content) == 0 { + // If the component is nil, we should clear the screen buffer. + frameArea.Max.Y = 0 + } + + content := uv.NewStyledString(view.Content) + if !view.AltScreen { + // We need to resizes the screen based on the frame height and + // terminal width. This is because the frame height can change based on + // the content of the frame. For example, if the frame contains a list + // of items, the height of the frame will be the number of items in the + // list. This is different from the alt screen buffer, which has a + // fixed height and width. + frameHeight := content.Height() + if frameHeight != frameArea.Dy() { + frameArea.Max.Y = frameHeight + } + } + + // Restore tab stops if we have tab optimizations enabled. + if s.starting && s.hardTabs { + _, _ = s.scr.WriteString(ansi.SetTabEvery8Columns) + } + + if !s.starting && !closing && s.lastView != nil && viewEquals(s.lastView, &view) && frameArea == s.cellbuf.Bounds() { + // No changes, nothing to do. + return nil + } + + // We're no longer starting. + s.starting = false + + if frameArea != s.cellbuf.Bounds() { + s.scr.Erase() // Force a full redraw to avoid artifacts. + + // We need to reset the touched lines buffer to match the new height. + s.cellbuf.Touched = nil + + // Resize the screen buffer to match the frame area. This is necessary + // to ensure that the screen buffer is the same size as the frame area + // and to avoid rendering issues when the frame area is smaller than + // the screen buffer. + s.cellbuf.Resize(frameArea.Dx(), frameArea.Dy()) + } + + // Clear our screen buffer before copying the new frame into it to ensure + // we erase any old content. + s.cellbuf.Clear() + content.Draw(s.cellbuf, s.cellbuf.Bounds()) + + // If the frame height is greater than the screen height, we drop the + // lines from the top of the buffer. + if frameHeight := frameArea.Dy(); frameHeight > s.height { + s.cellbuf.Lines = s.cellbuf.Lines[frameHeight-s.height:] + } + + // Alt screen mode. + shouldUpdateAltScreen := (s.lastView == nil && view.AltScreen) || (s.lastView != nil && s.lastView.AltScreen != view.AltScreen) + if shouldUpdateAltScreen { + // We want to enter/exit altscreen mode but defer writing the actual + // sequences until we flush the rest of the updates. This is because we + // control the cursor visibility and we need to ensure that happens + // after entering/exiting alt screen mode. Some terminals have + // different cursor visibility states for main and alt screen modes and + // this ensures we handle that correctly. + enableAltScreen(s, view.AltScreen, false) + } + + // bracketed paste mode. + if s.lastView == nil || view.DisableBracketedPasteMode != s.lastView.DisableBracketedPasteMode { + if !view.DisableBracketedPasteMode { + _, _ = s.scr.WriteString(ansi.SetModeBracketedPaste) + } else if s.lastView != nil { + _, _ = s.scr.WriteString(ansi.ResetModeBracketedPaste) + } + } + + // report focus events mode. + if s.lastView == nil || s.lastView.ReportFocus != view.ReportFocus { + if view.ReportFocus { + _, _ = s.scr.WriteString(ansi.SetModeFocusEvent) + } else if s.lastView != nil { + _, _ = s.scr.WriteString(ansi.ResetModeFocusEvent) + } + } + + // mouse events mode. + if s.lastView == nil || view.MouseMode != s.lastView.MouseMode { + switch view.MouseMode { + case MouseModeNone: + if s.lastView != nil && s.lastView.MouseMode != MouseModeNone { + _, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent + + ansi.ResetModeMouseAnyEvent + + ansi.ResetModeMouseExtSgr) + } + case MouseModeCellMotion: + if s.lastView != nil && s.lastView.MouseMode == MouseModeAllMotion { + _, _ = s.scr.WriteString(ansi.ResetModeMouseAnyEvent) + } + _, _ = s.scr.WriteString(ansi.SetModeMouseButtonEvent + ansi.SetModeMouseExtSgr) + case MouseModeAllMotion: + if s.lastView != nil && s.lastView.MouseMode == MouseModeCellMotion { + _, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent) + } + _, _ = s.scr.WriteString(ansi.SetModeMouseAnyEvent + ansi.SetModeMouseExtSgr) + } + } + + // Set window title. + if s.lastView == nil || view.WindowTitle != s.lastView.WindowTitle { + if s.lastView != nil || view.WindowTitle != "" { + _, _ = s.scr.WriteString(ansi.SetWindowTitle(view.WindowTitle)) + } + } + + // kitty keyboard protocol + if s.lastView == nil || view.KeyboardEnhancements != s.lastView.KeyboardEnhancements || + view.AltScreen != s.lastView.AltScreen { + // NOTE: We need to reset the keyboard protocol when switching + // between main and alt screen. This is because the specs specify + // two different states for the main and alt screen. + + // Enable modifyOtherKeys and Kitty keyboard protocol. + _, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2) + + kittyFlags := keyboardEnhancementsFlags(view.KeyboardEnhancements) + _, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1)) + if !closing { + // Request keyboard enhancements when they change + _, _ = s.scr.WriteString(ansi.RequestKittyKeyboard) + } + } + + // Set terminal colors. + var ( + cc, lcc color.Color + lfg, lbg color.Color + ) + if view.Cursor != nil { + cc = view.Cursor.Color + } + if s.lastView != nil { + if s.lastView.Cursor != nil { + lcc = s.lastView.Cursor.Color + } + lfg = s.lastView.ForegroundColor + lbg = s.lastView.BackgroundColor + } + for _, c := range []struct { + newColor color.Color + oldColor color.Color + reset string + setter func(string) string + }{ + {newColor: cc, oldColor: lcc, reset: ansi.ResetCursorColor, setter: ansi.SetCursorColor}, + {newColor: view.ForegroundColor, oldColor: lfg, reset: ansi.ResetForegroundColor, setter: ansi.SetForegroundColor}, + {newColor: view.BackgroundColor, oldColor: lbg, reset: ansi.ResetBackgroundColor, setter: ansi.SetBackgroundColor}, + } { + if c.newColor != c.oldColor { + if c.newColor == nil { + // Reset the color if it was set to nil. + _, _ = s.scr.WriteString(c.reset) + } else { + // Set the color. + col, ok := colorful.MakeColor(c.newColor) + if ok { + _, _ = s.scr.WriteString(c.setter(col.Hex())) + } + } + } + } + + // Set cursor shape and blink if set. + var ccStyle, lcStyle int + var lcur *Cursor + ccur := view.Cursor + if lv := s.lastView; lv != nil { + lcur = lv.Cursor + } + if ccur != nil { + ccStyle = encodeCursorStyle(ccur.Shape, ccur.Blink) + } + if lcur != nil { + lcStyle = encodeCursorStyle(lcur.Shape, lcur.Blink) + } + if ccStyle != lcStyle { + _, _ = s.scr.WriteString(ansi.SetCursorStyle(ccStyle)) + } + + // Render progress bar if it's changed. + if (s.lastView == nil && view.ProgressBar != nil && view.ProgressBar.State != ProgressBarNone) || + (s.lastView != nil && (s.lastView.ProgressBar == nil) != (view.ProgressBar == nil)) || + (s.lastView != nil && s.lastView.ProgressBar != nil && view.ProgressBar != nil && *s.lastView.ProgressBar != *view.ProgressBar) { + // Render or clear the progress bar if it was added or removed. + setProgressBar(s, view.ProgressBar) + } + + // Render and queue changes to the screen buffer. + s.scr.Render(s.cellbuf.RenderBuffer) + + if cur := view.Cursor; cur != nil { + // MoveTo must come after [uv.TerminalRenderer.Render] because the + // cursor position might get updated during rendering. + s.scr.MoveTo(view.Cursor.X, view.Cursor.Y) + } else if !view.AltScreen { + // We don't want the cursor to be dangling at the end of the line in + // inline mode because it can cause unwanted line wraps in some + // terminals. So we move it to the beginning of the next line if + // necessary. + // This is only needed when the cursor is hidden because when it's + // visible, we already set its position above. + x, y := s.scr.Position() + if x >= s.width-1 { + s.scr.MoveTo(0, y) + } + } + + if err := s.scr.Flush(); err != nil { + return fmt.Errorf("bubbletea: error flushing screen writer: %w", err) + } + + // Check if we have any render updates to flush. + hasUpdates := s.buf.Len() > 0 + + // Cursor visibility. + didShowCursor := s.lastView != nil && s.lastView.Cursor != nil + showCursor := view.Cursor != nil + hideCursor := !showCursor + shouldUpdateCursorVis := (s.lastView == nil || didShowCursor != showCursor) || shouldUpdateAltScreen + + // Build final output buffer with synchronized output or hide/show cursor + // updates. But first, enter/exit alt screen mode if needed. + // + // Here, we have two scenarios: + // 1. Synchronized output updates are supported. In this case, we want to + // wrap all updates, unless it's just a cursor visibility change, in + // synchronized output mode. This is because synchronized output mode + // takes care of rendering the updates atomically. In the case of + // just a cursor visibility change, we don't need to enter + // synchronized output mode because it's just a single sequence to + // flush out to the terminal. + // + // 2. We don't have synchronized output updates support. In this case, and + // if the cursor is visible or should be visible, we wrap the updates + // with hide/show cursor sequences to try and mitigate cursor + // flickering. This is terminal dependent and may still result in + // flickering in some terminals. It's the best effort we can do instead + // of showing the cursor flying around the screen during updates. + + var buf bytes.Buffer + if shouldUpdateAltScreen { + // We always disable keyboard enhancements when switching screens + // because the terminal is expected to have two different keyboard + // registries for main and alt screens. + _, _ = buf.WriteString(ansi.ResetModifyOtherKeys) + _, _ = buf.WriteString(ansi.KittyKeyboard(0, 1)) + if view.AltScreen { + // Entering alt screen mode. + buf.WriteString(ansi.SetModeAltScreenSaveCursor) + } else { + // Exiting alt screen mode. + buf.WriteString(ansi.ResetModeAltScreenSaveCursor) + } + } + + if s.syncdUpdates { + if hasUpdates { + // We have synchronized output updates enabled. + buf.WriteString(ansi.SetModeSynchronizedOutput) + } + if shouldUpdateCursorVis && hideCursor { + // Do we need to update the cursor visibility to hidden? If so, do + // it here before writing any updates to the buffer. + _, _ = buf.WriteString(ansi.ResetModeTextCursorEnable) + } + } else if (shouldUpdateCursorVis && hideCursor) || (hasUpdates && showCursor && didShowCursor) { + _, _ = buf.WriteString(ansi.ResetModeTextCursorEnable) + } + + if hasUpdates { + buf.Write(s.buf.Bytes()) + } + + if s.syncdUpdates { + if shouldUpdateCursorVis && showCursor { + // Do we need to update the cursor visibility to visible? If so, do + // it here after writing any updates to the buffer. + _, _ = buf.WriteString(ansi.SetModeTextCursorEnable) + } + if hasUpdates { + // Close synchronized output mode. + buf.WriteString(ansi.ResetModeSynchronizedOutput) + } + } else if (shouldUpdateCursorVis && showCursor) || (hasUpdates && showCursor && didShowCursor) { + _, _ = buf.WriteString(ansi.SetModeTextCursorEnable) + } + + // Reset internal screen renderer buffer. + s.buf.Reset() + + // If our updates flush buffer has content, write it to the output writer. + if buf.Len() > 0 { + if s.logger != nil { + s.logger.Printf("output: %q", buf.String()) + } + if _, err := io.Copy(s.w, &buf); err != nil { + return fmt.Errorf("bubbletea: error flushing update to the writer: %w", err) + } + } + + s.lastView = &view + + return nil +} + +// render implements renderer. +func (s *cursedRenderer) render(v View) { + s.mu.Lock() + defer s.mu.Unlock() + + s.view = v +} + +// reset implements renderer. +func (s *cursedRenderer) reset() { + s.mu.Lock() + reset(s) + s.mu.Unlock() +} + +func reset(s *cursedRenderer) { + s.buf.Reset() + scr := uv.NewTerminalRenderer(&s.buf, s.env) + scr.SetColorProfile(s.profile) + scr.SetRelativeCursor(true) // Always start in inline mode + scr.SetFullscreen(false) // Always start in inline mode + if s.hardTabs { + scr.SetTabStops(s.width) + } else { + scr.SetTabStops(-1) + } + scr.SetBackspace(s.backspace) + scr.SetMapNewline(s.mapnl) + // Hard scroll optimization (using terminal scroll-region sequences to + // shift existing lines instead of repainting) corrupts rendering badly + // on terminals that mishandle it. Upstream only disables this on + // GOOS=="windows", but that only catches a Windows terminal driving the + // program directly — it misses a Linux process (e.g. inside a multipass + // VM) whose output is ultimately displayed by Windows Terminal over a + // remote shell, which hits the same class of bug. There's no reliable + // way to detect that hop from in here, so disable scroll optimization + // unconditionally rather than trying to guess. + scr.SetScrollOptim(false) + s.scr = scr +} + +// setColorProfile implements renderer. +func (s *cursedRenderer) setColorProfile(p colorprofile.Profile) { + s.mu.Lock() + s.profile = p + s.scr.SetColorProfile(p) + s.mu.Unlock() +} + +// resize implements renderer. +func (s *cursedRenderer) resize(w, h int) { + s.mu.Lock() + // We need to mark the screen for clear to force a redraw. However, we + // only do so if we're using alt screen or the width has changed. + // That's because redrawing is expensive and we can avoid it if the + // width hasn't changed in inline mode. On the other hand, when using + // alt screen mode, we always want to redraw because some terminals + // would scroll the screen and our content would be lost. + s.scr.Erase() + s.width, s.height = w, h + s.scr.Resize(s.width, s.height) + s.mu.Unlock() +} + +// clearScreen implements renderer. +func (s *cursedRenderer) clearScreen() { + s.mu.Lock() + // Move the cursor to the top left corner of the screen and trigger a full + // screen redraw. + s.scr.MoveTo(0, 0) + s.scr.Erase() + s.mu.Unlock() +} + +// enableAltScreen sets the alt screen mode. +// Note that this writes to the buffer directly if write is true. +func enableAltScreen(s *cursedRenderer, enable bool, write bool) { + if enable { + enterAltScreen(s, write) + } else { + exitAltScreen(s, write) + } +} + +func enterAltScreen(s *cursedRenderer, write bool) { + s.scr.SaveCursor() + if write { + s.buf.WriteString(ansi.SetModeAltScreenSaveCursor) + } + s.scr.SetFullscreen(true) + s.scr.SetRelativeCursor(false) + s.scr.Erase() +} + +func exitAltScreen(s *cursedRenderer, write bool) { + s.scr.Erase() + s.scr.SetRelativeCursor(true) + s.scr.SetFullscreen(false) + if write { + s.buf.WriteString(ansi.ResetModeAltScreenSaveCursor) + } + s.scr.RestoreCursor() +} + +// enableTextCursor sets the text cursor mode. +func enableTextCursor(s *cursedRenderer, enable bool) { + if enable { + _, _ = s.scr.WriteString(ansi.SetModeTextCursorEnable) + } else { + _, _ = s.scr.WriteString(ansi.ResetModeTextCursorEnable) + } +} + +// setSyncdUpdates implements renderer. +func (s *cursedRenderer) setSyncdUpdates(syncd bool) { + s.mu.Lock() + s.syncdUpdates = syncd + s.mu.Unlock() +} + +// setWidthMethod implements renderer. +func (s *cursedRenderer) setWidthMethod(method ansi.Method) { + s.mu.Lock() + if method == ansi.GraphemeWidth { + // Turn on Unicode mode (2027) for accurate grapheme width calculation. + // This is needed for proper rendering of wide characters and emojis. + _, _ = s.scr.WriteString(ansi.SetModeUnicodeCore) + } else if s.cellbuf.Method == ansi.GraphemeWidth { + // Turn off Unicode mode if we're switching away from grapheme width + // calculation to avoid issues with some terminals that might still be + // in Unicode mode and render characters incorrectly. + _, _ = s.scr.WriteString(ansi.ResetModeUnicodeCore) + } + s.cellbuf.Method = method + s.mu.Unlock() +} + +// insertAbove implements renderer. +func (s *cursedRenderer) insertAbove(str string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if len(str) == 0 { + return nil + } + + var sb strings.Builder + w, h := s.cellbuf.Width(), s.cellbuf.Height() + _, y := s.scr.Position() + + // We need to scroll the screen up by the number of lines in the queue. + sb.WriteByte('\r') + down := h - y - 1 + if down > 0 { + sb.WriteString(ansi.CursorDown(down)) + } + + lines := strings.Split(str, "\n") + offset := len(lines) + for _, line := range lines { + lineWidth := ansi.StringWidth(line) + if w > 0 && lineWidth > w { + offset += (lineWidth / w) + } + } + + // Scroll the screen up by the offset to make room for the new lines. + sb.WriteString(strings.Repeat("\n", offset)) + + // XXX: Now go to the top of the screen, insert new lines, and write + // the queued strings. It is important to use [Screen.moveCursor] + // instead of [Screen.move] because we don't want to perform any checks + // on the cursor position. + up := offset + h - 1 + sb.WriteString(ansi.CursorUp(up)) + sb.WriteString(ansi.InsertLine(offset)) + for _, line := range lines { + sb.WriteString(line) + sb.WriteString(ansi.EraseLineRight) + sb.WriteString("\r\n") + } + + s.scr.SetPosition(0, 0) + + if s.logger != nil { + s.logger.Printf("insert above: %q", sb.String()) + } + + _, err := io.WriteString(s.w, sb.String()) + if err != nil { + return fmt.Errorf("bubbletea: error writing insert above to the writer: %w", err) + } + + return nil +} + +// onMouse implements renderer. +func (s *cursedRenderer) onMouse(m MouseMsg) Cmd { + var onMouse func(MouseMsg) Cmd + s.mu.Lock() + if s.lastView != nil { + onMouse = s.lastView.OnMouse + } + s.mu.Unlock() + if onMouse != nil { + return onMouse(m) + } + return nil +} + +func setProgressBar(s *cursedRenderer, pb *ProgressBar) { + if pb == nil { + _, _ = s.scr.WriteString(ansi.ResetProgressBar) + return + } + + var seq string + switch pb.State { + case ProgressBarNone: + seq = ansi.ResetProgressBar + case ProgressBarDefault: + seq = ansi.SetProgressBar(pb.Value) + case ProgressBarError: + seq = ansi.SetErrorProgressBar(pb.Value) + case ProgressBarIndeterminate: + seq = ansi.SetIndeterminateProgressBar + case ProgressBarWarning: + seq = ansi.SetWarningProgressBar(pb.Value) + } + if seq != "" { + _, _ = s.scr.WriteString(seq) + } +} + +func viewEquals(a, b *View) bool { + if a == nil || b == nil { + return false + } + + if a.Content != b.Content || + a.AltScreen != b.AltScreen || + a.DisableBracketedPasteMode != b.DisableBracketedPasteMode || + a.ReportFocus != b.ReportFocus || + a.MouseMode != b.MouseMode || + a.WindowTitle != b.WindowTitle || + a.ForegroundColor != b.ForegroundColor || + a.BackgroundColor != b.BackgroundColor || + a.KeyboardEnhancements != b.KeyboardEnhancements { + return false + } + + if (a.Cursor == nil) != (b.Cursor == nil) { + return false + } + if a.Cursor != nil && b.Cursor != nil { + if a.Cursor.X != b.Cursor.X || + a.Cursor.Y != b.Cursor.Y || + a.Cursor.Shape != b.Cursor.Shape || + a.Cursor.Blink != b.Cursor.Blink || + a.Cursor.Color != b.Cursor.Color { + return false + } + } + + if (a.ProgressBar == nil) != (b.ProgressBar == nil) { + return false + } + if a.ProgressBar != nil && b.ProgressBar != nil { + if *a.ProgressBar != *b.ProgressBar { + return false + } + } + + return true +} + +func keyboardEnhancementsFlags(ke KeyboardEnhancements) int { + flags := 1 // always enable basic key disambiguation + if ke.ReportEventTypes { + flags |= ansi.KittyReportEventTypes + } + if ke.ReportAlternateKeys { + flags |= ansi.KittyReportAlternateKeys + } + if ke.ReportAllKeysAsEscapeCodes { + flags |= ansi.KittyReportAllKeysAsEscapeCodes + } + if ke.ReportAssociatedText { + flags |= ansi.KittyReportAssociatedKeys + } + return flags +} diff --git a/third_party/bubbletea-v2-patched/cursed_renderer_test.go b/third_party/bubbletea-v2-patched/cursed_renderer_test.go new file mode 100644 index 000000000..5c4b9a659 --- /dev/null +++ b/third_party/bubbletea-v2-patched/cursed_renderer_test.go @@ -0,0 +1,80 @@ +package tea + +import ( + "fmt" + "io" + "testing" + "time" +) + +type mouseRaceModel struct { + i int +} + +func (m *mouseRaceModel) Init() Cmd { return nil } + +func (m *mouseRaceModel) Update(msg Msg) (Model, Cmd) { + switch msg.(type) { + case MouseClickMsg, MouseMotionMsg, MouseWheelMsg: + m.i++ + } + return m, nil +} + +func (m *mouseRaceModel) View() View { + return View{ + Content: fmt.Sprintf("tick-%d\n", m.i), + MouseMode: MouseModeCellMotion, + } +} + +// Fixes: https://github.com/charmbracelet/bubbletea/issues/1690 +func TestCursedRenderer_mouseVsFlush(t *testing.T) { + t.Parallel() + + pr, pw := io.Pipe() + defer func() { _ = pw.Close() }() + + m := &mouseRaceModel{} + p := NewProgram( + m, + WithContext(t.Context()), + WithInput(pr), + WithOutput(io.Discard), + WithEnvironment([]string{ + "TERM=xterm-256color", + "TERM_PROGRAM=Apple_Terminal", + }), + WithoutSignals(), + WithWindowSize(80, 24), + ) + + runDone := make(chan struct{}) + go func() { + defer close(runDone) + _, _ = p.Run() + }() + + time.Sleep(150 * time.Millisecond) + + const iterations = 100 + for i := range iterations { + switch i % 4 { + case 0: + p.Send(MouseClickMsg{X: i % 80, Y: i % 24, Button: MouseLeft}) + case 1: + p.Send(MouseMotionMsg{X: i % 80, Y: i % 24}) + case 2: + p.Send(MouseWheelMsg{X: 0, Y: 0, Button: MouseWheelUp}) + default: + p.Send(MouseReleaseMsg{X: i % 80, Y: i % 24, Button: MouseLeft}) + } + } + + p.Quit() + select { + case <-runDone: + case <-time.After(5 * time.Second): + t.Fatal("program did not exit after Quit") + } +} diff --git a/third_party/bubbletea-v2-patched/cursor.go b/third_party/bubbletea-v2-patched/cursor.go new file mode 100644 index 000000000..3375a9652 --- /dev/null +++ b/third_party/bubbletea-v2-patched/cursor.go @@ -0,0 +1,28 @@ +package tea + +// Position represents a position in the terminal. +type Position struct{ X, Y int } + +// CursorPositionMsg is a message that represents the terminal cursor position. +type CursorPositionMsg struct { + X, Y int +} + +// CursorShape represents a terminal cursor shape. +type CursorShape int + +// Cursor shapes. +const ( + CursorBlock CursorShape = iota + CursorUnderline + CursorBar +) + +// requestCursorPosMsg is a message that requests the cursor position. +type requestCursorPosMsg struct{} + +// RequestCursorPosition is a command that requests the cursor position. +// The cursor position will be sent as a [CursorPositionMsg] message. +func RequestCursorPosition() Msg { + return requestCursorPosMsg{} +} diff --git a/third_party/bubbletea-v2-patched/environ.go b/third_party/bubbletea-v2-patched/environ.go new file mode 100644 index 000000000..8217b02f4 --- /dev/null +++ b/third_party/bubbletea-v2-patched/environ.go @@ -0,0 +1,34 @@ +package tea + +import uv "github.com/charmbracelet/ultraviolet" + +// EnvMsg is a message that represents the environment variables of the +// program. This is useful for getting the environment variables of programs +// running in a remote session like SSH. In that case, using [os.Getenv] would +// return the server's environment variables, not the client's. +// +// This message is sent to the program when it starts. +// +// Example: +// +// switch msg := msg.(type) { +// case EnvMsg: +// // What terminal type is being used? +// term := msg.Getenv("TERM") +// } +type EnvMsg uv.Environ + +// Getenv returns the value of the environment variable named by the key. If +// the variable is not present in the environment, the value returned will be +// the empty string. +func (msg EnvMsg) Getenv(key string) (v string) { + return uv.Environ(msg).Getenv(key) +} + +// LookupEnv retrieves the value of the environment variable named by the key. +// If the variable is present in the environment the value (which may be empty) +// is returned and the boolean is true. Otherwise the returned value will be +// empty and the boolean will be false. +func (msg EnvMsg) LookupEnv(key string) (s string, v bool) { + return uv.Environ(msg).LookupEnv(key) +} diff --git a/third_party/bubbletea-v2-patched/exec.go b/third_party/bubbletea-v2-patched/exec.go new file mode 100644 index 000000000..e8af5c42f --- /dev/null +++ b/third_party/bubbletea-v2-patched/exec.go @@ -0,0 +1,129 @@ +package tea + +import ( + "io" + "os" + "os/exec" +) + +// execMsg is used internally to run an ExecCommand sent with Exec. +type execMsg struct { + cmd ExecCommand + fn ExecCallback +} + +// Exec is used to perform arbitrary I/O in a blocking fashion, effectively +// pausing the Program while execution is running and resuming it when +// execution has completed. +// +// Most of the time you'll want to use ExecProcess, which runs an exec.Cmd. +// +// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd). +func Exec(c ExecCommand, fn ExecCallback) Cmd { + return func() Msg { + return execMsg{cmd: c, fn: fn} + } +} + +// ExecProcess runs the given *exec.Cmd in a blocking fashion, effectively +// pausing the Program while the command is running. After the *exec.Cmd exists +// the Program resumes. It's useful for spawning other interactive applications +// such as editors and shells from within a Program. +// +// To produce the command, pass an *exec.Cmd and a function which returns +// a message containing the error which may have occurred when running the +// ExecCommand. +// +// type VimFinishedMsg struct { err error } +// +// c := exec.Command("vim", "file.txt") +// +// cmd := ExecProcess(c, func(err error) Msg { +// return VimFinishedMsg{err: err} +// }) +// +// Or, if you don't care about errors, you could simply: +// +// cmd := ExecProcess(exec.Command("vim", "file.txt"), nil) +// +// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd). +func ExecProcess(c *exec.Cmd, fn ExecCallback) Cmd { + return Exec(wrapExecCommand(c), fn) +} + +// ExecCallback is used when executing an *exec.Command to return a message +// with an error, which may or may not be nil. +type ExecCallback func(error) Msg + +// ExecCommand can be implemented to execute things in a blocking fashion in +// the current terminal. +type ExecCommand interface { + Run() error + SetStdin(io.Reader) + SetStdout(io.Writer) + SetStderr(io.Writer) +} + +// wrapExecCommand wraps an exec.Cmd so that it satisfies the ExecCommand +// interface so it can be used with Exec. +func wrapExecCommand(c *exec.Cmd) ExecCommand { + return &osExecCommand{Cmd: c} +} + +// osExecCommand is a layer over an exec.Cmd that satisfies the ExecCommand +// interface. +type osExecCommand struct{ *exec.Cmd } + +// SetStdin sets stdin on underlying exec.Cmd to the given io.Reader. +func (c *osExecCommand) SetStdin(r io.Reader) { + // If unset, have the command use the same input as the terminal. + if c.Stdin == nil { + c.Stdin = r + } +} + +// SetStdout sets stdout on underlying exec.Cmd to the given io.Writer. +func (c *osExecCommand) SetStdout(w io.Writer) { + // If unset, have the command use the same output as the terminal. + if c.Stdout == nil { + c.Stdout = w + } +} + +// SetStderr sets stderr on the underlying exec.Cmd to the given io.Writer. +func (c *osExecCommand) SetStderr(w io.Writer) { + // If unset, use stderr for the command's stderr + if c.Stderr == nil { + c.Stderr = w + } +} + +// exec runs an ExecCommand and delivers the results to the program as a Msg. +func (p *Program) exec(c ExecCommand, fn ExecCallback) { + if err := p.releaseTerminal(false); err != nil { + // If we can't release input, abort. + if fn != nil { + go p.Send(fn(err)) + } + return + } + + c.SetStdin(p.input) + c.SetStdout(p.output) + c.SetStderr(os.Stderr) + + // Execute system command. + if err := c.Run(); err != nil { + _ = p.RestoreTerminal() // also try to restore the terminal. + if fn != nil { + go p.Send(fn(err)) + } + return + } + + // Have the program re-capture input. + err := p.RestoreTerminal() + if fn != nil { + go p.Send(fn(err)) + } +} diff --git a/third_party/bubbletea-v2-patched/exec_test.go b/third_party/bubbletea-v2-patched/exec_test.go new file mode 100644 index 000000000..ba76f9197 --- /dev/null +++ b/third_party/bubbletea-v2-patched/exec_test.go @@ -0,0 +1,137 @@ +package tea + +import ( + "bytes" + "os/exec" + "runtime" + "testing" +) + +type execFinishedMsg struct{ err error } + +type testExecModel struct { + cmd string + err error +} + +type testExecNoInputModel struct{ testExecModel } + +func (m *testExecModel) Init() Cmd { + c := exec.Command(m.cmd) //nolint:gosec + return ExecProcess(c, func(err error) Msg { + return execFinishedMsg{err} + }) +} + +func (m *testExecNoInputModel) Init() Cmd { + return ExecProcess(successExecCommand(), func(err error) Msg { + return execFinishedMsg{err} + }) +} + +func (m *testExecModel) Update(msg Msg) (Model, Cmd) { + switch msg := msg.(type) { + case execFinishedMsg: + if msg.err != nil { + m.err = msg.err + } + return m, Quit + } + + return m, nil +} + +func (m *testExecModel) View() View { + return NewView("\n") +} + +type spyRenderer struct { + renderer + calledReset bool +} + +func successExecCommand() *exec.Cmd { + if runtime.GOOS == "windows" { + return exec.Command("cmd", "/c", "exit 0") + } + return exec.Command("true") +} + +func TestTeaExec(t *testing.T) { + type test struct { + name string + cmd string + expectErr bool + } + + // TODO: add more tests for windows + tests := []test{ + { + name: "invalid command", + cmd: "invalid", + expectErr: true, + }, + } + + if runtime.GOOS != "windows" { + tests = append(tests, []test{ + { + name: "true", + cmd: "true", + expectErr: false, + }, + { + name: "false", + cmd: "false", + expectErr: true, + }, + }...) + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + m := &testExecModel{cmd: test.cmd} + p := NewProgram(m, + WithInput(&in), + WithOutput(&buf), + ) + if _, err := p.Run(); err != nil { + t.Error(err) + } + p.renderer = &spyRenderer{renderer: p.renderer} + + if m.err != nil && !test.expectErr { + t.Errorf("expected no error, got %v", m.err) + + if !p.renderer.(*spyRenderer).calledReset { + t.Error("expected renderer to be reset") + } + } + if m.err == nil && test.expectErr { + t.Error("expected error, got nil") + } + }) + } +} + +func TestTeaExecWithNilInput(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + + m := &testExecNoInputModel{} + p := NewProgram(m, + WithInput(nil), + WithOutput(&buf), + ) + + if _, err := p.Run(); err != nil { + t.Fatal(err) + } + if m.err != nil { + t.Fatalf("expected no error, got %v", m.err) + } +} diff --git a/third_party/bubbletea-v2-patched/focus.go b/third_party/bubbletea-v2-patched/focus.go new file mode 100644 index 000000000..4d34bea6f --- /dev/null +++ b/third_party/bubbletea-v2-patched/focus.go @@ -0,0 +1,9 @@ +package tea + +// FocusMsg represents a terminal focus message. +// This occurs when the terminal gains focus. +type FocusMsg struct{} + +// BlurMsg represents a terminal blur message. +// This occurs when the terminal loses focus. +type BlurMsg struct{} diff --git a/third_party/bubbletea-v2-patched/go.mod b/third_party/bubbletea-v2-patched/go.mod new file mode 100644 index 000000000..5560b3cda --- /dev/null +++ b/third_party/bubbletea-v2-patched/go.mod @@ -0,0 +1,28 @@ +module charm.land/bubbletea/v2 + +retract v2.0.0-beta1 // We add a "." after the "beta" in the version number. + +go 1.25.0 + +require ( + github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 + github.com/charmbracelet/x/ansi v0.11.7 + github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f + github.com/charmbracelet/x/term v0.2.2 + github.com/lucasb-eyer/go-colorful v1.4.0 + github.com/muesli/cancelreader v0.2.2 + golang.org/x/sys v0.46.0 +) + +require ( + github.com/aymanbagabas/go-udiff v0.2.0 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.21.0 // indirect +) diff --git a/third_party/bubbletea-v2-patched/go.sum b/third_party/bubbletea-v2-patched/go.sum new file mode 100644 index 000000000..368d3945e --- /dev/null +++ b/third_party/bubbletea-v2-patched/go.sum @@ -0,0 +1,36 @@ +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w= +github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/third_party/bubbletea-v2-patched/input.go b/third_party/bubbletea-v2-patched/input.go new file mode 100644 index 000000000..db274fb0b --- /dev/null +++ b/third_party/bubbletea-v2-patched/input.go @@ -0,0 +1,54 @@ +package tea + +import ( + uv "github.com/charmbracelet/ultraviolet" +) + +// translateInputEvent translates an input event into a Bubble Tea Msg. +func (p *Program) translateInputEvent(e uv.Event) Msg { + switch e := e.(type) { + case uv.ClipboardEvent: + return ClipboardMsg(e) + case uv.ForegroundColorEvent: + return ForegroundColorMsg(e) + case uv.BackgroundColorEvent: + return BackgroundColorMsg(e) + case uv.CursorColorEvent: + return CursorColorMsg(e) + case uv.CursorPositionEvent: + return CursorPositionMsg(e) + case uv.FocusEvent: + return FocusMsg(e) + case uv.BlurEvent: + return BlurMsg(e) + case uv.KeyPressEvent: + return KeyPressMsg(e) + case uv.KeyReleaseEvent: + return KeyReleaseMsg(e) + case uv.MouseClickEvent: + return MouseClickMsg(e) + case uv.MouseMotionEvent: + return MouseMotionMsg(e) + case uv.MouseReleaseEvent: + return MouseReleaseMsg(e) + case uv.MouseWheelEvent: + return MouseWheelMsg(e) + case uv.PasteEvent: + return PasteMsg(e) + case uv.PasteStartEvent: + return PasteStartMsg(e) + case uv.PasteEndEvent: + return PasteEndMsg(e) + case uv.WindowSizeEvent: + return WindowSizeMsg(e) + case uv.CapabilityEvent: + return CapabilityMsg(e) + case uv.TerminalVersionEvent: + return TerminalVersionMsg(e) + case uv.KeyboardEnhancementsEvent: + return KeyboardEnhancementsMsg(e) + case uv.ModeReportEvent: + return ModeReportMsg(e) + } + return e +} diff --git a/third_party/bubbletea-v2-patched/key.go b/third_party/bubbletea-v2-patched/key.go new file mode 100644 index 000000000..b16e53c2f --- /dev/null +++ b/third_party/bubbletea-v2-patched/key.go @@ -0,0 +1,371 @@ +package tea + +import ( + "fmt" + + uv "github.com/charmbracelet/ultraviolet" +) + +const ( + // KeyExtended is a special key code used to signify that a key event + // contains multiple runes. + KeyExtended = uv.KeyExtended +) + +// Special key symbols. +const ( + + // Special keys. + + KeyUp = uv.KeyUp + KeyDown = uv.KeyDown + KeyRight = uv.KeyRight + KeyLeft = uv.KeyLeft + KeyBegin = uv.KeyBegin + KeyFind = uv.KeyFind + KeyInsert = uv.KeyInsert + KeyDelete = uv.KeyDelete + KeySelect = uv.KeySelect + KeyPgUp = uv.KeyPgUp + KeyPgDown = uv.KeyPgDown + KeyHome = uv.KeyHome + KeyEnd = uv.KeyEnd + + // Keypad keys. + + KeyKpEnter = uv.KeyKpEnter + KeyKpEqual = uv.KeyKpEqual + KeyKpMultiply = uv.KeyKpMultiply + KeyKpPlus = uv.KeyKpPlus + KeyKpComma = uv.KeyKpComma + KeyKpMinus = uv.KeyKpMinus + KeyKpDecimal = uv.KeyKpDecimal + KeyKpDivide = uv.KeyKpDivide + KeyKp0 = uv.KeyKp0 + KeyKp1 = uv.KeyKp1 + KeyKp2 = uv.KeyKp2 + KeyKp3 = uv.KeyKp3 + KeyKp4 = uv.KeyKp4 + KeyKp5 = uv.KeyKp5 + KeyKp6 = uv.KeyKp6 + KeyKp7 = uv.KeyKp7 + KeyKp8 = uv.KeyKp8 + KeyKp9 = uv.KeyKp9 + + // The following are keys defined in the Kitty keyboard protocol. + // XXX: Investigate the names of these keys. + KeyKpSep = uv.KeyKpSep + KeyKpUp = uv.KeyKpUp + KeyKpDown = uv.KeyKpDown + KeyKpLeft = uv.KeyKpLeft + KeyKpRight = uv.KeyKpRight + KeyKpPgUp = uv.KeyKpPgUp + KeyKpPgDown = uv.KeyKpPgDown + KeyKpHome = uv.KeyKpHome + KeyKpEnd = uv.KeyKpEnd + KeyKpInsert = uv.KeyKpInsert + KeyKpDelete = uv.KeyKpDelete + KeyKpBegin = uv.KeyKpBegin + + // Function keys. + + KeyF1 = uv.KeyF1 + KeyF2 = uv.KeyF2 + KeyF3 = uv.KeyF3 + KeyF4 = uv.KeyF4 + KeyF5 = uv.KeyF5 + KeyF6 = uv.KeyF6 + KeyF7 = uv.KeyF7 + KeyF8 = uv.KeyF8 + KeyF9 = uv.KeyF9 + KeyF10 = uv.KeyF10 + KeyF11 = uv.KeyF11 + KeyF12 = uv.KeyF12 + KeyF13 = uv.KeyF13 + KeyF14 = uv.KeyF14 + KeyF15 = uv.KeyF15 + KeyF16 = uv.KeyF16 + KeyF17 = uv.KeyF17 + KeyF18 = uv.KeyF18 + KeyF19 = uv.KeyF19 + KeyF20 = uv.KeyF20 + KeyF21 = uv.KeyF21 + KeyF22 = uv.KeyF22 + KeyF23 = uv.KeyF23 + KeyF24 = uv.KeyF24 + KeyF25 = uv.KeyF25 + KeyF26 = uv.KeyF26 + KeyF27 = uv.KeyF27 + KeyF28 = uv.KeyF28 + KeyF29 = uv.KeyF29 + KeyF30 = uv.KeyF30 + KeyF31 = uv.KeyF31 + KeyF32 = uv.KeyF32 + KeyF33 = uv.KeyF33 + KeyF34 = uv.KeyF34 + KeyF35 = uv.KeyF35 + KeyF36 = uv.KeyF36 + KeyF37 = uv.KeyF37 + KeyF38 = uv.KeyF38 + KeyF39 = uv.KeyF39 + KeyF40 = uv.KeyF40 + KeyF41 = uv.KeyF41 + KeyF42 = uv.KeyF42 + KeyF43 = uv.KeyF43 + KeyF44 = uv.KeyF44 + KeyF45 = uv.KeyF45 + KeyF46 = uv.KeyF46 + KeyF47 = uv.KeyF47 + KeyF48 = uv.KeyF48 + KeyF49 = uv.KeyF49 + KeyF50 = uv.KeyF50 + KeyF51 = uv.KeyF51 + KeyF52 = uv.KeyF52 + KeyF53 = uv.KeyF53 + KeyF54 = uv.KeyF54 + KeyF55 = uv.KeyF55 + KeyF56 = uv.KeyF56 + KeyF57 = uv.KeyF57 + KeyF58 = uv.KeyF58 + KeyF59 = uv.KeyF59 + KeyF60 = uv.KeyF60 + KeyF61 = uv.KeyF61 + KeyF62 = uv.KeyF62 + KeyF63 = uv.KeyF63 + + // The following are keys defined in the Kitty keyboard protocol. + // XXX: Investigate the names of these keys. + + KeyCapsLock = uv.KeyCapsLock + KeyScrollLock = uv.KeyScrollLock + KeyNumLock = uv.KeyNumLock + KeyPrintScreen = uv.KeyPrintScreen + KeyPause = uv.KeyPause + KeyMenu = uv.KeyMenu + + KeyMediaPlay = uv.KeyMediaPlay + KeyMediaPause = uv.KeyMediaPause + KeyMediaPlayPause = uv.KeyMediaPlayPause + KeyMediaReverse = uv.KeyMediaReverse + KeyMediaStop = uv.KeyMediaStop + KeyMediaFastForward = uv.KeyMediaFastForward + KeyMediaRewind = uv.KeyMediaRewind + KeyMediaNext = uv.KeyMediaNext + KeyMediaPrev = uv.KeyMediaPrev + KeyMediaRecord + + KeyLowerVol = uv.KeyLowerVol + KeyRaiseVol = uv.KeyRaiseVol + KeyMute = uv.KeyMute + + KeyLeftShift = uv.KeyLeftShift + KeyLeftAlt = uv.KeyLeftAlt + KeyLeftCtrl = uv.KeyLeftCtrl + KeyLeftSuper = uv.KeyLeftSuper + KeyLeftHyper = uv.KeyLeftHyper + KeyLeftMeta = uv.KeyLeftMeta + KeyRightShift = uv.KeyRightShift + KeyRightAlt = uv.KeyRightAlt + KeyRightCtrl = uv.KeyRightCtrl + KeyRightSuper = uv.KeyRightSuper + KeyRightHyper = uv.KeyRightHyper + KeyRightMeta = uv.KeyRightMeta + KeyIsoLevel3Shift = uv.KeyIsoLevel3Shift + KeyIsoLevel5Shift = uv.KeyIsoLevel5Shift + + // Special names in C0. + + KeyBackspace = uv.KeyBackspace + KeyTab = uv.KeyTab + KeyEnter = uv.KeyEnter + KeyReturn = uv.KeyReturn + KeyEscape = uv.KeyEscape + KeyEsc = uv.KeyEsc + + // Special names in G0. + + KeySpace = uv.KeySpace +) + +// KeyPressMsg represents a key press message. +type KeyPressMsg Key + +// String implements [fmt.Stringer] and is quite useful for matching key +// events. For details, on what this returns see [Key.String]. +func (k KeyPressMsg) String() string { + return Key(k).String() +} + +// Keystroke returns the keystroke representation of the [Key]. While less type +// safe than looking at the individual fields, it will usually be more +// convenient and readable to use this method when matching against keys. +// +// Note that modifier keys are always printed in the following order: +// - ctrl +// - alt +// - shift +// - meta +// - hyper +// - super +// +// For example, you'll always see "ctrl+shift+alt+a" and never +// "shift+ctrl+alt+a". +func (k KeyPressMsg) Keystroke() string { + return uv.Key(k).Keystroke() +} + +// Key returns the underlying key event. This is a syntactic sugar for casting +// the key event to a [Key]. +func (k KeyPressMsg) Key() Key { + return Key(k) +} + +// KeyReleaseMsg represents a key release message. +type KeyReleaseMsg Key + +// String implements [fmt.Stringer] and is quite useful for matching key +// events. For details, on what this returns see [Key.String]. +func (k KeyReleaseMsg) String() string { + return Key(k).String() +} + +// Keystroke returns the keystroke representation of the [Key]. While less type +// safe than looking at the individual fields, it will usually be more +// convenient and readable to use this method when matching against keys. +// +// Note that modifier keys are always printed in the following order: +// - ctrl +// - alt +// - shift +// - meta +// - hyper +// - super +// +// For example, you'll always see "ctrl+shift+alt+a" and never +// "shift+ctrl+alt+a". +func (k KeyReleaseMsg) Keystroke() string { + return uv.Key(k).Keystroke() +} + +// Key returns the underlying key event. This is a convenience method and +// syntactic sugar to satisfy the [KeyMsg] interface, and cast the key event to +// [Key]. +func (k KeyReleaseMsg) Key() Key { + return Key(k) +} + +// KeyMsg represents a key event. This can be either a key press or a key +// release event. +type KeyMsg interface { + fmt.Stringer + + // Key returns the underlying key event. + Key() Key +} + +// Key represents a Key press or release event. It contains information about +// the Key pressed, like the runes, the type of Key, and the modifiers pressed. +// There are a couple general patterns you could use to check for key presses +// or releases: +// +// // Switch on the string representation of the key (shorter) +// switch msg := msg.(type) { +// case KeyPressMsg: +// switch msg.String() { +// case "enter": +// fmt.Println("you pressed enter!") +// case "a": +// fmt.Println("you pressed a!") +// } +// } +// +// // Switch on the key type (more foolproof) +// switch msg := msg.(type) { +// case KeyMsg: +// // catch both KeyPressMsg and KeyReleaseMsg +// switch key := msg.Key(); key.Code { +// case KeyEnter: +// fmt.Println("you pressed enter!") +// default: +// switch key.Text { +// case "a": +// fmt.Println("you pressed a!") +// } +// } +// } +// +// Note that [Key.Text] will be empty for special keys like [KeyEnter], +// [KeyTab], and for keys that don't represent printable characters like key +// combos with modifier keys. In other words, [Key.Text] is populated only for +// keys that represent printable characters shifted or unshifted (like 'a', +// 'A', '1', '!', etc.). +type Key struct { + // Text contains the actual characters received. This usually the same as + // [Key.Code]. When [Key.Text] is non-empty, it indicates that the key + // pressed represents printable character(s). + Text string + + // Mod represents modifier keys, like [ModCtrl], [ModAlt], and so on. + Mod KeyMod + + // Code represents the key pressed. This is usually a special key like + // [KeyTab], [KeyEnter], [KeyF1], or a printable character like 'a'. + Code rune + + // ShiftedCode is the actual, shifted key pressed by the user. For example, + // if the user presses shift+a, or caps lock is on, [Key.ShiftedCode] will + // be 'A' and [Key.Code] will be 'a'. + // + // In the case of non-latin keyboards, like Arabic, [Key.ShiftedCode] is the + // unshifted key on the keyboard. + // + // This is only available with the Kitty Keyboard Protocol or the Windows + // Console API. + ShiftedCode rune + + // BaseCode is the key pressed according to the standard PC-101 key layout. + // On international keyboards, this is the key that would be pressed if the + // keyboard was set to US PC-101 layout. + // + // For example, if the user presses 'q' on a French AZERTY keyboard, + // [Key.BaseCode] will be 'q'. + // + // This is only available with the Kitty Keyboard Protocol or the Windows + // Console API. + BaseCode rune + + // IsRepeat indicates whether the key is being held down and sending events + // repeatedly. + // + // This is only available with the Kitty Keyboard Protocol or the Windows + // Console API. + IsRepeat bool +} + +// String implements [fmt.Stringer] and is quite useful for matching key +// events. It will return the textual representation of the [Key] if there is +// one, otherwise, it will fallback to [Key.Keystroke]. +// +// For example, you'll always get "?" and instead of "shift+/" on a US ANSI +// keyboard. +func (k Key) String() string { + return uv.Key(k).String() +} + +// Keystroke returns the keystroke representation of the [Key]. While less type +// safe than looking at the individual fields, it will usually be more +// convenient and readable to use this method when matching against keys. +// +// Note that modifier keys are always printed in the following order: +// - ctrl +// - alt +// - shift +// - meta +// - hyper +// - super +// +// For example, you'll always see "ctrl+shift+alt+a" and never +// "shift+ctrl+alt+a". +func (k Key) Keystroke() string { + return uv.Key(k).Keystroke() +} diff --git a/third_party/bubbletea-v2-patched/keyboard.go b/third_party/bubbletea-v2-patched/keyboard.go new file mode 100644 index 000000000..33747a456 --- /dev/null +++ b/third_party/bubbletea-v2-patched/keyboard.go @@ -0,0 +1,59 @@ +package tea + +import ( + "github.com/charmbracelet/x/ansi" +) + +// KeyboardEnhancementsMsg is a message that gets sent when the terminal +// supports keyboard enhancements. +type KeyboardEnhancementsMsg struct { + // Flags is a bitmask of enabled keyboard enhancement features. A non-zero + // value indicates that at least we have key disambiguation support. + // + // See [ansi.KittyReportEventTypes] and other constants for details. + // + // Example: + // + // ```go + // // The hard way + // if msg.Flags&ansi.KittyReportEventTypes != 0 { + // // Terminal supports reporting different key event types + // } + // + // // The easy way + // if msg.SupportsEventTypes() { + // // Terminal supports reporting different key event types + // } + // ``` + Flags int +} + +// SupportsKeyDisambiguation returns whether the terminal supports key +// disambiguation (e.g., distinguishing between different modifier keys). +func (k KeyboardEnhancementsMsg) SupportsKeyDisambiguation() bool { + return k.Flags > 0 +} + +// SupportsEventTypes returns whether the terminal supports reporting +// different types of key events (press, release, and repeat). +func (k KeyboardEnhancementsMsg) SupportsEventTypes() bool { + return k.Flags&ansi.KittyReportEventTypes != 0 +} + +// SupportsAlternateKeys returns whether the terminal supports reporting +// alternate key codes. +func (k KeyboardEnhancementsMsg) SupportsAlternateKeys() bool { + return k.Flags&ansi.KittyReportAlternateKeys != 0 +} + +// SupportsAllKeysAsEscapeCodes returns whether the terminal supports reporting +// all keys as escape codes. +func (k KeyboardEnhancementsMsg) SupportsAllKeysAsEscapeCodes() bool { + return k.Flags&ansi.KittyReportAllKeysAsEscapeCodes != 0 +} + +// SupportsAssociatedText returns whether the terminal supports reporting +// associated text with key events. +func (k KeyboardEnhancementsMsg) SupportsAssociatedText() bool { + return k.Flags&ansi.KittyReportAssociatedKeys != 0 +} diff --git a/third_party/bubbletea-v2-patched/logging.go b/third_party/bubbletea-v2-patched/logging.go new file mode 100644 index 000000000..349758cbc --- /dev/null +++ b/third_party/bubbletea-v2-patched/logging.go @@ -0,0 +1,53 @@ +package tea + +import ( + "fmt" + "io" + "log" + "os" + "unicode" +) + +// LogToFile sets up default logging to log to a file. This is helpful as we +// can't print to the terminal since our TUI is occupying it. If the file +// doesn't exist it will be created. +// +// Don't forget to close the file when you're done with it. +// +// f, err := LogToFile("debug.log", "debug") +// if err != nil { +// fmt.Println("fatal:", err) +// os.Exit(1) +// } +// defer f.Close() +func LogToFile(path string, prefix string) (*os.File, error) { + return LogToFileWith(path, prefix, log.Default()) +} + +// LogOptionsSetter is an interface implemented by stdlib's log and charm's log +// libraries. +type LogOptionsSetter interface { + SetOutput(io.Writer) + SetPrefix(string) +} + +// LogToFileWith does allows to call LogToFile with a custom LogOptionsSetter. +func LogToFileWith(path string, prefix string, log LogOptionsSetter) (*os.File, error) { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:mnd + if err != nil { + return nil, fmt.Errorf("error opening file for logging: %w", err) + } + log.SetOutput(f) + + // Add a space after the prefix if a prefix is being specified and it + // doesn't already have a trailing space. + if len(prefix) > 0 { + finalChar := prefix[len(prefix)-1] + if !unicode.IsSpace(rune(finalChar)) { + prefix += " " + } + } + log.SetPrefix(prefix) + + return f, nil +} diff --git a/third_party/bubbletea-v2-patched/logging_test.go b/third_party/bubbletea-v2-patched/logging_test.go new file mode 100644 index 000000000..70c2065e2 --- /dev/null +++ b/third_party/bubbletea-v2-patched/logging_test.go @@ -0,0 +1,29 @@ +package tea + +import ( + "log" + "os" + "path/filepath" + "testing" +) + +func TestLogToFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "log.txt") + prefix := "logprefix" + f, err := LogToFile(path, prefix) + if err != nil { + t.Error(err) + } + log.SetFlags(log.Lmsgprefix) + log.Println("some test log") + if closeErr := f.Close(); closeErr != nil { + t.Error(closeErr) + } + out, err := os.ReadFile(path) + if err != nil { + t.Error(err) + } + if string(out) != prefix+" some test log\n" { + t.Fatalf("wrong log msg: %q", string(out)) + } +} diff --git a/third_party/bubbletea-v2-patched/mod.go b/third_party/bubbletea-v2-patched/mod.go new file mode 100644 index 000000000..f85a4c32c --- /dev/null +++ b/third_party/bubbletea-v2-patched/mod.go @@ -0,0 +1,27 @@ +package tea + +import uv "github.com/charmbracelet/ultraviolet" + +// KeyMod represents modifier keys. +type KeyMod = uv.KeyMod + +// Modifier keys. +const ( + ModShift = uv.ModShift + ModAlt = uv.ModAlt + ModCtrl = uv.ModCtrl + ModMeta = uv.ModMeta + + // These modifiers are used with the Kitty protocol. + // XXX: Meta and Super are swapped in the Kitty protocol, + // this is to preserve compatibility with XTerm modifiers. + + ModHyper = uv.ModHyper + ModSuper = uv.ModSuper // Windows/Command keys + + // These are key lock states. + + ModCapsLock = uv.ModCapsLock + ModNumLock = uv.ModNumLock + ModScrollLock = uv.ModScrollLock // Defined in Windows API only +) diff --git a/third_party/bubbletea-v2-patched/mouse.go b/third_party/bubbletea-v2-patched/mouse.go new file mode 100644 index 000000000..05b83128b --- /dev/null +++ b/third_party/bubbletea-v2-patched/mouse.go @@ -0,0 +1,144 @@ +package tea + +import ( + "fmt" + + uv "github.com/charmbracelet/ultraviolet" +) + +// MouseButton represents the button that was pressed during a mouse message. +type MouseButton = uv.MouseButton + +// Mouse event buttons +// +// This is based on X11 mouse button codes. +// +// 1 = left button +// 2 = middle button (pressing the scroll wheel) +// 3 = right button +// 4 = turn scroll wheel up +// 5 = turn scroll wheel down +// 6 = push scroll wheel left +// 7 = push scroll wheel right +// 8 = 4th button (aka browser backward button) +// 9 = 5th button (aka browser forward button) +// 10 +// 11 +// +// Other buttons are not supported. +const ( + MouseNone = uv.MouseNone + MouseLeft = uv.MouseLeft + MouseMiddle = uv.MouseMiddle + MouseRight = uv.MouseRight + MouseWheelUp = uv.MouseWheelUp + MouseWheelDown = uv.MouseWheelDown + MouseWheelLeft = uv.MouseWheelLeft + MouseWheelRight = uv.MouseWheelRight + MouseBackward = uv.MouseBackward + MouseForward = uv.MouseForward + MouseButton10 = uv.MouseButton10 + MouseButton11 +) + +// MouseMsg represents a mouse message. This is a generic mouse message that +// can represent any kind of mouse event. +type MouseMsg interface { + fmt.Stringer + + // Mouse returns the underlying mouse event. + Mouse() Mouse +} + +// Mouse represents a Mouse message. Use [MouseMsg] to represent all mouse +// messages. +// +// The X and Y coordinates are zero-based, with (0,0) being the upper left +// corner of the terminal. +// +// // Catch all mouse events +// switch msg := msg.(type) { +// case MouseMsg: +// m := msg.Mouse() +// fmt.Println("Mouse event:", m.X, m.Y, m) +// } +// +// // Only catch mouse click events +// switch msg := msg.(type) { +// case MouseClickMsg: +// fmt.Println("Mouse click event:", msg.X, msg.Y, msg) +// } +type Mouse struct { + X, Y int + Button MouseButton + Mod KeyMod +} + +// String returns a string representation of the mouse message. +func (m Mouse) String() (s string) { + return uv.Mouse(m).String() +} + +// MouseClickMsg represents a mouse button click message. +type MouseClickMsg Mouse + +// String returns a string representation of the mouse click message. +func (e MouseClickMsg) String() string { + return Mouse(e).String() +} + +// Mouse returns the underlying mouse event. This is a convenience method and +// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse +// event to [Mouse]. +func (e MouseClickMsg) Mouse() Mouse { + return Mouse(e) +} + +// MouseReleaseMsg represents a mouse button release message. +type MouseReleaseMsg Mouse + +// String returns a string representation of the mouse release message. +func (e MouseReleaseMsg) String() string { + return Mouse(e).String() +} + +// Mouse returns the underlying mouse event. This is a convenience method and +// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse +// event to [Mouse]. +func (e MouseReleaseMsg) Mouse() Mouse { + return Mouse(e) +} + +// MouseWheelMsg represents a mouse wheel message event. +type MouseWheelMsg Mouse + +// String returns a string representation of the mouse wheel message. +func (e MouseWheelMsg) String() string { + return Mouse(e).String() +} + +// Mouse returns the underlying mouse event. This is a convenience method and +// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse +// event to [Mouse]. +func (e MouseWheelMsg) Mouse() Mouse { + return Mouse(e) +} + +// MouseMotionMsg represents a mouse motion message. +type MouseMotionMsg Mouse + +// String returns a string representation of the mouse motion message. +func (e MouseMotionMsg) String() string { + m := Mouse(e) + if m.Button != 0 { + return m.String() + "+motion" + } + return m.String() + "motion" +} + +// Mouse returns the underlying mouse event. This is a convenience method and +// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse +// event to [Mouse]. +func (e MouseMotionMsg) Mouse() Mouse { + return Mouse(e) +} diff --git a/third_party/bubbletea-v2-patched/nil_renderer.go b/third_party/bubbletea-v2-patched/nil_renderer.go new file mode 100644 index 000000000..1edafea7e --- /dev/null +++ b/third_party/bubbletea-v2-patched/nil_renderer.go @@ -0,0 +1,53 @@ +package tea + +import ( + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" +) + +// nilRenderer is a no-op renderer. It implements the Renderer interface but +// doesn't render anything to the terminal. +type nilRenderer struct{} + +var _ renderer = nilRenderer{} + +// start implements renderer. +func (n nilRenderer) start() {} + +// clearScreen implements renderer. +func (n nilRenderer) clearScreen() {} + +// insertAbove implements renderer. +func (n nilRenderer) insertAbove(string) error { return nil } + +// resize implements renderer. +func (n nilRenderer) resize(int, int) {} + +// setColorProfile implements renderer. +func (n nilRenderer) setColorProfile(colorprofile.Profile) {} + +// flush implements the Renderer interface. +func (nilRenderer) flush(bool) error { return nil } + +// close implements the Renderer interface. +func (nilRenderer) close() error { return nil } + +// render implements the Renderer interface. +func (nilRenderer) render(View) {} + +// reset implements the Renderer interface. +func (nilRenderer) reset() {} + +// writeString implements the Renderer interface. +func (nilRenderer) writeString(string) (int, error) { return 0, nil } + +// setSyncdUpdates implements the Renderer interface. +func (n nilRenderer) setSyncdUpdates(bool) {} + +// setWidthMethod implements the Renderer interface. +func (n nilRenderer) setWidthMethod(ansi.Method) {} + +// onMouse implements the Renderer interface. +func (n nilRenderer) onMouse(MouseMsg) Cmd { + return nil +} diff --git a/third_party/bubbletea-v2-patched/options.go b/third_party/bubbletea-v2-patched/options.go new file mode 100644 index 000000000..dfd8cda4f --- /dev/null +++ b/third_party/bubbletea-v2-patched/options.go @@ -0,0 +1,168 @@ +package tea + +import ( + "context" + "io" + "sync/atomic" + + "github.com/charmbracelet/colorprofile" +) + +// ProgramOption is used to set options when initializing a Program. Program can +// accept a variable number of options. +// +// Example usage: +// +// p := NewProgram(model, WithInput(someInput), WithOutput(someOutput)) +type ProgramOption func(*Program) + +// WithContext lets you specify a context in which to run the Program. This is +// useful if you want to cancel the execution from outside. When a Program gets +// cancelled it will exit with an error ErrProgramKilled. +func WithContext(ctx context.Context) ProgramOption { + return func(p *Program) { + p.externalCtx = ctx + } +} + +// WithOutput sets the output which, by default, is stdout. In most cases you +// won't need to use this. +func WithOutput(output io.Writer) ProgramOption { + return func(p *Program) { + p.output = output + } +} + +// WithInput sets the input which, by default, is stdin. In most cases you +// won't need to use this. To disable input entirely pass nil. +// +// p := NewProgram(model, WithInput(nil)) +func WithInput(input io.Reader) ProgramOption { + return func(p *Program) { + p.input = input + p.disableInput = input == nil + } +} + +// WithEnvironment sets the environment variables that the program will use. +// This useful when the program is running in a remote session (e.g. SSH) and +// you want to pass the environment variables from the remote session to the +// program. +// +// Example: +// +// var sess ssh.Session // ssh.Session is a type from the github.com/charmbracelet/ssh package +// pty, _, _ := sess.Pty() +// environ := append(sess.Environ(), "TERM="+pty.Term) +// p := tea.NewProgram(model, tea.WithEnvironment(environ) +func WithEnvironment(env []string) ProgramOption { + return func(p *Program) { + p.environ = env + } +} + +// WithoutSignalHandler disables the signal handler that Bubble Tea sets up for +// Programs. This is useful if you want to handle signals yourself. +func WithoutSignalHandler() ProgramOption { + return func(p *Program) { + p.disableSignalHandler = true + } +} + +// WithoutCatchPanics disables the panic catching that Bubble Tea does by +// default. If panic catching is disabled the terminal will be in a fairly +// unusable state after a panic because Bubble Tea will not perform its usual +// cleanup on exit. +func WithoutCatchPanics() ProgramOption { + return func(p *Program) { + p.disableCatchPanics = true + } +} + +// WithoutSignals will ignore OS signals. +// This is mainly useful for testing. +func WithoutSignals() ProgramOption { + return func(p *Program) { + atomic.StoreUint32(&p.ignoreSignals, 1) + } +} + +// WithoutRenderer disables the renderer. When this is set output and log +// statements will be plainly sent to stdout (or another output if one is set) +// without any rendering and redrawing logic. In other words, printing and +// logging will behave the same way it would in a non-TUI commandline tool. +// This can be useful if you want to use the Bubble Tea framework for a non-TUI +// application, or to provide an additional non-TUI mode to your Bubble Tea +// programs. For example, your program could behave like a daemon if output is +// not a TTY. +func WithoutRenderer() ProgramOption { + return func(p *Program) { + p.disableRenderer = true + } +} + +// WithFilter supplies an event filter that will be invoked before Bubble Tea +// processes a tea.Msg. The event filter can return any tea.Msg which will then +// get handled by Bubble Tea instead of the original event. If the event filter +// returns nil, the event will be ignored and Bubble Tea will not process it. +// +// As an example, this could be used to prevent a program from shutting down if +// there are unsaved changes. +// +// Example: +// +// func filter(m tea.Model, msg tea.Msg) tea.Msg { +// if _, ok := msg.(tea.QuitMsg); !ok { +// return msg +// } +// +// model := m.(myModel) +// if model.hasChanges { +// return nil +// } +// +// return msg +// } +// +// p := tea.NewProgram(Model{}, tea.WithFilter(filter)); +// +// if _,err := p.Run(); err != nil { +// fmt.Println("Error running program:", err) +// os.Exit(1) +// } +func WithFilter(filter func(Model, Msg) Msg) ProgramOption { + return func(p *Program) { + p.filter = filter + } +} + +// WithFPS sets a custom maximum FPS at which the renderer should run. If +// less than 1, the default value of 60 will be used. If over 120, the FPS +// will be capped at 120. +func WithFPS(fps int) ProgramOption { + return func(p *Program) { + p.fps = fps + } +} + +// WithColorProfile sets the color profile that the program will use. This is +// useful when you want to force a specific color profile. By default, Bubble +// Tea will try to detect the terminal's color profile from environment +// variables and terminfo capabilities. Use [tea.WithEnvironment] to set custom +// environment variables. +func WithColorProfile(profile colorprofile.Profile) ProgramOption { + return func(p *Program) { + p.profile = &profile + } +} + +// WithWindowSize sets the initial size of the terminal window. This is useful +// when you need to set the initial size of the terminal window, for example +// during testing or when you want to run your program in a non-interactive +// environment. +func WithWindowSize(width, height int) ProgramOption { + return func(p *Program) { + p.width = width + p.height = height + } +} diff --git a/third_party/bubbletea-v2-patched/options_test.go b/third_party/bubbletea-v2-patched/options_test.go new file mode 100644 index 000000000..423dc4c2f --- /dev/null +++ b/third_party/bubbletea-v2-patched/options_test.go @@ -0,0 +1,106 @@ +package tea + +import ( + "bytes" + "context" + "os" + "sync/atomic" + "testing" +) + +func TestOptions(t *testing.T) { + t.Run("output", func(t *testing.T) { + t.Parallel() + var b bytes.Buffer + p := NewProgram(nil, WithOutput(&b)) + if f, ok := p.output.(*os.File); ok { + t.Errorf("expected output to custom, got %v", f.Fd()) + } + }) + + t.Run("renderer", func(t *testing.T) { + t.Parallel() + p := NewProgram(nil, WithoutRenderer()) + if !p.disableRenderer { + t.Errorf("expected renderer to be a nilRenderer, got %v", p.renderer) + } + }) + + t.Run("without signals", func(t *testing.T) { + t.Parallel() + p := NewProgram(nil, WithoutSignals()) + if atomic.LoadUint32(&p.ignoreSignals) == 0 { + t.Errorf("ignore signals should have been set") + } + }) + + t.Run("filter", func(t *testing.T) { + t.Parallel() + p := NewProgram(nil, WithFilter(func(_ Model, msg Msg) Msg { return msg })) + if p.filter == nil { + t.Errorf("expected filter to be set") + } + }) + + t.Run("external context", func(t *testing.T) { + t.Parallel() + extCtx, extCancel := context.WithCancel(context.Background()) + defer extCancel() + + p := NewProgram(nil, WithContext(extCtx)) + if p.externalCtx != extCtx || p.externalCtx == context.Background() { + t.Errorf("expected passed in external context, got default") + } + }) + + t.Run("input options", func(t *testing.T) { + exercise := func(t *testing.T, opt ProgramOption, fn func(*Program)) { + p := NewProgram(nil, opt) + fn(p) + } + + t.Run("nil input", func(t *testing.T) { + t.Parallel() + exercise(t, WithInput(nil), func(p *Program) { + if !p.disableInput || p.input != nil { + t.Errorf("expected input to be disabled, got %v", p.input) + } + }) + }) + + t.Run("custom input", func(t *testing.T) { + t.Parallel() + var b bytes.Buffer + exercise(t, WithInput(&b), func(p *Program) { + if p.input != &b { + t.Errorf("expected input to be custom, got %v", p.input) + } + }) + }) + }) + + t.Run("startup options", func(t *testing.T) { + exercise := func(t *testing.T, opt ProgramOption, fn func(*Program)) { + p := NewProgram(nil, opt) + fn(p) + } + + t.Run("without catch panics", func(t *testing.T) { + t.Parallel() + exercise(t, WithoutCatchPanics(), func(p *Program) { + if !p.disableCatchPanics { + t.Errorf("expected catch panics to be disabled") + } + }) + }) + + t.Run("without signal handler", func(t *testing.T) { + t.Parallel() + exercise(t, WithoutSignalHandler(), func(p *Program) { + if !p.disableSignalHandler { + t.Errorf("expected signal handler to be disabled") + } + }) + }) + }) +} diff --git a/third_party/bubbletea-v2-patched/paste.go b/third_party/bubbletea-v2-patched/paste.go new file mode 100644 index 000000000..6a3f54c1f --- /dev/null +++ b/third_party/bubbletea-v2-patched/paste.go @@ -0,0 +1,20 @@ +package tea + +// PasteMsg is an message that is emitted when a terminal receives pasted text +// using bracketed-paste. +type PasteMsg struct { + Content string +} + +// String returns the pasted content as a string. +func (p PasteMsg) String() string { + return p.Content +} + +// PasteStartMsg is an message that is emitted when the terminal starts the +// bracketed-paste text. +type PasteStartMsg struct{} + +// PasteEndMsg is an message that is emitted when the terminal ends the +// bracketed-paste text. +type PasteEndMsg struct{} diff --git a/third_party/bubbletea-v2-patched/profile.go b/third_party/bubbletea-v2-patched/profile.go new file mode 100644 index 000000000..f67186636 --- /dev/null +++ b/third_party/bubbletea-v2-patched/profile.go @@ -0,0 +1,15 @@ +package tea + +import "github.com/charmbracelet/colorprofile" + +// ColorProfileMsg is a message that describes the terminal's color profile. +// This message is send to the program's update function when the program is +// started. +// +// To upgrade the terminal color profile, use the `tea.RequestCapability` +// command to request the `RGB` and `Tc` terminfo capabilities. Bubble Tea will +// then cache the terminal's color profile and send a `ColorProfileMsg` to the +// program's update function. +type ColorProfileMsg struct { + colorprofile.Profile +} diff --git a/third_party/bubbletea-v2-patched/raw.go b/third_party/bubbletea-v2-patched/raw.go new file mode 100644 index 000000000..fb6ae44dd --- /dev/null +++ b/third_party/bubbletea-v2-patched/raw.go @@ -0,0 +1,37 @@ +package tea + +// RawMsg is a message that contains a string to be printed to the terminal +// without any intermediate processing. +type RawMsg struct { + Msg any +} + +// Raw is a command that prints the given string to the terminal without any +// formatting. +// +// This is intended for advanced use cases where you need to query the terminal +// or send escape sequences directly. Don't use this unless you know what +// you're doing :) +// +// Example: +// +// func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +// switch msg := msg.(type) { +// case input.PrimaryDeviceAttributesEvent: +// for _, attr := range msg { +// if attr == 4 { +// // We have Sixel graphics support! +// break +// } +// } +// } +// +// // Request the terminal primary device attributes to detect Sixel graphics +// // support. +// return m, tea.Raw(ansi.RequestPrimaryDeviceAttributes) +// } +func Raw(r any) Cmd { + return func() Msg { + return RawMsg{r} + } +} diff --git a/third_party/bubbletea-v2-patched/renderer.go b/third_party/bubbletea-v2-patched/renderer.go new file mode 100644 index 000000000..e8d792b44 --- /dev/null +++ b/third_party/bubbletea-v2-patched/renderer.go @@ -0,0 +1,104 @@ +package tea + +import ( + "fmt" + + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" +) + +const ( + // defaultFramerate specifies the maximum interval at which we should + // update the view. + defaultFPS = 60 + maxFPS = 120 +) + +// renderer is the interface for Bubble Tea renderers. +type renderer interface { + // start starts the renderer. + start() + + // close closes the renderer and flushes any remaining data. + close() error + + // render renders a frame to the output. + render(View) + + // flush flushes the renderer's buffer to the output. + flush(closing bool) error + + // reset resets the renderer's state to its initial state. + reset() + + // insertAbove inserts unmanaged lines above the renderer. + insertAbove(string) error + + // setSyncdUpdates sets whether to use synchronized updates. + setSyncdUpdates(bool) + + // setWidthMethod sets the method for calculating the width of the terminal. + setWidthMethod(ansi.Method) + + // resize notify the renderer of a terminal resize. + resize(int, int) + + // setColorProfile sets the color profile. + setColorProfile(colorprofile.Profile) + + // clearScreen clears the screen. + clearScreen() + + // writeString writes a string to the renderer's output. + writeString(string) (int, error) + + // onMouse handles a mouse event. + onMouse(MouseMsg) Cmd +} + +type printLineMessage struct { + messageBody string +} + +// Println prints above the Program. This output is unmanaged by the program and +// will persist across renders by the Program. +// +// Unlike fmt.Println (but similar to log.Println) the message will be print on +// its own line. +// +// If the altscreen is active no output will be printed. +func Println(args ...any) Cmd { + return func() Msg { + return printLineMessage{ + messageBody: fmt.Sprint(args...), + } + } +} + +// Printf prints above the Program. It takes a format template followed by +// values similar to fmt.Printf. This output is unmanaged by the program and +// will persist across renders by the Program. +// +// Unlike fmt.Printf (but similar to log.Printf) the message will be print on +// its own line. +// +// If the altscreen is active no output will be printed. +func Printf(template string, args ...any) Cmd { + return func() Msg { + return printLineMessage{ + messageBody: fmt.Sprintf(template, args...), + } + } +} + +// encodeCursorStyle returns the integer value for the given cursor style and +// blink state. +func encodeCursorStyle(style CursorShape, blink bool) int { + // We're using the ANSI escape sequence values for cursor styles. + // We need to map both [style] and [steady] to the correct value. + style = (style * 2) + 1 //nolint:mnd + if !blink { + style++ + } + return int(style) +} diff --git a/third_party/bubbletea-v2-patched/screen.go b/third_party/bubbletea-v2-patched/screen.go new file mode 100644 index 000000000..f835bb376 --- /dev/null +++ b/third_party/bubbletea-v2-patched/screen.go @@ -0,0 +1,68 @@ +package tea + +import "github.com/charmbracelet/x/ansi" + +// WindowSizeMsg is used to report the terminal size. It's sent to Update once +// initially and then on every terminal resize. Note that Windows does not +// have support for reporting when resizes occur as it does not support the +// SIGWINCH signal. +type WindowSizeMsg struct { + Width int + Height int +} + +// ClearScreen is a special command that tells the program to clear the screen +// before the next update. This can be used to move the cursor to the top left +// of the screen and clear visual clutter when the alt screen is not in use. +// +// Note that it should never be necessary to call ClearScreen() for regular +// redraws. +func ClearScreen() Msg { + return clearScreenMsg{} +} + +// clearScreenMsg is an internal message that signals to clear the screen. +// You can send a clearScreenMsg with ClearScreen. +type clearScreenMsg struct{} + +// ModeReportMsg is a message that represents a mode report event (DECRPM). +// +// This is sent by the terminal in response to a request for a terminal mode +// report (DECRQM). It indicates the current setting of a specific terminal +// mode like cursor visibility, mouse tracking, etc. +// +// Example: +// +// ```go +// func (m model) Init() tea.Cmd { +// // Does my terminal support reporting focus events? +// return tea.Raw(ansi.RequestModeFocusEvent) +// } +// +// func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +// switch msg := msg.(type) { +// case tea.ModeReportMsg: +// if msg.Mode == ansi.ModeFocusEvent && !msg.Value.IsNotRecognized() { +// // Terminal supports focus events +// m.supportsFocus = true +// } +// } +// return m, nil +// } +// +// func (m model) View() tea.View { +// var view tea.View +// view.ReportFocus = m.supportsFocus +// view.SetContent(fmt.Sprintf("Terminal supports focus events: %v", m.supportsFocus)) +// return view +// } +// ``` +// +// See: https://vt100.net/docs/vt510-rm/DECRPM.html +type ModeReportMsg struct { + // Mode is the mode number. + Mode ansi.Mode + + // Value is the mode value. + Value ansi.ModeSetting +} diff --git a/third_party/bubbletea-v2-patched/screen_test.go b/third_party/bubbletea-v2-patched/screen_test.go new file mode 100644 index 000000000..c3fe24ab5 --- /dev/null +++ b/third_party/bubbletea-v2-patched/screen_test.go @@ -0,0 +1,207 @@ +package tea + +import ( + "bytes" + "image/color" + "testing" + + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/exp/golden" +) + +type testViewOpts struct { + altScreen bool + mouseMode MouseMode + showCursor bool + disableBp bool + keyReleases bool + bgColor color.Color +} + +func testViewOptsCmds(opts ...testViewOpts) []Cmd { + cmds := make([]Cmd, len(opts)) + for i, o := range opts { + o := o + cmds[i] = func() Msg { + return o + } + } + return cmds +} + +type testViewModel struct { + *testModel + opts testViewOpts +} + +func (m *testViewModel) Update(msg Msg) (Model, Cmd) { + switch msg := msg.(type) { + case testViewOpts: + m.opts = msg + return m, nil + } + tm, cmd := m.testModel.Update(msg) + m.testModel = tm.(*testModel) + return m, cmd +} + +func (m *testViewModel) View() View { + v := m.testModel.View() + v.AltScreen = m.opts.altScreen + v.MouseMode = m.opts.mouseMode + v.DisableBracketedPasteMode = m.opts.disableBp + v.KeyboardEnhancements.ReportEventTypes = m.opts.keyReleases + v.BackgroundColor = m.opts.bgColor + if m.opts.showCursor { + v.Cursor = NewCursor(0, 0) + } + return v +} + +func TestViewModel(t *testing.T) { + tests := []struct { + name string + opts []testViewOpts + }{ + { + name: "altscreen", + opts: []testViewOpts{ + {altScreen: true}, + {altScreen: false}, + }, + }, + { + name: "altscreen_autoexit", + opts: []testViewOpts{ + {altScreen: true}, + }, + }, + { + name: "mouse_cellmotion", + opts: []testViewOpts{ + {mouseMode: MouseModeCellMotion}, + }, + }, + { + name: "mouse_allmotion", + opts: []testViewOpts{ + {mouseMode: MouseModeAllMotion}, + }, + }, + { + name: "mouse_disable", + opts: []testViewOpts{ + {mouseMode: MouseModeAllMotion}, + {mouseMode: MouseModeNone}, + }, + }, + { + name: "cursor_hide", + opts: []testViewOpts{ + {}, + }, + }, + { + name: "cursor_hideshow", + opts: []testViewOpts{ + {showCursor: false}, + {showCursor: true}, + }, + }, + { + name: "bp_stop_start", + opts: []testViewOpts{ + {disableBp: true}, + {disableBp: false}, + }, + }, + { + name: "kitty_stop_startreleases", + opts: []testViewOpts{ + {}, + {keyReleases: true}, + }, + }, + { + name: "bg_set_color", + opts: []testViewOpts{ + {bgColor: color.RGBA{255, 255, 255, 255}}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + m := &testViewModel{testModel: &testModel{}} + p := NewProgram(m, + // Set the initial window size for the program. + WithWindowSize(80, 24), + // Use ANSI256 to increase test coverage. + WithColorProfile(colorprofile.ANSI256), + // always use xterm and 256 colors for tests + WithEnvironment([]string{"TERM=xterm-256color"}), + WithInput(&in), + WithOutput(&buf), + ) + + go p.Send(append(sequenceMsg(testViewOptsCmds(test.opts...)), Quit)) + + if _, err := p.Run(); err != nil { + t.Fatal(err) + } + golden.RequireEqual(t, buf.Bytes()) + }) + } +} + +func TestClearMsg(t *testing.T) { + type test struct { + name string + cmds sequenceMsg + } + tests := []test{ + { + name: "clear_screen", + cmds: []Cmd{ClearScreen}, + }, + { + name: "read_set_clipboard", + cmds: []Cmd{ReadClipboard, SetClipboard("success")}, + }, + { + name: "bg_fg_cur_color", + cmds: []Cmd{RequestForegroundColor, RequestBackgroundColor, RequestCursorColor}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + m := &testModel{} + p := NewProgram(m, + // Set the initial window size for the program. + WithWindowSize(80, 24), + // Use ANSI256 to increase test coverage. + WithColorProfile(colorprofile.ANSI256), + // always use xterm and 256 colors for tests + WithEnvironment([]string{"TERM=xterm-256color"}), + WithInput(&in), + WithOutput(&buf), + ) + + go p.Send(append(test.cmds, Quit)) + + if _, err := p.Run(); err != nil { + t.Fatal(err) + } + golden.RequireEqual(t, buf.Bytes()) + }) + } +} diff --git a/third_party/bubbletea-v2-patched/signals_unix.go b/third_party/bubbletea-v2-patched/signals_unix.go new file mode 100644 index 000000000..409540385 --- /dev/null +++ b/third_party/bubbletea-v2-patched/signals_unix.go @@ -0,0 +1,33 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix || zos +// +build darwin dragonfly freebsd linux netbsd openbsd solaris aix zos + +package tea + +import ( + "os" + "os/signal" + "syscall" +) + +// listenForResize sends messages (or errors) when the terminal resizes. +// Argument output should be the file descriptor for the terminal; usually +// os.Stdout. +func (p *Program) listenForResize(done chan struct{}) { + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGWINCH) + + defer func() { + signal.Stop(sig) + close(done) + }() + + for { + select { + case <-p.ctx.Done(): + return + case <-sig: + } + + p.checkResize() + } +} diff --git a/third_party/bubbletea-v2-patched/signals_windows.go b/third_party/bubbletea-v2-patched/signals_windows.go new file mode 100644 index 000000000..2fc6f8ae7 --- /dev/null +++ b/third_party/bubbletea-v2-patched/signals_windows.go @@ -0,0 +1,10 @@ +//go:build windows +// +build windows + +package tea + +// listenForResize is not available on windows because windows does not +// implement syscall.SIGWINCH. +func (p *Program) listenForResize(done chan struct{}) { + close(done) +} diff --git a/third_party/bubbletea-v2-patched/tea.go b/third_party/bubbletea-v2-patched/tea.go new file mode 100644 index 000000000..e249d71bb --- /dev/null +++ b/third_party/bubbletea-v2-patched/tea.go @@ -0,0 +1,1439 @@ +// Package tea provides a framework for building rich terminal user interfaces +// based on the paradigms of The Elm Architecture. It's well-suited for simple +// and complex terminal applications, either inline, full-window, or a mix of +// both. It's been battle-tested in several large projects and is +// production-ready. +// +// A tutorial is available at https://github.com/charmbracelet/bubbletea/tree/master/tutorials +// +// Example programs can be found at https://github.com/charmbracelet/bubbletea/tree/master/examples +package tea + +import ( + "bytes" + "context" + "errors" + "fmt" + "image/color" + "io" + "log" + "os" + "os/signal" + "runtime" + "runtime/debug" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/charmbracelet/colorprofile" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/term" + "github.com/muesli/cancelreader" +) + +// ErrProgramPanic is returned by [Program.Run] when the program recovers from a panic. +var ErrProgramPanic = errors.New("program experienced a panic") + +// ErrProgramKilled is returned by [Program.Run] when the program gets killed. +var ErrProgramKilled = errors.New("program was killed") + +// ErrInterrupted is returned by [Program.Run] when the program get a SIGINT +// signal, or when it receives a [InterruptMsg]. +var ErrInterrupted = errors.New("program was interrupted") + +// Msg contain data from the result of a IO operation. Msgs trigger the update +// function and, henceforth, the UI. +type Msg = uv.Event + +// Model contains the program's state as well as its core functions. +type Model interface { + // Init is the first function that will be called. It returns an optional + // initial command. To not perform an initial command return nil. + Init() Cmd + + // Update is called when a message is received. Use it to inspect messages + // and, in response, update the model and/or send a command. + Update(Msg) (Model, Cmd) + + // View renders the program's UI, which can be a string or a [Layer]. The + // view is rendered after every Update. + View() View +} + +// NewView is a helper function to create a new [View] with the given styled +// string. A styled string represents text with styles and hyperlinks encoded +// as ANSI escape codes. +// +// Example: +// +// ```go +// v := tea.NewView("Hello, World!") +// ``` +func NewView(s string) View { + var view View + view.SetContent(s) + return view +} + +// View represents a terminal view that can be composed of multiple layers. +// It can also contain a cursor that will be rendered on top of the layers. +type View struct { + // Content is the screen content of the view. It holds styled strings that + // will be rendered to the terminal when the view is rendered. + // + // A styled string represents text with styles and hyperlinks encoded as + // ANSI escape codes. + // + // Example: + // + // ```go + // v := tea.NewView("Hello, World!") + // ``` + Content string + + // OnMouse is an optional mouse message handler that can be used to + // intercept mouse messages that depends on view content from last render. + // It can be useful for implementing view-specific behavior without + // breaking the unidirectional data flow of Bubble Tea. + // + // Example: + // + // ```go + // content := "Hello, World!" + // v := tea.NewView(content) + // v.OnMouse = func(msg tea.MouseMsg) tea.Cmd { + // return func() tea.Msg { + // m := msg.Mouse() + // // Check if the mouse is within the bounds of "World!" + // start := strings.Index(content, "World!") + // end := start + len("World!") + // if m.Y == 0 && m.X >= start && m.X < end { + // // Mouse is over "World!" + // return MyCustomMsg{ + // MouseMsg: msg, + // } + // } + // } + // } + // return nil + // } + // return v + // ``` + OnMouse func(msg MouseMsg) Cmd + + // Cursor represents the cursor position, style, and visibility on the + // screen. When not nil, the cursor will be shown at the specified + // position. + Cursor *Cursor + + // BackgroundColor when not nil, sets the terminal background color. Use + // nil to reset to the terminal's default background color. + BackgroundColor color.Color + + // ForegroundColor when not nil, sets the terminal foreground color. Use + // nil to reset to the terminal's default foreground color. + ForegroundColor color.Color + + // WindowTitle sets the terminal window title. Support depends on the + // terminal. + WindowTitle string + + // ProgressBar when not nil, shows a progress bar in the terminal's + // progress bar section. Support depends on the terminal. + ProgressBar *ProgressBar + + // AltScreen puts the program in the alternate screen buffer + // (i.e. the program goes into full window mode). Note that the altscreen will + // be automatically exited when the program quits. + // + // Example: + // + // func (m model) View() tea.View { + // v := tea.NewView("Hello, World!") + // v.AltScreen = true + // return v + // } + // + AltScreen bool + + // ReportFocus enables reporting when the terminal gains and loses focus. + // When this is enabled [FocusMsg] and [BlurMsg] messages will be sent to + // your Update method. + // + // Note that while most terminals and multiplexers support focus reporting, + // some do not. Also note that tmux needs to be configured to report focus + // events. + ReportFocus bool + + // DisableBracketedPasteMode disables bracketed paste mode for this view. + DisableBracketedPasteMode bool + + // MouseMode sets the mouse mode for this view. It can be one of + // [MouseModeNone], [MouseModeCellMotion], or [MouseModeAllMotion]. + MouseMode MouseMode + + // KeyboardEnhancements describes what keyboard enhancement features Bubble + // Tea should request from the terminal. + // + // Bubble Tea supports requesting the following keyboard enhancement features: + // - ReportEventTypes: requests the terminal to report key repeat and + // release events. + // + // If the terminal supports any of these features, your program will + // receive a [KeyboardEnhancementsMsg] that indicates which features are + // available. + KeyboardEnhancements KeyboardEnhancements +} + +// KeyboardEnhancements describes the requested keyboard enhancement features. +// If the terminal supports any of them, it will respond with a +// [KeyboardEnhancementsMsg] that indicates which features are supported. + +// KeyboardEnhancements defines different keyboard enhancement features that +// can be requested from the terminal. + +// KeyboardEnhancements defines different keyboard enhancement features that +// can be requested from the terminal. +// +// By default, Bubble Tea requests basic key disambiguation features from the +// terminal. If the terminal supports keyboard enhancements, or any of its +// additional features, it will respond with a [KeyboardEnhancementsMsg] that +// indicates which features are supported. +// +// Example: +// +// ```go +// func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +// switch msg := msg.(type) { +// case tea.KeyboardEnhancementsMsg: +// // We have basic key disambiguation support. +// // We can handle "shift+enter", "ctrl+i", etc. +// m.keyboardEnhancements = msg +// if msg.ReportEventTypes { +// // Even better! We can now handle key repeat and release events. +// } +// case tea.KeyPressMsg: +// switch msg.String() { +// case "shift+enter": +// // Handle shift+enter +// // This would not be possible without keyboard enhancements. +// case "ctrl+j": +// // Handle ctrl+j +// } +// case tea.KeyReleaseMsg: +// // Whoa! A key was released! +// } +// +// return m, nil +// } +// +// func (m model) View() tea.View { +// v := tea.NewView("Press some keys!") +// // Request reporting key repeat and release events. +// v.KeyboardEnhancements.ReportEventTypes = true +// return v +// } +// ``` +type KeyboardEnhancements struct { + // ReportEventTypes requests the terminal to report key repeat and release + // events. + // If supported, your program will receive [KeyReleaseMsg]s and + // [KeyPressMsg] with the [Key.IsRepeat] field set indicating that this is + // a it's part of a key repeat sequence. + ReportEventTypes bool + + // ReportAlternateKeys requests the terminal to report alternate key values + // in addition to the main ones. + // Note that only key events represented as escape codes will affected by + // this enhancement. + ReportAlternateKeys bool + + // ReportAllKeysAsEscapeCodes requests the terminal to report all key + // events, including plain text keys, as escape codes. + // When this is enabled, text won't be sent as plain text but instead as + // escape codes that encode the key value and modifiers. + ReportAllKeysAsEscapeCodes bool + + // ReportAssociatedText requests the terminal to report the text associated + // with key events. + // Note that this is an enhancement to + // [KeyboardEnhancements.ReportAllKeysAsEscapeCodes] and only has an effect + // if that is enabled. + ReportAssociatedText bool +} + +// SetContent is a helper method to set the content of a [View] with a styled +// string. A styled string represents text with styles and hyperlinks encoded +// as ANSI escape codes. +// +// Example: +// +// ```go +// var v tea.View +// v.SetContent("Hello, World!") +// ``` +func (v *View) SetContent(s string) { + v.Content = s +} + +// MouseMode represents the mouse mode of a view. +type MouseMode int + +const ( + // MouseModeNone disables mouse events. + MouseModeNone MouseMode = iota + + // MouseModeCellMotion enables mouse click, release, and wheel events. + // Mouse movement events are also captured if a mouse button is pressed + // (i.e., drag events). Cell motion mode is better supported than all + // motion mode. + // + // This will try to enable the mouse in extended mode (SGR), if that is not + // supported by the terminal it will fall back to normal mode (X10). + MouseModeCellMotion + + // MouseModeAllMotion enables all mouse events, including click, release, + // wheel, and movement events. You will receive mouse movement events even + // when no buttons are pressed. + // + // This will try to enable the mouse in extended mode (SGR), if that is not + // supported by the terminal it will fall back to normal mode (X10). + MouseModeAllMotion +) + +// ProgressBarState represents the state of the progress bar. +type ProgressBarState int + +// Progress bar states. +const ( + ProgressBarNone ProgressBarState = iota + ProgressBarDefault + ProgressBarError + ProgressBarIndeterminate + ProgressBarWarning +) + +// String return a human-readable value for the given [ProgressBarState]. +func (s ProgressBarState) String() string { + return [...]string{ + "None", + "Default", + "Error", + "Indeterminate", + "Warning", + }[s] +} + +// ProgressBar represents the terminal progress bar. +// +// Support depends on the terminal. +// +// See https://learn.microsoft.com/en-us/windows/terminal/tutorials/progress-bar-sequences +type ProgressBar struct { + // State is the current state of the progress bar. It can be one of + // [ProgressBarNone], [ProgressBarDefault], [ProgressBarError], + // [ProgressBarIndeterminate], and [ProgressBarWarn]. + State ProgressBarState + // Value is the current value of the progress bar. It should be between + // 0 and 100. + Value int +} + +// NewProgressBar returns a new progress bar with the given state and value. +// The value is ignored if the state is [ProgressBarNone] or +// [ProgressBarIndeterminate]. +func NewProgressBar(state ProgressBarState, value int) *ProgressBar { + return &ProgressBar{ + State: state, + Value: min(max(value, 0), 100), + } +} + +// Cursor represents a cursor on the terminal screen. +type Cursor struct { + // Position is a [Position] that determines the cursor's position on the + // screen relative to the top left corner of the frame. + Position + + // Color is a [color.Color] that determines the cursor's color. + Color color.Color + + // Shape is a [CursorShape] that determines the cursor's shape. + Shape CursorShape + + // Blink is a boolean that determines whether the cursor should blink. + Blink bool +} + +// NewCursor returns a new cursor with the default settings and the given +// position. +func NewCursor(x, y int) *Cursor { + return &Cursor{ + Position: Position{X: x, Y: y}, + Color: nil, + Shape: CursorBlock, + Blink: true, + } +} + +// Cmd is an IO operation that returns a message when it's complete. If it's +// nil it's considered a no-op. Use it for things like HTTP requests, timers, +// saving and loading from disk, and so on. +// +// Note that there's almost never a reason to use a command to send a message +// to another part of your program. That can almost always be done in the +// update function. +type Cmd func() Msg + +// channelHandlers manages the series of channels returned by various processes. +// It allows us to wait for those processes to terminate before exiting the +// program. +type channelHandlers struct { + handlers []chan struct{} + mu sync.RWMutex +} + +// Adds a channel to the list of handlers. We wait for all handlers to terminate +// gracefully on shutdown. +func (h *channelHandlers) add(ch chan struct{}) { + h.mu.Lock() + h.handlers = append(h.handlers, ch) + h.mu.Unlock() +} + +// shutdown waits for all handlers to terminate. +func (h *channelHandlers) shutdown() { + var wg sync.WaitGroup + + h.mu.RLock() + defer h.mu.RUnlock() + + for _, ch := range h.handlers { + wg.Add(1) + go func(ch chan struct{}) { + <-ch + wg.Done() + }(ch) + } + wg.Wait() +} + +// Program is a terminal user interface. +type Program struct { + // disableInput disables all input. This is useful for programs that + // don't need input, like a progress bar or a spinner. + disableInput bool + + // disableSignalHandler disables the signal handler that Bubble Tea sets up + // for Programs. This is useful if you want to handle signals yourself. + disableSignalHandler bool + + // disableCatchPanics disables the panic catching that Bubble Tea does by + // default. If panic catching is disabled the terminal will be in a fairly + // unusable state after a panic because Bubble Tea will not perform its usual + // cleanup on exit. + disableCatchPanics bool + + // filter supplies an event filter that will be invoked before Bubble Tea + // processes a tea.Msg. The event filter can return any tea.Msg which will + // then get handled by Bubble Tea instead of the original event. If the + // event filter returns nil, the event will be ignored and Bubble Tea will + // not process it. + // + // As an example, this could be used to prevent a program from shutting + // down if there are unsaved changes. + // + // Example: + // + // func filter(m tea.Model, msg tea.Msg) tea.Msg { + // if _, ok := msg.(tea.QuitMsg); !ok { + // return msg + // } + // + // model := m.(myModel) + // if model.hasChanges { + // return nil + // } + // + // return msg + // } + // + // p := tea.NewProgram(Model{}); + // p.filter = filter + // + // if _,err := p.Run(context.Background()); err != nil { + // fmt.Println("Error running program:", err) + // os.Exit(1) + // } + filter func(Model, Msg) Msg + + // fps sets a custom maximum fps at which the renderer should run. If less + // than 1, the default value of 60 will be used. If over 120, the fps will + // be capped at 120. + fps int + + // initialModel is the initial model for the program and is the only + // required field when creating a new program. + initialModel Model + + // disableRenderer prevents the program from rendering to the terminal. + // This can be useful for running daemon-like programs that don't require a + // UI but still want to take advantage of Bubble Tea's architecture. + disableRenderer bool + + // handlers is a list of channels that need to be waited on before the + // program can exit. + handlers channelHandlers + + // ctx is the programs's internal context for signalling internal teardown. + // It is built and derived from the externalCtx in NewProgram(). + ctx context.Context + cancel context.CancelFunc + + // externalCtx is a context that was passed in via WithContext, otherwise defaulting + // to ctx.Background() (in case it was not), the internal context is derived from it. + externalCtx context.Context + + msgs chan Msg + errs chan error + finished chan struct{} + shutdownOnce sync.Once + + profile *colorprofile.Profile // the terminal color profile + + // where to send output, this will usually be os.Stdout. + output io.Writer + outputBuf bytes.Buffer // buffer used to queue commands to be sent to the output + + // ttyOutput is null if output is not a TTY. + ttyOutput term.File + previousOutputState *term.State + renderer renderer + + // the environment variables for the program, defaults to os.Environ(). + environ uv.Environ + // the program's logger for debugging. + logger uv.Logger + + // where to read inputs from, this will usually be os.Stdin. + input io.Reader + // ttyInput is null if input is not a TTY. + ttyInput term.File + previousTtyInputState *term.State + cancelReader cancelreader.CancelReader + inputScanner *uv.TerminalReader + readLoopDone chan struct{} + + // modes keeps track of terminal modes that have been enabled or disabled. + ignoreSignals uint32 + + // ticker is the ticker that will be used to write to the renderer. + ticker *time.Ticker + + // once is used to stop the renderer. + once sync.Once + + // rendererDone is used to stop the renderer. + rendererDone chan struct{} + + // Initial window size. Mainly used for testing. + width, height int + + // whether to use hard tabs to optimize cursor movements + useHardTabs bool + // whether to use backspace to optimize cursor movements + useBackspace bool + + mu sync.Mutex +} + +// Quit is a special command that tells the Bubble Tea program to exit. +func Quit() Msg { + return QuitMsg{} +} + +// QuitMsg signals that the program should quit. You can send a [QuitMsg] with +// [Quit]. +type QuitMsg struct{} + +// Suspend is a special command that tells the Bubble Tea program to suspend. +func Suspend() Msg { + return SuspendMsg{} +} + +// SuspendMsg signals the program should suspend. +// This usually happens when ctrl+z is pressed on common programs, but since +// bubbletea puts the terminal in raw mode, we need to handle it in a +// per-program basis. +// +// You can send this message with [Suspend()]. +type SuspendMsg struct{} + +// ResumeMsg can be listen to do something once a program is resumed back +// from a suspend state. +type ResumeMsg struct{} + +// InterruptMsg signals the program should suspend. +// This usually happens when ctrl+c is pressed on common programs, but since +// bubbletea puts the terminal in raw mode, we need to handle it in a +// per-program basis. +// +// You can send this message with [Interrupt()]. +type InterruptMsg struct{} + +// Interrupt is a special command that tells the Bubble Tea program to +// interrupt. +func Interrupt() Msg { + return InterruptMsg{} +} + +// NewProgram creates a new [Program]. +func NewProgram(model Model, opts ...ProgramOption) *Program { + p := &Program{ + initialModel: model, + msgs: make(chan Msg), + errs: make(chan error, 1), + rendererDone: make(chan struct{}), + } + + // Apply all options to the program. + for _, opt := range opts { + opt(p) + } + + // A context can be provided with a ProgramOption, but if none was provided + // we'll use the default background context. + if p.externalCtx == nil { + p.externalCtx = context.Background() + } + // Initialize context and teardown channel. + p.ctx, p.cancel = context.WithCancel(p.externalCtx) + + // if no output was set, set it to stdout + if p.output == nil { + p.output = os.Stdout + } + + // if no environment was set, set it to os.Environ() + if p.environ == nil { + p.environ = os.Environ() + } + + if p.fps < 1 { + p.fps = defaultFPS + } else if p.fps > maxFPS { + p.fps = maxFPS + } + + tracePath, traceOk := os.LookupEnv("TEA_TRACE") + if traceOk && len(tracePath) > 0 { + // We have a trace filepath. + if f, err := os.OpenFile(tracePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o600); err == nil { + p.logger = log.New(f, "bubbletea: ", log.LstdFlags|log.Lshortfile) + } + } + + return p +} + +func (p *Program) handleSignals() chan struct{} { + ch := make(chan struct{}) + + // Listen for SIGINT and SIGTERM. + // + // In most cases ^C will not send an interrupt because the terminal will be + // in raw mode and ^C will be captured as a keystroke and sent along to + // Program.Update as a KeyMsg. When input is not a TTY, however, ^C will be + // caught here. + // + // SIGTERM is sent by unix utilities (like kill) to terminate a process. + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + defer func() { + signal.Stop(sig) + close(ch) + }() + + for { + select { + case <-p.ctx.Done(): + return + + case s := <-sig: + if atomic.LoadUint32(&p.ignoreSignals) == 0 { + switch s { + case syscall.SIGINT: + p.msgs <- InterruptMsg{} + default: + p.msgs <- QuitMsg{} + } + return + } + } + } + }() + + return ch +} + +// handleResize handles terminal resize events. +func (p *Program) handleResize() chan struct{} { + ch := make(chan struct{}) + + if p.ttyOutput != nil { + // Listen for window resizes. + go p.listenForResize(ch) + } else { + close(ch) + } + + return ch +} + +// handleCommands runs commands in a goroutine and sends the result to the +// program's message channel. +func (p *Program) handleCommands(cmds chan Cmd) chan struct{} { + ch := make(chan struct{}) + + go func() { + defer close(ch) + + for { + select { + case <-p.ctx.Done(): + return + + case cmd := <-cmds: + if cmd == nil { + continue + } + + // Don't wait on these goroutines, otherwise the shutdown + // latency would get too large as a Cmd can run for some time + // (e.g. tick commands that sleep for half a second). It's not + // possible to cancel them so we'll have to leak the goroutine + // until Cmd returns. + go func() { + // Recover from panics. + if !p.disableCatchPanics { + defer func() { + if r := recover(); r != nil { + p.recoverFromPanic(r) + } + }() + } + + msg := cmd() // this can be long. + p.Send(msg) + }() + } + } + }() + + return ch +} + +// eventLoop is the central message loop. It receives and handles the default +// Bubble Tea messages, update the model and triggers redraws. +func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { + for { + select { + case <-p.ctx.Done(): + return model, nil + + case err := <-p.errs: + return model, err + + case msg := <-p.msgs: + msg = p.translateInputEvent(msg) + + // Filter messages. + if p.filter != nil { + msg = p.filter(model, msg) + } + if msg == nil { + continue + } + + // Handle special internal messages. + switch msg := msg.(type) { + case QuitMsg: + return model, nil + + case InterruptMsg: + return model, ErrInterrupted + + case SuspendMsg: + if suspendSupported { + p.suspend() + } + + case CapabilityMsg: + switch msg.Content { + case "RGB", "Tc": + if *p.profile != colorprofile.TrueColor { + tc := colorprofile.TrueColor + p.profile = &tc + go p.Send(ColorProfileMsg{*p.profile}) + } + } + + case ModeReportMsg: + switch msg.Mode { + case ansi.ModeSynchronizedOutput: + if msg.Value == ansi.ModeReset { + // The terminal supports synchronized output and it's + // currently disabled, so we can enable it on the renderer. + p.renderer.setSyncdUpdates(true) + } + case ansi.ModeUnicodeCore: + if msg.Value == ansi.ModeReset || msg.Value == ansi.ModeSet || msg.Value == ansi.ModePermanentlySet { + p.renderer.setWidthMethod(ansi.GraphemeWidth) + } + } + + case MouseMsg: + switch msg.(type) { + case MouseClickMsg, MouseReleaseMsg, MouseWheelMsg, MouseMotionMsg: + // Only send mouse messages to the renderer if they are an + // actual mouse event. + if cmd := p.renderer.onMouse(msg); cmd != nil { + go p.Send(cmd()) + } + } + + case readClipboardMsg: + p.execute(ansi.RequestSystemClipboard) + + case setClipboardMsg: + p.execute(ansi.SetSystemClipboard(string(msg))) + + case readPrimaryClipboardMsg: + p.execute(ansi.RequestPrimaryClipboard) + + case setPrimaryClipboardMsg: + p.execute(ansi.SetPrimaryClipboard(string(msg))) + + case backgroundColorMsg: + p.execute(ansi.RequestBackgroundColor) + + case foregroundColorMsg: + p.execute(ansi.RequestForegroundColor) + + case cursorColorMsg: + p.execute(ansi.RequestCursorColor) + + case execMsg: + // NB: this blocks. + p.exec(msg.cmd, msg.fn) + + case terminalVersion: + p.execute(ansi.RequestNameVersion) + + case requestCapabilityMsg: + p.execute(ansi.RequestTermcap(string(msg))) + + case BatchMsg: + go p.execBatchMsg(msg) + continue + + case sequenceMsg: + go p.execSequenceMsg(msg) + continue + + case WindowSizeMsg: + p.renderer.resize(msg.Width, msg.Height) + + case windowSizeMsg: + go p.checkResize() + + case requestCursorPosMsg: + p.execute(ansi.RequestCursorPositionReport) + + case RawMsg: + p.execute(fmt.Sprint(msg.Msg)) + + case printLineMessage: + p.renderer.insertAbove(msg.messageBody) //nolint:errcheck,gosec + + case clearScreenMsg: + p.renderer.clearScreen() + + case ColorProfileMsg: + p.renderer.setColorProfile(msg.Profile) + } + + var cmd Cmd + model, cmd = model.Update(msg) // run update + + select { + case <-p.ctx.Done(): + return model, nil + case cmds <- cmd: // process command (if any) + } + + p.render(model) // render view + } + } +} + +// render renders the given view to the renderer. +func (p *Program) render(model Model) { + if p.renderer != nil { + p.renderer.render(model.View()) // send view to renderer + } +} + +func (p *Program) execSequenceMsg(msg sequenceMsg) { + if !p.disableCatchPanics { + defer func() { + if r := recover(); r != nil { + p.recoverFromGoPanic(r) + } + }() + } + + // Execute commands one at a time, in order. + for _, cmd := range msg { + if cmd == nil { + continue + } + msg := cmd() + switch msg := msg.(type) { + case BatchMsg: + p.execBatchMsg(msg) + case sequenceMsg: + p.execSequenceMsg(msg) + default: + p.Send(msg) + } + } +} + +func (p *Program) execBatchMsg(msg BatchMsg) { + if !p.disableCatchPanics { + defer func() { + if r := recover(); r != nil { + p.recoverFromGoPanic(r) + } + }() + } + + // Execute commands one at a time. + var wg sync.WaitGroup + for _, cmd := range msg { + if cmd == nil { + continue + } + wg.Add(1) + go func() { + defer wg.Done() + + if !p.disableCatchPanics { + defer func() { + if r := recover(); r != nil { + p.recoverFromGoPanic(r) + } + }() + } + + msg := cmd() + switch msg := msg.(type) { + case BatchMsg: + p.execBatchMsg(msg) + case sequenceMsg: + p.execSequenceMsg(msg) + default: + p.Send(msg) + } + }() + } + + wg.Wait() // wait for all commands from batch msg to finish +} + +// shouldQuerySynchronizedOutput determines whether the terminal should be +// queried for various capabilities. +// +// This function checks for terminals that are known to support mode 2026, +// while excluding SSH sessions which may be unreliable, unless it's a +// known-good terminal like Windows Terminal. +// +// The function returns true for: +// - Terminals without TERM_PROGRAM set and not in SSH sessions +// - Windows Terminal (WT_SESSION is set) +// - Terminals with TERM_PROGRAM set (except Apple Terminal) and not in SSH sessions +// - Specific terminal types: ghostty, wezterm, alacritty, kitty, rio +func shouldQuerySynchronizedOutput(environ uv.Environ) bool { + termType := environ.Getenv("TERM") + termProg, okTermProg := environ.LookupEnv("TERM_PROGRAM") + _, okSSHTTY := environ.LookupEnv("SSH_TTY") + _, okWTSession := environ.LookupEnv("WT_SESSION") + + return (!okTermProg && !okSSHTTY) || + okWTSession || + (okTermProg && !strings.Contains(termProg, "Apple") && !okSSHTTY) || + strings.Contains(termType, "ghostty") || + strings.Contains(termType, "wezterm") || + strings.Contains(termType, "alacritty") || + strings.Contains(termType, "kitty") || + strings.Contains(termType, "rio") +} + +// Run initializes the program and runs its event loops, blocking until it gets +// terminated by either [Program.Quit], [Program.Kill], or its signal handler. +// Returns the final model. +func (p *Program) Run() (returnModel Model, returnErr error) { + if p.initialModel == nil { + return nil, errors.New("bubbletea: InitialModel cannot be nil") + } + + // Initialize context and teardown channel. + p.handlers = channelHandlers{} + cmds := make(chan Cmd) + + p.finished = make(chan struct{}) + defer func() { + close(p.finished) + }() + + defer p.cancel() + + if p.disableInput { + p.input = nil + } else if p.input == nil { + p.input = os.Stdin + if !term.IsTerminal(os.Stdin.Fd()) { + ttyIn, _, err := OpenTTY() + if err != nil { + return p.initialModel, fmt.Errorf("bubbletea: error opening TTY: %w", err) + } + p.input = ttyIn + } + } + + // Handle signals. + if !p.disableSignalHandler { + p.handlers.add(p.handleSignals()) + } + + // Recover from panics. + if !p.disableCatchPanics { + defer func() { + if r := recover(); r != nil { + returnErr = fmt.Errorf("%w: %w", ErrProgramKilled, ErrProgramPanic) + p.recoverFromPanic(r) + } + }() + } + + // Check if output is a TTY before entering raw mode, hiding the cursor and + // so on. + if err := p.initTerminal(); err != nil { + return p.initialModel, err + } + + // Get the initial window size. + width, height := p.width, p.height + if p.ttyOutput != nil { + // Set the initial size of the terminal. + w, h, err := term.GetSize(p.ttyOutput.Fd()) + if err != nil { + return p.initialModel, fmt.Errorf("bubbletea: error getting terminal size: %w", err) + } + + width, height = w, h + } + + p.width, p.height = width, height + resizeMsg := WindowSizeMsg{Width: p.width, Height: p.height} + + if p.renderer == nil { + if p.disableRenderer { + p.renderer = &nilRenderer{} + } else { + // If no renderer is set use the cursed one. + r := newCursedRenderer( + p.output, + p.environ, + p.width, + p.height, + ) + r.setLogger(p.logger) + // XXX: This breaks many things especially when we want the output + // to be compatible with terminals that are not necessary a TTY. + // This was originally done to work around a Wish emulated-pty + // issue where when a PTY session is detected, and we don't + // allocate a real PTY, the terminal settings (Termios and WinCon) + // don't change and the we end up working in cooked mode instead of + // raw mode. See issue #1572. + mapNl := runtime.GOOS != "windows" && p.ttyInput == nil + r.setOptimizations(p.useHardTabs, p.useBackspace, mapNl) + p.renderer = r + } + } + + // Get the color profile and send it to the program. + if p.profile == nil { + cp := colorprofile.Detect(p.output, p.environ) + p.profile = &cp + } + + // Set the color profile on the renderer and send it to the program. + p.renderer.setColorProfile(*p.profile) + go p.Send(ColorProfileMsg{*p.profile}) + + // Send the initial size to the program. + go p.Send(resizeMsg) + p.renderer.resize(resizeMsg.Width, resizeMsg.Height) + + // Send the environment variables used by the program. + go p.Send(EnvMsg(p.environ)) + + // Init the input reader and initial model. + model := p.initialModel + if p.input != nil { + if err := p.initInputReader(false); err != nil { + return model, err + } + } + + // Start the renderer. + p.startRenderer() + + if !p.disableRenderer && shouldQuerySynchronizedOutput(p.environ) { + // Query for synchronized updates support (mode 2026) and unicode core + // (mode 2027). If the terminal supports it, the renderer will enable + // it once we get the response. + p.execute(ansi.RequestModeSynchronizedOutput + + ansi.RequestModeUnicodeCore) + } + + // Initialize the program. + initCmd := model.Init() + if initCmd != nil { + ch := make(chan struct{}) + p.handlers.add(ch) + + go func() { + defer close(ch) + + select { + case cmds <- initCmd: + case <-p.ctx.Done(): + } + }() + } + + // Render the initial view. + p.render(model) + + // Handle resize events. + p.handlers.add(p.handleResize()) + + // Process commands. + p.handlers.add(p.handleCommands(cmds)) + + // Run event loop, handle updates and draw. + var err error + model, err = p.eventLoop(model, cmds) + + if err == nil && len(p.errs) > 0 { + err = <-p.errs // Drain a leftover error in case eventLoop crashed. + } + + killed := p.externalCtx.Err() != nil || p.ctx.Err() != nil || err != nil + if killed { + if err == nil && p.externalCtx.Err() != nil { + // Return also as context error the cancellation of an external context. + // This is the context the user knows about and should be able to act on. + err = fmt.Errorf("%w: %w", ErrProgramKilled, p.externalCtx.Err()) + } else if err == nil && p.ctx.Err() != nil { + // Return only that the program was killed (not the internal mechanism). + // The user does not know or need to care about the internal program context. + err = ErrProgramKilled + } else { + // Return that the program was killed and also the error that caused it. + err = fmt.Errorf("%w: %w", ErrProgramKilled, err) + } + } else { + // Graceful shutdown of the program (not killed): + // Ensure we rendered the final state of the model. + p.render(model) + } + + // Restore terminal state. + p.shutdown(killed) + + return model, err +} + +// Send sends a message to the main update function, effectively allowing +// messages to be injected from outside the program for interoperability +// purposes. +// +// If the program hasn't started yet this will be a blocking operation. +// If the program has already been terminated this will be a no-op, so it's safe +// to send messages after the program has exited. +func (p *Program) Send(msg Msg) { + select { + case <-p.ctx.Done(): + case p.msgs <- msg: + } +} + +// Quit is a convenience function for quitting Bubble Tea programs. Use it +// when you need to shut down a Bubble Tea program from the outside. +// +// If you wish to quit from within a Bubble Tea program use the Quit command. +// +// If the program is not running this will be a no-op, so it's safe to call +// if the program is unstarted or has already exited. +func (p *Program) Quit() { + p.Send(Quit()) +} + +// Kill stops the program immediately and restores the former terminal state. +// The final render that you would normally see when quitting will be skipped. +// [program.Run] returns a [ErrProgramKilled] error. +func (p *Program) Kill() { + p.shutdown(true) +} + +// Wait waits/blocks until the underlying Program finished shutting down. +func (p *Program) Wait() { + <-p.finished +} + +// execute writes the given sequence to the program output. +func (p *Program) execute(seq string) { + p.mu.Lock() + _, _ = p.outputBuf.WriteString(seq) + p.mu.Unlock() +} + +// flush flushes the output buffer to the program output. +func (p *Program) flush() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.outputBuf.Len() == 0 { + return nil + } + if p.logger != nil { + p.logger.Printf("output: %q", p.outputBuf.String()) + } + _, err := p.output.Write(p.outputBuf.Bytes()) + p.outputBuf.Reset() + if err != nil { + return fmt.Errorf("error writing to output: %w", err) + } + return nil +} + +// shutdown performs operations to free up resources and restore the terminal +// to its original state. +func (p *Program) shutdown(kill bool) { + p.shutdownOnce.Do(func() { + p.cancel() + + // Wait for all handlers to finish. + p.handlers.shutdown() + + // Check if the cancel reader has been setup before waiting and closing. + if p.cancelReader != nil { + // Wait for input loop to finish. + if p.cancelReader.Cancel() { + if !kill { + p.waitForReadLoop() + } + } + _ = p.cancelReader.Close() + } + + if p.renderer != nil { + p.stopRenderer(kill) + } + + _ = p.restoreTerminalState() + }) +} + +// recoverFromPanic recovers from a panic, prints the stack trace, and restores +// the terminal to a usable state. +func (p *Program) recoverFromPanic(r interface{}) { + select { + case p.errs <- ErrProgramPanic: + default: + } + p.shutdown(true) // Ok to call here, p.Run() cannot do it anymore. + // We use "\r\n" to ensure the output is formatted even when restoring the + // terminal does not work or when raw mode is still active. + rec := strings.ReplaceAll(fmt.Sprintf("%s", r), "\n", "\r\n") + fmt.Fprintf(os.Stderr, "Caught panic:\r\n\r\n%s\r\n\r\nRestoring terminal...\r\n\r\n", rec) + stack := strings.ReplaceAll(fmt.Sprintf("%s\n", debug.Stack()), "\n", "\r\n") + fmt.Fprint(os.Stderr, stack) + if v, err := strconv.ParseBool(os.Getenv("TEA_DEBUG")); err == nil && v { + f, err := os.Create(fmt.Sprintf("bubbletea-panic-%d.log", time.Now().Unix())) + if err == nil { + defer f.Close() //nolint:errcheck + fmt.Fprintln(f, rec) //nolint:errcheck + fmt.Fprintln(f) //nolint:errcheck + fmt.Fprintln(f, stack) //nolint:errcheck + } + } +} + +// recoverFromGoPanic recovers from a goroutine panic, prints a stack trace and +// signals for the program to be killed and terminal restored to a usable state. +func (p *Program) recoverFromGoPanic(r interface{}) { + select { + case p.errs <- ErrProgramPanic: + default: + } + p.cancel() + // We use "\r\n" to ensure the output is formatted even when restoring the + // terminal does not work or when raw mode is still active. + rec := strings.ReplaceAll(fmt.Sprintf("%s", r), "\n", "\r\n") + fmt.Fprintf(os.Stderr, "Caught panic:\r\n\r\n%s\r\n\r\nRestoring terminal...\r\n\r\n", rec) + stack := strings.ReplaceAll(fmt.Sprintf("%s\n", debug.Stack()), "\n", "\r\n") + fmt.Fprint(os.Stderr, stack) + if v, err := strconv.ParseBool(os.Getenv("TEA_DEBUG")); err == nil && v { + f, err := os.Create(fmt.Sprintf("bubbletea-panic-%d.log", time.Now().Unix())) + if err == nil { + defer f.Close() //nolint:errcheck + fmt.Fprintln(f, rec) //nolint:errcheck + fmt.Fprintln(f) //nolint:errcheck + fmt.Fprintln(f, stack) //nolint:errcheck + } + } +} + +// ReleaseTerminal restores the original terminal state and cancels the input +// reader. You can return control to the Program with RestoreTerminal. +func (p *Program) ReleaseTerminal() error { + return p.releaseTerminal(false) +} + +func (p *Program) releaseTerminal(reset bool) error { + atomic.StoreUint32(&p.ignoreSignals, 1) + if p.cancelReader != nil { + p.cancelReader.Cancel() + } + + p.waitForReadLoop() + + if p.renderer != nil { + p.stopRenderer(false) + if reset { + p.renderer.reset() + } + } + + return p.restoreTerminalState() +} + +// RestoreTerminal reinitializes the Program's input reader, restores the +// terminal to the former state when the program was running, and repaints. +// Use it to reinitialize a Program after running ReleaseTerminal. +func (p *Program) RestoreTerminal() error { + atomic.StoreUint32(&p.ignoreSignals, 0) + + if err := p.initTerminal(); err != nil { + return err + } + if p.input != nil { + if err := p.initInputReader(false); err != nil { + return err + } + } + + p.startRenderer() + + // If the output is a terminal, it may have been resized while another + // process was at the foreground, in which case we may not have received + // SIGWINCH. Detect any size change now and propagate the new size as + // needed. + go p.checkResize() + + // Flush queued commands. + return p.flush() +} + +// Println prints above the Program. This output is unmanaged by the program +// and will persist across renders by the Program. +// +// If the altscreen is active no output will be printed. +func (p *Program) Println(args ...any) { + p.msgs <- printLineMessage{ + messageBody: fmt.Sprint(args...), + } +} + +// Printf prints above the Program. It takes a format template followed by +// values similar to fmt.Printf. This output is unmanaged by the program and +// will persist across renders by the Program. +// +// Unlike fmt.Printf (but similar to log.Printf) the message will be print on +// its own line. +// +// If the altscreen is active no output will be printed. +func (p *Program) Printf(template string, args ...any) { + p.msgs <- printLineMessage{ + messageBody: fmt.Sprintf(template, args...), + } +} + +// startRenderer starts the renderer. +func (p *Program) startRenderer() { + framerate := time.Second / time.Duration(p.fps) + if p.ticker == nil { + p.ticker = time.NewTicker(framerate) + } else { + // If the ticker already exists, it has been stopped and we need to + // reset it. + p.ticker.Reset(framerate) + } + + // Since the renderer can be restarted after a stop, we need to reset + // the done channel and its corresponding sync.Once. + p.once = sync.Once{} + + // Start the renderer. + p.renderer.start() + go func() { + for { + select { + case <-p.rendererDone: + p.ticker.Stop() + return + + case <-p.ticker.C: + _ = p.flush() + _ = p.renderer.flush(false) + } + } + }() +} + +// stopRenderer stops the renderer. +// If kill is true, the renderer will be stopped immediately without flushing +// the last frame. +func (p *Program) stopRenderer(kill bool) { + // Stop the renderer before acquiring the mutex to avoid a deadlock. + p.once.Do(func() { + p.rendererDone <- struct{}{} + }) + + if !kill { + // flush locks the mutex + _ = p.renderer.flush(true) + } + + _ = p.renderer.close() +} diff --git a/third_party/bubbletea-v2-patched/tea_test.go b/third_party/bubbletea-v2-patched/tea_test.go new file mode 100644 index 000000000..8d81c77b0 --- /dev/null +++ b/third_party/bubbletea-v2-patched/tea_test.go @@ -0,0 +1,671 @@ +package tea + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type ctxImplodeMsg struct { + cancel context.CancelFunc +} + +type incrementMsg struct{} + +type panicMsg struct{} + +func panicCmd() Msg { + panic("testing goroutine panic behavior") +} + +type testModel struct { + executed atomic.Value + counter atomic.Value +} + +func (m *testModel) Init() Cmd { + return nil +} + +func (m *testModel) Update(msg Msg) (Model, Cmd) { + switch msg := msg.(type) { + case ctxImplodeMsg: + msg.cancel() + time.Sleep(100 * time.Millisecond) + + case incrementMsg: + i := m.counter.Load() + if i == nil { + m.counter.Store(1) + } else { + m.counter.Store(i.(int) + 1) + } + + case KeyPressMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, Quit + } + + case panicMsg: + panic("testing panic behavior") + } + + return m, nil +} + +func (m *testModel) View() View { + m.executed.Store(true) + return NewView("success") +} + +func TestTeaModel(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + in.Write([]byte("q")) + + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + + p := NewProgram(&testModel{}, + WithContext(ctx), + WithInput(&in), + WithOutput(&buf), + ) + if _, err := p.Run(); err != nil { + t.Fatal(err) + } + + if buf.Len() == 0 { + t.Fatal("no output") + } +} + +func TestTeaQuit(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + m := &testModel{} + p := NewProgram(m, + WithInput(&in), + WithOutput(&buf), + ) + go func() { + for { + time.Sleep(time.Millisecond) + if m.executed.Load() != nil { + p.Quit() + return + } + } + }() + + if _, err := p.Run(); err != nil { + t.Fatal(err) + } +} + +func TestTeaWaitQuit(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + progStarted := make(chan struct{}) + waitStarted := make(chan struct{}) + errChan := make(chan error, 1) + + m := &testModel{} + p := NewProgram(m, + WithInput(&in), + WithOutput(&buf), + ) + + go func() { + _, err := p.Run() + errChan <- err + }() + + go func() { + for { + time.Sleep(time.Millisecond) + if m.executed.Load() != nil { + close(progStarted) + + <-waitStarted + time.Sleep(50 * time.Millisecond) + p.Quit() + + return + } + } + }() + + <-progStarted + + var wg sync.WaitGroup + for range 5 { + wg.Add(1) + go func() { + p.Wait() + wg.Done() + }() + } + close(waitStarted) + wg.Wait() + + err := <-errChan + if err != nil { + t.Fatalf("Expected nil, got %v", err) + } +} + +func TestTeaWaitKill(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + progStarted := make(chan struct{}) + waitStarted := make(chan struct{}) + errChan := make(chan error, 1) + + m := &testModel{} + p := NewProgram(m, + WithInput(&in), + WithOutput(&buf), + ) + + go func() { + _, err := p.Run() + errChan <- err + }() + + go func() { + for { + time.Sleep(time.Millisecond) + if m.executed.Load() != nil { + close(progStarted) + + <-waitStarted + time.Sleep(50 * time.Millisecond) + p.Kill() + + return + } + } + }() + + <-progStarted + + var wg sync.WaitGroup + for range 5 { + wg.Add(1) + go func() { + p.Wait() + wg.Done() + }() + } + close(waitStarted) + wg.Wait() + + err := <-errChan + if !errors.Is(err, ErrProgramKilled) { + t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) + } +} + +func TestTeaWithFilter(t *testing.T) { + for _, preventCount := range []uint32{0, 1, 2} { + t.Run(fmt.Sprintf("prevent_%d", preventCount), func(t *testing.T) { + t.Parallel() + testTeaWithFilter(t, preventCount) + }) + } +} + +func testTeaWithFilter(t *testing.T, preventCount uint32) { + var buf bytes.Buffer + var in bytes.Buffer + + m := &testModel{} + shutdowns := uint32(0) + p := NewProgram(m, + WithInput(&in), + WithOutput(&buf), + ) + p.filter = func(_ Model, msg Msg) Msg { + if _, ok := msg.(QuitMsg); !ok { + return msg + } + if shutdowns < preventCount { + atomic.AddUint32(&shutdowns, 1) + return nil + } + return msg + } + + go func() { + for atomic.LoadUint32(&shutdowns) <= preventCount { + time.Sleep(time.Millisecond) + p.Quit() + } + }() + + if _, err := p.Run(); err != nil { + t.Fatal(err) + } + if shutdowns != preventCount { + t.Errorf("Expected %d prevented shutdowns, got %d", preventCount, shutdowns) + } +} + +func TestTeaKill(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + m := &testModel{} + p := NewProgram(m, + WithInput(&in), + WithOutput(&buf), + ) + go func() { + for { + time.Sleep(time.Millisecond) + if m.executed.Load() != nil { + p.Kill() + return + } + } + }() + + _, err := p.Run() + + if !errors.Is(err, ErrProgramKilled) { + t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) + } + + if errors.Is(err, context.Canceled) { + // The end user should not know about the program's internal context state. + // The program should only report external context cancellation as a context error. + t.Fatalf("Internal context cancellation was reported as context error!") + } +} + +func TestTeaContext(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + var buf bytes.Buffer + var in bytes.Buffer + + m := &testModel{} + p := NewProgram(m, + WithContext(ctx), + WithInput(&in), + WithOutput(&buf), + ) + go func() { + for { + time.Sleep(time.Millisecond) + if m.executed.Load() != nil { + cancel() + return + } + } + }() + + _, err := p.Run() + + if !errors.Is(err, ErrProgramKilled) { + t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) + } + + if !errors.Is(err, context.Canceled) { + // The end user should know that their passed in context caused the kill. + t.Fatalf("Expected %v, got %v", context.Canceled, err) + } +} + +func TestTeaContextImplodeDeadlock(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + var buf bytes.Buffer + var in bytes.Buffer + + m := &testModel{} + p := NewProgram(m, + WithContext(ctx), + WithInput(&in), + WithOutput(&buf), + ) + go func() { + for { + time.Sleep(time.Millisecond) + if m.executed.Load() != nil { + p.Send(ctxImplodeMsg{cancel: cancel}) + return + } + } + }() + + if _, err := p.Run(); !errors.Is(err, ErrProgramKilled) { + t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) + } +} + +func TestTeaContextBatchDeadlock(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + var buf bytes.Buffer + var in bytes.Buffer + + inc := func() Msg { + cancel() + return incrementMsg{} + } + + m := &testModel{} + p := NewProgram(m, + WithContext(ctx), + WithInput(&in), + WithOutput(&buf), + ) + go func() { + for { + time.Sleep(time.Millisecond) + if m.executed.Load() != nil { + batch := make(BatchMsg, 100) + for i := range batch { + batch[i] = inc + } + p.Send(batch) + return + } + } + }() + + if _, err := p.Run(); !errors.Is(err, ErrProgramKilled) { + t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) + } +} + +func TestTeaBatchMsg(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + inc := func() Msg { + return incrementMsg{} + } + + m := &testModel{} + p := NewProgram(m, + WithInput(&in), + WithOutput(&buf), + ) + go func() { + p.Send(BatchMsg{inc, inc}) + + for { + time.Sleep(time.Millisecond) + i := m.counter.Load() + if i != nil && i.(int) >= 2 { + p.Quit() + return + } + } + }() + + if _, err := p.Run(); err != nil { + t.Fatal(err) + } + + if m.counter.Load() != 2 { + t.Fatalf("counter should be 2, got %d", m.counter.Load()) + } +} + +func TestTeaSequenceMsg(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + inc := func() Msg { + return incrementMsg{} + } + + m := &testModel{} + p := NewProgram(m, + WithInput(&in), + WithOutput(&buf), + ) + go p.Send(sequenceMsg{inc, inc, Quit}) + + if _, err := p.Run(); err != nil { + t.Fatal(err) + } + + if m.counter.Load() != 2 { + t.Fatalf("counter should be 2, got %d", m.counter.Load()) + } +} + +func TestTeaSequenceMsgWithBatchMsg(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + inc := func() Msg { + return incrementMsg{} + } + batch := func() Msg { + return BatchMsg{inc, inc} + } + + m := &testModel{} + p := NewProgram(m, + WithInput(&in), + WithOutput(&buf), + ) + go p.Send(sequenceMsg{batch, inc, Quit}) + + if _, err := p.Run(); err != nil { + t.Fatal(err) + } + + if m.counter.Load() != 3 { + t.Fatalf("counter should be 3, got %d", m.counter.Load()) + } +} + +func TestTeaNestedSequenceMsg(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + inc := func() Msg { + return incrementMsg{} + } + + m := &testModel{} + p := NewProgram(m, + WithInput(&in), + WithOutput(&buf), + ) + go p.Send(sequenceMsg{inc, Sequence(inc, inc, Batch(inc, inc)), Quit}) + + if _, err := p.Run(); err != nil { + t.Fatal(err) + } + + if m.counter.Load() != 5 { + t.Fatalf("counter should be 5, got %d", m.counter.Load()) + } +} + +func TestTeaSend(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + m := &testModel{} + p := NewProgram(m, + WithInput(&in), + WithOutput(&buf), + ) + + // sending before the program is started is a blocking operation + go p.Send(Quit()) + + if _, err := p.Run(); err != nil { + t.Fatal(err) + } + + // sending a message after program has quit is a no-op + p.Send(Quit()) +} + +func TestTeaNoRun(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + m := &testModel{} + NewProgram(m, + WithInput(&in), + WithOutput(&buf), + ) +} + +func TestTeaPanic(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + m := &testModel{} + p := NewProgram(m, + WithInput(&in), + WithOutput(&buf), + ) + go func() { + for { + time.Sleep(time.Millisecond) + if m.executed.Load() != nil { + p.Send(panicMsg{}) + return + } + } + }() + + _, err := p.Run() + + if !errors.Is(err, ErrProgramPanic) { + t.Fatalf("Expected %v, got %v", ErrProgramPanic, err) + } + + if !errors.Is(err, ErrProgramKilled) { + t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) + } +} + +func TestTeaGoroutinePanic(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + var in bytes.Buffer + + m := &testModel{} + p := NewProgram(m, + WithInput(&in), + WithOutput(&buf), + ) + go func() { + for { + time.Sleep(time.Millisecond) + if m.executed.Load() != nil { + batch := make(BatchMsg, 10) + for i := 0; i < len(batch); i += 2 { + batch[i] = Sequence(panicCmd) + batch[i+1] = Batch(panicCmd) + } + p.Send(batch) + return + } + } + }() + + _, err := p.Run() + + if !errors.Is(err, ErrProgramPanic) { + t.Fatalf("Expected %v, got %v", ErrProgramPanic, err) + } + + if !errors.Is(err, ErrProgramKilled) { + t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) + } +} + +type benchModel struct { + t testing.TB +} + +func (m benchModel) Init() Cmd { + return nil +} + +func (m benchModel) Update(msg Msg) (Model, Cmd) { + switch msg := msg.(type) { + case KeyPressMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, Quit + } + } + + return m, nil +} + +func (m benchModel) View() View { + view := strings.Join([]string{ + " \x1b[38;5;63m╭─────────────────────────╮\x1b[m", + " \x1b[38;5;63m│\x1b[m\x1b[25X\x1b[28G\x1b[38;5;63m│\x1b[m", + " \x1b[38;5;63m│\x1b[m \x1b[38;5;231mHello There!\x1b[m \x1b[38;5;63m│\x1b[m", + " \x1b[38;5;63m│\x1b[m\x1b[25X\x1b[28G\x1b[38;5;63m│\x1b[m", + " \x1b[38;5;63m╰─────────────────────────╯\x1b[m", + }, "\n") + + return NewView(view) +} + +func BenchmarkTeaRun(b *testing.B) { + for i := 0; i < b.N; i++ { + var buf bytes.Buffer + + m := benchModel{b} + r, w := io.Pipe() + p := NewProgram(m, + WithInput(r), + WithOutput(&buf), + ) + + go func() { + for _, input := range "abcdefghijklmnopq" { + time.Sleep(10 * time.Millisecond) + w.Write([]byte(string(input))) + } + }() + + if _, err := p.Run(); err != nil { + b.Fatalf("Run failed: %v", err) + } + + _ = r.CloseWithError(io.EOF) + } +} diff --git a/third_party/bubbletea-v2-patched/termcap.go b/third_party/bubbletea-v2-patched/termcap.go new file mode 100644 index 000000000..c66d95ae5 --- /dev/null +++ b/third_party/bubbletea-v2-patched/termcap.go @@ -0,0 +1,48 @@ +package tea + +// requestCapabilityMsg is an internal message that requests the terminal to +// send its Termcap/Terminfo response. +type requestCapabilityMsg string + +// RequestCapability is a command that requests the terminal to send its +// Termcap/Terminfo response for the given capability. +// +// Bubble Tea recognizes the following capabilities and will use them to +// upgrade the program's color profile: +// - "RGB" Xterm direct color +// - "Tc" True color support +// +// Note: that some terminal's like Apple's Terminal.app do not support this and +// will send the wrong response to the terminal breaking the program's output. +// +// When the Bubble Tea advertises a non-TrueColor profile, you can use this +// command to query the terminal for its color capabilities. Example: +// +// switch msg := msg.(type) { +// case tea.ColorProfileMsg: +// if msg.Profile != colorprofile.TrueColor { +// return m, tea.Batch( +// tea.RequestCapability("RGB"), +// tea.RequestCapability("Tc"), +// ) +// } +// } +func RequestCapability(s string) Cmd { + return func() Msg { + return requestCapabilityMsg(s) + } +} + +// CapabilityMsg represents a Termcap/Terminfo response event. Termcap +// responses are generated by the terminal in response to RequestTermcap +// (XTGETTCAP) requests. +// +// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands +type CapabilityMsg struct { + Content string +} + +// String returns the capability content as a string. +func (c CapabilityMsg) String() string { + return c.Content +} diff --git a/third_party/bubbletea-v2-patched/termios_bsd.go b/third_party/bubbletea-v2-patched/termios_bsd.go new file mode 100644 index 000000000..cc716e585 --- /dev/null +++ b/third_party/bubbletea-v2-patched/termios_bsd.go @@ -0,0 +1,13 @@ +//go:build dragonfly || freebsd +// +build dragonfly freebsd + +package tea + +import ( + "github.com/charmbracelet/x/term" + "golang.org/x/sys/unix" +) + +func (p *Program) checkOptimizedMovements(s *term.State) { + p.useHardTabs = s.Oflag&unix.TABDLY == unix.TAB0 +} diff --git a/third_party/bubbletea-v2-patched/termios_other.go b/third_party/bubbletea-v2-patched/termios_other.go new file mode 100644 index 000000000..d4ede6d04 --- /dev/null +++ b/third_party/bubbletea-v2-patched/termios_other.go @@ -0,0 +1,8 @@ +//go:build !windows && !darwin && !dragonfly && !freebsd && !linux && !solaris && !aix +// +build !windows,!darwin,!dragonfly,!freebsd,!linux,!solaris,!aix + +package tea + +import "github.com/charmbracelet/x/term" + +func (*Program) checkOptimizedMovements(*term.State) {} diff --git a/third_party/bubbletea-v2-patched/termios_unix.go b/third_party/bubbletea-v2-patched/termios_unix.go new file mode 100644 index 000000000..3ee0e9948 --- /dev/null +++ b/third_party/bubbletea-v2-patched/termios_unix.go @@ -0,0 +1,14 @@ +//go:build darwin || linux || solaris || aix +// +build darwin linux solaris aix + +package tea + +import ( + "github.com/charmbracelet/x/term" + "golang.org/x/sys/unix" +) + +func (p *Program) checkOptimizedMovements(s *term.State) { + p.useHardTabs = s.Oflag&unix.TABDLY == unix.TAB0 + p.useBackspace = s.Lflag&unix.BSDLY == unix.BS0 +} diff --git a/third_party/bubbletea-v2-patched/termios_windows.go b/third_party/bubbletea-v2-patched/termios_windows.go new file mode 100644 index 000000000..c743652c8 --- /dev/null +++ b/third_party/bubbletea-v2-patched/termios_windows.go @@ -0,0 +1,11 @@ +//go:build windows +// +build windows + +package tea + +import "github.com/charmbracelet/x/term" + +func (p *Program) checkOptimizedMovements(*term.State) { + p.useHardTabs = true + p.useBackspace = true +} diff --git a/third_party/bubbletea-v2-patched/testdata/TestClearMsg/bg_fg_cur_color.golden b/third_party/bubbletea-v2-patched/testdata/TestClearMsg/bg_fg_cur_color.golden new file mode 100644 index 000000000..dd7ab7f1d --- /dev/null +++ b/third_party/bubbletea-v2-patched/testdata/TestClearMsg/bg_fg_cur_color.golden @@ -0,0 +1 @@ +[?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p]10;?]11;?]12;? \ No newline at end of file diff --git a/third_party/bubbletea-v2-patched/testdata/TestClearMsg/clear_screen.golden b/third_party/bubbletea-v2-patched/testdata/TestClearMsg/clear_screen.golden new file mode 100644 index 000000000..7d97a1535 --- /dev/null +++ b/third_party/bubbletea-v2-patched/testdata/TestClearMsg/clear_screen.golden @@ -0,0 +1 @@ +[?25l [?2004h[>4;2m[=1;1usuccess[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/third_party/bubbletea-v2-patched/testdata/TestClearMsg/read_set_clipboard.golden b/third_party/bubbletea-v2-patched/testdata/TestClearMsg/read_set_clipboard.golden new file mode 100644 index 000000000..e75f5c615 --- /dev/null +++ b/third_party/bubbletea-v2-patched/testdata/TestClearMsg/read_set_clipboard.golden @@ -0,0 +1 @@ +[?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p]52;c;?]52;c;c3VjY2Vzcw== \ No newline at end of file diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/altscreen.golden b/third_party/bubbletea-v2-patched/testdata/TestViewModel/altscreen.golden new file mode 100644 index 000000000..503fac477 --- /dev/null +++ b/third_party/bubbletea-v2-patched/testdata/TestViewModel/altscreen.golden @@ -0,0 +1 @@ +[?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/altscreen_autoexit.golden b/third_party/bubbletea-v2-patched/testdata/TestViewModel/altscreen_autoexit.golden new file mode 100644 index 000000000..810564717 --- /dev/null +++ b/third_party/bubbletea-v2-patched/testdata/TestViewModel/altscreen_autoexit.golden @@ -0,0 +1 @@ +[>4m[=0;1u[?1049h[?25l[?2004h[>4;2m[=1;1usuccess[>4m[=0;1u[?1049l[?25h[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/bg_set_color.golden b/third_party/bubbletea-v2-patched/testdata/TestViewModel/bg_set_color.golden new file mode 100644 index 000000000..cf7e4c4ee --- /dev/null +++ b/third_party/bubbletea-v2-patched/testdata/TestViewModel/bg_set_color.golden @@ -0,0 +1 @@ +[?25l[?2004h[>4;2m[=1;1u]11;#ffffff success[>4m[=0;1u [?25h[?2004l]111[?2026$p[?2027$p \ No newline at end of file diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/bp_stop_start.golden b/third_party/bubbletea-v2-patched/testdata/TestViewModel/bp_stop_start.golden new file mode 100644 index 000000000..503fac477 --- /dev/null +++ b/third_party/bubbletea-v2-patched/testdata/TestViewModel/bp_stop_start.golden @@ -0,0 +1 @@ +[?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/cursor_hide.golden b/third_party/bubbletea-v2-patched/testdata/TestViewModel/cursor_hide.golden new file mode 100644 index 000000000..503fac477 --- /dev/null +++ b/third_party/bubbletea-v2-patched/testdata/TestViewModel/cursor_hide.golden @@ -0,0 +1 @@ +[?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/cursor_hideshow.golden b/third_party/bubbletea-v2-patched/testdata/TestViewModel/cursor_hideshow.golden new file mode 100644 index 000000000..9fd5343c0 --- /dev/null +++ b/third_party/bubbletea-v2-patched/testdata/TestViewModel/cursor_hideshow.golden @@ -0,0 +1 @@ +[?2004h[>4;2m[=1;1u[1 q success [?25h[>4m[=0;1u[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/kitty_stop_startreleases.golden b/third_party/bubbletea-v2-patched/testdata/TestViewModel/kitty_stop_startreleases.golden new file mode 100644 index 000000000..0af542ed6 --- /dev/null +++ b/third_party/bubbletea-v2-patched/testdata/TestViewModel/kitty_stop_startreleases.golden @@ -0,0 +1 @@ +[?25l[?2004h[>4;2m[=3;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_allmotion.golden b/third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_allmotion.golden new file mode 100644 index 000000000..da6639489 --- /dev/null +++ b/third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_allmotion.golden @@ -0,0 +1 @@ +[?25l[?2004h[?1003h[?1006h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?1002l[?1003l[?1006l[?2026$p[?2027$p \ No newline at end of file diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_cellmotion.golden b/third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_cellmotion.golden new file mode 100644 index 000000000..7206798e5 --- /dev/null +++ b/third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_cellmotion.golden @@ -0,0 +1 @@ +[?25l[?2004h[?1002h[?1006h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?1002l[?1003l[?1006l[?2026$p[?2027$p \ No newline at end of file diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_disable.golden b/third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_disable.golden new file mode 100644 index 000000000..503fac477 --- /dev/null +++ b/third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_disable.golden @@ -0,0 +1 @@ +[?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/third_party/bubbletea-v2-patched/tty.go b/third_party/bubbletea-v2-patched/tty.go new file mode 100644 index 000000000..12b86493b --- /dev/null +++ b/third_party/bubbletea-v2-patched/tty.go @@ -0,0 +1,136 @@ +package tea + +import ( + "fmt" + "os" + "time" + + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/term" +) + +func (p *Program) suspend() { + if err := p.releaseTerminal(true); err != nil { + // If we can't release input, abort. + return + } + + suspendProcess() + + _ = p.RestoreTerminal() + go p.Send(ResumeMsg{}) +} + +func (p *Program) initTerminal() error { + if p.disableRenderer { + return nil + } + return p.initInput() +} + +// restoreTerminalState restores the terminal to the state prior to running the +// Bubble Tea program. +func (p *Program) restoreTerminalState() error { + // Flush queued commands. + _ = p.flush() + + return p.restoreInput() +} + +// restoreInput restores the tty input to its original state. +func (p *Program) restoreInput() error { + if p.ttyInput != nil && p.previousTtyInputState != nil { + if err := term.Restore(p.ttyInput.Fd(), p.previousTtyInputState); err != nil { + return fmt.Errorf("bubbletea: error restoring console: %w", err) + } + } + if p.ttyOutput != nil && p.previousOutputState != nil { + if err := term.Restore(p.ttyOutput.Fd(), p.previousOutputState); err != nil { + return fmt.Errorf("bubbletea: error restoring console: %w", err) + } + } + return nil +} + +// initInputReader (re)commences reading inputs. +func (p *Program) initInputReader(cancel bool) error { + if cancel && p.cancelReader != nil { + p.cancelReader.Cancel() + p.waitForReadLoop() + } + + term := p.environ.Getenv("TERM") + + // Initialize the input reader. + // This need to be done after the terminal has been initialized and set to + // raw mode. + + var err error + p.cancelReader, err = uv.NewCancelReader(p.input) + if err != nil { + return fmt.Errorf("bubbletea: could not create cancelable reader: %w", err) + } + + drv := uv.NewTerminalReader(p.cancelReader, term) + drv.SetLogger(p.logger) + p.inputScanner = drv + p.readLoopDone = make(chan struct{}) + + go p.readLoop() + + return nil +} + +func (p *Program) readLoop() { + defer close(p.readLoopDone) + + if err := p.inputScanner.StreamEvents(p.ctx, p.msgs); err != nil { + select { + case <-p.ctx.Done(): + return + case p.errs <- err: + } + } +} + +// waitForReadLoop waits for the cancelReader to finish its read loop. +func (p *Program) waitForReadLoop() { + select { + case <-p.readLoopDone: + case <-time.After(500 * time.Millisecond): //nolint:mnd + // The read loop hangs, which means the input + // cancelReader's cancel function has returned true even + // though it was not able to cancel the read. + } +} + +// checkResize detects the current size of the output and informs the program +// via a WindowSizeMsg. +func (p *Program) checkResize() { + if p.ttyOutput == nil { + // can't query window size + return + } + + w, h, err := term.GetSize(p.ttyOutput.Fd()) + if err != nil { + select { + case <-p.ctx.Done(): + case p.errs <- err: + } + + return + } + + p.width, p.height = w, h + p.Send(WindowSizeMsg{Width: w, Height: h}) +} + +// OpenTTY opens the running terminal's TTY for reading and writing. +func OpenTTY() (*os.File, *os.File, error) { + in, out, err := uv.OpenTTY() + if err != nil { + return nil, nil, fmt.Errorf("bubbletea: could not open TTY: %w", err) + } + return in, out, nil +} diff --git a/third_party/bubbletea-v2-patched/tty_unix.go b/third_party/bubbletea-v2-patched/tty_unix.go new file mode 100644 index 000000000..4981a560a --- /dev/null +++ b/third_party/bubbletea-v2-patched/tty_unix.go @@ -0,0 +1,47 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix || zos +// +build darwin dragonfly freebsd linux netbsd openbsd solaris aix zos + +package tea + +import ( + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/charmbracelet/x/term" +) + +func (p *Program) initInput() (err error) { + // Check if input is a terminal + if f, ok := p.input.(term.File); ok && term.IsTerminal(f.Fd()) { + p.ttyInput = f + p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd()) + if err != nil { + return fmt.Errorf("error entering raw mode: %w", err) + } + + // OPTIM: We can use hard tabs and backspaces to optimize cursor + // movements. This is based on termios settings support and whether + // they exist and enabled. + p.checkOptimizedMovements(p.previousTtyInputState) + } + + if f, ok := p.output.(term.File); ok && term.IsTerminal(f.Fd()) { + p.ttyOutput = f + } + + return nil +} + +const suspendSupported = true + +// Send SIGTSTP to the entire process group. +func suspendProcess() { + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGCONT) + defer signal.Stop(c) + _ = syscall.Kill(0, syscall.SIGTSTP) + // blocks until a CONT happens... + <-c +} diff --git a/third_party/bubbletea-v2-patched/tty_windows.go b/third_party/bubbletea-v2-patched/tty_windows.go new file mode 100644 index 000000000..27b31182a --- /dev/null +++ b/third_party/bubbletea-v2-patched/tty_windows.go @@ -0,0 +1,64 @@ +//go:build windows +// +build windows + +package tea + +import ( + "fmt" + + "github.com/charmbracelet/x/term" + "golang.org/x/sys/windows" +) + +func (p *Program) initInput() (err error) { + // Save stdin state and enable VT input + // We also need to enable VT + // input here. + if f, ok := p.input.(term.File); ok && term.IsTerminal(f.Fd()) { + p.ttyInput = f + p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd()) + if err != nil { + return fmt.Errorf("error making terminal raw: %w", err) + } + + // Enable VT input + var mode uint32 + if err := windows.GetConsoleMode(windows.Handle(p.ttyInput.Fd()), &mode); err != nil { + return fmt.Errorf("error getting console mode: %w", err) + } + + if err := windows.SetConsoleMode(windows.Handle(p.ttyInput.Fd()), mode|windows.ENABLE_VIRTUAL_TERMINAL_INPUT); err != nil { + return fmt.Errorf("error setting console mode: %w", err) + } + } + + // Save output screen buffer state and enable VT processing. + if f, ok := p.output.(term.File); ok && term.IsTerminal(f.Fd()) { + p.ttyOutput = f + p.previousOutputState, err = term.GetState(f.Fd()) + if err != nil { + return fmt.Errorf("error getting terminal state: %w", err) + } + + var mode uint32 + if err := windows.GetConsoleMode(windows.Handle(p.ttyOutput.Fd()), &mode); err != nil { + return fmt.Errorf("error getting console mode: %w", err) + } + + if err := windows.SetConsoleMode(windows.Handle(p.ttyOutput.Fd()), + mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING| + windows.DISABLE_NEWLINE_AUTO_RETURN); err != nil { + return fmt.Errorf("error setting console mode: %w", err) + } + + //nolint:godox + // TODO: check if we can optimize cursor movements on Windows. + p.checkOptimizedMovements(p.previousOutputState) + } + + return //nolint:nakedret +} + +const suspendSupported = false + +func suspendProcess() {} diff --git a/third_party/bubbletea-v2-patched/xterm.go b/third_party/bubbletea-v2-patched/xterm.go new file mode 100644 index 000000000..0ffcd08fe --- /dev/null +++ b/third_party/bubbletea-v2-patched/xterm.go @@ -0,0 +1,22 @@ +package tea + +// TerminalVersionMsg is a message that represents the terminal version. +type TerminalVersionMsg struct { + Name string +} + +// String returns the terminal name as a string. +func (t TerminalVersionMsg) String() string { + return t.Name +} + +// terminalVersion is an internal message that queries the terminal for its +// version using XTVERSION. +type terminalVersion struct{} + +// RequestTerminalVersion is a command that queries the terminal for its +// version using XTVERSION. Note that some terminals may not support this +// command. +func RequestTerminalVersion() Msg { + return terminalVersion{} +} From f0cd7f9ce4b98725d231faf106d9787447315c3f Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:59:24 -0400 Subject: [PATCH 02/10] fix(tui): force redraw on streamed newlines to clear stale caret The streaming caret (appendStreamingCursor) is appended to whichever visual line is currently last. Over a remote terminal chain (multipass VM output displayed by Windows Terminal), the renderer's incremental cell diff sometimes fails to clear the caret's old cell when a newline moves it to a new line, leaving ghost carets behind. Fire tea.ClearScreen when a streamed delta contains a newline: that's exactly the moment the caret moves and the risk exists, so it forces a full redraw right then instead of leaving it to the incremental diff, without doing so on every animation tick. --- internal/tui/model.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 280c8ccfb..9a8bc9d93 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1984,6 +1984,17 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // re-stamps the in-progress last entry so the line that's still // being filled stays visibly fresh. m.recordStreamingDelta(msg.delta) + var cmds []tea.Cmd + // The streaming caret (appendStreamingCursor) is appended to whatever + // visual line is currently last. Some terminal/renderer combinations + // (observed over multipass + Windows Terminal) fail to clear the + // caret's old cell when a newline moves it to a new line, leaving + // ghost carets behind. A newline is exactly the moment that risk + // exists, so force one full-screen redraw right then rather than + // leaving it to the incremental diff. + if strings.Contains(msg.delta, "\n") { + cmds = append(cmds, tea.ClearScreen) + } // The fade's tick is self-perpetuating (the streamingFadeTickMsg // case schedules the next one). Schedule the FIRST tick only on // the inactive→active transition; subsequent deltas just refresh @@ -1995,10 +2006,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { startTick := !m.fadeActive m.fadeActive = true if startTick { - return m, streamingFadeTick() + cmds = append(cmds, streamingFadeTick()) } } - return m, nil + return m, tea.Batch(cmds...) case agentReasoningMsg: if msg.runID != m.activeRunID { return m, nil From eda790ace71ca42f5e1661e2b9d8245d5fdf66b8 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:47:36 -0400 Subject: [PATCH 03/10] chore(tui): trim upstream repo metadata from vendored bubbletea fork The vendored bubbletea-v2-patched module is ~99% byte-identical to upstream charm.land/bubbletea/v2 v2.0.8; only cursed_renderer.go differs (one line disabling hard scroll optimization). The .github workflows, README.md, UPGRADE_GUIDE_V2.md, Taskfile.yaml and CI config files are upstream repo metadata that serve no purpose inside this repo, so remove them to slim the PR. The Go source and testdata golden files are kept because Go's replace directive requires the full module to be present and buildable. Refs #709 --- .../bubbletea-v2-patched/.gitattributes | 1 - .../.github/ISSUE_TEMPLATE/bug.yml | 61 -- .../.github/ISSUE_TEMPLATE/bug_report.md | 37 -- .../.github/ISSUE_TEMPLATE/config.yml | 5 - .../.github/ISSUE_TEMPLATE/feature_request.md | 20 - .../.github/dependabot.yml | 91 --- .../.github/workflows/build.yml | 20 - .../.github/workflows/coverage.yml | 29 - .../.github/workflows/dependabot-sync.yml | 17 - .../.github/workflows/examples.yml | 38 -- .../.github/workflows/lint-sync.yml | 14 - .../.github/workflows/lint.yml | 12 - .../.github/workflows/release.yml | 29 - third_party/bubbletea-v2-patched/.gitignore | 23 - .../bubbletea-v2-patched/.golangci.yml | 47 -- .../bubbletea-v2-patched/.goreleaser.yml | 5 - third_party/bubbletea-v2-patched/README.md | 402 ------------ .../bubbletea-v2-patched/Taskfile.yaml | 38 -- .../bubbletea-v2-patched/UPGRADE_GUIDE_V2.md | 573 ------------------ 19 files changed, 1462 deletions(-) delete mode 100644 third_party/bubbletea-v2-patched/.gitattributes delete mode 100644 third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug.yml delete mode 100644 third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/config.yml delete mode 100644 third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 third_party/bubbletea-v2-patched/.github/dependabot.yml delete mode 100644 third_party/bubbletea-v2-patched/.github/workflows/build.yml delete mode 100644 third_party/bubbletea-v2-patched/.github/workflows/coverage.yml delete mode 100644 third_party/bubbletea-v2-patched/.github/workflows/dependabot-sync.yml delete mode 100644 third_party/bubbletea-v2-patched/.github/workflows/examples.yml delete mode 100644 third_party/bubbletea-v2-patched/.github/workflows/lint-sync.yml delete mode 100644 third_party/bubbletea-v2-patched/.github/workflows/lint.yml delete mode 100644 third_party/bubbletea-v2-patched/.github/workflows/release.yml delete mode 100644 third_party/bubbletea-v2-patched/.gitignore delete mode 100644 third_party/bubbletea-v2-patched/.golangci.yml delete mode 100644 third_party/bubbletea-v2-patched/.goreleaser.yml delete mode 100644 third_party/bubbletea-v2-patched/README.md delete mode 100644 third_party/bubbletea-v2-patched/Taskfile.yaml delete mode 100644 third_party/bubbletea-v2-patched/UPGRADE_GUIDE_V2.md diff --git a/third_party/bubbletea-v2-patched/.gitattributes b/third_party/bubbletea-v2-patched/.gitattributes deleted file mode 100644 index 6c929d480..000000000 --- a/third_party/bubbletea-v2-patched/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -*.golden -text diff --git a/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug.yml b/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug.yml deleted file mode 100644 index a9b077005..000000000 --- a/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Bug Report -description: File a bug report -labels: [bug] -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to fill out this bug report! Please fill the form below. - - type: textarea - id: what-happened - attributes: - label: What happened? - description: Also tell us, what did you expect to happen? - validations: - required: true - - type: textarea - id: reproducible - attributes: - label: How can we reproduce this? - description: | - Please share a code snippet, gist, or public repository that reproduces the issue. - Make sure to make the reproducible as concise as possible, - with only the minimum required code to reproduce the issue. - validations: - required: true - - type: textarea - id: version - attributes: - label: Which version of bubbletea are you using? - description: '' - render: bash - validations: - required: true - - type: textarea - id: terminaal - attributes: - label: Which terminals did you reproduce this with? - description: | - Other helpful information: - was it over SSH? - On tmux? - Which version of said terminal? - validations: - required: true - - type: checkboxes - id: search - attributes: - label: Search - options: - - label: | - I searched for other open and closed issues and pull requests before opening this, - and didn't find anything that seems related. - required: true - - type: textarea - id: ctx - attributes: - label: Additional context - description: Anything else you would like to add - validations: - required: false - diff --git a/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug_report.md b/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 0e7a6cb54..000000000 --- a/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**Setup** -Please complete the following information along with version numbers, if applicable. - - OS [e.g. Ubuntu, macOS] - - Shell [e.g. zsh, fish] - - Terminal Emulator [e.g. kitty, iterm] - - Terminal Multiplexer [e.g. tmux] - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Source Code** -Please include source code if needed to reproduce the behavior. - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -Add screenshots to help explain your problem. - -**Additional context** -Add any other context about the problem here. diff --git a/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/config.yml b/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 897a394e0..000000000 --- a/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,5 +0,0 @@ -blank_issues_enabled: true -contact_links: -- name: Discord - url: https://charm.sh/discord - about: Chat on our Discord. diff --git a/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/feature_request.md b/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 11fc491ef..000000000 --- a/third_party/bubbletea-v2-patched/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: enhancement -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/third_party/bubbletea-v2-patched/.github/dependabot.yml b/third_party/bubbletea-v2-patched/.github/dependabot.yml deleted file mode 100644 index 46cd07a8d..000000000 --- a/third_party/bubbletea-v2-patched/.github/dependabot.yml +++ /dev/null @@ -1,91 +0,0 @@ -version: 2 - -updates: - - package-ecosystem: "gomod" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "05:00" - timezone: "America/New_York" - labels: - - "dependencies" - commit-message: - prefix: "chore" - include: "scope" - groups: - all: - patterns: - - "*" - ignore: - - dependency-name: github.com/charmbracelet/bubbletea/v2 - versions: - - v2.0.0-beta1 - - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "05:00" - timezone: "America/New_York" - labels: - - "dependencies" - commit-message: - prefix: "chore" - include: "scope" - groups: - all: - patterns: - - "*" - - - package-ecosystem: "docker" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "05:00" - timezone: "America/New_York" - labels: - - "dependencies" - commit-message: - prefix: "chore" - include: "scope" - groups: - all: - patterns: - - "*" - - - package-ecosystem: "gomod" - directory: "/examples" - schedule: - interval: "weekly" - day: "monday" - time: "05:00" - timezone: "America/New_York" - labels: - - "dependencies" - commit-message: - prefix: "chore" - include: "scope" - groups: - all: - patterns: - - "*" - - - package-ecosystem: "gomod" - directory: "/tutorials" - schedule: - interval: "weekly" - day: "monday" - time: "05:00" - timezone: "America/New_York" - labels: - - "dependencies" - commit-message: - prefix: "chore" - include: "scope" - groups: - all: - patterns: - - "*" diff --git a/third_party/bubbletea-v2-patched/.github/workflows/build.yml b/third_party/bubbletea-v2-patched/.github/workflows/build.yml deleted file mode 100644 index b3fbbad04..000000000 --- a/third_party/bubbletea-v2-patched/.github/workflows/build.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: build -on: [push, pull_request] - -jobs: - build: - uses: charmbracelet/meta/.github/workflows/build.yml@main - - build-go-mod: - uses: charmbracelet/meta/.github/workflows/build.yml@main - with: - go-version: "" - go-version-file: ./go.mod - - build-examples: - if: github.actor != 'dependabot[bot]' - uses: charmbracelet/meta/.github/workflows/build.yml@main - with: - go-version: "" - go-version-file: ./examples/go.mod - working-directory: ./examples diff --git a/third_party/bubbletea-v2-patched/.github/workflows/coverage.yml b/third_party/bubbletea-v2-patched/.github/workflows/coverage.yml deleted file mode 100644 index 4a218734d..000000000 --- a/third_party/bubbletea-v2-patched/.github/workflows/coverage.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: coverage -on: [push, pull_request] - -jobs: - coverage: - strategy: - matrix: - go-version: [^1] - os: [ubuntu-latest] - runs-on: ${{ matrix.os }} - env: - GO111MODULE: "on" - steps: - - name: Install Go - uses: actions/setup-go@v6 - with: - go-version: ${{ matrix.go-version }} - - - name: Checkout code - uses: actions/checkout@v6 - - - name: Coverage - run: | - go test -race -covermode=atomic -coverprofile=coverage.txt ./... - - - uses: codecov/codecov-action@v6 - with: - file: ./coverage.txt - token: ${{ secrets.CODECOV_TOKEN }} diff --git a/third_party/bubbletea-v2-patched/.github/workflows/dependabot-sync.yml b/third_party/bubbletea-v2-patched/.github/workflows/dependabot-sync.yml deleted file mode 100644 index 9b082590b..000000000 --- a/third_party/bubbletea-v2-patched/.github/workflows/dependabot-sync.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: dependabot-sync -on: - schedule: - - cron: "0 0 * * 0" # every Sunday at midnight - workflow_dispatch: # allows manual triggering - -permissions: - contents: write - pull-requests: write - -jobs: - dependabot-sync: - uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main - with: - repo_name: ${{ github.event.repository.name }} - secrets: - gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} diff --git a/third_party/bubbletea-v2-patched/.github/workflows/examples.yml b/third_party/bubbletea-v2-patched/.github/workflows/examples.yml deleted file mode 100644 index d5f402b87..000000000 --- a/third_party/bubbletea-v2-patched/.github/workflows/examples.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: examples - -on: - push: - branches: - - 'master' - paths: - - '.github/workflows/examples.yml' - - './examples/go.mod' - - './examples/go.sum' - - './tutorials/go.mod' - - './tutorials/go.sum' - - './go.mod' - - './go.sum' - workflow_dispatch: {} - -jobs: - tidy: - permissions: - contents: write - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 - with: - go-version: '^1' - cache: true - - shell: bash - run: | - (cd ./examples && go mod tidy) - (cd ./tutorials && go mod tidy) - - uses: stefanzweifel/git-auto-commit-action@v7 - with: - commit_message: "chore: go mod tidy tutorials and examples" - branch: master - commit_user_name: actions-user - commit_user_email: actions@github.com - diff --git a/third_party/bubbletea-v2-patched/.github/workflows/lint-sync.yml b/third_party/bubbletea-v2-patched/.github/workflows/lint-sync.yml deleted file mode 100644 index ecf858024..000000000 --- a/third_party/bubbletea-v2-patched/.github/workflows/lint-sync.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: lint-sync -on: - schedule: - # every Sunday at midnight - - cron: "0 0 * * 0" - workflow_dispatch: # allows manual triggering - -permissions: - contents: write - pull-requests: write - -jobs: - lint: - uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main diff --git a/third_party/bubbletea-v2-patched/.github/workflows/lint.yml b/third_party/bubbletea-v2-patched/.github/workflows/lint.yml deleted file mode 100644 index d71849463..000000000 --- a/third_party/bubbletea-v2-patched/.github/workflows/lint.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: lint -on: - push: - pull_request: - -jobs: - lint: - uses: charmbracelet/meta/.github/workflows/lint.yml@main - with: - golangci_path: .golangci.yml - golangci_version: v2.9 - timeout: 10m diff --git a/third_party/bubbletea-v2-patched/.github/workflows/release.yml b/third_party/bubbletea-v2-patched/.github/workflows/release.yml deleted file mode 100644 index f649dfded..000000000 --- a/third_party/bubbletea-v2-patched/.github/workflows/release.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: goreleaser - -on: - push: - tags: - - v*.*.* - -concurrency: - group: goreleaser - cancel-in-progress: true - -jobs: - goreleaser: - uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main - secrets: - docker_username: ${{ secrets.DOCKERHUB_USERNAME }} - docker_token: ${{ secrets.DOCKERHUB_TOKEN }} - gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - goreleaser_key: ${{ secrets.GORELEASER_KEY }} - twitter_consumer_key: ${{ secrets.TWITTER_CONSUMER_KEY }} - twitter_consumer_secret: ${{ secrets.TWITTER_CONSUMER_SECRET }} - twitter_access_token: ${{ secrets.TWITTER_ACCESS_TOKEN }} - twitter_access_token_secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} - mastodon_client_id: ${{ secrets.MASTODON_CLIENT_ID }} - mastodon_client_secret: ${{ secrets.MASTODON_CLIENT_SECRET }} - mastodon_access_token: ${{ secrets.MASTODON_ACCESS_TOKEN }} - discord_webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }} - discord_webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }} -# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json diff --git a/third_party/bubbletea-v2-patched/.gitignore b/third_party/bubbletea-v2-patched/.gitignore deleted file mode 100644 index abd7c0612..000000000 --- a/third_party/bubbletea-v2-patched/.gitignore +++ /dev/null @@ -1,23 +0,0 @@ -.DS_Store -.envrc - -examples/fullscreen/fullscreen -examples/help/help -examples/http/http -examples/list-default/list-default -examples/list-fancy/list-fancy -examples/list-simple/list-simple -examples/mouse/mouse -examples/pager/pager -examples/progress-download/color_vortex.blend -examples/progress-download/progress-download -examples/simple/simple -examples/spinner/spinner -examples/textinput/textinput -examples/textinputs/textinputs -examples/views/views -tutorials/basics/basics -tutorials/commands/commands -.idea -coverage.txt -dist/ diff --git a/third_party/bubbletea-v2-patched/.golangci.yml b/third_party/bubbletea-v2-patched/.golangci.yml deleted file mode 100644 index c90f03161..000000000 --- a/third_party/bubbletea-v2-patched/.golangci.yml +++ /dev/null @@ -1,47 +0,0 @@ -version: "2" -run: - tests: false -linters: - enable: - - bodyclose - - exhaustive - - goconst - - godot - - gomoddirectives - - goprintffuncname - - gosec - - misspell - - nakedret - - nestif - - nilerr - - noctx - - nolintlint - - prealloc - - revive - - rowserrcheck - - sqlclosecheck - - tparallel - - unconvert - - unparam - - whitespace - - wrapcheck - exclusions: - rules: - - text: '(slog|log)\.\w+' - linters: - - noctx - generated: lax - presets: - - common-false-positives - settings: - exhaustive: - default-signifies-exhaustive: true -issues: - max-issues-per-linter: 0 - max-same-issues: 0 -formatters: - enable: - - gofumpt - - goimports - exclusions: - generated: lax diff --git a/third_party/bubbletea-v2-patched/.goreleaser.yml b/third_party/bubbletea-v2-patched/.goreleaser.yml deleted file mode 100644 index 3353d0202..000000000 --- a/third_party/bubbletea-v2-patched/.goreleaser.yml +++ /dev/null @@ -1,5 +0,0 @@ -# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json -version: 2 -includes: - - from_url: - url: charmbracelet/meta/main/goreleaser-lib.yaml diff --git a/third_party/bubbletea-v2-patched/README.md b/third_party/bubbletea-v2-patched/README.md deleted file mode 100644 index 4d08d355b..000000000 --- a/third_party/bubbletea-v2-patched/README.md +++ /dev/null @@ -1,402 +0,0 @@ -# Bubble Tea - -

-
- Latest Release - GoDoc - Build Status -

- -The fun, functional and stateful way to build terminal apps. A Go framework -based on [The Elm Architecture][elm]. Bubble Tea is well-suited for simple and -complex terminal applications, either inline, full-window, or a mix of both. - -

- Bubble Tea Example -

- -Bubble Tea is in use in production and includes a number of features and -performance optimizations we’ve added along the way. Among those is -a high-performance cell-based renderer, built-in color downsampling, -declarative views, high-fidelity keyboard and mouse handling, native clipboard -support, and more. - -To get started, see the tutorial below, the [examples][examples], the -[docs][docs], and some common [resources](#libraries-we-use-with-bubble-tea). - -> [!TIP] -> -> Upgrading from v1? Check out the [upgrade guide](./UPGRADE_GUIDE_V2.md), or -> point your LLM at it and let it go to town. - -## By the way - -Be sure to check out [Bubbles][bubbles], a library of common UI components for Bubble Tea. - -

- Bubbles Badge   - Text Input Example from Bubbles -

- ---- - -## Tutorial - -Bubble Tea is based on the functional design paradigms of [The Elm -Architecture][elm], which happens to work nicely with Go. It's a delightful way -to build applications. - -This tutorial assumes you have a working knowledge of Go. - -By the way, the non-annotated source code for this program is available -[on GitHub][tut-source]. - -[elm]: https://guide.elm-lang.org/architecture/ -[tut-source]: https://github.com/charmbracelet/bubbletea/tree/main/tutorials/basics - -### Enough! Let's get to it. - -For this tutorial, we're making a shopping list. - -To start we'll define our package and import some libraries. Our only external -import will be the Bubble Tea library, which we'll call `tea` for short. - -```go -package main - -// These imports will be used later in the tutorial. If you save the file -// now, Go might complain they are unused, but that's fine. -// You may also need to run `go mod tidy` to download bubbletea and its -// dependencies. -import ( - "fmt" - "os" - - tea "charm.land/bubbletea/v2" -) -``` - -Bubble Tea programs are comprised of a **model** that describes the application -state and three simple methods on that model: - -- **Init**, a function that returns an initial command for the application to run. -- **Update**, a function that handles incoming events and updates the model accordingly. -- **View**, a function that renders the UI based on the data in the model. - -### The Model - -So let's start by defining our model which will store our application's state. -It can be any type, but a `struct` usually makes the most sense. - -```go -type model struct { - choices []string // items on the to-do list - cursor int // which to-do list item our cursor is pointing at - selected map[int]struct{} // which to-do items are selected -} -``` - -## Initialization - -Next, we’ll define our application’s initial state. `Init` can return a `Cmd` -that could perform some initial I/O. For now, we don’t need to do any I/O, so -for the command, we’ll just return `nil`, which translates to “no command.” - -```go -func initialModel() model { - return model{ - // Our to-do list is a grocery list - choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"}, - - // A map which indicates which choices are selected. We're using - // the map like a mathematical set. The keys refer to the indexes - // of the `choices` slice, above. - selected: make(map[int]struct{}), - } -} -``` - -After that, we’ll define our application’s initial state in the `Init` method. `Init` -can return a `Cmd` that could perform some initial I/O. For now, we don't need -to do any I/O, so for the command, we'll just return `nil`, which translates to -"no command." - -```go -func (m model) Init() tea.Cmd { - // Just return `nil`, which means "no I/O right now, please." - return nil -} -``` - -### The Update Method - -Next up is the update method. The update function is called when “things -happen.” Its job is to look at what has happened and return an updated model in -response. It can also return a `Cmd` to make more things happen, but for now -don't worry about that part. - -In our case, when a user presses the down arrow, `Update`’s job is to notice -that the down arrow was pressed and move the cursor accordingly (or not). - -The “something happened” comes in the form of a `Msg`, which can be any type. -Messages are the result of some I/O that took place, such as a keypress, timer -tick, or a response from a server. - -We usually figure out which type of `Msg` we received with a type switch, but -you could also use a type assertion. - -For now, we'll just deal with `tea.KeyPressMsg` messages, which are -automatically sent to the update function when keys are pressed. - -```go -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - - // Is it a key press? - case tea.KeyPressMsg: - - // Cool, what was the actual key pressed? - switch msg.String() { - - // These keys should exit the program. - case "ctrl+c", "q": - return m, tea.Quit - - // The "up" and "k" keys move the cursor up - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - - // The "down" and "j" keys move the cursor down - case "down", "j": - if m.cursor < len(m.choices)-1 { - m.cursor++ - } - - // The "enter" key and the space bar toggle the selected state - // for the item that the cursor is pointing at. - case "enter", "space": - _, ok := m.selected[m.cursor] - if ok { - delete(m.selected, m.cursor) - } else { - m.selected[m.cursor] = struct{}{} - } - } - } - - // Return the updated model to the Bubble Tea runtime for processing. - // Note that we're not returning a command. - return m, nil -} -``` - -You may have noticed that ctrl+c and q above return -a `tea.Quit` command with the model. That’s a special command which instructs -the Bubble Tea runtime to quit, exiting the program. - -### The View Method - -At last, it’s time to render our UI. Of all the methods, the view is the -simplest. We look at the model in its current state and use it to build a -`tea.View`. The view declares our UI content and, optionally, terminal features -like alt screen mode, mouse tracking, cursor position, and more. - -Because the view describes the entire UI of your application, you don’t have to -worry about redrawing logic and stuff like that. Bubble Tea takes care of it -for you. - -```go -func (m model) View() tea.View { - // The header - s := "What should we buy at the market?\n\n" - - // Iterate over our choices - for i, choice := range m.choices { - - // Is the cursor pointing at this choice? - cursor := " " // no cursor - if m.cursor == i { - cursor = ">" // cursor! - } - - // Is this choice selected? - checked := " " // not selected - if _, ok := m.selected[i]; ok { - checked = "x" // selected! - } - - // Render the row - s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) - } - - // The footer - s += "\nPress q to quit.\n" - - // Send the UI for rendering - return tea.NewView(s) -} -``` - -### All Together Now - -The last step is to simply run our program. We pass our initial model to -`tea.NewProgram` and let it rip: - -```go -func main() { - p := tea.NewProgram(initialModel()) - if _, err := p.Run(); err != nil { - fmt.Printf("Alas, there's been an error: %v", err) - os.Exit(1) - } -} -``` - -## What’s Next? - -This tutorial covers the basics of building an interactive terminal UI, but -in the real world you'll also need to perform I/O. To learn about that have a -look at the [Command Tutorial][cmd]. It's pretty simple. - -There are also several [Bubble Tea examples][examples] available and, of course, -there are [Go Docs][docs]. - -[cmd]: https://github.com/charmbracelet/bubbletea/tree/main/tutorials/commands/ -[examples]: https://github.com/charmbracelet/bubbletea/tree/main/examples -[docs]: https://pkg.go.dev/charm.land/bubbletea/v2?tab=doc - -## Debugging - -### Debugging with Delve - -Since Bubble Tea apps assume control of stdin and stdout, you’ll need to run -delve in headless mode and then connect to it: - -```bash -# Start the debugger -$ dlv debug --headless --api-version=2 --listen=127.0.0.1:43000 . -API server listening at: 127.0.0.1:43000 - -# Connect to it from another terminal -$ dlv connect 127.0.0.1:43000 -``` - -If you do not explicitly supply the `--listen` flag, the port used will vary -per run, so passing this in makes the debugger easier to use from a script -or your IDE of choice. - -Additionally, we pass in `--api-version=2` because delve defaults to version 1 -for backwards compatibility reasons. However, delve recommends using version 2 -for all new development and some clients may no longer work with version 1. -For more information, see the [Delve documentation](https://github.com/go-delve/delve/tree/master/Documentation/api). - -### Logging Stuff - -You can’t really log to stdout with Bubble Tea because your TUI is busy -occupying that! You can, however, log to a file by including something like -the following prior to starting your Bubble Tea program: - -```go -if len(os.Getenv("DEBUG")) > 0 { - f, err := tea.LogToFile("debug.log", "debug") - if err != nil { - fmt.Println("fatal:", err) - os.Exit(1) - } - defer f.Close() -} -``` - -To see what’s being logged in real time, run `tail -f debug.log` while you run -your program in another window. - -## Libraries we use with Bubble Tea - -- [Bubbles][bubbles]: Common Bubble Tea components such as text inputs, viewports, spinners and so on -- [Lip Gloss][lipgloss]: Style, format and layout tools for terminal applications -- [Harmonica][harmonica]: A spring animation library for smooth, natural motion -- [BubbleZone][bubblezone]: Easy mouse event tracking for Bubble Tea components -- [ntcharts][ntcharts]: A terminal charting library built for Bubble Tea and [Lip Gloss][lipgloss] - -[bubbles]: https://github.com/charmbracelet/bubbles -[lipgloss]: https://github.com/charmbracelet/lipgloss -[harmonica]: https://github.com/charmbracelet/harmonica -[bubblezone]: https://github.com/lrstanley/bubblezone -[ntcharts]: https://github.com/NimbleMarkets/ntcharts - -## Bubble Tea in the Wild - -There are over [18,000 applications](https://github.com/charmbracelet/bubbletea/network/dependents) built with Bubble Tea! Here are a handful of ’em. - -### Staff favourites - -- [chezmoi](https://github.com/twpayne/chezmoi): securely manage your dotfiles across multiple machines -- [circumflex](https://github.com/bensadeh/circumflex): read Hacker News in the terminal -- [gh-dash](https://www.github.com/dlvhdr/gh-dash): a GitHub CLI extension for PRs and issues -- [Tetrigo](https://github.com/Broderick-Westrope/tetrigo): Tetris in the terminal -- [Signls](https://github.com/emprcl/signls): a generative midi sequencer designed for composition and live performance -- [Superfile](https://github.com/yorukot/superfile): a super file manager - -### In Industry - -- Microsoft Azure – [Aztify](https://github.com/Azure/aztfy): bring Microsoft Azure resources under Terraform -- Daytona – [Daytona](https://github.com/daytonaio/daytona): an AI infrastructure platform -- Cockroach Labs – [CockroachDB](https://github.com/cockroachdb/cockroach): a cloud-native, high-availability distributed SQL database -- Truffle Security Co. – [Trufflehog](https://github.com/trufflesecurity/trufflehog): find leaked credentials -- NVIDIA – [container-canary](https://github.com/NVIDIA/container-canary): a container validator -- AWS – [eks-node-viewer](https://github.com/awslabs/eks-node-viewer): a tool for visualizing dynamic node usage within an EKS cluster -- MinIO – [mc](https://github.com/minio/mc): the official [MinIO](https://min.io) client -- Ubuntu – [Authd](https://github.com/ubuntu/authd): an authentication daemon for cloud-based identity providers - -### Charm stuff - -- [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser, and online markdown stash -- [Huh?](https://github.com/charmbracelet/huh): an interactive prompt and form toolkit -- [Mods](https://github.com/charmbracelet/mods): AI on the CLI, built for pipelines -- [Wishlist](https://github.com/charmbracelet/wishlist): an SSH directory (and bastion!) - -### There’s so much more where that came from - -For more applications built with Bubble Tea see [Charm & Friends][community]. -Is there something cool you made with Bubble Tea you want to share? [PRs][community] are -welcome! - -## Contributing - -See [contributing][contribute]. - -[contribute]: https://github.com/charmbracelet/bubbletea/contribute - -## Feedback - -We’d love to hear your thoughts on this project. Feel free to drop us a note! - -- [Twitter](https://twitter.com/charmcli) -- [The Fediverse](https://mastodon.social/@charmcli) -- [Discord](https://charm.sh/chat) - -## Acknowledgments - -Bubble Tea is based on the paradigms of [The Elm Architecture][elm] by Evan -Czaplicki et alia and the excellent [go-tea][gotea] by TJ Holowaychuk. It’s -inspired by the many great [_Zeichenorientierte Benutzerschnittstellen_][zb] -of days past. - -[elm]: https://guide.elm-lang.org/architecture/ -[gotea]: https://github.com/tj/go-tea -[zb]: https://de.wikipedia.org/wiki/Zeichenorientierte_Benutzerschnittstelle -[community]: https://github.com/charm-and-friends/charm-in-the-wild - -## License - -[MIT](https://github.com/charmbracelet/bubbletea/raw/main/LICENSE) - ---- - -Part of [Charm](https://charm.sh). - -The Charm logo - -Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة diff --git a/third_party/bubbletea-v2-patched/Taskfile.yaml b/third_party/bubbletea-v2-patched/Taskfile.yaml deleted file mode 100644 index 10e47f306..000000000 --- a/third_party/bubbletea-v2-patched/Taskfile.yaml +++ /dev/null @@ -1,38 +0,0 @@ -# https://taskfile.dev - -version: "3" - -tasks: - lint: - desc: Run lint - cmds: - - golangci-lint run - - test: - desc: Run tests - cmds: - - go test -race -count 4 -cpu 1,4 ./... {{.CLI_ARGS}} - - release: - desc: Create and push a new tag following semver - vars: - NEXT: - sh: svu next --always || go run github.com/caarlos0/svu/v3@latest next --always - prompt: "This will release {{.NEXT}}. Continue?" - preconditions: - - sh: '[ $(git symbolic-ref --short HEAD) = "main" ]' - msg: Not on main branch - - sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]" - msg: "Git is dirty" - - sh: 'gh run list --workflow build.yml --commit $(git rev-parse HEAD) --status success --json conclusion -q ".[0].conclusion" | grep -q success' - msg: "Test build for this commit failed or not present" - cmds: - - task: fetch-tags - - git commit --allow-empty -m "{{.NEXT}}" - - git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}} - - echo "Pushing {{.NEXT}}..." - - git push origin main --follow-tags - - fetch-tags: - cmds: - - git fetch --tags diff --git a/third_party/bubbletea-v2-patched/UPGRADE_GUIDE_V2.md b/third_party/bubbletea-v2-patched/UPGRADE_GUIDE_V2.md deleted file mode 100644 index 2ca7b0a08..000000000 --- a/third_party/bubbletea-v2-patched/UPGRADE_GUIDE_V2.md +++ /dev/null @@ -1,573 +0,0 @@ -# Bubble Tea v2 Upgrade Guide - -This guide covers everything you need to change when upgrading from Bubble Tea v1 to v2. For a tour of all the exciting new features, check out the [What's New](https://github.com/charmbracelet/bubbletea/releases/tag/v2.0.0) doc. - -> [!NOTE] -> We don't take API changes lightly and strive to make the upgrade process as simple as possible. If something feels way off, let us know. - -## Migration Checklist - -Here's the short version — a checklist you can follow top to bottom. Each item links to the relevant section below. - -- [ ] [Update import paths](#import-paths) -- [ ] [Change `View() string` to `View() tea.View`](#view-returns-a-teaview-now) -- [ ] [Replace `tea.KeyMsg` with `tea.KeyPressMsg`](#key-messages) -- [ ] [Update key fields: `msg.Type` / `msg.Runes` / `msg.Alt`](#key-messages) -- [ ] [Replace `case " ":` with `case "space":`](#key-messages) -- [ ] [Update mouse message usage](#mouse-messages) -- [ ] [Rename mouse button constants](#mouse-messages) -- [ ] [Remove old program options → use View fields](#removed-program-options) -- [ ] [Remove imperative commands → use View fields](#removed-commands) -- [ ] [Remove old program methods](#removed-program-methods) -- [ ] [Rename `tea.WindowSize()` → `tea.RequestWindowSize`](#renamed-apis) -- [ ] [Replace `tea.Sequentially(...)` → `tea.Sequence(...)`](#renamed-apis) - -## Import Paths - -The module path changed to a vanity domain. Lip Gloss moved too. - -```go -// Before -import tea "github.com/charmbracelet/bubbletea" -import "github.com/charmbracelet/lipgloss" - -// After -import tea "charm.land/bubbletea/v2" -import "charm.land/lipgloss/v2" -``` - -## The Big Idea: Declarative Views - -The single biggest change in v2 is the shift from **imperative commands** to **declarative View fields**. In v1, you'd use program options like `tea.WithAltScreen()` and commands like `tea.EnterAltScreen` to toggle terminal features on and off. In v2, you just set fields on the `tea.View` struct in your `View()` method and Bubble Tea handles the rest. - -This means: no more startup option flags, no more toggle commands, no more fighting over state. Just declare what you want and Bubble Tea will make it so. - -```go -// v1: imperative — scattered across NewProgram, Init, and Update -p := tea.NewProgram(model{}, tea.WithAltScreen(), tea.WithMouseCellMotion()) - -// v2: declarative — everything lives in View() -func (m model) View() tea.View { - v := tea.NewView("Hello!") - v.AltScreen = true - v.MouseMode = tea.MouseModeCellMotion - return v -} -``` - -Keep this in mind as you go through the rest of the guide — most of the "removed" things simply moved into View fields. - -## View Returns a `tea.View` Now - -The `View()` method no longer returns a `string`. It returns a `tea.View` struct. - -```go -// Before: -func (m model) View() string { - return "Hello, world!" -} - -// After: -func (m model) View() tea.View { - return tea.NewView("Hello, world!") -} -``` - -You can also use the longer form if you need to set additional fields: - -```go -func (m model) View() tea.View { - var v tea.View - v.SetContent("Hello, world!") - v.AltScreen = true - return v -} -``` - -The `tea.View` struct has fields for everything that used to be controlled by options and commands: - -| View Field | What It Does | -|---|---| -| `Content` | The rendered string (set via `SetContent()` or `NewView()`) | -| `AltScreen` | Enter/exit the alternate screen buffer | -| `MouseMode` | `MouseModeNone`, `MouseModeCellMotion`, or `MouseModeAllMotion` | -| `ReportFocus` | Enable focus/blur event reporting | -| `DisableBracketedPasteMode` | Disable bracketed paste | -| `WindowTitle` | Set the terminal window title | -| `Cursor` | Control cursor position, shape, color, and blink | -| `ForegroundColor` | Set the terminal foreground color | -| `BackgroundColor` | Set the terminal background color | -| `ProgressBar` | Show a native terminal progress bar | -| `KeyboardEnhancements` | Request keyboard enhancement features | -| `OnMouse` | Intercept mouse messages based on view content | - -## Key Messages - -Key messages got a major overhaul. Here's the quick rundown: - -### `tea.KeyMsg` is now an interface - -In v1, `tea.KeyMsg` was a struct you'd match on for key presses. In v2, it's an **interface** that covers both key presses and releases. For most code, you want `tea.KeyPressMsg`: - -```go -// Before: -case tea.KeyMsg: - switch msg.String() { - case "q": - return m, tea.Quit - } - -// After: -case tea.KeyPressMsg: - switch msg.String() { - case "q": - return m, tea.Quit - } -``` - -If you want to handle both presses and releases, use `tea.KeyMsg` and type-switch inside: - -```go -case tea.KeyMsg: - switch key := msg.(type) { - case tea.KeyPressMsg: - // key press - case tea.KeyReleaseMsg: - // key release - } -``` - -### Key fields changed - -| v1 | v2 | Notes | -|---|---|---| -| `msg.Type` | `msg.Code` | A `rune` — can be `tea.KeyEnter`, `'a'`, etc. | -| `msg.Runes` | `msg.Text` | Now a `string`, not `[]rune` | -| `msg.Alt` | `msg.Mod` | `msg.Mod.Contains(tea.ModAlt)` for alt, etc. | -| `tea.KeyRune` | — | Check `len(msg.Text) > 0` instead | -| `tea.KeyCtrlC` | — | Use `msg.String() == "ctrl+c"` or check `msg.Code` + `msg.Mod` | - -### Space bar changed - -Space bar now returns `"space"` instead of `" "` when using `msg.String()`: - -```go -// Before: -case " ": - -// After: -case "space": -``` - -`key.Code` is still `' '` and `key.Text` is still `" "`, but `String()` returns `"space"`. - -### Ctrl+key matching - -```go -// Before: -case tea.KeyCtrlC: - // ctrl+c - -// After (option A — string matching): -case tea.KeyPressMsg: - switch msg.String() { - case "ctrl+c": - // ctrl+c - } - -// After (option B — field matching): -case tea.KeyPressMsg: - if msg.Code == 'c' && msg.Mod == tea.ModCtrl { - // ctrl+c - } -``` - -### New Key fields - -These are new in v2 and don't have v1 equivalents: - -- **`key.ShiftedCode`** — the shifted key code (e.g., `'B'` when pressing shift+b) -- **`key.BaseCode`** — the key on a US PC-101 layout (handy for international keyboards) -- **`key.IsRepeat`** — whether the key is auto-repeating (Kitty protocol / Windows Console only) -- **`key.Keystroke()`** — like `String()` but always includes modifier info - -## Paste Messages - -Paste events no longer come in as `tea.KeyMsg` with a `Paste` flag. They're now their own message types: - -```go -// Before: -case tea.KeyMsg: - if msg.Paste { - m.text += string(msg.Runes) - } - -// After: -case tea.PasteMsg: - m.text += msg.Content -case tea.PasteStartMsg: - // paste started -case tea.PasteEndMsg: - // paste ended -``` - -## Mouse Messages - -### `tea.MouseMsg` is now an interface - -In v1, `tea.MouseMsg` was a struct with `X`, `Y`, `Button`, etc. In v2, it's an **interface**. You get the coordinates by calling `msg.Mouse()`: - -```go -// Before: -case tea.MouseMsg: - x, y := msg.X, msg.Y - -// After: -case tea.MouseMsg: - mouse := msg.Mouse() - x, y := mouse.X, mouse.Y -``` - -### Mouse events are split by type - -Instead of checking `msg.Action`, match on specific message types: - -```go -// Before: -case tea.MouseMsg: - if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { - // left click - } - -// After: -case tea.MouseClickMsg: - if msg.Button == tea.MouseLeft { - // left click - } -case tea.MouseReleaseMsg: - // release -case tea.MouseWheelMsg: - // scroll -case tea.MouseMotionMsg: - // movement -``` - -### Button constants renamed - -| v1 | v2 | -|---|---| -| `tea.MouseButtonLeft` | `tea.MouseLeft` | -| `tea.MouseButtonRight` | `tea.MouseRight` | -| `tea.MouseButtonMiddle` | `tea.MouseMiddle` | -| `tea.MouseButtonWheelUp` | `tea.MouseWheelUp` | -| `tea.MouseButtonWheelDown` | `tea.MouseWheelDown` | -| `tea.MouseButtonWheelLeft` | `tea.MouseWheelLeft` | -| `tea.MouseButtonWheelRight` | `tea.MouseWheelRight` | - -### `tea.MouseEvent` → `tea.Mouse` - -The `MouseEvent` struct is gone. The new `Mouse` struct has `X`, `Y`, `Button`, and `Mod` fields. - -### Mouse mode is now a View field - -```go -// Before: -p := tea.NewProgram(model{}, tea.WithMouseCellMotion()) - -// After: -func (m model) View() tea.View { - v := tea.NewView("...") - v.MouseMode = tea.MouseModeCellMotion - return v -} -``` - -## Removed Program Options - -These options no longer exist. They all moved to View fields. - -| Removed Option | Do This Instead | -|---|---| -| `tea.WithAltScreen()` | `view.AltScreen = true` | -| `tea.WithMouseCellMotion()` | `view.MouseMode = tea.MouseModeCellMotion` | -| `tea.WithMouseAllMotion()` | `view.MouseMode = tea.MouseModeAllMotion` | -| `tea.WithReportFocus()` | `view.ReportFocus = true` | -| `tea.WithoutBracketedPaste()` | `view.DisableBracketedPasteMode = true` | -| `tea.WithInputTTY()` | Just remove it — v2 always opens the TTY for input automatically | -| `tea.WithANSICompressor()` | Just remove it — the new renderer handles optimization automatically | - -## Removed Commands - -These commands no longer exist. Set the corresponding View field instead. - -| Removed Command | Do This Instead | -|---|---| -| `tea.EnterAltScreen` | `view.AltScreen = true` | -| `tea.ExitAltScreen` | `view.AltScreen = false` | -| `tea.EnableMouseCellMotion` | `view.MouseMode = tea.MouseModeCellMotion` | -| `tea.EnableMouseAllMotion` | `view.MouseMode = tea.MouseModeAllMotion` | -| `tea.DisableMouse` | `view.MouseMode = tea.MouseModeNone` | -| `tea.HideCursor` | `view.Cursor = nil` | -| `tea.ShowCursor` | `view.Cursor = &tea.Cursor{...}` or `tea.NewCursor(x, y)` | -| `tea.EnableBracketedPaste` | `view.DisableBracketedPasteMode = false` | -| `tea.DisableBracketedPaste` | `view.DisableBracketedPasteMode = true` | -| `tea.EnableReportFocus` | `view.ReportFocus = true` | -| `tea.DisableReportFocus` | `view.ReportFocus = false` | -| `tea.SetWindowTitle("...")` | `view.WindowTitle = "..."` | - -## Removed Program Methods - -These methods on `*Program` are gone. - -| Removed Method | Do This Instead | -|---|---| -| `p.Start()` | `p.Run()` | -| `p.StartReturningModel()` | `p.Run()` | -| `p.EnterAltScreen()` | `view.AltScreen = true` in `View()` | -| `p.ExitAltScreen()` | `view.AltScreen = false` in `View()` | -| `p.EnableMouseCellMotion()` | `view.MouseMode` in `View()` | -| `p.DisableMouseCellMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` | -| `p.EnableMouseAllMotion()` | `view.MouseMode` in `View()` | -| `p.DisableMouseAllMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` | -| `p.SetWindowTitle(...)` | `view.WindowTitle` in `View()` | - -## Renamed APIs - -| v1 | v2 | Notes | -|---|---|---| -| `tea.Sequentially(...)` | `tea.Sequence(...)` | `Sequentially` was already deprecated in v1 | -| `tea.WindowSize()` | `tea.RequestWindowSize` | Now returns `Msg` directly, not a `Cmd` | - -## New Program Options - -These are new in v2: - -| Option | What It Does | -|---|---| -| `tea.WithColorProfile(p)` | Force a specific color profile (great for testing) | -| `tea.WithWindowSize(w, h)` | Set initial terminal size (great for testing) | - -## Complete Before & After - -Here's a minimal but complete program showing the most common migration patterns side by side. - -**v1:** - -```go -package main - -import ( - "fmt" - "os" - - tea "github.com/charmbracelet/bubbletea" -) - -type model struct { - count int -} - -func (m model) Init() tea.Cmd { - return nil -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case " ": - m.count++ - } - case tea.MouseMsg: - if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { - m.count++ - } - } - return m, nil -} - -func (m model) View() string { - return fmt.Sprintf("Count: %d\n\nSpace or click to increment. q to quit.\n", m.count) -} - -func main() { - p := tea.NewProgram(model{}, tea.WithAltScreen(), tea.WithMouseCellMotion()) - if _, err := p.Run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} -``` - -**v2:** - -```go -package main - -import ( - "fmt" - "os" - - tea "charm.land/bubbletea/v2" -) - -type model struct { - count int -} - -func (m model) Init() tea.Cmd { - return nil -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "space": - m.count++ - } - case tea.MouseClickMsg: - if msg.Button == tea.MouseLeft { - m.count++ - } - } - return m, nil -} - -func (m model) View() tea.View { - v := tea.NewView(fmt.Sprintf("Count: %d\n\nSpace or click to increment. q to quit.\n", m.count)) - v.AltScreen = true - v.MouseMode = tea.MouseModeCellMotion - return v -} - -func main() { - p := tea.NewProgram(model{}) - if _, err := p.Run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} -``` - -Notice how the `NewProgram` call got simpler? All the terminal feature flags moved into `View()` where they belong. - -## Quick Reference - -A flat old → new lookup table. Handy for search-and-replace and LLM-assisted migration. - -### Import Paths - -| v1 | v2 | -|---|---| -| `github.com/charmbracelet/bubbletea` | `charm.land/bubbletea/v2` | -| `github.com/charmbracelet/lipgloss` | `charm.land/lipgloss/v2` | - -### Model Interface - -| v1 | v2 | -|---|---| -| `View() string` | `View() tea.View` | - -### Key Events - -| v1 | v2 | -|---|---| -| `tea.KeyMsg` (struct) | `tea.KeyPressMsg` for presses, `tea.KeyMsg` (interface) for both | -| `msg.Type` | `msg.Code` | -| `msg.Runes` | `msg.Text` (string, not `[]rune`) | -| `msg.Alt` | `msg.Mod.Contains(tea.ModAlt)` | -| `tea.KeyRune` | check `len(msg.Text) > 0` | -| `tea.KeyCtrlC` | `msg.Code == 'c' && msg.Mod == tea.ModCtrl` or `msg.String() == "ctrl+c"` | -| `case " ":` (space) | `case "space":` | - -### Mouse Events - -| v1 | v2 | -|---|---| -| `tea.MouseMsg` (struct) | `tea.MouseMsg` (interface) — call `.Mouse()` for the data | -| `tea.MouseEvent` | `tea.Mouse` | -| `tea.MouseButtonLeft` | `tea.MouseLeft` | -| `tea.MouseButtonRight` | `tea.MouseRight` | -| `tea.MouseButtonMiddle` | `tea.MouseMiddle` | -| `tea.MouseButtonWheelUp` | `tea.MouseWheelUp` | -| `tea.MouseButtonWheelDown` | `tea.MouseWheelDown` | -| `msg.X`, `msg.Y` (direct) | `msg.Mouse().X`, `msg.Mouse().Y` | - -### Options → View Fields - -| v1 Option | v2 View Field | -|---|---| -| `tea.WithAltScreen()` | `view.AltScreen = true` | -| `tea.WithMouseCellMotion()` | `view.MouseMode = tea.MouseModeCellMotion` | -| `tea.WithMouseAllMotion()` | `view.MouseMode = tea.MouseModeAllMotion` | -| `tea.WithReportFocus()` | `view.ReportFocus = true` | -| `tea.WithoutBracketedPaste()` | `view.DisableBracketedPasteMode = true` | - -### Commands → View Fields - -| v1 Command | v2 View Field | -|---|---| -| `tea.EnterAltScreen` / `tea.ExitAltScreen` | `view.AltScreen = true/false` | -| `tea.EnableMouseCellMotion` | `view.MouseMode = tea.MouseModeCellMotion` | -| `tea.EnableMouseAllMotion` | `view.MouseMode = tea.MouseModeAllMotion` | -| `tea.DisableMouse` | `view.MouseMode = tea.MouseModeNone` | -| `tea.HideCursor` / `tea.ShowCursor` | `view.Cursor = nil` / `view.Cursor = &tea.Cursor{...}` | -| `tea.EnableBracketedPaste` / `tea.DisableBracketedPaste` | `view.DisableBracketedPasteMode = false/true` | -| `tea.EnableReportFocus` / `tea.DisableReportFocus` | `view.ReportFocus = true/false` | -| `tea.SetWindowTitle("...")` | `view.WindowTitle = "..."` | - -### Removed Options (No Replacement Needed) - -| v1 Option | What Happened | -|---|---| -| `tea.WithInputTTY()` | v2 always opens the TTY for input automatically | -| `tea.WithANSICompressor()` | The new renderer handles optimization automatically | - -### Removed Program Methods - -| v1 Method | v2 Replacement | -|---|---| -| `p.Start()` | `p.Run()` | -| `p.StartReturningModel()` | `p.Run()` | -| `p.EnterAltScreen()` | `view.AltScreen = true` in `View()` | -| `p.ExitAltScreen()` | `view.AltScreen = false` in `View()` | -| `p.EnableMouseCellMotion()` | `view.MouseMode` in `View()` | -| `p.DisableMouseCellMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` | -| `p.EnableMouseAllMotion()` | `view.MouseMode` in `View()` | -| `p.DisableMouseAllMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` | -| `p.SetWindowTitle(...)` | `view.WindowTitle` in `View()` | - -### Other Renames - -| v1 | v2 | -|---|---| -| `tea.Sequentially(...)` | `tea.Sequence(...)` | -| `tea.WindowSize()` | `tea.RequestWindowSize` (now returns `Msg`, not `Cmd`) | - -### New Program Options - -| Option | Description | -|---|---| -| `tea.WithColorProfile(p)` | Force a specific color profile | -| `tea.WithWindowSize(w, h)` | Set initial window size (great for testing) | - -## Feedback - -Have thoughts on the v2 upgrade? We'd _love_ to hear about it. Let us know on… - -- [Discord](https://charm.land/chat) -- [Matrix](https://charm.land/matrix) -- [Email](mailto:vt100@charm.land) - ---- - -Part of [Charm](https://charm.land). - -The Charm logo - -Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة From d21091a27eccf8704d5c69a3d7276af730757add Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:04:47 -0400 Subject: [PATCH 04/10] fix(tui): address review round on the rendering workaround and vendored fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork correctness fixes (all flagged by review): - key.go: KeyMediaRecord repeated the previous const expression and decoded record presses as previous-track; map it to uv.KeyMediaRecord. - mouse.go: MouseButton11 likewise collapsed onto MouseButton10. - termios_unix.go: BSDLY/BS0 are output-delay bits; read them from Oflag instead of Lflag so useBackspace is set correctly. - cursed_renderer.go: count wrapped rows with (lineWidth-1)/w so an exact terminal-width multiple no longer inserts a blank line; compare colors by normalized RGBA via a nil-safe helper instead of interface equality (which panics on non-comparable implementations); and make clearScreen invalidate lastView so the queued erase flushes with the next frame instead of deferring until an unrelated view change — the reordering that could leave the ghost caret in place. - exec_test.go: implement reset() on the spy renderer and inject it before Run; assert exec preserves renderer state (releaseTerminal with reset=false). - logging_test.go: restore the default logger's output/prefix/flags and fail fast on setup errors. - tea_test.go: tie the Quit-pumping goroutine to a channel closed when Run returns instead of leaking it until process exit. TUI scoping (review P2): the forced redraw on streamed newlines is now rate-limited to ~10/s so heavy streaming output no longer turns every newline into a full-screen repaint, and ZERO_NO_STREAM_CLEAR=1 opts a correctly-rendering terminal back into the fast incremental path. Co-Authored-By: Claude Fable 5 --- internal/tui/model.go | 28 ++++++++++++++-- .../bubbletea-v2-patched/cursed_renderer.go | 32 ++++++++++++++++--- third_party/bubbletea-v2-patched/exec_test.go | 22 ++++++++++--- third_party/bubbletea-v2-patched/key.go | 2 +- third_party/bubbletea-v2-patched/mouse.go | 2 +- third_party/bubbletea-v2-patched/tea_test.go | 15 +++++++-- .../bubbletea-v2-patched/termios_unix.go | 2 +- 7 files changed, 85 insertions(+), 18 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 9a8bc9d93..9c3added7 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -377,7 +377,15 @@ type model struct { lastStreamActivity time.Time fadeActive bool fadeDisabled bool // streaming fade off (ZERO_NO_FADE / SSH / tmux / low-color / reduced motion) - reducedMotion bool // ZERO_REDUCED_MOTION / no-TTY: static spinner glyph, no fade + // streamClearDisabled turns off the full-redraw-on-streamed-newline + // workaround for terminals that render scroll regions correctly + // (ZERO_NO_STREAM_CLEAR=1). lastStreamClear rate-limits the redraws the + // workaround schedules so heavy streaming output (code, logs, diffs) + // coalesces to a bounded number of repaints per second instead of one + // per newline. + streamClearDisabled bool + lastStreamClear time.Time + reducedMotion bool // ZERO_REDUCED_MOTION / no-TTY: static spinner glyph, no fade // In-progress tool call whose arguments are streaming (a file being written), // shown live by streamingToolCallView so a long write/edit isn't a frozen // spinner. Cleared when the call completes (next text/turn) — see updateModel. @@ -887,6 +895,12 @@ func newModel(ctx context.Context, options Options) model { // Streaming text always renders statically at base ink (the disabled path in // styleStreamingLine), so no accent glow and no per-line fade ticks. m.fadeDisabled = true + // Terminals that handle scroll regions correctly can opt back into the + // fast incremental path; the redraw workaround (see the ClearScreen + // scheduling in updateModel) is otherwise on, rate-limited. + if v := strings.TrimSpace(os.Getenv("ZERO_NO_STREAM_CLEAR")); v != "" && v != "0" && !strings.EqualFold(v, "false") { + m.streamClearDisabled = true + } // One session-long LSP manager (cheap to build — servers start lazily on the // first Check), reused across prompts so gopls stays warm between turns. if cwd != "" { @@ -1991,8 +2005,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // caret's old cell when a newline moves it to a new line, leaving // ghost carets behind. A newline is exactly the moment that risk // exists, so force one full-screen redraw right then rather than - // leaving it to the incremental diff. - if strings.Contains(msg.delta, "\n") { + // leaving it to the incremental diff. Rate-limited: heavy streaming + // output (code, logs, diffs) would otherwise turn every coalesced + // newline into a full-screen repaint, a real throughput/latency cost + // on SSH and slow links. ~10 redraws/second is enough to keep the + // caret clean without dominating the write path; terminals that + // render scroll regions correctly can opt out entirely with + // ZERO_NO_STREAM_CLEAR=1. + if strings.Contains(msg.delta, "\n") && !m.streamClearDisabled && + time.Since(m.lastStreamClear) >= 100*time.Millisecond { + m.lastStreamClear = time.Now() cmds = append(cmds, tea.ClearScreen) } // The fade's tick is self-perpetuating (the streamingFadeTickMsg diff --git a/third_party/bubbletea-v2-patched/cursed_renderer.go b/third_party/bubbletea-v2-patched/cursed_renderer.go index 5e4982040..e2df5bf17 100644 --- a/third_party/bubbletea-v2-patched/cursed_renderer.go +++ b/third_party/bubbletea-v2-patched/cursed_renderer.go @@ -417,7 +417,7 @@ func (s *cursedRenderer) flush(closing bool) error { {newColor: view.ForegroundColor, oldColor: lfg, reset: ansi.ResetForegroundColor, setter: ansi.SetForegroundColor}, {newColor: view.BackgroundColor, oldColor: lbg, reset: ansi.ResetBackgroundColor, setter: ansi.SetBackgroundColor}, } { - if c.newColor != c.oldColor { + if !colorsEqual(c.newColor, c.oldColor) { if c.newColor == nil { // Reset the color if it was set to nil. _, _ = s.scr.WriteString(c.reset) @@ -645,6 +645,11 @@ func (s *cursedRenderer) clearScreen() { // screen redraw. s.scr.MoveTo(0, 0) s.scr.Erase() + // Invalidate the last view: without this, a render of an unchanged view + // short-circuits on viewEquals and the queued erase sits in the buffer + // until an unrelated view change (or shutdown) flushes it — exactly the + // reordering that leaves the ghost caret this call is meant to repair. + s.lastView = nil s.mu.Unlock() } @@ -736,7 +741,11 @@ func (s *cursedRenderer) insertAbove(str string) error { for _, line := range lines { lineWidth := ansi.StringWidth(line) if w > 0 && lineWidth > w { - offset += (lineWidth / w) + // Rows beyond the first: ceil(lineWidth/w) - 1. Plain + // lineWidth/w over-counts an exact multiple (a 160-cell line in + // an 80-column terminal needs one extra row, not two), leaving + // blank lines in unmanaged output. + offset += (lineWidth - 1) / w } } @@ -808,6 +817,19 @@ func setProgressBar(s *cursedRenderer, pb *ProgressBar) { } } +// colorsEqual compares two possibly-nil color.Color values by normalized +// RGBA. Direct interface equality (==) panics when a caller supplies a +// non-comparable implementation and treats equal colors held in different +// concrete types as different. +func colorsEqual(a, b color.Color) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + ar, ag, ab, aa := a.RGBA() + br, bg, bb, ba := b.RGBA() + return ar == br && ag == bg && ab == bb && aa == ba +} + func viewEquals(a, b *View) bool { if a == nil || b == nil { return false @@ -819,8 +841,8 @@ func viewEquals(a, b *View) bool { a.ReportFocus != b.ReportFocus || a.MouseMode != b.MouseMode || a.WindowTitle != b.WindowTitle || - a.ForegroundColor != b.ForegroundColor || - a.BackgroundColor != b.BackgroundColor || + !colorsEqual(a.ForegroundColor, b.ForegroundColor) || + !colorsEqual(a.BackgroundColor, b.BackgroundColor) || a.KeyboardEnhancements != b.KeyboardEnhancements { return false } @@ -833,7 +855,7 @@ func viewEquals(a, b *View) bool { a.Cursor.Y != b.Cursor.Y || a.Cursor.Shape != b.Cursor.Shape || a.Cursor.Blink != b.Cursor.Blink || - a.Cursor.Color != b.Cursor.Color { + !colorsEqual(a.Cursor.Color, b.Cursor.Color) { return false } } diff --git a/third_party/bubbletea-v2-patched/exec_test.go b/third_party/bubbletea-v2-patched/exec_test.go index ba76f9197..649d358e7 100644 --- a/third_party/bubbletea-v2-patched/exec_test.go +++ b/third_party/bubbletea-v2-patched/exec_test.go @@ -50,6 +50,13 @@ type spyRenderer struct { calledReset bool } +func (s *spyRenderer) reset() { + s.calledReset = true + if s.renderer != nil { + s.renderer.reset() + } +} + func successExecCommand() *exec.Cmd { if runtime.GOOS == "windows" { return exec.Command("cmd", "/c", "exit 0") @@ -99,21 +106,26 @@ func TestTeaExec(t *testing.T) { WithInput(&in), WithOutput(&buf), ) + // Inject the spy BEFORE Run so it observes execution-time + // operations; assigning it afterwards records nothing. + spy := &spyRenderer{renderer: &nilRenderer{}} + p.renderer = spy if _, err := p.Run(); err != nil { t.Error(err) } - p.renderer = &spyRenderer{renderer: p.renderer} if m.err != nil && !test.expectErr { t.Errorf("expected no error, got %v", m.err) - - if !p.renderer.(*spyRenderer).calledReset { - t.Error("expected renderer to be reset") - } } if m.err == nil && test.expectErr { t.Error("expected error, got nil") } + // Exec hands the terminal to the child via releaseTerminal(false): + // the renderer must NOT be reset, or the resumed TUI would repaint + // from scratch instead of continuing where it left off. + if spy.calledReset { + t.Error("exec run reset the renderer; expected releaseTerminal(false) to preserve its state") + } }) } } diff --git a/third_party/bubbletea-v2-patched/key.go b/third_party/bubbletea-v2-patched/key.go index b16e53c2f..d6798ee6a 100644 --- a/third_party/bubbletea-v2-patched/key.go +++ b/third_party/bubbletea-v2-patched/key.go @@ -152,7 +152,7 @@ const ( KeyMediaRewind = uv.KeyMediaRewind KeyMediaNext = uv.KeyMediaNext KeyMediaPrev = uv.KeyMediaPrev - KeyMediaRecord + KeyMediaRecord = uv.KeyMediaRecord KeyLowerVol = uv.KeyLowerVol KeyRaiseVol = uv.KeyRaiseVol diff --git a/third_party/bubbletea-v2-patched/mouse.go b/third_party/bubbletea-v2-patched/mouse.go index 05b83128b..ee28c7fdc 100644 --- a/third_party/bubbletea-v2-patched/mouse.go +++ b/third_party/bubbletea-v2-patched/mouse.go @@ -38,7 +38,7 @@ const ( MouseBackward = uv.MouseBackward MouseForward = uv.MouseForward MouseButton10 = uv.MouseButton10 - MouseButton11 + MouseButton11 = uv.MouseButton11 ) // MouseMsg represents a mouse message. This is a generic mouse message that diff --git a/third_party/bubbletea-v2-patched/tea_test.go b/third_party/bubbletea-v2-patched/tea_test.go index 8d81c77b0..f8be8b08a 100644 --- a/third_party/bubbletea-v2-patched/tea_test.go +++ b/third_party/bubbletea-v2-patched/tea_test.go @@ -252,14 +252,25 @@ func testTeaWithFilter(t *testing.T, preventCount uint32) { return msg } + // The loop condition alone never turns false (shutdowns stays equal to + // preventCount after the final accepted Quit), so tie the goroutine to a + // channel closed when Run returns instead of leaking it until process + // exit. + done := make(chan struct{}) go func() { for atomic.LoadUint32(&shutdowns) <= preventCount { - time.Sleep(time.Millisecond) + select { + case <-done: + return + case <-time.After(time.Millisecond): + } p.Quit() } }() - if _, err := p.Run(); err != nil { + _, err := p.Run() + close(done) + if err != nil { t.Fatal(err) } if shutdowns != preventCount { diff --git a/third_party/bubbletea-v2-patched/termios_unix.go b/third_party/bubbletea-v2-patched/termios_unix.go index 3ee0e9948..e322141b7 100644 --- a/third_party/bubbletea-v2-patched/termios_unix.go +++ b/third_party/bubbletea-v2-patched/termios_unix.go @@ -10,5 +10,5 @@ import ( func (p *Program) checkOptimizedMovements(s *term.State) { p.useHardTabs = s.Oflag&unix.TABDLY == unix.TAB0 - p.useBackspace = s.Lflag&unix.BSDLY == unix.BS0 + p.useBackspace = s.Oflag&unix.BSDLY == unix.BS0 } From fadf5522af0c34f1c3741dce5f176fca189ddc71 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 19 Jul 2026 04:49:37 -0400 Subject: [PATCH 05/10] fix(tui): guarantee deferred stream-clear redraw and other review fixes A throttled stream-clear could drop the redraw entirely when a newline arrived in the last delta of a turn, leaving a ghost caret on screen. Add a one-shot deferred flush timer plus a flush on stream end. Fix a stale comment and a test that leaked global logger state in the vendored bubbletea fork. --- internal/tui/model.go | 69 ++++++++- internal/tui/stream_clear_test.go | 134 ++++++++++++++++++ .../bubbletea-v2-patched/logging_test.go | 17 ++- third_party/bubbletea-v2-patched/renderer.go | 2 +- 4 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 internal/tui/stream_clear_test.go diff --git a/internal/tui/model.go b/internal/tui/model.go index 9c3added7..13dc23fd0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -382,9 +382,14 @@ type model struct { // (ZERO_NO_STREAM_CLEAR=1). lastStreamClear rate-limits the redraws the // workaround schedules so heavy streaming output (code, logs, diffs) // coalesces to a bounded number of repaints per second instead of one - // per newline. + // per newline. pendingStreamClear tracks a newline that arrived while + // throttled: the redraw it would have triggered is deferred (flushed by + // a scheduled streamClearFlushMsg, or at stream end) instead of dropped + // outright, so a throttled newline that happens to be the last one of + // the turn still gets its caret repaired. streamClearDisabled bool lastStreamClear time.Time + pendingStreamClear bool reducedMotion bool // ZERO_REDUCED_MOTION / no-TTY: static spinner glyph, no fade // In-progress tool call whose arguments are streaming (a file being written), // shown live by streamingToolCallView so a long write/edit isn't a frozen @@ -505,6 +510,21 @@ type agentTextMsg struct { delta string } +// streamClearFlushMsg fires once, roughly when the stream-clear throttle +// window (see lastStreamClear) has elapsed, to flush a ClearScreen that a +// throttled newline deferred rather than fired directly. It's a no-op if +// nothing is pending by the time it lands (the common case, since most +// throttled newlines are followed by another one that flushes them first). +type streamClearFlushMsg struct{} + +// scheduleStreamClearFlush returns a one-shot command that delivers a +// streamClearFlushMsg after d. Used to guarantee a deferred stream-clear +// redraw is eventually flushed even if no later newline or stream-end event +// does it first (see the streamClearFlushMsg case in Update). +func scheduleStreamClearFlush(d time.Duration) tea.Cmd { + return tea.Tick(d, func(time.Time) tea.Msg { return streamClearFlushMsg{} }) +} + type exitConfirmExpiredMsg struct { seq int } @@ -2011,11 +2031,23 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // on SSH and slow links. ~10 redraws/second is enough to keep the // caret clean without dominating the write path; terminals that // render scroll regions correctly can opt out entirely with - // ZERO_NO_STREAM_CLEAR=1. - if strings.Contains(msg.delta, "\n") && !m.streamClearDisabled && - time.Since(m.lastStreamClear) >= 100*time.Millisecond { - m.lastStreamClear = time.Now() - cmds = append(cmds, tea.ClearScreen) + // ZERO_NO_STREAM_CLEAR=1. A newline that arrives inside the throttle + // window still owes a repair — it's marked pending and a one-shot + // timer is scheduled to flush it (see streamClearFlushMsg), instead + // of being dropped outright. That covers a throttled newline that + // turns out to be the turn's last one (agentResponseMsg also flushes + // any still-pending clear at stream end, belt-and-suspenders) as + // well as one buried in the middle of a long, still-streaming turn. + if strings.Contains(msg.delta, "\n") && !m.streamClearDisabled { + const throttle = 100 * time.Millisecond + if elapsed := time.Since(m.lastStreamClear); elapsed >= throttle { + m.lastStreamClear = time.Now() + m.pendingStreamClear = false + cmds = append(cmds, tea.ClearScreen) + } else if !m.pendingStreamClear { + m.pendingStreamClear = true + cmds = append(cmds, scheduleStreamClearFlush(throttle-elapsed)) + } } // The fade's tick is self-perpetuating (the streamingFadeTickMsg // case schedules the next one). Schedule the FIRST tick only on @@ -2105,6 +2137,20 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } return m, streamingFadeTick() + case streamClearFlushMsg: + // Flush a newline-triggered redraw that the stream-clear throttle + // deferred (see agentTextMsg) once its window has elapsed. This runs + // independent of the streaming fade (which is unconditionally off — + // fadeDisabled is hardcoded true in newModel — so its tick can't be + // relied on to drive this), and independent of stream end: a turn + // that keeps streaming for a while after the throttled newline would + // otherwise leave the ghost caret up for the rest of the turn. + if m.pendingStreamClear && time.Since(m.lastStreamClear) >= 100*time.Millisecond { + m.lastStreamClear = time.Now() + m.pendingStreamClear = false + return m, tea.ClearScreen + } + return m, nil case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -2257,6 +2303,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block m.pending = false m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling + // A newline-triggered redraw deferred by the stream-clear throttle + // (see agentTextMsg) may never get a later newline or fade tick to + // flush it if this was the turn's last delta — flush it here so the + // ghost caret isn't left behind at stream end. + var pendingClearCmd tea.Cmd + if m.pendingStreamClear { + m.pendingStreamClear = false + pendingClearCmd = tea.ClearScreen + } // Fully reset the fade state at stream end. The next render // emits the final row in solid ink (no settling animation), and // the pending streamingFadeTickMsg that lands after this point @@ -2398,7 +2453,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m, loopTickCmd = m.ensureLoopTick() } next, queuedCmd := m.launchQueuedMessageIfReady() - return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd) + return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd) case sessionTitleGeneratedMsg: return m.handleSessionTitleGenerated(msg) case recapGeneratedMsg: diff --git a/internal/tui/stream_clear_test.go b/internal/tui/stream_clear_test.go new file mode 100644 index 000000000..63337c859 --- /dev/null +++ b/internal/tui/stream_clear_test.go @@ -0,0 +1,134 @@ +package tui + +import ( + "testing" + "time" + + tea "charm.land/bubbletea/v2" +) + +// cmdIncludesClearScreen runs cmd (and, transitively, every command inside a +// tea.BatchMsg it produces) looking for a tea.ClearScreen. nil commands and +// nil messages are treated as "not found" rather than a crash so callers can +// pass a cmd straight off an Update return without a nil check. +func cmdIncludesClearScreen(cmd tea.Cmd) bool { + if cmd == nil { + return false + } + return msgIncludesClearScreen(cmd()) +} + +func msgIncludesClearScreen(msg tea.Msg) bool { + if msg == nil { + return false + } + if batch, ok := msg.(tea.BatchMsg); ok { + for _, c := range batch { + if c == nil { + continue + } + if msgIncludesClearScreen(c()) { + return true + } + } + return false + } + return msg == tea.ClearScreen() +} + +// TestStreamClearThrottledNewlineIsDeferredNotDropped guards against a +// regression in the multipass/Windows-Terminal ghost-caret workaround: a +// newline arriving inside the stream-clear throttle window used to have its +// redraw silently dropped. If that throttled newline turned out to be the +// turn's last delta, nothing later would ever repair the ghost caret. The +// throttled newline must instead be remembered and flushed, here at stream +// end, rather than discarded. +func TestStreamClearThrottledNewlineIsDeferredNotDropped(t *testing.T) { + m := newModel(t.Context(), Options{ModelName: "gpt-4.1"}) + m = m.beginRun(nil) + rid := m.activeRunID + + // lastStreamClear is still the zero Time here, so it's far outside the + // throttle window and the first newline fires an immediate ClearScreen. + updated, cmd := m.Update(agentTextMsg{runID: rid, delta: "first line\n"}) + m = updated.(model) + if !cmdIncludesClearScreen(cmd) { + t.Fatal("first newline after a long gap should fire an immediate ClearScreen") + } + if m.pendingStreamClear { + t.Fatal("an immediate clear should not also leave a clear pending") + } + + // A second newline lands inside the 100ms throttle window: it must not + // fire its own ClearScreen, but it must be remembered as owed. + updated, cmd = m.Update(agentTextMsg{runID: rid, delta: "second line\n"}) + m = updated.(model) + if cmdIncludesClearScreen(cmd) { + t.Fatal("a throttled newline should not fire its own ClearScreen") + } + if !m.pendingStreamClear { + t.Fatal("a throttled newline must mark a clear as pending instead of dropping it") + } + + // This was, in fact, the turn's last delta — stream end must flush the + // pending clear rather than losing it. + updated, cmd = m.Update(agentResponseMsg{runID: rid}) + m = updated.(model) + if !cmdIncludesClearScreen(cmd) { + t.Fatal("stream end must flush a pending stream-clear rather than dropping it") + } + if m.pendingStreamClear { + t.Fatal("stream end should clear the pending flag once it's flushed") + } +} + +// TestStreamClearScheduledFlushRepairsGhostCaretMidStream guards the +// mid-stream flush path: once the throttle window has elapsed, the one-shot +// timer scheduled alongside the pending clear (see scheduleStreamClearFlush) +// repairs a throttled newline's redraw instead of waiting for stream end, +// which may be much later for a long-running turn. This has to be +// independent of the streaming-text fade tick: fadeDisabled is hardcoded true +// in newModel, so that ticker never actually runs. +func TestStreamClearScheduledFlushRepairsGhostCaretMidStream(t *testing.T) { + m := newModel(t.Context(), Options{ModelName: "gpt-4.1"}) + m = m.beginRun(nil) + rid := m.activeRunID + + updated, _ := m.Update(agentTextMsg{runID: rid, delta: "first line\n"}) + m = updated.(model) + updated, _ = m.Update(agentTextMsg{runID: rid, delta: "second line\n"}) + m = updated.(model) + if !m.pendingStreamClear { + t.Fatal("setup: expected the second newline to be throttled and pending") + } + + // Move lastStreamClear far enough into the past that the throttle + // window has elapsed, without an actual sleep. + m.lastStreamClear = time.Now().Add(-time.Second) + + updated, cmd := m.Update(streamClearFlushMsg{}) + m = updated.(model) + if !cmdIncludesClearScreen(cmd) { + t.Fatal("the scheduled flush should fire ClearScreen once the throttle window has elapsed") + } + if m.pendingStreamClear { + t.Fatal("the scheduled flush should clear the pending flag once it's flushed") + } +} + +// TestStreamClearScheduledFlushNoopsWhenNothingPending guards against a stale +// timer re-firing a ClearScreen after its pending clear was already flushed +// by something else (e.g. stream end), or when nothing was ever pending. +func TestStreamClearScheduledFlushNoopsWhenNothingPending(t *testing.T) { + m := newModel(t.Context(), Options{ModelName: "gpt-4.1"}) + m = m.beginRun(nil) + + updated, cmd := m.Update(streamClearFlushMsg{}) + m = updated.(model) + if cmd != nil { + t.Fatal("a flush with nothing pending should be a no-op") + } + if m.pendingStreamClear { + t.Fatal("a no-op flush should not set the pending flag") + } +} diff --git a/third_party/bubbletea-v2-patched/logging_test.go b/third_party/bubbletea-v2-patched/logging_test.go index 70c2065e2..2c8cb68e9 100644 --- a/third_party/bubbletea-v2-patched/logging_test.go +++ b/third_party/bubbletea-v2-patched/logging_test.go @@ -8,11 +8,24 @@ import ( ) func TestLogToFile(t *testing.T) { + // LogToFile mutates the process-global default logger (output, prefix); + // SetFlags below mutates its flags too. Restore all three so a later + // test doesn't inherit this test's prefix/flags, or try to write into + // the file this test is about to close. + originalOutput := log.Writer() + originalPrefix := log.Prefix() + originalFlags := log.Flags() + t.Cleanup(func() { + log.SetOutput(originalOutput) + log.SetPrefix(originalPrefix) + log.SetFlags(originalFlags) + }) + path := filepath.Join(t.TempDir(), "log.txt") prefix := "logprefix" f, err := LogToFile(path, prefix) if err != nil { - t.Error(err) + t.Fatal(err) } log.SetFlags(log.Lmsgprefix) log.Println("some test log") @@ -21,7 +34,7 @@ func TestLogToFile(t *testing.T) { } out, err := os.ReadFile(path) if err != nil { - t.Error(err) + t.Fatal(err) } if string(out) != prefix+" some test log\n" { t.Fatalf("wrong log msg: %q", string(out)) diff --git a/third_party/bubbletea-v2-patched/renderer.go b/third_party/bubbletea-v2-patched/renderer.go index e8d792b44..9d641b4fe 100644 --- a/third_party/bubbletea-v2-patched/renderer.go +++ b/third_party/bubbletea-v2-patched/renderer.go @@ -8,7 +8,7 @@ import ( ) const ( - // defaultFramerate specifies the maximum interval at which we should + // defaultFPS specifies the maximum interval at which we should // update the view. defaultFPS = 60 maxFPS = 120 From 8d2b08fc1825604ba881f42f9d3075c611ccd748 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:17:04 -0400 Subject: [PATCH 06/10] fix(tui): use m.now for stream-clear throttle and harden tests Drive stream-clear rate limiting through the mockable clock and a shared streamClearThrottle constant so both the agentTextMsg and flush paths stay in sync. Add an explicit two-newline-within-window regression and detect ClearScreen by function pointer so tests do not evaluate Tick side effects. --- internal/tui/model.go | 20 ++++-- internal/tui/stream_clear_test.go | 105 ++++++++++++++++++++++++++---- 2 files changed, 107 insertions(+), 18 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 13dc23fd0..2bb950de0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -510,6 +510,13 @@ type agentTextMsg struct { delta string } +// streamClearThrottle is the minimum gap between full-screen stream-clear +// redraws. Newlines that arrive inside this window mark a deferred clear +// (pendingStreamClear) instead of firing immediately, so heavy streaming +// output coalesces to ~10 repaints/second while still guaranteeing a +// eventual caret repair. +const streamClearThrottle = 100 * time.Millisecond + // streamClearFlushMsg fires once, roughly when the stream-clear throttle // window (see lastStreamClear) has elapsed, to flush a ClearScreen that a // throttled newline deferred rather than fired directly. It's a no-op if @@ -2039,14 +2046,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // any still-pending clear at stream end, belt-and-suspenders) as // well as one buried in the middle of a long, still-streaming turn. if strings.Contains(msg.delta, "\n") && !m.streamClearDisabled { - const throttle = 100 * time.Millisecond - if elapsed := time.Since(m.lastStreamClear); elapsed >= throttle { - m.lastStreamClear = time.Now() + now := m.now() + if elapsed := now.Sub(m.lastStreamClear); elapsed >= streamClearThrottle { + m.lastStreamClear = now m.pendingStreamClear = false cmds = append(cmds, tea.ClearScreen) } else if !m.pendingStreamClear { m.pendingStreamClear = true - cmds = append(cmds, scheduleStreamClearFlush(throttle-elapsed)) + cmds = append(cmds, scheduleStreamClearFlush(streamClearThrottle-elapsed)) } } // The fade's tick is self-perpetuating (the streamingFadeTickMsg @@ -2145,8 +2152,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // relied on to drive this), and independent of stream end: a turn // that keeps streaming for a while after the throttled newline would // otherwise leave the ghost caret up for the rest of the turn. - if m.pendingStreamClear && time.Since(m.lastStreamClear) >= 100*time.Millisecond { - m.lastStreamClear = time.Now() + now := m.now() + if m.pendingStreamClear && now.Sub(m.lastStreamClear) >= streamClearThrottle { + m.lastStreamClear = now m.pendingStreamClear = false return m, tea.ClearScreen } diff --git a/internal/tui/stream_clear_test.go b/internal/tui/stream_clear_test.go index 63337c859..6732c154a 100644 --- a/internal/tui/stream_clear_test.go +++ b/internal/tui/stream_clear_test.go @@ -1,21 +1,42 @@ package tui import ( + "reflect" "testing" "time" tea "charm.land/bubbletea/v2" ) -// cmdIncludesClearScreen runs cmd (and, transitively, every command inside a -// tea.BatchMsg it produces) looking for a tea.ClearScreen. nil commands and -// nil messages are treated as "not found" rather than a crash so callers can -// pass a cmd straight off an Update return without a nil check. +// isClearScreenCmd reports whether c is the tea.ClearScreen command by +// function-pointer identity. That avoids evaluating the command (and any +// sibling commands in a batch) just to detect a clear. +func isClearScreenCmd(c tea.Cmd) bool { + return c != nil && reflect.ValueOf(c).Pointer() == reflect.ValueOf(tea.ClearScreen).Pointer() +} + +// cmdIncludesClearScreen looks for a tea.ClearScreen among cmd and, if cmd +// is a tea.Batch wrapper, among its children. Batch wrappers return a +// BatchMsg without running their children; Tick (and similar) commands block, +// so those are only expanded with a short timeout and treated as "not a +// clear" if they don't return promptly. Nil commands are "not found". func cmdIncludesClearScreen(cmd tea.Cmd) bool { if cmd == nil { return false } - return msgIncludesClearScreen(cmd()) + if isClearScreenCmd(cmd) { + return true + } + // Expand batches (and any other immediately-returning cmds). Bound the + // wait so a deferred stream-clear Tick is never a multi-100ms sleep. + ch := make(chan tea.Msg, 1) + go func() { ch <- cmd() }() + select { + case msg := <-ch: + return msgIncludesClearScreen(msg) + case <-time.After(20 * time.Millisecond): + return false + } } func msgIncludesClearScreen(msg tea.Msg) bool { @@ -24,18 +45,21 @@ func msgIncludesClearScreen(msg tea.Msg) bool { } if batch, ok := msg.(tea.BatchMsg); ok { for _, c := range batch { - if c == nil { - continue - } - if msgIncludesClearScreen(c()) { + if cmdIncludesClearScreen(c) { return true } } return false } + // clearScreenMsg is unexported; match by value equality with ClearScreen(). return msg == tea.ClearScreen() } +// withFrozenClock pins m.now to t0 for deterministic throttle math. +func withFrozenClock(m *model, t0 time.Time) { + m.now = func() time.Time { return t0 } +} + // TestStreamClearThrottledNewlineIsDeferredNotDropped guards against a // regression in the multipass/Windows-Terminal ghost-caret workaround: a // newline arriving inside the stream-clear throttle window used to have its @@ -47,6 +71,8 @@ func TestStreamClearThrottledNewlineIsDeferredNotDropped(t *testing.T) { m := newModel(t.Context(), Options{ModelName: "gpt-4.1"}) m = m.beginRun(nil) rid := m.activeRunID + t0 := time.Unix(1_700_000_000, 0) + withFrozenClock(&m, t0) // lastStreamClear is still the zero Time here, so it's far outside the // throttle window and the first newline fires an immediate ClearScreen. @@ -58,6 +84,9 @@ func TestStreamClearThrottledNewlineIsDeferredNotDropped(t *testing.T) { if m.pendingStreamClear { t.Fatal("an immediate clear should not also leave a clear pending") } + if !m.lastStreamClear.Equal(t0) { + t.Fatalf("lastStreamClear should record m.now()=%v, got %v", t0, m.lastStreamClear) + } // A second newline lands inside the 100ms throttle window: it must not // fire its own ClearScreen, but it must be remembered as owed. @@ -82,6 +111,57 @@ func TestStreamClearThrottledNewlineIsDeferredNotDropped(t *testing.T) { } } +// TestStreamClearTwoNewlinesWithinThrottleWindow is the explicit two-newline +// regression for the rate-limit path: both deltas arrive inside the same +// streamClearThrottle window after an initial clear, so neither should fire +// an immediate ClearScreen, and the deferred pending flag must stay set +// (coalesced) until a later flush. +func TestStreamClearTwoNewlinesWithinThrottleWindow(t *testing.T) { + m := newModel(t.Context(), Options{ModelName: "gpt-4.1"}) + m = m.beginRun(nil) + rid := m.activeRunID + t0 := time.Unix(1_700_000_000, 0) + withFrozenClock(&m, t0) + + updated, cmd := m.Update(agentTextMsg{runID: rid, delta: "line one\n"}) + m = updated.(model) + if !cmdIncludesClearScreen(cmd) { + t.Fatal("setup: first newline should clear immediately") + } + + // Stay frozen at t0 so both follow-ups fall inside the throttle window. + updated, cmd = m.Update(agentTextMsg{runID: rid, delta: "line two\n"}) + m = updated.(model) + if cmdIncludesClearScreen(cmd) { + t.Fatal("second newline inside the window must not ClearScreen immediately") + } + if !m.pendingStreamClear { + t.Fatal("second newline inside the window must set pendingStreamClear") + } + + // A third newline still inside the window must coalesce onto the same + // pending clear rather than dropping it or firing early. + updated, cmd = m.Update(agentTextMsg{runID: rid, delta: "line three\n"}) + m = updated.(model) + if cmdIncludesClearScreen(cmd) { + t.Fatal("third newline inside the window must not ClearScreen immediately") + } + if !m.pendingStreamClear { + t.Fatal("pendingStreamClear must remain set across coalesced newlines") + } + + // Advance past the throttle window and let the scheduled flush repair it. + withFrozenClock(&m, t0.Add(streamClearThrottle)) + updated, cmd = m.Update(streamClearFlushMsg{}) + m = updated.(model) + if !cmdIncludesClearScreen(cmd) { + t.Fatal("flush after the window must fire ClearScreen for coalesced newlines") + } + if m.pendingStreamClear { + t.Fatal("flush should clear the pending flag") + } +} + // TestStreamClearScheduledFlushRepairsGhostCaretMidStream guards the // mid-stream flush path: once the throttle window has elapsed, the one-shot // timer scheduled alongside the pending clear (see scheduleStreamClearFlush) @@ -93,6 +173,8 @@ func TestStreamClearScheduledFlushRepairsGhostCaretMidStream(t *testing.T) { m := newModel(t.Context(), Options{ModelName: "gpt-4.1"}) m = m.beginRun(nil) rid := m.activeRunID + t0 := time.Unix(1_700_000_000, 0) + withFrozenClock(&m, t0) updated, _ := m.Update(agentTextMsg{runID: rid, delta: "first line\n"}) m = updated.(model) @@ -102,9 +184,8 @@ func TestStreamClearScheduledFlushRepairsGhostCaretMidStream(t *testing.T) { t.Fatal("setup: expected the second newline to be throttled and pending") } - // Move lastStreamClear far enough into the past that the throttle - // window has elapsed, without an actual sleep. - m.lastStreamClear = time.Now().Add(-time.Second) + // Move past the throttle window via the mockable clock, without a sleep. + withFrozenClock(&m, t0.Add(time.Second)) updated, cmd := m.Update(streamClearFlushMsg{}) m = updated.(model) From e6fb5eb2e8121b992b501b9f31adcafd07f1859a Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:17:08 -0400 Subject: [PATCH 07/10] fix(tui): relocate bubbletea fork out of third_party Move the maintained bubbletea/v2 hard-scroll patch from third_party/bubbletea-v2-patched to patches/bubbletea-v2 and update the root go.mod replace. third_party/ is immutable vendored content per AGENTS.md; a maintained fork belongs outside that tree. --- go.mod | 14 ++++++++------ .../bubbletea-v2}/LICENSE | 0 .../bubbletea-v2}/clipboard.go | 0 .../bubbletea-v2}/color.go | 0 .../bubbletea-v2}/commands.go | 0 .../bubbletea-v2}/commands_test.go | 0 .../bubbletea-v2}/cursed_renderer.go | 0 .../bubbletea-v2}/cursed_renderer_test.go | 0 .../bubbletea-v2}/cursor.go | 0 .../bubbletea-v2}/environ.go | 0 .../bubbletea-v2}/exec.go | 0 .../bubbletea-v2}/exec_test.go | 0 .../bubbletea-v2}/focus.go | 0 .../bubbletea-v2}/go.mod | 0 .../bubbletea-v2}/go.sum | 0 .../bubbletea-v2}/input.go | 0 .../bubbletea-v2}/key.go | 0 .../bubbletea-v2}/keyboard.go | 0 .../bubbletea-v2}/logging.go | 0 .../bubbletea-v2}/logging_test.go | 0 .../bubbletea-v2}/mod.go | 0 .../bubbletea-v2}/mouse.go | 0 .../bubbletea-v2}/nil_renderer.go | 0 .../bubbletea-v2}/options.go | 0 .../bubbletea-v2}/options_test.go | 0 .../bubbletea-v2}/paste.go | 0 .../bubbletea-v2}/profile.go | 0 .../bubbletea-v2}/raw.go | 0 .../bubbletea-v2}/renderer.go | 0 .../bubbletea-v2}/screen.go | 0 .../bubbletea-v2}/screen_test.go | 0 .../bubbletea-v2}/signals_unix.go | 0 .../bubbletea-v2}/signals_windows.go | 0 .../bubbletea-v2}/tea.go | 0 .../bubbletea-v2}/tea_test.go | 0 .../bubbletea-v2}/termcap.go | 0 .../bubbletea-v2}/termios_bsd.go | 0 .../bubbletea-v2}/termios_other.go | 0 .../bubbletea-v2}/termios_unix.go | 0 .../bubbletea-v2}/termios_windows.go | 0 .../testdata/TestClearMsg/bg_fg_cur_color.golden | 0 .../testdata/TestClearMsg/clear_screen.golden | 0 .../TestClearMsg/read_set_clipboard.golden | 0 .../testdata/TestViewModel/altscreen.golden | 0 .../TestViewModel/altscreen_autoexit.golden | 0 .../testdata/TestViewModel/bg_set_color.golden | 0 .../testdata/TestViewModel/bp_stop_start.golden | 0 .../testdata/TestViewModel/cursor_hide.golden | 0 .../testdata/TestViewModel/cursor_hideshow.golden | 0 .../TestViewModel/kitty_stop_startreleases.golden | 0 .../testdata/TestViewModel/mouse_allmotion.golden | 0 .../testdata/TestViewModel/mouse_cellmotion.golden | 0 .../testdata/TestViewModel/mouse_disable.golden | 0 .../bubbletea-v2}/tty.go | 0 .../bubbletea-v2}/tty_unix.go | 0 .../bubbletea-v2}/tty_windows.go | 0 .../bubbletea-v2}/xterm.go | 0 57 files changed, 8 insertions(+), 6 deletions(-) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/LICENSE (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/clipboard.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/color.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/commands.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/commands_test.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/cursed_renderer.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/cursed_renderer_test.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/cursor.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/environ.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/exec.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/exec_test.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/focus.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/go.mod (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/go.sum (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/input.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/key.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/keyboard.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/logging.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/logging_test.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/mod.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/mouse.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/nil_renderer.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/options.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/options_test.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/paste.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/profile.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/raw.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/renderer.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/screen.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/screen_test.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/signals_unix.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/signals_windows.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/tea.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/tea_test.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/termcap.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/termios_bsd.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/termios_other.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/termios_unix.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/termios_windows.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/testdata/TestClearMsg/bg_fg_cur_color.golden (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/testdata/TestClearMsg/clear_screen.golden (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/testdata/TestClearMsg/read_set_clipboard.golden (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/testdata/TestViewModel/altscreen.golden (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/testdata/TestViewModel/altscreen_autoexit.golden (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/testdata/TestViewModel/bg_set_color.golden (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/testdata/TestViewModel/bp_stop_start.golden (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/testdata/TestViewModel/cursor_hide.golden (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/testdata/TestViewModel/cursor_hideshow.golden (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/testdata/TestViewModel/kitty_stop_startreleases.golden (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/testdata/TestViewModel/mouse_allmotion.golden (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/testdata/TestViewModel/mouse_cellmotion.golden (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/testdata/TestViewModel/mouse_disable.golden (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/tty.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/tty_unix.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/tty_windows.go (100%) rename {third_party/bubbletea-v2-patched => patches/bubbletea-v2}/xterm.go (100%) diff --git a/go.mod b/go.mod index 74097a8a3..9d00196f2 100644 --- a/go.mod +++ b/go.mod @@ -37,9 +37,11 @@ require ( golang.org/x/sync v0.22.0 // indirect ) -// Vendored fork of bubbletea/v2 with hard scroll optimization disabled -// unconditionally (see third_party/bubbletea-v2-patched/cursed_renderer.go): -// it corrupts rendering when zero's output is displayed by Windows Terminal -// over a remote shell (e.g. running inside a multipass VM), a case the -// upstream GOOS=="windows" check doesn't catch. -replace charm.land/bubbletea/v2 => ./third_party/bubbletea-v2-patched +// Maintained fork of bubbletea/v2 with hard scroll optimization disabled +// unconditionally (see patches/bubbletea-v2/cursed_renderer.go). +// Placed under patches/ (not third_party/) because third_party/ is immutable +// vendored content per AGENTS.md. The hard-scroll path corrupts rendering when +// zero's output is displayed by Windows Terminal over a remote shell (e.g. +// running inside a multipass VM), a case the upstream GOOS=="windows" check +// does not catch. +replace charm.land/bubbletea/v2 => ./patches/bubbletea-v2 diff --git a/third_party/bubbletea-v2-patched/LICENSE b/patches/bubbletea-v2/LICENSE similarity index 100% rename from third_party/bubbletea-v2-patched/LICENSE rename to patches/bubbletea-v2/LICENSE diff --git a/third_party/bubbletea-v2-patched/clipboard.go b/patches/bubbletea-v2/clipboard.go similarity index 100% rename from third_party/bubbletea-v2-patched/clipboard.go rename to patches/bubbletea-v2/clipboard.go diff --git a/third_party/bubbletea-v2-patched/color.go b/patches/bubbletea-v2/color.go similarity index 100% rename from third_party/bubbletea-v2-patched/color.go rename to patches/bubbletea-v2/color.go diff --git a/third_party/bubbletea-v2-patched/commands.go b/patches/bubbletea-v2/commands.go similarity index 100% rename from third_party/bubbletea-v2-patched/commands.go rename to patches/bubbletea-v2/commands.go diff --git a/third_party/bubbletea-v2-patched/commands_test.go b/patches/bubbletea-v2/commands_test.go similarity index 100% rename from third_party/bubbletea-v2-patched/commands_test.go rename to patches/bubbletea-v2/commands_test.go diff --git a/third_party/bubbletea-v2-patched/cursed_renderer.go b/patches/bubbletea-v2/cursed_renderer.go similarity index 100% rename from third_party/bubbletea-v2-patched/cursed_renderer.go rename to patches/bubbletea-v2/cursed_renderer.go diff --git a/third_party/bubbletea-v2-patched/cursed_renderer_test.go b/patches/bubbletea-v2/cursed_renderer_test.go similarity index 100% rename from third_party/bubbletea-v2-patched/cursed_renderer_test.go rename to patches/bubbletea-v2/cursed_renderer_test.go diff --git a/third_party/bubbletea-v2-patched/cursor.go b/patches/bubbletea-v2/cursor.go similarity index 100% rename from third_party/bubbletea-v2-patched/cursor.go rename to patches/bubbletea-v2/cursor.go diff --git a/third_party/bubbletea-v2-patched/environ.go b/patches/bubbletea-v2/environ.go similarity index 100% rename from third_party/bubbletea-v2-patched/environ.go rename to patches/bubbletea-v2/environ.go diff --git a/third_party/bubbletea-v2-patched/exec.go b/patches/bubbletea-v2/exec.go similarity index 100% rename from third_party/bubbletea-v2-patched/exec.go rename to patches/bubbletea-v2/exec.go diff --git a/third_party/bubbletea-v2-patched/exec_test.go b/patches/bubbletea-v2/exec_test.go similarity index 100% rename from third_party/bubbletea-v2-patched/exec_test.go rename to patches/bubbletea-v2/exec_test.go diff --git a/third_party/bubbletea-v2-patched/focus.go b/patches/bubbletea-v2/focus.go similarity index 100% rename from third_party/bubbletea-v2-patched/focus.go rename to patches/bubbletea-v2/focus.go diff --git a/third_party/bubbletea-v2-patched/go.mod b/patches/bubbletea-v2/go.mod similarity index 100% rename from third_party/bubbletea-v2-patched/go.mod rename to patches/bubbletea-v2/go.mod diff --git a/third_party/bubbletea-v2-patched/go.sum b/patches/bubbletea-v2/go.sum similarity index 100% rename from third_party/bubbletea-v2-patched/go.sum rename to patches/bubbletea-v2/go.sum diff --git a/third_party/bubbletea-v2-patched/input.go b/patches/bubbletea-v2/input.go similarity index 100% rename from third_party/bubbletea-v2-patched/input.go rename to patches/bubbletea-v2/input.go diff --git a/third_party/bubbletea-v2-patched/key.go b/patches/bubbletea-v2/key.go similarity index 100% rename from third_party/bubbletea-v2-patched/key.go rename to patches/bubbletea-v2/key.go diff --git a/third_party/bubbletea-v2-patched/keyboard.go b/patches/bubbletea-v2/keyboard.go similarity index 100% rename from third_party/bubbletea-v2-patched/keyboard.go rename to patches/bubbletea-v2/keyboard.go diff --git a/third_party/bubbletea-v2-patched/logging.go b/patches/bubbletea-v2/logging.go similarity index 100% rename from third_party/bubbletea-v2-patched/logging.go rename to patches/bubbletea-v2/logging.go diff --git a/third_party/bubbletea-v2-patched/logging_test.go b/patches/bubbletea-v2/logging_test.go similarity index 100% rename from third_party/bubbletea-v2-patched/logging_test.go rename to patches/bubbletea-v2/logging_test.go diff --git a/third_party/bubbletea-v2-patched/mod.go b/patches/bubbletea-v2/mod.go similarity index 100% rename from third_party/bubbletea-v2-patched/mod.go rename to patches/bubbletea-v2/mod.go diff --git a/third_party/bubbletea-v2-patched/mouse.go b/patches/bubbletea-v2/mouse.go similarity index 100% rename from third_party/bubbletea-v2-patched/mouse.go rename to patches/bubbletea-v2/mouse.go diff --git a/third_party/bubbletea-v2-patched/nil_renderer.go b/patches/bubbletea-v2/nil_renderer.go similarity index 100% rename from third_party/bubbletea-v2-patched/nil_renderer.go rename to patches/bubbletea-v2/nil_renderer.go diff --git a/third_party/bubbletea-v2-patched/options.go b/patches/bubbletea-v2/options.go similarity index 100% rename from third_party/bubbletea-v2-patched/options.go rename to patches/bubbletea-v2/options.go diff --git a/third_party/bubbletea-v2-patched/options_test.go b/patches/bubbletea-v2/options_test.go similarity index 100% rename from third_party/bubbletea-v2-patched/options_test.go rename to patches/bubbletea-v2/options_test.go diff --git a/third_party/bubbletea-v2-patched/paste.go b/patches/bubbletea-v2/paste.go similarity index 100% rename from third_party/bubbletea-v2-patched/paste.go rename to patches/bubbletea-v2/paste.go diff --git a/third_party/bubbletea-v2-patched/profile.go b/patches/bubbletea-v2/profile.go similarity index 100% rename from third_party/bubbletea-v2-patched/profile.go rename to patches/bubbletea-v2/profile.go diff --git a/third_party/bubbletea-v2-patched/raw.go b/patches/bubbletea-v2/raw.go similarity index 100% rename from third_party/bubbletea-v2-patched/raw.go rename to patches/bubbletea-v2/raw.go diff --git a/third_party/bubbletea-v2-patched/renderer.go b/patches/bubbletea-v2/renderer.go similarity index 100% rename from third_party/bubbletea-v2-patched/renderer.go rename to patches/bubbletea-v2/renderer.go diff --git a/third_party/bubbletea-v2-patched/screen.go b/patches/bubbletea-v2/screen.go similarity index 100% rename from third_party/bubbletea-v2-patched/screen.go rename to patches/bubbletea-v2/screen.go diff --git a/third_party/bubbletea-v2-patched/screen_test.go b/patches/bubbletea-v2/screen_test.go similarity index 100% rename from third_party/bubbletea-v2-patched/screen_test.go rename to patches/bubbletea-v2/screen_test.go diff --git a/third_party/bubbletea-v2-patched/signals_unix.go b/patches/bubbletea-v2/signals_unix.go similarity index 100% rename from third_party/bubbletea-v2-patched/signals_unix.go rename to patches/bubbletea-v2/signals_unix.go diff --git a/third_party/bubbletea-v2-patched/signals_windows.go b/patches/bubbletea-v2/signals_windows.go similarity index 100% rename from third_party/bubbletea-v2-patched/signals_windows.go rename to patches/bubbletea-v2/signals_windows.go diff --git a/third_party/bubbletea-v2-patched/tea.go b/patches/bubbletea-v2/tea.go similarity index 100% rename from third_party/bubbletea-v2-patched/tea.go rename to patches/bubbletea-v2/tea.go diff --git a/third_party/bubbletea-v2-patched/tea_test.go b/patches/bubbletea-v2/tea_test.go similarity index 100% rename from third_party/bubbletea-v2-patched/tea_test.go rename to patches/bubbletea-v2/tea_test.go diff --git a/third_party/bubbletea-v2-patched/termcap.go b/patches/bubbletea-v2/termcap.go similarity index 100% rename from third_party/bubbletea-v2-patched/termcap.go rename to patches/bubbletea-v2/termcap.go diff --git a/third_party/bubbletea-v2-patched/termios_bsd.go b/patches/bubbletea-v2/termios_bsd.go similarity index 100% rename from third_party/bubbletea-v2-patched/termios_bsd.go rename to patches/bubbletea-v2/termios_bsd.go diff --git a/third_party/bubbletea-v2-patched/termios_other.go b/patches/bubbletea-v2/termios_other.go similarity index 100% rename from third_party/bubbletea-v2-patched/termios_other.go rename to patches/bubbletea-v2/termios_other.go diff --git a/third_party/bubbletea-v2-patched/termios_unix.go b/patches/bubbletea-v2/termios_unix.go similarity index 100% rename from third_party/bubbletea-v2-patched/termios_unix.go rename to patches/bubbletea-v2/termios_unix.go diff --git a/third_party/bubbletea-v2-patched/termios_windows.go b/patches/bubbletea-v2/termios_windows.go similarity index 100% rename from third_party/bubbletea-v2-patched/termios_windows.go rename to patches/bubbletea-v2/termios_windows.go diff --git a/third_party/bubbletea-v2-patched/testdata/TestClearMsg/bg_fg_cur_color.golden b/patches/bubbletea-v2/testdata/TestClearMsg/bg_fg_cur_color.golden similarity index 100% rename from third_party/bubbletea-v2-patched/testdata/TestClearMsg/bg_fg_cur_color.golden rename to patches/bubbletea-v2/testdata/TestClearMsg/bg_fg_cur_color.golden diff --git a/third_party/bubbletea-v2-patched/testdata/TestClearMsg/clear_screen.golden b/patches/bubbletea-v2/testdata/TestClearMsg/clear_screen.golden similarity index 100% rename from third_party/bubbletea-v2-patched/testdata/TestClearMsg/clear_screen.golden rename to patches/bubbletea-v2/testdata/TestClearMsg/clear_screen.golden diff --git a/third_party/bubbletea-v2-patched/testdata/TestClearMsg/read_set_clipboard.golden b/patches/bubbletea-v2/testdata/TestClearMsg/read_set_clipboard.golden similarity index 100% rename from third_party/bubbletea-v2-patched/testdata/TestClearMsg/read_set_clipboard.golden rename to patches/bubbletea-v2/testdata/TestClearMsg/read_set_clipboard.golden diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/altscreen.golden b/patches/bubbletea-v2/testdata/TestViewModel/altscreen.golden similarity index 100% rename from third_party/bubbletea-v2-patched/testdata/TestViewModel/altscreen.golden rename to patches/bubbletea-v2/testdata/TestViewModel/altscreen.golden diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/altscreen_autoexit.golden b/patches/bubbletea-v2/testdata/TestViewModel/altscreen_autoexit.golden similarity index 100% rename from third_party/bubbletea-v2-patched/testdata/TestViewModel/altscreen_autoexit.golden rename to patches/bubbletea-v2/testdata/TestViewModel/altscreen_autoexit.golden diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/bg_set_color.golden b/patches/bubbletea-v2/testdata/TestViewModel/bg_set_color.golden similarity index 100% rename from third_party/bubbletea-v2-patched/testdata/TestViewModel/bg_set_color.golden rename to patches/bubbletea-v2/testdata/TestViewModel/bg_set_color.golden diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/bp_stop_start.golden b/patches/bubbletea-v2/testdata/TestViewModel/bp_stop_start.golden similarity index 100% rename from third_party/bubbletea-v2-patched/testdata/TestViewModel/bp_stop_start.golden rename to patches/bubbletea-v2/testdata/TestViewModel/bp_stop_start.golden diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/cursor_hide.golden b/patches/bubbletea-v2/testdata/TestViewModel/cursor_hide.golden similarity index 100% rename from third_party/bubbletea-v2-patched/testdata/TestViewModel/cursor_hide.golden rename to patches/bubbletea-v2/testdata/TestViewModel/cursor_hide.golden diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/cursor_hideshow.golden b/patches/bubbletea-v2/testdata/TestViewModel/cursor_hideshow.golden similarity index 100% rename from third_party/bubbletea-v2-patched/testdata/TestViewModel/cursor_hideshow.golden rename to patches/bubbletea-v2/testdata/TestViewModel/cursor_hideshow.golden diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/kitty_stop_startreleases.golden b/patches/bubbletea-v2/testdata/TestViewModel/kitty_stop_startreleases.golden similarity index 100% rename from third_party/bubbletea-v2-patched/testdata/TestViewModel/kitty_stop_startreleases.golden rename to patches/bubbletea-v2/testdata/TestViewModel/kitty_stop_startreleases.golden diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_allmotion.golden b/patches/bubbletea-v2/testdata/TestViewModel/mouse_allmotion.golden similarity index 100% rename from third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_allmotion.golden rename to patches/bubbletea-v2/testdata/TestViewModel/mouse_allmotion.golden diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_cellmotion.golden b/patches/bubbletea-v2/testdata/TestViewModel/mouse_cellmotion.golden similarity index 100% rename from third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_cellmotion.golden rename to patches/bubbletea-v2/testdata/TestViewModel/mouse_cellmotion.golden diff --git a/third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_disable.golden b/patches/bubbletea-v2/testdata/TestViewModel/mouse_disable.golden similarity index 100% rename from third_party/bubbletea-v2-patched/testdata/TestViewModel/mouse_disable.golden rename to patches/bubbletea-v2/testdata/TestViewModel/mouse_disable.golden diff --git a/third_party/bubbletea-v2-patched/tty.go b/patches/bubbletea-v2/tty.go similarity index 100% rename from third_party/bubbletea-v2-patched/tty.go rename to patches/bubbletea-v2/tty.go diff --git a/third_party/bubbletea-v2-patched/tty_unix.go b/patches/bubbletea-v2/tty_unix.go similarity index 100% rename from third_party/bubbletea-v2-patched/tty_unix.go rename to patches/bubbletea-v2/tty_unix.go diff --git a/third_party/bubbletea-v2-patched/tty_windows.go b/patches/bubbletea-v2/tty_windows.go similarity index 100% rename from third_party/bubbletea-v2-patched/tty_windows.go rename to patches/bubbletea-v2/tty_windows.go diff --git a/third_party/bubbletea-v2-patched/xterm.go b/patches/bubbletea-v2/xterm.go similarity index 100% rename from third_party/bubbletea-v2-patched/xterm.go rename to patches/bubbletea-v2/xterm.go From 8cb5469c3d82b1dd3291d91ea50b64b250a0c54a Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:27:52 -0400 Subject: [PATCH 08/10] fix(ci,docs): run vendored bubbletea fork tests and document unsupported go install path patches/bubbletea-v2 is its own Go module so `go test ./...` from the repo root never touched its 7 test files. Add a CI step (and a make test-patches target for local use) that runs the fork's own suite across the smoke matrix. The root go.mod's local `replace charm.land/bubbletea/v2 => ./patches/bubbletea-v2` also breaks `go install github.com/Gitlawb/zero/cmd/zero@`, since a versioned install can't resolve a replace pointing at a filesystem path. Document that path as unsupported in README.md and docs/INSTALL.md and point at git clone + go build instead, per jatmn's review findings on PR #709. --- .github/workflows/ci.yml | 8 ++++++++ Makefile | 10 ++++++++-- README.md | 4 ++++ docs/INSTALL.md | 7 +++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58b335ef6..df90bc529 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,14 @@ jobs: - name: Test run: go test ./... + # patches/bubbletea-v2 is its own Go module (kept separate from the + # main module's dependency graph on purpose, see go.mod's replace + # directive) so `go test ./...` above never reaches it. Run its suite + # explicitly so the vendored fork's own tests are part of required CI. + - name: Test vendored bubbletea fork (patches/bubbletea-v2) + working-directory: patches/bubbletea-v2 + run: go test ./... + - name: Build binary run: go run ./cmd/zero-release build diff --git a/Makefile b/Makefile index 4e9734450..b64be7a5f 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make # lint` before opening a PR" — these targets back those instructions. .DEFAULT_GOAL := build -.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean baseline help +.PHONY: build build-all test test-race test-patches vet fmt fmt-check lint tidy clean baseline help # Build the main CLI binary into ./zero. build: @@ -19,6 +19,12 @@ test: test-quick: go test ./... +# patches/bubbletea-v2 is a separate Go module (see the replace directive in +# go.mod), so it's outside the `./...` pattern above and needs its own +# invocation. Mirrors the CI step that runs it. +test-patches: + cd patches/bubbletea-v2 && go test ./... + vet: go vet ./... @@ -55,4 +61,4 @@ baseline: build --output internal/perfbench/reports/baseline.json help: - @echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean, baseline" + @echo "Targets: build (default), build-all, test, test-quick, test-patches, vet, fmt, fmt-check, lint, tidy, clean, baseline" diff --git a/README.md b/README.md index 84fc86baf..a717d460e 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,10 @@ Put `zero` and `zero-linux-sandbox` in the same directory on `PATH` Windows source builds can use the main `zero.exe` as their sandbox helper; release archives still ship standalone Windows helper executables. +`go install github.com/Gitlawb/zero/cmd/zero@` is not supported (the +root `go.mod` has a local `replace` for the vendored TUI fork); clone and build +as shown above instead. + More install details: [docs/INSTALL.md](docs/INSTALL.md). ## First Run diff --git a/docs/INSTALL.md b/docs/INSTALL.md index b65a0493f..ce1505dd2 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -127,6 +127,13 @@ go build -o zero ./cmd/zero Source builds require Go 1.26.5+. +`go install github.com/Gitlawb/zero/cmd/zero@` is not supported. The +root `go.mod` carries a local `replace charm.land/bubbletea/v2 => +./patches/bubbletea-v2` directive for the vendored TUI fork, and `go install` +against a versioned module can't resolve a `replace` that points at a path on +disk, that path only exists in a full checkout. Use `git clone` plus `go +build`/`go run` above instead. + ### Sandbox Helpers For Source Builds Release archives include the platform sandbox helpers. If you build directly From 9c4d9a97c4b4a1c693d0fad19f29612b168d64c6 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 03:42:49 -0400 Subject: [PATCH 09/10] ci: run vendored bubbletea fork tests with -race CodeRabbit review on this PR. The fork exists specifically to patch a concurrency bug (charmbracelet/bubbletea#1690), so its regression test needs the race detector to actually catch a reintroduced race instead of just passing on a benign-looking run. --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df90bc529..402ae8881 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,9 +52,13 @@ jobs: # main module's dependency graph on purpose, see go.mod's replace # directive) so `go test ./...` above never reaches it. Run its suite # explicitly so the vendored fork's own tests are part of required CI. + # -race: the patches in this fork specifically target a concurrency bug + # (charmbracelet/bubbletea#1690), so its regression test needs the race + # detector to actually catch a reintroduced race rather than passing on + # a benign-looking but still-racy run. - name: Test vendored bubbletea fork (patches/bubbletea-v2) working-directory: patches/bubbletea-v2 - run: go test ./... + run: go test -race ./... - name: Build binary run: go run ./cmd/zero-release build From 5d81745dcb8e7424cba6521d6076af36138bdf6c Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:17:23 -0400 Subject: [PATCH 10/10] fix(tui): drop vendored bubbletea fork; keep stream-clear workaround Maintainers asked not to ship an in-tree bubbletea replace: it breaks versioned go install and is a large maintenance surface for a narrow multipass/Windows Terminal scroll bug. Remove patches/bubbletea-v2 and the replace directive; keep the model.go full-redraw-on-streamed-newline workaround (ZERO_NO_STREAM_CLEAR to opt out). Restore normal install docs. Refs Gitlawb/zero#709 --- .github/workflows/ci.yml | 12 - Makefile | 10 +- README.md | 4 - docs/INSTALL.md | 7 - go.mod | 9 - patches/bubbletea-v2/LICENSE | 21 - patches/bubbletea-v2/clipboard.go | 70 - patches/bubbletea-v2/color.go | 91 -- patches/bubbletea-v2/commands.go | 175 -- patches/bubbletea-v2/commands_test.go | 67 - patches/bubbletea-v2/cursed_renderer.go | 890 ---------- patches/bubbletea-v2/cursed_renderer_test.go | 80 - patches/bubbletea-v2/cursor.go | 28 - patches/bubbletea-v2/environ.go | 34 - patches/bubbletea-v2/exec.go | 129 -- patches/bubbletea-v2/exec_test.go | 149 -- patches/bubbletea-v2/focus.go | 9 - patches/bubbletea-v2/go.mod | 28 - patches/bubbletea-v2/go.sum | 36 - patches/bubbletea-v2/input.go | 54 - patches/bubbletea-v2/key.go | 371 ----- patches/bubbletea-v2/keyboard.go | 59 - patches/bubbletea-v2/logging.go | 53 - patches/bubbletea-v2/logging_test.go | 42 - patches/bubbletea-v2/mod.go | 27 - patches/bubbletea-v2/mouse.go | 144 -- patches/bubbletea-v2/nil_renderer.go | 53 - patches/bubbletea-v2/options.go | 168 -- patches/bubbletea-v2/options_test.go | 106 -- patches/bubbletea-v2/paste.go | 20 - patches/bubbletea-v2/profile.go | 15 - patches/bubbletea-v2/raw.go | 37 - patches/bubbletea-v2/renderer.go | 104 -- patches/bubbletea-v2/screen.go | 68 - patches/bubbletea-v2/screen_test.go | 207 --- patches/bubbletea-v2/signals_unix.go | 33 - patches/bubbletea-v2/signals_windows.go | 10 - patches/bubbletea-v2/tea.go | 1439 ----------------- patches/bubbletea-v2/tea_test.go | 682 -------- patches/bubbletea-v2/termcap.go | 48 - patches/bubbletea-v2/termios_bsd.go | 13 - patches/bubbletea-v2/termios_other.go | 8 - patches/bubbletea-v2/termios_unix.go | 14 - patches/bubbletea-v2/termios_windows.go | 11 - .../TestClearMsg/bg_fg_cur_color.golden | 1 - .../testdata/TestClearMsg/clear_screen.golden | 1 - .../TestClearMsg/read_set_clipboard.golden | 1 - .../testdata/TestViewModel/altscreen.golden | 1 - .../TestViewModel/altscreen_autoexit.golden | 1 - .../TestViewModel/bg_set_color.golden | 1 - .../TestViewModel/bp_stop_start.golden | 1 - .../testdata/TestViewModel/cursor_hide.golden | 1 - .../TestViewModel/cursor_hideshow.golden | 1 - .../kitty_stop_startreleases.golden | 1 - .../TestViewModel/mouse_allmotion.golden | 1 - .../TestViewModel/mouse_cellmotion.golden | 1 - .../TestViewModel/mouse_disable.golden | 1 - patches/bubbletea-v2/tty.go | 136 -- patches/bubbletea-v2/tty_unix.go | 47 - patches/bubbletea-v2/tty_windows.go | 64 - patches/bubbletea-v2/xterm.go | 22 - 61 files changed, 2 insertions(+), 5915 deletions(-) delete mode 100644 patches/bubbletea-v2/LICENSE delete mode 100644 patches/bubbletea-v2/clipboard.go delete mode 100644 patches/bubbletea-v2/color.go delete mode 100644 patches/bubbletea-v2/commands.go delete mode 100644 patches/bubbletea-v2/commands_test.go delete mode 100644 patches/bubbletea-v2/cursed_renderer.go delete mode 100644 patches/bubbletea-v2/cursed_renderer_test.go delete mode 100644 patches/bubbletea-v2/cursor.go delete mode 100644 patches/bubbletea-v2/environ.go delete mode 100644 patches/bubbletea-v2/exec.go delete mode 100644 patches/bubbletea-v2/exec_test.go delete mode 100644 patches/bubbletea-v2/focus.go delete mode 100644 patches/bubbletea-v2/go.mod delete mode 100644 patches/bubbletea-v2/go.sum delete mode 100644 patches/bubbletea-v2/input.go delete mode 100644 patches/bubbletea-v2/key.go delete mode 100644 patches/bubbletea-v2/keyboard.go delete mode 100644 patches/bubbletea-v2/logging.go delete mode 100644 patches/bubbletea-v2/logging_test.go delete mode 100644 patches/bubbletea-v2/mod.go delete mode 100644 patches/bubbletea-v2/mouse.go delete mode 100644 patches/bubbletea-v2/nil_renderer.go delete mode 100644 patches/bubbletea-v2/options.go delete mode 100644 patches/bubbletea-v2/options_test.go delete mode 100644 patches/bubbletea-v2/paste.go delete mode 100644 patches/bubbletea-v2/profile.go delete mode 100644 patches/bubbletea-v2/raw.go delete mode 100644 patches/bubbletea-v2/renderer.go delete mode 100644 patches/bubbletea-v2/screen.go delete mode 100644 patches/bubbletea-v2/screen_test.go delete mode 100644 patches/bubbletea-v2/signals_unix.go delete mode 100644 patches/bubbletea-v2/signals_windows.go delete mode 100644 patches/bubbletea-v2/tea.go delete mode 100644 patches/bubbletea-v2/tea_test.go delete mode 100644 patches/bubbletea-v2/termcap.go delete mode 100644 patches/bubbletea-v2/termios_bsd.go delete mode 100644 patches/bubbletea-v2/termios_other.go delete mode 100644 patches/bubbletea-v2/termios_unix.go delete mode 100644 patches/bubbletea-v2/termios_windows.go delete mode 100644 patches/bubbletea-v2/testdata/TestClearMsg/bg_fg_cur_color.golden delete mode 100644 patches/bubbletea-v2/testdata/TestClearMsg/clear_screen.golden delete mode 100644 patches/bubbletea-v2/testdata/TestClearMsg/read_set_clipboard.golden delete mode 100644 patches/bubbletea-v2/testdata/TestViewModel/altscreen.golden delete mode 100644 patches/bubbletea-v2/testdata/TestViewModel/altscreen_autoexit.golden delete mode 100644 patches/bubbletea-v2/testdata/TestViewModel/bg_set_color.golden delete mode 100644 patches/bubbletea-v2/testdata/TestViewModel/bp_stop_start.golden delete mode 100644 patches/bubbletea-v2/testdata/TestViewModel/cursor_hide.golden delete mode 100644 patches/bubbletea-v2/testdata/TestViewModel/cursor_hideshow.golden delete mode 100644 patches/bubbletea-v2/testdata/TestViewModel/kitty_stop_startreleases.golden delete mode 100644 patches/bubbletea-v2/testdata/TestViewModel/mouse_allmotion.golden delete mode 100644 patches/bubbletea-v2/testdata/TestViewModel/mouse_cellmotion.golden delete mode 100644 patches/bubbletea-v2/testdata/TestViewModel/mouse_disable.golden delete mode 100644 patches/bubbletea-v2/tty.go delete mode 100644 patches/bubbletea-v2/tty_unix.go delete mode 100644 patches/bubbletea-v2/tty_windows.go delete mode 100644 patches/bubbletea-v2/xterm.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 402ae8881..58b335ef6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,18 +48,6 @@ jobs: - name: Test run: go test ./... - # patches/bubbletea-v2 is its own Go module (kept separate from the - # main module's dependency graph on purpose, see go.mod's replace - # directive) so `go test ./...` above never reaches it. Run its suite - # explicitly so the vendored fork's own tests are part of required CI. - # -race: the patches in this fork specifically target a concurrency bug - # (charmbracelet/bubbletea#1690), so its regression test needs the race - # detector to actually catch a reintroduced race rather than passing on - # a benign-looking but still-racy run. - - name: Test vendored bubbletea fork (patches/bubbletea-v2) - working-directory: patches/bubbletea-v2 - run: go test -race ./... - - name: Build binary run: go run ./cmd/zero-release build diff --git a/Makefile b/Makefile index b64be7a5f..4e9734450 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make # lint` before opening a PR" — these targets back those instructions. .DEFAULT_GOAL := build -.PHONY: build build-all test test-race test-patches vet fmt fmt-check lint tidy clean baseline help +.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean baseline help # Build the main CLI binary into ./zero. build: @@ -19,12 +19,6 @@ test: test-quick: go test ./... -# patches/bubbletea-v2 is a separate Go module (see the replace directive in -# go.mod), so it's outside the `./...` pattern above and needs its own -# invocation. Mirrors the CI step that runs it. -test-patches: - cd patches/bubbletea-v2 && go test ./... - vet: go vet ./... @@ -61,4 +55,4 @@ baseline: build --output internal/perfbench/reports/baseline.json help: - @echo "Targets: build (default), build-all, test, test-quick, test-patches, vet, fmt, fmt-check, lint, tidy, clean, baseline" + @echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean, baseline" diff --git a/README.md b/README.md index a717d460e..84fc86baf 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,6 @@ Put `zero` and `zero-linux-sandbox` in the same directory on `PATH` Windows source builds can use the main `zero.exe` as their sandbox helper; release archives still ship standalone Windows helper executables. -`go install github.com/Gitlawb/zero/cmd/zero@` is not supported (the -root `go.mod` has a local `replace` for the vendored TUI fork); clone and build -as shown above instead. - More install details: [docs/INSTALL.md](docs/INSTALL.md). ## First Run diff --git a/docs/INSTALL.md b/docs/INSTALL.md index ce1505dd2..b65a0493f 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -127,13 +127,6 @@ go build -o zero ./cmd/zero Source builds require Go 1.26.5+. -`go install github.com/Gitlawb/zero/cmd/zero@` is not supported. The -root `go.mod` carries a local `replace charm.land/bubbletea/v2 => -./patches/bubbletea-v2` directive for the vendored TUI fork, and `go install` -against a versioned module can't resolve a `replace` that points at a path on -disk, that path only exists in a full checkout. Use `git clone` plus `go -build`/`go run` above instead. - ### Sandbox Helpers For Source Builds Release archives include the platform sandbox helpers. If you build directly diff --git a/go.mod b/go.mod index 9d00196f2..d415c4d35 100644 --- a/go.mod +++ b/go.mod @@ -36,12 +36,3 @@ require ( golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect golang.org/x/sync v0.22.0 // indirect ) - -// Maintained fork of bubbletea/v2 with hard scroll optimization disabled -// unconditionally (see patches/bubbletea-v2/cursed_renderer.go). -// Placed under patches/ (not third_party/) because third_party/ is immutable -// vendored content per AGENTS.md. The hard-scroll path corrupts rendering when -// zero's output is displayed by Windows Terminal over a remote shell (e.g. -// running inside a multipass VM), a case the upstream GOOS=="windows" check -// does not catch. -replace charm.land/bubbletea/v2 => ./patches/bubbletea-v2 diff --git a/patches/bubbletea-v2/LICENSE b/patches/bubbletea-v2/LICENSE deleted file mode 100644 index 01d14e6ae..000000000 --- a/patches/bubbletea-v2/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2020-2026 Charmbracelet, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/patches/bubbletea-v2/clipboard.go b/patches/bubbletea-v2/clipboard.go deleted file mode 100644 index 237a589ed..000000000 --- a/patches/bubbletea-v2/clipboard.go +++ /dev/null @@ -1,70 +0,0 @@ -package tea - -// ClipboardMsg is a clipboard read message event. This message is emitted when -// a terminal receives an OSC52 clipboard read message event. -type ClipboardMsg struct { - Content string - Selection byte -} - -// Clipboard returns the clipboard selection type. This will be one of the -// following values: -// -// - c: System clipboard. -// - p: Primary clipboard (X11/Wayland only). -func (e ClipboardMsg) Clipboard() byte { - return e.Selection -} - -// String returns the string representation of the clipboard message. -func (e ClipboardMsg) String() string { - return e.Content -} - -// setClipboardMsg is an internal message used to set the system clipboard -// using OSC52. -type setClipboardMsg string - -// SetClipboard produces a command that sets the system clipboard using OSC52. -// Note that OSC52 is not supported in all terminals. -func SetClipboard(s string) Cmd { - return func() Msg { - return setClipboardMsg(s) - } -} - -// readClipboardMsg is an internal message used to read the system clipboard -// using OSC52. -type readClipboardMsg struct{} - -// ReadClipboard produces a command that reads the system clipboard using OSC52. -// Note that OSC52 is not supported in all terminals. -func ReadClipboard() Msg { - return readClipboardMsg{} -} - -// setPrimaryClipboardMsg is an internal message used to set the primary -// clipboard using OSC52. -type setPrimaryClipboardMsg string - -// SetPrimaryClipboard produces a command that sets the primary clipboard using -// OSC52. Primary clipboard selection is a feature present in X11 and Wayland -// only. -// Note that OSC52 is not supported in all terminals. -func SetPrimaryClipboard(s string) Cmd { - return func() Msg { - return setPrimaryClipboardMsg(s) - } -} - -// readPrimaryClipboardMsg is an internal message used to read the primary -// clipboard using OSC52. -type readPrimaryClipboardMsg struct{} - -// ReadPrimaryClipboard produces a command that reads the primary clipboard -// using OSC52. Primary clipboard selection is a feature present in X11 and -// Wayland only. -// Note that OSC52 is not supported in all terminals. -func ReadPrimaryClipboard() Msg { - return readPrimaryClipboardMsg{} -} diff --git a/patches/bubbletea-v2/color.go b/patches/bubbletea-v2/color.go deleted file mode 100644 index 54514e4b3..000000000 --- a/patches/bubbletea-v2/color.go +++ /dev/null @@ -1,91 +0,0 @@ -package tea - -import ( - "image/color" - - uv "github.com/charmbracelet/ultraviolet" -) - -// backgroundColorMsg is a message that requests the terminal background color. -type backgroundColorMsg struct{} - -// RequestBackgroundColor is a command that requests the terminal background color. -func RequestBackgroundColor() Msg { - return backgroundColorMsg{} -} - -// foregroundColorMsg is a message that requests the terminal foreground color. -type foregroundColorMsg struct{} - -// RequestForegroundColor is a command that requests the terminal foreground color. -func RequestForegroundColor() Msg { - return foregroundColorMsg{} -} - -// cursorColorMsg is a message that requests the terminal cursor color. -type cursorColorMsg struct{} - -// RequestCursorColor is a command that requests the terminal cursor color. -func RequestCursorColor() Msg { - return cursorColorMsg{} -} - -// ForegroundColorMsg represents a foreground color message. This message is -// emitted when the program requests the terminal foreground color with the -// [RequestForegroundColor] Cmd. -type ForegroundColorMsg struct{ color.Color } - -// String returns the hex representation of the color. -func (e ForegroundColorMsg) String() string { - return uv.ForegroundColorEvent(e).String() -} - -// IsDark returns whether the color is dark. -func (e ForegroundColorMsg) IsDark() bool { - return uv.ForegroundColorEvent(e).IsDark() -} - -// BackgroundColorMsg represents a background color message. This message is -// emitted when the program requests the terminal background color with the -// [RequestBackgroundColor] Cmd. -// -// This is commonly used in [Update.Init] to get the terminal background color -// for style definitions. For that you'll want to call -// [BackgroundColorMsg.IsDark] to determine if the color is dark or light. For -// example: -// -// func (m Model) Init() (Model, Cmd) { -// return m, RequestBackgroundColor() -// } -// -// func (m Model) Update(msg Msg) (Model, Cmd) { -// switch msg := msg.(type) { -// case BackgroundColorMsg: -// m.styles = newStyles(msg.IsDark()) -// } -// } -type BackgroundColorMsg struct{ color.Color } - -// String returns the hex representation of the color. -func (e BackgroundColorMsg) String() string { - return uv.BackgroundColorEvent(e).String() -} - -// IsDark returns whether the color is dark. -func (e BackgroundColorMsg) IsDark() bool { - return uv.BackgroundColorEvent(e).IsDark() -} - -// CursorColorMsg represents a cursor color change message. This message is -// emitted when the program requests the terminal cursor color. -type CursorColorMsg struct{ color.Color } - -// String returns the hex representation of the color. -func (e CursorColorMsg) String() string { - return uv.CursorColorEvent(e).String() -} - -// IsDark returns whether the color is dark. -func (e CursorColorMsg) IsDark() bool { - return uv.CursorColorEvent(e).IsDark() -} diff --git a/patches/bubbletea-v2/commands.go b/patches/bubbletea-v2/commands.go deleted file mode 100644 index 4497c3a38..000000000 --- a/patches/bubbletea-v2/commands.go +++ /dev/null @@ -1,175 +0,0 @@ -package tea - -import ( - "time" -) - -// Batch performs a bunch of commands concurrently with no ordering guarantees -// about the results. Use a Batch to return several commands. -// -// Example: -// -// func (m model) Init() (Model, Cmd) { -// return m, tea.Batch(someCommand, someOtherCommand) -// } -func Batch(cmds ...Cmd) Cmd { - return compactCmds[BatchMsg](cmds) -} - -// BatchMsg is a message used to perform a bunch of commands concurrently with -// no ordering guarantees. You can send a BatchMsg with Batch. -type BatchMsg []Cmd - -// Sequence runs the given commands one at a time, in order. Contrast this with -// Batch, which runs commands concurrently. -func Sequence(cmds ...Cmd) Cmd { - return compactCmds[sequenceMsg](cmds) -} - -// sequenceMsg is used internally to run the given commands in order. -type sequenceMsg []Cmd - -// compactCmds ignores any nil commands in cmds, and returns the most direct -// command possible. That is, considering the non-nil commands, if there are -// none it returns nil, if there is exactly one it returns that command -// directly, else it returns the non-nil commands as type T. -func compactCmds[T ~[]Cmd](cmds []Cmd) Cmd { - var validCmds []Cmd - for _, c := range cmds { - if c == nil { - continue - } - validCmds = append(validCmds, c) - } - switch len(validCmds) { - case 0: - return nil - case 1: - return validCmds[0] - default: - return func() Msg { - return T(validCmds) - } - } -} - -// Every is a command that ticks in sync with the system clock. So, if you -// wanted to tick with the system clock every second, minute or hour you -// could use this. It's also handy for having different things tick in sync. -// -// Because we're ticking with the system clock the tick will likely not run for -// the entire specified duration. For example, if we're ticking for one minute -// and the clock is at 12:34:20 then the next tick will happen at 12:35:00, 40 -// seconds later. -// -// To produce the command, pass a duration and a function which returns -// a message containing the time at which the tick occurred. -// -// type TickMsg time.Time -// -// cmd := Every(time.Second, func(t time.Time) Msg { -// return TickMsg(t) -// }) -// -// Beginners' note: Every sends a single message and won't automatically -// dispatch messages at an interval. To do that, you'll want to return another -// Every command after receiving your tick message. For example: -// -// type TickMsg time.Time -// -// // Send a message every second. -// func tickEvery() Cmd { -// return Every(time.Second, func(t time.Time) Msg { -// return TickMsg(t) -// }) -// } -// -// func (m model) Init() (Model, Cmd) { -// // Start ticking. -// return m, tickEvery() -// } -// -// func (m model) Update(msg Msg) (Model, Cmd) { -// switch msg.(type) { -// case TickMsg: -// // Return your Every command again to loop. -// return m, tickEvery() -// } -// return m, nil -// } -// -// Every is analogous to Tick in the Elm Architecture. -func Every(duration time.Duration, fn func(time.Time) Msg) Cmd { - n := time.Now() - d := n.Truncate(duration).Add(duration).Sub(n) - t := time.NewTimer(d) - return func() Msg { - ts := <-t.C - t.Stop() - for len(t.C) > 0 { - <-t.C - } - return fn(ts) - } -} - -// Tick produces a command at an interval independent of the system clock at -// the given duration. That is, the timer begins precisely when invoked, -// and runs for its entire duration. -// -// To produce the command, pass a duration and a function which returns -// a message containing the time at which the tick occurred. -// -// type TickMsg time.Time -// -// cmd := Tick(time.Second, func(t time.Time) Msg { -// return TickMsg(t) -// }) -// -// Beginners' note: Tick sends a single message and won't automatically -// dispatch messages at an interval. To do that, you'll want to return another -// Tick command after receiving your tick message. For example: -// -// type TickMsg time.Time -// -// func doTick() Cmd { -// return Tick(time.Second, func(t time.Time) Msg { -// return TickMsg(t) -// }) -// } -// -// func (m model) Init() (Model, Cmd) { -// // Start ticking. -// return m, doTick() -// } -// -// func (m model) Update(msg Msg) (Model, Cmd) { -// switch msg.(type) { -// case TickMsg: -// // Return your Tick command again to loop. -// return m, doTick() -// } -// return m, nil -// } -func Tick(d time.Duration, fn func(time.Time) Msg) Cmd { - t := time.NewTimer(d) - return func() Msg { - ts := <-t.C - t.Stop() - for len(t.C) > 0 { - <-t.C - } - return fn(ts) - } -} - -type windowSizeMsg struct{} - -// RequestWindowSize is a command that queries the terminal for its current -// size. It delivers the results to Update via a [WindowSizeMsg]. Keep in mind -// that WindowSizeMsgs will automatically be delivered to Update when the -// [Program] starts and when the window dimensions change so in many cases you -// will not need to explicitly invoke this command. -func RequestWindowSize() Msg { - return windowSizeMsg{} -} diff --git a/patches/bubbletea-v2/commands_test.go b/patches/bubbletea-v2/commands_test.go deleted file mode 100644 index d647ea9ec..000000000 --- a/patches/bubbletea-v2/commands_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package tea - -import ( - "testing" - "time" -) - -func TestEvery(t *testing.T) { - t.Parallel() - expected := "every ms" - msg := Every(time.Millisecond, func(t time.Time) Msg { - return expected - })() - if expected != msg { - t.Fatalf("expected a msg %v but got %v", expected, msg) - } -} - -func TestTick(t *testing.T) { - t.Parallel() - expected := "tick" - msg := Tick(time.Millisecond, func(t time.Time) Msg { - return expected - })() - if expected != msg { - t.Fatalf("expected a msg %v but got %v", expected, msg) - } -} - -func TestBatch(t *testing.T) { - t.Parallel() - testMultipleCommands[BatchMsg](t, Batch) -} - -func TestSequence(t *testing.T) { - t.Parallel() - testMultipleCommands[sequenceMsg](t, Sequence) -} - -func testMultipleCommands[T ~[]Cmd](t *testing.T, createFn func(cmd ...Cmd) Cmd) { - t.Run("nil cmd", func(t *testing.T) { - t.Parallel() - if b := createFn(nil); b != nil { - t.Fatalf("expected nil, got %+v", b) - } - }) - t.Run("empty cmd", func(t *testing.T) { - t.Parallel() - if b := createFn(); b != nil { - t.Fatalf("expected nil, got %+v", b) - } - }) - t.Run("single cmd", func(t *testing.T) { - t.Parallel() - b := createFn(Quit)() - if _, ok := b.(QuitMsg); !ok { - t.Fatalf("expected a QuitMsg, got %T", b) - } - }) - t.Run("mixed nil cmds", func(t *testing.T) { - t.Parallel() - b := createFn(nil, Quit, nil, Quit, nil, nil)() - if l := len(b.(T)); l != 2 { - t.Fatalf("expected a []Cmd with len 2, got %d", l) - } - }) -} diff --git a/patches/bubbletea-v2/cursed_renderer.go b/patches/bubbletea-v2/cursed_renderer.go deleted file mode 100644 index e2df5bf17..000000000 --- a/patches/bubbletea-v2/cursed_renderer.go +++ /dev/null @@ -1,890 +0,0 @@ -package tea - -import ( - "bytes" - "fmt" - "image/color" - "io" - "strings" - "sync" - - "github.com/charmbracelet/colorprofile" - uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/ansi" - "github.com/lucasb-eyer/go-colorful" -) - -type cursedRenderer struct { - w io.Writer - buf bytes.Buffer // updates buffer to be flushed to [w] - scr *uv.TerminalRenderer - cellbuf uv.ScreenBuffer - lastView *View - env []string - term string // the terminal type $TERM - width, height int - mu sync.Mutex - profile colorprofile.Profile - logger uv.Logger - view View - hardTabs bool // whether to use hard tabs to optimize cursor movements - backspace bool // whether to use backspace to optimize cursor movements - mapnl bool - syncdUpdates bool // whether to use synchronized output mode for updates - starting bool // indicates whether the renderer is starting after being stopped -} - -var _ renderer = &cursedRenderer{} - -func newCursedRenderer(w io.Writer, env []string, width, height int) (s *cursedRenderer) { - s = new(cursedRenderer) - s.w = w - s.env = env - s.term = uv.Environ(env).Getenv("TERM") - s.width, s.height = width, height // This needs to happen before [cursedRenderer.reset]. - s.cellbuf = uv.NewScreenBuffer(s.width, s.height) - reset(s) - return -} - -// setLogger sets the logger for the renderer. -func (s *cursedRenderer) setLogger(logger uv.Logger) { - s.mu.Lock() - s.logger = logger - s.mu.Unlock() -} - -// setOptimizations sets the cursor movement optimizations. -func (s *cursedRenderer) setOptimizations(hardTabs, backspace, mapnl bool) { - s.mu.Lock() - s.hardTabs = hardTabs - s.backspace = backspace - s.mapnl = mapnl - if s.hardTabs { - s.scr.SetTabStops(s.width) - } else { - s.scr.SetTabStops(-1) - } - s.scr.SetBackspace(s.backspace) - s.scr.SetMapNewline(s.mapnl) - s.mu.Unlock() -} - -// start implements renderer. -func (s *cursedRenderer) start() { - s.mu.Lock() - defer s.mu.Unlock() - - // Mark that we're starting. This is used to restore some state when - // starting the renderer again after it was stopped. - s.starting = true - - if s.lastView == nil { - return - } - - if s.lastView.AltScreen { - enableAltScreen(s, true, true) - } - enableTextCursor(s, s.lastView.Cursor != nil) - if s.lastView.Cursor != nil { - if s.lastView.Cursor.Color != nil { - col, ok := colorful.MakeColor(s.lastView.Cursor.Color) - if ok { - _, _ = s.scr.WriteString(ansi.SetCursorColor(col.Hex())) - } - } - curStyle := encodeCursorStyle(s.lastView.Cursor.Shape, s.lastView.Cursor.Blink) - if curStyle != 0 && curStyle != 1 { - _, _ = s.scr.WriteString(ansi.SetCursorStyle(curStyle)) - } - } - if s.lastView.ForegroundColor != nil { - col, ok := colorful.MakeColor(s.lastView.ForegroundColor) - if ok { - _, _ = s.scr.WriteString(ansi.SetForegroundColor(col.Hex())) - } - } - if s.lastView.BackgroundColor != nil { - col, ok := colorful.MakeColor(s.lastView.BackgroundColor) - if ok { - _, _ = s.scr.WriteString(ansi.SetBackgroundColor(col.Hex())) - } - } - if !s.lastView.DisableBracketedPasteMode { - _, _ = s.scr.WriteString(ansi.SetModeBracketedPaste) - } - if s.lastView.ReportFocus { - _, _ = s.scr.WriteString(ansi.SetModeFocusEvent) - } - switch s.lastView.MouseMode { - case MouseModeNone: - case MouseModeCellMotion: - _, _ = s.scr.WriteString(ansi.SetModeMouseButtonEvent + ansi.SetModeMouseExtSgr) - case MouseModeAllMotion: - _, _ = s.scr.WriteString(ansi.SetModeMouseAnyEvent + ansi.SetModeMouseExtSgr) - } - if s.lastView.WindowTitle != "" { - _, _ = s.scr.WriteString(ansi.SetWindowTitle(s.lastView.WindowTitle)) - } - if s.lastView.ProgressBar != nil { - setProgressBar(s, s.lastView.ProgressBar) - } - // Enable modifyOtherKeys and Kitty keyboard protocol. - // Both can coexist; terminals ignore what they don't support. - _, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2) - - kittyFlags := keyboardEnhancementsFlags(s.lastView.KeyboardEnhancements) - _, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1)) -} - -// close implements renderer. -func (s *cursedRenderer) close() (err error) { - s.mu.Lock() - defer s.mu.Unlock() - - // Exit the altScreen and show cursor before closing. It's important that - // we don't change the [cursedRenderer] altScreen and cursorHidden states - // so that we can restore them when we start the renderer again. This is - // used when the user suspends the program and then resumes it. - if lv := s.lastView; lv != nil { //nolint:nestif - // NOTE: The Kitty keyboard specs specify that the terminal should have - // two registries for the main and alt screens. We disable keyboard - // enhancements whenever we enter/exit alt screen mode in - // [cursedRenderer.flush]. - // Here, we reset the keyboard protocol of the last screen used - // assuming the other screen is already reset when we switched screens. - _, _ = s.buf.WriteString(ansi.ResetModifyOtherKeys) - _, _ = s.buf.WriteString(ansi.KittyKeyboard(0, 1)) - - // Go to the bottom of the screen. - // We need to go to the bottom of the screen regardless of whether - // we're in alt screen mode or not to avoid leaving the cursor in the - // middle in terminals that don't support alt screen mode. - s.scr.MoveTo(0, s.cellbuf.Height()-1) - _ = s.scr.Flush() // we need to flush to write the cursor movement - if lv.AltScreen { - enableAltScreen(s, false, true) - } else { - _, _ = s.scr.WriteString(ansi.EraseScreenBelow) - } - if lv.Cursor == nil { - enableTextCursor(s, true) - } - if !lv.DisableBracketedPasteMode { - _, _ = s.scr.WriteString(ansi.ResetModeBracketedPaste) - } - if lv.ReportFocus { - _, _ = s.scr.WriteString(ansi.ResetModeFocusEvent) - } - switch lv.MouseMode { - case MouseModeNone: - case MouseModeCellMotion, MouseModeAllMotion: - _, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent + - ansi.ResetModeMouseAnyEvent + - ansi.ResetModeMouseExtSgr) - } - - if lv.WindowTitle != "" { - // Clear the window title if it was set. - _, _ = s.scr.WriteString(ansi.SetWindowTitle("")) - } - if lc := lv.Cursor; lc != nil { - curShape := encodeCursorStyle(lc.Shape, lc.Blink) - if curShape != 0 && curShape != 1 { - // Reset the cursor style to default if it was set to something other - // blinking block. - _, _ = s.scr.WriteString(ansi.SetCursorStyle(0)) - } - - if lc.Color != nil { - _, _ = s.scr.WriteString(ansi.ResetCursorColor) - } - } - - if lv.BackgroundColor != nil { - _, _ = s.scr.WriteString(ansi.ResetBackgroundColor) - } - if lv.ForegroundColor != nil { - _, _ = s.scr.WriteString(ansi.ResetForegroundColor) - } - if lv.ProgressBar != nil && lv.ProgressBar.State != ProgressBarNone { - _, _ = s.scr.WriteString(ansi.ResetProgressBar) - } - } - - if s.cellbuf.Method == ansi.GraphemeWidth { - // Make sure to turn off Unicode mode (2027) - _, _ = s.scr.WriteString(ansi.ResetModeUnicodeCore) - } - - if err := s.scr.Flush(); err != nil { - return fmt.Errorf("bubbletea: error closing screen writer: %w", err) - } - - if s.buf.Len() > 0 { - if s.logger != nil { - s.logger.Printf("output: %q", s.buf.String()) - } - if _, err := io.Copy(s.w, &s.buf); err != nil { - return fmt.Errorf("bubbletea: error writing to screen: %w", err) - } - s.buf.Reset() - } - - x, y := s.scr.Position() - - // We want to clear the renderer state but not the cursor position. This is - // because we might be putting the tea process in the background, run some - // other process, and then return to the tea process. We want to keep the - // cursor position so that we can continue where we left off. - reset(s) - s.scr.SetPosition(x, y) - - return nil -} - -// writeString implements renderer. -func (s *cursedRenderer) writeString(str string) (int, error) { - s.mu.Lock() - defer s.mu.Unlock() - - return s.scr.WriteString(str) //nolint:wrapcheck -} - -// flush implements renderer. -func (s *cursedRenderer) flush(closing bool) error { - s.mu.Lock() - defer s.mu.Unlock() - - view := s.view - frameArea := uv.Rect(0, 0, s.width, s.height) - if len(view.Content) == 0 { - // If the component is nil, we should clear the screen buffer. - frameArea.Max.Y = 0 - } - - content := uv.NewStyledString(view.Content) - if !view.AltScreen { - // We need to resizes the screen based on the frame height and - // terminal width. This is because the frame height can change based on - // the content of the frame. For example, if the frame contains a list - // of items, the height of the frame will be the number of items in the - // list. This is different from the alt screen buffer, which has a - // fixed height and width. - frameHeight := content.Height() - if frameHeight != frameArea.Dy() { - frameArea.Max.Y = frameHeight - } - } - - // Restore tab stops if we have tab optimizations enabled. - if s.starting && s.hardTabs { - _, _ = s.scr.WriteString(ansi.SetTabEvery8Columns) - } - - if !s.starting && !closing && s.lastView != nil && viewEquals(s.lastView, &view) && frameArea == s.cellbuf.Bounds() { - // No changes, nothing to do. - return nil - } - - // We're no longer starting. - s.starting = false - - if frameArea != s.cellbuf.Bounds() { - s.scr.Erase() // Force a full redraw to avoid artifacts. - - // We need to reset the touched lines buffer to match the new height. - s.cellbuf.Touched = nil - - // Resize the screen buffer to match the frame area. This is necessary - // to ensure that the screen buffer is the same size as the frame area - // and to avoid rendering issues when the frame area is smaller than - // the screen buffer. - s.cellbuf.Resize(frameArea.Dx(), frameArea.Dy()) - } - - // Clear our screen buffer before copying the new frame into it to ensure - // we erase any old content. - s.cellbuf.Clear() - content.Draw(s.cellbuf, s.cellbuf.Bounds()) - - // If the frame height is greater than the screen height, we drop the - // lines from the top of the buffer. - if frameHeight := frameArea.Dy(); frameHeight > s.height { - s.cellbuf.Lines = s.cellbuf.Lines[frameHeight-s.height:] - } - - // Alt screen mode. - shouldUpdateAltScreen := (s.lastView == nil && view.AltScreen) || (s.lastView != nil && s.lastView.AltScreen != view.AltScreen) - if shouldUpdateAltScreen { - // We want to enter/exit altscreen mode but defer writing the actual - // sequences until we flush the rest of the updates. This is because we - // control the cursor visibility and we need to ensure that happens - // after entering/exiting alt screen mode. Some terminals have - // different cursor visibility states for main and alt screen modes and - // this ensures we handle that correctly. - enableAltScreen(s, view.AltScreen, false) - } - - // bracketed paste mode. - if s.lastView == nil || view.DisableBracketedPasteMode != s.lastView.DisableBracketedPasteMode { - if !view.DisableBracketedPasteMode { - _, _ = s.scr.WriteString(ansi.SetModeBracketedPaste) - } else if s.lastView != nil { - _, _ = s.scr.WriteString(ansi.ResetModeBracketedPaste) - } - } - - // report focus events mode. - if s.lastView == nil || s.lastView.ReportFocus != view.ReportFocus { - if view.ReportFocus { - _, _ = s.scr.WriteString(ansi.SetModeFocusEvent) - } else if s.lastView != nil { - _, _ = s.scr.WriteString(ansi.ResetModeFocusEvent) - } - } - - // mouse events mode. - if s.lastView == nil || view.MouseMode != s.lastView.MouseMode { - switch view.MouseMode { - case MouseModeNone: - if s.lastView != nil && s.lastView.MouseMode != MouseModeNone { - _, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent + - ansi.ResetModeMouseAnyEvent + - ansi.ResetModeMouseExtSgr) - } - case MouseModeCellMotion: - if s.lastView != nil && s.lastView.MouseMode == MouseModeAllMotion { - _, _ = s.scr.WriteString(ansi.ResetModeMouseAnyEvent) - } - _, _ = s.scr.WriteString(ansi.SetModeMouseButtonEvent + ansi.SetModeMouseExtSgr) - case MouseModeAllMotion: - if s.lastView != nil && s.lastView.MouseMode == MouseModeCellMotion { - _, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent) - } - _, _ = s.scr.WriteString(ansi.SetModeMouseAnyEvent + ansi.SetModeMouseExtSgr) - } - } - - // Set window title. - if s.lastView == nil || view.WindowTitle != s.lastView.WindowTitle { - if s.lastView != nil || view.WindowTitle != "" { - _, _ = s.scr.WriteString(ansi.SetWindowTitle(view.WindowTitle)) - } - } - - // kitty keyboard protocol - if s.lastView == nil || view.KeyboardEnhancements != s.lastView.KeyboardEnhancements || - view.AltScreen != s.lastView.AltScreen { - // NOTE: We need to reset the keyboard protocol when switching - // between main and alt screen. This is because the specs specify - // two different states for the main and alt screen. - - // Enable modifyOtherKeys and Kitty keyboard protocol. - _, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2) - - kittyFlags := keyboardEnhancementsFlags(view.KeyboardEnhancements) - _, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1)) - if !closing { - // Request keyboard enhancements when they change - _, _ = s.scr.WriteString(ansi.RequestKittyKeyboard) - } - } - - // Set terminal colors. - var ( - cc, lcc color.Color - lfg, lbg color.Color - ) - if view.Cursor != nil { - cc = view.Cursor.Color - } - if s.lastView != nil { - if s.lastView.Cursor != nil { - lcc = s.lastView.Cursor.Color - } - lfg = s.lastView.ForegroundColor - lbg = s.lastView.BackgroundColor - } - for _, c := range []struct { - newColor color.Color - oldColor color.Color - reset string - setter func(string) string - }{ - {newColor: cc, oldColor: lcc, reset: ansi.ResetCursorColor, setter: ansi.SetCursorColor}, - {newColor: view.ForegroundColor, oldColor: lfg, reset: ansi.ResetForegroundColor, setter: ansi.SetForegroundColor}, - {newColor: view.BackgroundColor, oldColor: lbg, reset: ansi.ResetBackgroundColor, setter: ansi.SetBackgroundColor}, - } { - if !colorsEqual(c.newColor, c.oldColor) { - if c.newColor == nil { - // Reset the color if it was set to nil. - _, _ = s.scr.WriteString(c.reset) - } else { - // Set the color. - col, ok := colorful.MakeColor(c.newColor) - if ok { - _, _ = s.scr.WriteString(c.setter(col.Hex())) - } - } - } - } - - // Set cursor shape and blink if set. - var ccStyle, lcStyle int - var lcur *Cursor - ccur := view.Cursor - if lv := s.lastView; lv != nil { - lcur = lv.Cursor - } - if ccur != nil { - ccStyle = encodeCursorStyle(ccur.Shape, ccur.Blink) - } - if lcur != nil { - lcStyle = encodeCursorStyle(lcur.Shape, lcur.Blink) - } - if ccStyle != lcStyle { - _, _ = s.scr.WriteString(ansi.SetCursorStyle(ccStyle)) - } - - // Render progress bar if it's changed. - if (s.lastView == nil && view.ProgressBar != nil && view.ProgressBar.State != ProgressBarNone) || - (s.lastView != nil && (s.lastView.ProgressBar == nil) != (view.ProgressBar == nil)) || - (s.lastView != nil && s.lastView.ProgressBar != nil && view.ProgressBar != nil && *s.lastView.ProgressBar != *view.ProgressBar) { - // Render or clear the progress bar if it was added or removed. - setProgressBar(s, view.ProgressBar) - } - - // Render and queue changes to the screen buffer. - s.scr.Render(s.cellbuf.RenderBuffer) - - if cur := view.Cursor; cur != nil { - // MoveTo must come after [uv.TerminalRenderer.Render] because the - // cursor position might get updated during rendering. - s.scr.MoveTo(view.Cursor.X, view.Cursor.Y) - } else if !view.AltScreen { - // We don't want the cursor to be dangling at the end of the line in - // inline mode because it can cause unwanted line wraps in some - // terminals. So we move it to the beginning of the next line if - // necessary. - // This is only needed when the cursor is hidden because when it's - // visible, we already set its position above. - x, y := s.scr.Position() - if x >= s.width-1 { - s.scr.MoveTo(0, y) - } - } - - if err := s.scr.Flush(); err != nil { - return fmt.Errorf("bubbletea: error flushing screen writer: %w", err) - } - - // Check if we have any render updates to flush. - hasUpdates := s.buf.Len() > 0 - - // Cursor visibility. - didShowCursor := s.lastView != nil && s.lastView.Cursor != nil - showCursor := view.Cursor != nil - hideCursor := !showCursor - shouldUpdateCursorVis := (s.lastView == nil || didShowCursor != showCursor) || shouldUpdateAltScreen - - // Build final output buffer with synchronized output or hide/show cursor - // updates. But first, enter/exit alt screen mode if needed. - // - // Here, we have two scenarios: - // 1. Synchronized output updates are supported. In this case, we want to - // wrap all updates, unless it's just a cursor visibility change, in - // synchronized output mode. This is because synchronized output mode - // takes care of rendering the updates atomically. In the case of - // just a cursor visibility change, we don't need to enter - // synchronized output mode because it's just a single sequence to - // flush out to the terminal. - // - // 2. We don't have synchronized output updates support. In this case, and - // if the cursor is visible or should be visible, we wrap the updates - // with hide/show cursor sequences to try and mitigate cursor - // flickering. This is terminal dependent and may still result in - // flickering in some terminals. It's the best effort we can do instead - // of showing the cursor flying around the screen during updates. - - var buf bytes.Buffer - if shouldUpdateAltScreen { - // We always disable keyboard enhancements when switching screens - // because the terminal is expected to have two different keyboard - // registries for main and alt screens. - _, _ = buf.WriteString(ansi.ResetModifyOtherKeys) - _, _ = buf.WriteString(ansi.KittyKeyboard(0, 1)) - if view.AltScreen { - // Entering alt screen mode. - buf.WriteString(ansi.SetModeAltScreenSaveCursor) - } else { - // Exiting alt screen mode. - buf.WriteString(ansi.ResetModeAltScreenSaveCursor) - } - } - - if s.syncdUpdates { - if hasUpdates { - // We have synchronized output updates enabled. - buf.WriteString(ansi.SetModeSynchronizedOutput) - } - if shouldUpdateCursorVis && hideCursor { - // Do we need to update the cursor visibility to hidden? If so, do - // it here before writing any updates to the buffer. - _, _ = buf.WriteString(ansi.ResetModeTextCursorEnable) - } - } else if (shouldUpdateCursorVis && hideCursor) || (hasUpdates && showCursor && didShowCursor) { - _, _ = buf.WriteString(ansi.ResetModeTextCursorEnable) - } - - if hasUpdates { - buf.Write(s.buf.Bytes()) - } - - if s.syncdUpdates { - if shouldUpdateCursorVis && showCursor { - // Do we need to update the cursor visibility to visible? If so, do - // it here after writing any updates to the buffer. - _, _ = buf.WriteString(ansi.SetModeTextCursorEnable) - } - if hasUpdates { - // Close synchronized output mode. - buf.WriteString(ansi.ResetModeSynchronizedOutput) - } - } else if (shouldUpdateCursorVis && showCursor) || (hasUpdates && showCursor && didShowCursor) { - _, _ = buf.WriteString(ansi.SetModeTextCursorEnable) - } - - // Reset internal screen renderer buffer. - s.buf.Reset() - - // If our updates flush buffer has content, write it to the output writer. - if buf.Len() > 0 { - if s.logger != nil { - s.logger.Printf("output: %q", buf.String()) - } - if _, err := io.Copy(s.w, &buf); err != nil { - return fmt.Errorf("bubbletea: error flushing update to the writer: %w", err) - } - } - - s.lastView = &view - - return nil -} - -// render implements renderer. -func (s *cursedRenderer) render(v View) { - s.mu.Lock() - defer s.mu.Unlock() - - s.view = v -} - -// reset implements renderer. -func (s *cursedRenderer) reset() { - s.mu.Lock() - reset(s) - s.mu.Unlock() -} - -func reset(s *cursedRenderer) { - s.buf.Reset() - scr := uv.NewTerminalRenderer(&s.buf, s.env) - scr.SetColorProfile(s.profile) - scr.SetRelativeCursor(true) // Always start in inline mode - scr.SetFullscreen(false) // Always start in inline mode - if s.hardTabs { - scr.SetTabStops(s.width) - } else { - scr.SetTabStops(-1) - } - scr.SetBackspace(s.backspace) - scr.SetMapNewline(s.mapnl) - // Hard scroll optimization (using terminal scroll-region sequences to - // shift existing lines instead of repainting) corrupts rendering badly - // on terminals that mishandle it. Upstream only disables this on - // GOOS=="windows", but that only catches a Windows terminal driving the - // program directly — it misses a Linux process (e.g. inside a multipass - // VM) whose output is ultimately displayed by Windows Terminal over a - // remote shell, which hits the same class of bug. There's no reliable - // way to detect that hop from in here, so disable scroll optimization - // unconditionally rather than trying to guess. - scr.SetScrollOptim(false) - s.scr = scr -} - -// setColorProfile implements renderer. -func (s *cursedRenderer) setColorProfile(p colorprofile.Profile) { - s.mu.Lock() - s.profile = p - s.scr.SetColorProfile(p) - s.mu.Unlock() -} - -// resize implements renderer. -func (s *cursedRenderer) resize(w, h int) { - s.mu.Lock() - // We need to mark the screen for clear to force a redraw. However, we - // only do so if we're using alt screen or the width has changed. - // That's because redrawing is expensive and we can avoid it if the - // width hasn't changed in inline mode. On the other hand, when using - // alt screen mode, we always want to redraw because some terminals - // would scroll the screen and our content would be lost. - s.scr.Erase() - s.width, s.height = w, h - s.scr.Resize(s.width, s.height) - s.mu.Unlock() -} - -// clearScreen implements renderer. -func (s *cursedRenderer) clearScreen() { - s.mu.Lock() - // Move the cursor to the top left corner of the screen and trigger a full - // screen redraw. - s.scr.MoveTo(0, 0) - s.scr.Erase() - // Invalidate the last view: without this, a render of an unchanged view - // short-circuits on viewEquals and the queued erase sits in the buffer - // until an unrelated view change (or shutdown) flushes it — exactly the - // reordering that leaves the ghost caret this call is meant to repair. - s.lastView = nil - s.mu.Unlock() -} - -// enableAltScreen sets the alt screen mode. -// Note that this writes to the buffer directly if write is true. -func enableAltScreen(s *cursedRenderer, enable bool, write bool) { - if enable { - enterAltScreen(s, write) - } else { - exitAltScreen(s, write) - } -} - -func enterAltScreen(s *cursedRenderer, write bool) { - s.scr.SaveCursor() - if write { - s.buf.WriteString(ansi.SetModeAltScreenSaveCursor) - } - s.scr.SetFullscreen(true) - s.scr.SetRelativeCursor(false) - s.scr.Erase() -} - -func exitAltScreen(s *cursedRenderer, write bool) { - s.scr.Erase() - s.scr.SetRelativeCursor(true) - s.scr.SetFullscreen(false) - if write { - s.buf.WriteString(ansi.ResetModeAltScreenSaveCursor) - } - s.scr.RestoreCursor() -} - -// enableTextCursor sets the text cursor mode. -func enableTextCursor(s *cursedRenderer, enable bool) { - if enable { - _, _ = s.scr.WriteString(ansi.SetModeTextCursorEnable) - } else { - _, _ = s.scr.WriteString(ansi.ResetModeTextCursorEnable) - } -} - -// setSyncdUpdates implements renderer. -func (s *cursedRenderer) setSyncdUpdates(syncd bool) { - s.mu.Lock() - s.syncdUpdates = syncd - s.mu.Unlock() -} - -// setWidthMethod implements renderer. -func (s *cursedRenderer) setWidthMethod(method ansi.Method) { - s.mu.Lock() - if method == ansi.GraphemeWidth { - // Turn on Unicode mode (2027) for accurate grapheme width calculation. - // This is needed for proper rendering of wide characters and emojis. - _, _ = s.scr.WriteString(ansi.SetModeUnicodeCore) - } else if s.cellbuf.Method == ansi.GraphemeWidth { - // Turn off Unicode mode if we're switching away from grapheme width - // calculation to avoid issues with some terminals that might still be - // in Unicode mode and render characters incorrectly. - _, _ = s.scr.WriteString(ansi.ResetModeUnicodeCore) - } - s.cellbuf.Method = method - s.mu.Unlock() -} - -// insertAbove implements renderer. -func (s *cursedRenderer) insertAbove(str string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if len(str) == 0 { - return nil - } - - var sb strings.Builder - w, h := s.cellbuf.Width(), s.cellbuf.Height() - _, y := s.scr.Position() - - // We need to scroll the screen up by the number of lines in the queue. - sb.WriteByte('\r') - down := h - y - 1 - if down > 0 { - sb.WriteString(ansi.CursorDown(down)) - } - - lines := strings.Split(str, "\n") - offset := len(lines) - for _, line := range lines { - lineWidth := ansi.StringWidth(line) - if w > 0 && lineWidth > w { - // Rows beyond the first: ceil(lineWidth/w) - 1. Plain - // lineWidth/w over-counts an exact multiple (a 160-cell line in - // an 80-column terminal needs one extra row, not two), leaving - // blank lines in unmanaged output. - offset += (lineWidth - 1) / w - } - } - - // Scroll the screen up by the offset to make room for the new lines. - sb.WriteString(strings.Repeat("\n", offset)) - - // XXX: Now go to the top of the screen, insert new lines, and write - // the queued strings. It is important to use [Screen.moveCursor] - // instead of [Screen.move] because we don't want to perform any checks - // on the cursor position. - up := offset + h - 1 - sb.WriteString(ansi.CursorUp(up)) - sb.WriteString(ansi.InsertLine(offset)) - for _, line := range lines { - sb.WriteString(line) - sb.WriteString(ansi.EraseLineRight) - sb.WriteString("\r\n") - } - - s.scr.SetPosition(0, 0) - - if s.logger != nil { - s.logger.Printf("insert above: %q", sb.String()) - } - - _, err := io.WriteString(s.w, sb.String()) - if err != nil { - return fmt.Errorf("bubbletea: error writing insert above to the writer: %w", err) - } - - return nil -} - -// onMouse implements renderer. -func (s *cursedRenderer) onMouse(m MouseMsg) Cmd { - var onMouse func(MouseMsg) Cmd - s.mu.Lock() - if s.lastView != nil { - onMouse = s.lastView.OnMouse - } - s.mu.Unlock() - if onMouse != nil { - return onMouse(m) - } - return nil -} - -func setProgressBar(s *cursedRenderer, pb *ProgressBar) { - if pb == nil { - _, _ = s.scr.WriteString(ansi.ResetProgressBar) - return - } - - var seq string - switch pb.State { - case ProgressBarNone: - seq = ansi.ResetProgressBar - case ProgressBarDefault: - seq = ansi.SetProgressBar(pb.Value) - case ProgressBarError: - seq = ansi.SetErrorProgressBar(pb.Value) - case ProgressBarIndeterminate: - seq = ansi.SetIndeterminateProgressBar - case ProgressBarWarning: - seq = ansi.SetWarningProgressBar(pb.Value) - } - if seq != "" { - _, _ = s.scr.WriteString(seq) - } -} - -// colorsEqual compares two possibly-nil color.Color values by normalized -// RGBA. Direct interface equality (==) panics when a caller supplies a -// non-comparable implementation and treats equal colors held in different -// concrete types as different. -func colorsEqual(a, b color.Color) bool { - if a == nil || b == nil { - return a == nil && b == nil - } - ar, ag, ab, aa := a.RGBA() - br, bg, bb, ba := b.RGBA() - return ar == br && ag == bg && ab == bb && aa == ba -} - -func viewEquals(a, b *View) bool { - if a == nil || b == nil { - return false - } - - if a.Content != b.Content || - a.AltScreen != b.AltScreen || - a.DisableBracketedPasteMode != b.DisableBracketedPasteMode || - a.ReportFocus != b.ReportFocus || - a.MouseMode != b.MouseMode || - a.WindowTitle != b.WindowTitle || - !colorsEqual(a.ForegroundColor, b.ForegroundColor) || - !colorsEqual(a.BackgroundColor, b.BackgroundColor) || - a.KeyboardEnhancements != b.KeyboardEnhancements { - return false - } - - if (a.Cursor == nil) != (b.Cursor == nil) { - return false - } - if a.Cursor != nil && b.Cursor != nil { - if a.Cursor.X != b.Cursor.X || - a.Cursor.Y != b.Cursor.Y || - a.Cursor.Shape != b.Cursor.Shape || - a.Cursor.Blink != b.Cursor.Blink || - !colorsEqual(a.Cursor.Color, b.Cursor.Color) { - return false - } - } - - if (a.ProgressBar == nil) != (b.ProgressBar == nil) { - return false - } - if a.ProgressBar != nil && b.ProgressBar != nil { - if *a.ProgressBar != *b.ProgressBar { - return false - } - } - - return true -} - -func keyboardEnhancementsFlags(ke KeyboardEnhancements) int { - flags := 1 // always enable basic key disambiguation - if ke.ReportEventTypes { - flags |= ansi.KittyReportEventTypes - } - if ke.ReportAlternateKeys { - flags |= ansi.KittyReportAlternateKeys - } - if ke.ReportAllKeysAsEscapeCodes { - flags |= ansi.KittyReportAllKeysAsEscapeCodes - } - if ke.ReportAssociatedText { - flags |= ansi.KittyReportAssociatedKeys - } - return flags -} diff --git a/patches/bubbletea-v2/cursed_renderer_test.go b/patches/bubbletea-v2/cursed_renderer_test.go deleted file mode 100644 index 5c4b9a659..000000000 --- a/patches/bubbletea-v2/cursed_renderer_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package tea - -import ( - "fmt" - "io" - "testing" - "time" -) - -type mouseRaceModel struct { - i int -} - -func (m *mouseRaceModel) Init() Cmd { return nil } - -func (m *mouseRaceModel) Update(msg Msg) (Model, Cmd) { - switch msg.(type) { - case MouseClickMsg, MouseMotionMsg, MouseWheelMsg: - m.i++ - } - return m, nil -} - -func (m *mouseRaceModel) View() View { - return View{ - Content: fmt.Sprintf("tick-%d\n", m.i), - MouseMode: MouseModeCellMotion, - } -} - -// Fixes: https://github.com/charmbracelet/bubbletea/issues/1690 -func TestCursedRenderer_mouseVsFlush(t *testing.T) { - t.Parallel() - - pr, pw := io.Pipe() - defer func() { _ = pw.Close() }() - - m := &mouseRaceModel{} - p := NewProgram( - m, - WithContext(t.Context()), - WithInput(pr), - WithOutput(io.Discard), - WithEnvironment([]string{ - "TERM=xterm-256color", - "TERM_PROGRAM=Apple_Terminal", - }), - WithoutSignals(), - WithWindowSize(80, 24), - ) - - runDone := make(chan struct{}) - go func() { - defer close(runDone) - _, _ = p.Run() - }() - - time.Sleep(150 * time.Millisecond) - - const iterations = 100 - for i := range iterations { - switch i % 4 { - case 0: - p.Send(MouseClickMsg{X: i % 80, Y: i % 24, Button: MouseLeft}) - case 1: - p.Send(MouseMotionMsg{X: i % 80, Y: i % 24}) - case 2: - p.Send(MouseWheelMsg{X: 0, Y: 0, Button: MouseWheelUp}) - default: - p.Send(MouseReleaseMsg{X: i % 80, Y: i % 24, Button: MouseLeft}) - } - } - - p.Quit() - select { - case <-runDone: - case <-time.After(5 * time.Second): - t.Fatal("program did not exit after Quit") - } -} diff --git a/patches/bubbletea-v2/cursor.go b/patches/bubbletea-v2/cursor.go deleted file mode 100644 index 3375a9652..000000000 --- a/patches/bubbletea-v2/cursor.go +++ /dev/null @@ -1,28 +0,0 @@ -package tea - -// Position represents a position in the terminal. -type Position struct{ X, Y int } - -// CursorPositionMsg is a message that represents the terminal cursor position. -type CursorPositionMsg struct { - X, Y int -} - -// CursorShape represents a terminal cursor shape. -type CursorShape int - -// Cursor shapes. -const ( - CursorBlock CursorShape = iota - CursorUnderline - CursorBar -) - -// requestCursorPosMsg is a message that requests the cursor position. -type requestCursorPosMsg struct{} - -// RequestCursorPosition is a command that requests the cursor position. -// The cursor position will be sent as a [CursorPositionMsg] message. -func RequestCursorPosition() Msg { - return requestCursorPosMsg{} -} diff --git a/patches/bubbletea-v2/environ.go b/patches/bubbletea-v2/environ.go deleted file mode 100644 index 8217b02f4..000000000 --- a/patches/bubbletea-v2/environ.go +++ /dev/null @@ -1,34 +0,0 @@ -package tea - -import uv "github.com/charmbracelet/ultraviolet" - -// EnvMsg is a message that represents the environment variables of the -// program. This is useful for getting the environment variables of programs -// running in a remote session like SSH. In that case, using [os.Getenv] would -// return the server's environment variables, not the client's. -// -// This message is sent to the program when it starts. -// -// Example: -// -// switch msg := msg.(type) { -// case EnvMsg: -// // What terminal type is being used? -// term := msg.Getenv("TERM") -// } -type EnvMsg uv.Environ - -// Getenv returns the value of the environment variable named by the key. If -// the variable is not present in the environment, the value returned will be -// the empty string. -func (msg EnvMsg) Getenv(key string) (v string) { - return uv.Environ(msg).Getenv(key) -} - -// LookupEnv retrieves the value of the environment variable named by the key. -// If the variable is present in the environment the value (which may be empty) -// is returned and the boolean is true. Otherwise the returned value will be -// empty and the boolean will be false. -func (msg EnvMsg) LookupEnv(key string) (s string, v bool) { - return uv.Environ(msg).LookupEnv(key) -} diff --git a/patches/bubbletea-v2/exec.go b/patches/bubbletea-v2/exec.go deleted file mode 100644 index e8af5c42f..000000000 --- a/patches/bubbletea-v2/exec.go +++ /dev/null @@ -1,129 +0,0 @@ -package tea - -import ( - "io" - "os" - "os/exec" -) - -// execMsg is used internally to run an ExecCommand sent with Exec. -type execMsg struct { - cmd ExecCommand - fn ExecCallback -} - -// Exec is used to perform arbitrary I/O in a blocking fashion, effectively -// pausing the Program while execution is running and resuming it when -// execution has completed. -// -// Most of the time you'll want to use ExecProcess, which runs an exec.Cmd. -// -// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd). -func Exec(c ExecCommand, fn ExecCallback) Cmd { - return func() Msg { - return execMsg{cmd: c, fn: fn} - } -} - -// ExecProcess runs the given *exec.Cmd in a blocking fashion, effectively -// pausing the Program while the command is running. After the *exec.Cmd exists -// the Program resumes. It's useful for spawning other interactive applications -// such as editors and shells from within a Program. -// -// To produce the command, pass an *exec.Cmd and a function which returns -// a message containing the error which may have occurred when running the -// ExecCommand. -// -// type VimFinishedMsg struct { err error } -// -// c := exec.Command("vim", "file.txt") -// -// cmd := ExecProcess(c, func(err error) Msg { -// return VimFinishedMsg{err: err} -// }) -// -// Or, if you don't care about errors, you could simply: -// -// cmd := ExecProcess(exec.Command("vim", "file.txt"), nil) -// -// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd). -func ExecProcess(c *exec.Cmd, fn ExecCallback) Cmd { - return Exec(wrapExecCommand(c), fn) -} - -// ExecCallback is used when executing an *exec.Command to return a message -// with an error, which may or may not be nil. -type ExecCallback func(error) Msg - -// ExecCommand can be implemented to execute things in a blocking fashion in -// the current terminal. -type ExecCommand interface { - Run() error - SetStdin(io.Reader) - SetStdout(io.Writer) - SetStderr(io.Writer) -} - -// wrapExecCommand wraps an exec.Cmd so that it satisfies the ExecCommand -// interface so it can be used with Exec. -func wrapExecCommand(c *exec.Cmd) ExecCommand { - return &osExecCommand{Cmd: c} -} - -// osExecCommand is a layer over an exec.Cmd that satisfies the ExecCommand -// interface. -type osExecCommand struct{ *exec.Cmd } - -// SetStdin sets stdin on underlying exec.Cmd to the given io.Reader. -func (c *osExecCommand) SetStdin(r io.Reader) { - // If unset, have the command use the same input as the terminal. - if c.Stdin == nil { - c.Stdin = r - } -} - -// SetStdout sets stdout on underlying exec.Cmd to the given io.Writer. -func (c *osExecCommand) SetStdout(w io.Writer) { - // If unset, have the command use the same output as the terminal. - if c.Stdout == nil { - c.Stdout = w - } -} - -// SetStderr sets stderr on the underlying exec.Cmd to the given io.Writer. -func (c *osExecCommand) SetStderr(w io.Writer) { - // If unset, use stderr for the command's stderr - if c.Stderr == nil { - c.Stderr = w - } -} - -// exec runs an ExecCommand and delivers the results to the program as a Msg. -func (p *Program) exec(c ExecCommand, fn ExecCallback) { - if err := p.releaseTerminal(false); err != nil { - // If we can't release input, abort. - if fn != nil { - go p.Send(fn(err)) - } - return - } - - c.SetStdin(p.input) - c.SetStdout(p.output) - c.SetStderr(os.Stderr) - - // Execute system command. - if err := c.Run(); err != nil { - _ = p.RestoreTerminal() // also try to restore the terminal. - if fn != nil { - go p.Send(fn(err)) - } - return - } - - // Have the program re-capture input. - err := p.RestoreTerminal() - if fn != nil { - go p.Send(fn(err)) - } -} diff --git a/patches/bubbletea-v2/exec_test.go b/patches/bubbletea-v2/exec_test.go deleted file mode 100644 index 649d358e7..000000000 --- a/patches/bubbletea-v2/exec_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package tea - -import ( - "bytes" - "os/exec" - "runtime" - "testing" -) - -type execFinishedMsg struct{ err error } - -type testExecModel struct { - cmd string - err error -} - -type testExecNoInputModel struct{ testExecModel } - -func (m *testExecModel) Init() Cmd { - c := exec.Command(m.cmd) //nolint:gosec - return ExecProcess(c, func(err error) Msg { - return execFinishedMsg{err} - }) -} - -func (m *testExecNoInputModel) Init() Cmd { - return ExecProcess(successExecCommand(), func(err error) Msg { - return execFinishedMsg{err} - }) -} - -func (m *testExecModel) Update(msg Msg) (Model, Cmd) { - switch msg := msg.(type) { - case execFinishedMsg: - if msg.err != nil { - m.err = msg.err - } - return m, Quit - } - - return m, nil -} - -func (m *testExecModel) View() View { - return NewView("\n") -} - -type spyRenderer struct { - renderer - calledReset bool -} - -func (s *spyRenderer) reset() { - s.calledReset = true - if s.renderer != nil { - s.renderer.reset() - } -} - -func successExecCommand() *exec.Cmd { - if runtime.GOOS == "windows" { - return exec.Command("cmd", "/c", "exit 0") - } - return exec.Command("true") -} - -func TestTeaExec(t *testing.T) { - type test struct { - name string - cmd string - expectErr bool - } - - // TODO: add more tests for windows - tests := []test{ - { - name: "invalid command", - cmd: "invalid", - expectErr: true, - }, - } - - if runtime.GOOS != "windows" { - tests = append(tests, []test{ - { - name: "true", - cmd: "true", - expectErr: false, - }, - { - name: "false", - cmd: "false", - expectErr: true, - }, - }...) - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - m := &testExecModel{cmd: test.cmd} - p := NewProgram(m, - WithInput(&in), - WithOutput(&buf), - ) - // Inject the spy BEFORE Run so it observes execution-time - // operations; assigning it afterwards records nothing. - spy := &spyRenderer{renderer: &nilRenderer{}} - p.renderer = spy - if _, err := p.Run(); err != nil { - t.Error(err) - } - - if m.err != nil && !test.expectErr { - t.Errorf("expected no error, got %v", m.err) - } - if m.err == nil && test.expectErr { - t.Error("expected error, got nil") - } - // Exec hands the terminal to the child via releaseTerminal(false): - // the renderer must NOT be reset, or the resumed TUI would repaint - // from scratch instead of continuing where it left off. - if spy.calledReset { - t.Error("exec run reset the renderer; expected releaseTerminal(false) to preserve its state") - } - }) - } -} - -func TestTeaExecWithNilInput(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - - m := &testExecNoInputModel{} - p := NewProgram(m, - WithInput(nil), - WithOutput(&buf), - ) - - if _, err := p.Run(); err != nil { - t.Fatal(err) - } - if m.err != nil { - t.Fatalf("expected no error, got %v", m.err) - } -} diff --git a/patches/bubbletea-v2/focus.go b/patches/bubbletea-v2/focus.go deleted file mode 100644 index 4d34bea6f..000000000 --- a/patches/bubbletea-v2/focus.go +++ /dev/null @@ -1,9 +0,0 @@ -package tea - -// FocusMsg represents a terminal focus message. -// This occurs when the terminal gains focus. -type FocusMsg struct{} - -// BlurMsg represents a terminal blur message. -// This occurs when the terminal loses focus. -type BlurMsg struct{} diff --git a/patches/bubbletea-v2/go.mod b/patches/bubbletea-v2/go.mod deleted file mode 100644 index 5560b3cda..000000000 --- a/patches/bubbletea-v2/go.mod +++ /dev/null @@ -1,28 +0,0 @@ -module charm.land/bubbletea/v2 - -retract v2.0.0-beta1 // We add a "." after the "beta" in the version number. - -go 1.25.0 - -require ( - github.com/charmbracelet/colorprofile v0.4.3 - github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 - github.com/charmbracelet/x/ansi v0.11.7 - github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f - github.com/charmbracelet/x/term v0.2.2 - github.com/lucasb-eyer/go-colorful v1.4.0 - github.com/muesli/cancelreader v0.2.2 - golang.org/x/sys v0.46.0 -) - -require ( - github.com/aymanbagabas/go-udiff v0.2.0 // indirect - github.com/charmbracelet/x/termios v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.21.0 // indirect -) diff --git a/patches/bubbletea-v2/go.sum b/patches/bubbletea-v2/go.sum deleted file mode 100644 index 368d3945e..000000000 --- a/patches/bubbletea-v2/go.sum +++ /dev/null @@ -1,36 +0,0 @@ -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= -github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= -github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= -github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= -github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w= -github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= -github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= -github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= -github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= -github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/patches/bubbletea-v2/input.go b/patches/bubbletea-v2/input.go deleted file mode 100644 index db274fb0b..000000000 --- a/patches/bubbletea-v2/input.go +++ /dev/null @@ -1,54 +0,0 @@ -package tea - -import ( - uv "github.com/charmbracelet/ultraviolet" -) - -// translateInputEvent translates an input event into a Bubble Tea Msg. -func (p *Program) translateInputEvent(e uv.Event) Msg { - switch e := e.(type) { - case uv.ClipboardEvent: - return ClipboardMsg(e) - case uv.ForegroundColorEvent: - return ForegroundColorMsg(e) - case uv.BackgroundColorEvent: - return BackgroundColorMsg(e) - case uv.CursorColorEvent: - return CursorColorMsg(e) - case uv.CursorPositionEvent: - return CursorPositionMsg(e) - case uv.FocusEvent: - return FocusMsg(e) - case uv.BlurEvent: - return BlurMsg(e) - case uv.KeyPressEvent: - return KeyPressMsg(e) - case uv.KeyReleaseEvent: - return KeyReleaseMsg(e) - case uv.MouseClickEvent: - return MouseClickMsg(e) - case uv.MouseMotionEvent: - return MouseMotionMsg(e) - case uv.MouseReleaseEvent: - return MouseReleaseMsg(e) - case uv.MouseWheelEvent: - return MouseWheelMsg(e) - case uv.PasteEvent: - return PasteMsg(e) - case uv.PasteStartEvent: - return PasteStartMsg(e) - case uv.PasteEndEvent: - return PasteEndMsg(e) - case uv.WindowSizeEvent: - return WindowSizeMsg(e) - case uv.CapabilityEvent: - return CapabilityMsg(e) - case uv.TerminalVersionEvent: - return TerminalVersionMsg(e) - case uv.KeyboardEnhancementsEvent: - return KeyboardEnhancementsMsg(e) - case uv.ModeReportEvent: - return ModeReportMsg(e) - } - return e -} diff --git a/patches/bubbletea-v2/key.go b/patches/bubbletea-v2/key.go deleted file mode 100644 index d6798ee6a..000000000 --- a/patches/bubbletea-v2/key.go +++ /dev/null @@ -1,371 +0,0 @@ -package tea - -import ( - "fmt" - - uv "github.com/charmbracelet/ultraviolet" -) - -const ( - // KeyExtended is a special key code used to signify that a key event - // contains multiple runes. - KeyExtended = uv.KeyExtended -) - -// Special key symbols. -const ( - - // Special keys. - - KeyUp = uv.KeyUp - KeyDown = uv.KeyDown - KeyRight = uv.KeyRight - KeyLeft = uv.KeyLeft - KeyBegin = uv.KeyBegin - KeyFind = uv.KeyFind - KeyInsert = uv.KeyInsert - KeyDelete = uv.KeyDelete - KeySelect = uv.KeySelect - KeyPgUp = uv.KeyPgUp - KeyPgDown = uv.KeyPgDown - KeyHome = uv.KeyHome - KeyEnd = uv.KeyEnd - - // Keypad keys. - - KeyKpEnter = uv.KeyKpEnter - KeyKpEqual = uv.KeyKpEqual - KeyKpMultiply = uv.KeyKpMultiply - KeyKpPlus = uv.KeyKpPlus - KeyKpComma = uv.KeyKpComma - KeyKpMinus = uv.KeyKpMinus - KeyKpDecimal = uv.KeyKpDecimal - KeyKpDivide = uv.KeyKpDivide - KeyKp0 = uv.KeyKp0 - KeyKp1 = uv.KeyKp1 - KeyKp2 = uv.KeyKp2 - KeyKp3 = uv.KeyKp3 - KeyKp4 = uv.KeyKp4 - KeyKp5 = uv.KeyKp5 - KeyKp6 = uv.KeyKp6 - KeyKp7 = uv.KeyKp7 - KeyKp8 = uv.KeyKp8 - KeyKp9 = uv.KeyKp9 - - // The following are keys defined in the Kitty keyboard protocol. - // XXX: Investigate the names of these keys. - KeyKpSep = uv.KeyKpSep - KeyKpUp = uv.KeyKpUp - KeyKpDown = uv.KeyKpDown - KeyKpLeft = uv.KeyKpLeft - KeyKpRight = uv.KeyKpRight - KeyKpPgUp = uv.KeyKpPgUp - KeyKpPgDown = uv.KeyKpPgDown - KeyKpHome = uv.KeyKpHome - KeyKpEnd = uv.KeyKpEnd - KeyKpInsert = uv.KeyKpInsert - KeyKpDelete = uv.KeyKpDelete - KeyKpBegin = uv.KeyKpBegin - - // Function keys. - - KeyF1 = uv.KeyF1 - KeyF2 = uv.KeyF2 - KeyF3 = uv.KeyF3 - KeyF4 = uv.KeyF4 - KeyF5 = uv.KeyF5 - KeyF6 = uv.KeyF6 - KeyF7 = uv.KeyF7 - KeyF8 = uv.KeyF8 - KeyF9 = uv.KeyF9 - KeyF10 = uv.KeyF10 - KeyF11 = uv.KeyF11 - KeyF12 = uv.KeyF12 - KeyF13 = uv.KeyF13 - KeyF14 = uv.KeyF14 - KeyF15 = uv.KeyF15 - KeyF16 = uv.KeyF16 - KeyF17 = uv.KeyF17 - KeyF18 = uv.KeyF18 - KeyF19 = uv.KeyF19 - KeyF20 = uv.KeyF20 - KeyF21 = uv.KeyF21 - KeyF22 = uv.KeyF22 - KeyF23 = uv.KeyF23 - KeyF24 = uv.KeyF24 - KeyF25 = uv.KeyF25 - KeyF26 = uv.KeyF26 - KeyF27 = uv.KeyF27 - KeyF28 = uv.KeyF28 - KeyF29 = uv.KeyF29 - KeyF30 = uv.KeyF30 - KeyF31 = uv.KeyF31 - KeyF32 = uv.KeyF32 - KeyF33 = uv.KeyF33 - KeyF34 = uv.KeyF34 - KeyF35 = uv.KeyF35 - KeyF36 = uv.KeyF36 - KeyF37 = uv.KeyF37 - KeyF38 = uv.KeyF38 - KeyF39 = uv.KeyF39 - KeyF40 = uv.KeyF40 - KeyF41 = uv.KeyF41 - KeyF42 = uv.KeyF42 - KeyF43 = uv.KeyF43 - KeyF44 = uv.KeyF44 - KeyF45 = uv.KeyF45 - KeyF46 = uv.KeyF46 - KeyF47 = uv.KeyF47 - KeyF48 = uv.KeyF48 - KeyF49 = uv.KeyF49 - KeyF50 = uv.KeyF50 - KeyF51 = uv.KeyF51 - KeyF52 = uv.KeyF52 - KeyF53 = uv.KeyF53 - KeyF54 = uv.KeyF54 - KeyF55 = uv.KeyF55 - KeyF56 = uv.KeyF56 - KeyF57 = uv.KeyF57 - KeyF58 = uv.KeyF58 - KeyF59 = uv.KeyF59 - KeyF60 = uv.KeyF60 - KeyF61 = uv.KeyF61 - KeyF62 = uv.KeyF62 - KeyF63 = uv.KeyF63 - - // The following are keys defined in the Kitty keyboard protocol. - // XXX: Investigate the names of these keys. - - KeyCapsLock = uv.KeyCapsLock - KeyScrollLock = uv.KeyScrollLock - KeyNumLock = uv.KeyNumLock - KeyPrintScreen = uv.KeyPrintScreen - KeyPause = uv.KeyPause - KeyMenu = uv.KeyMenu - - KeyMediaPlay = uv.KeyMediaPlay - KeyMediaPause = uv.KeyMediaPause - KeyMediaPlayPause = uv.KeyMediaPlayPause - KeyMediaReverse = uv.KeyMediaReverse - KeyMediaStop = uv.KeyMediaStop - KeyMediaFastForward = uv.KeyMediaFastForward - KeyMediaRewind = uv.KeyMediaRewind - KeyMediaNext = uv.KeyMediaNext - KeyMediaPrev = uv.KeyMediaPrev - KeyMediaRecord = uv.KeyMediaRecord - - KeyLowerVol = uv.KeyLowerVol - KeyRaiseVol = uv.KeyRaiseVol - KeyMute = uv.KeyMute - - KeyLeftShift = uv.KeyLeftShift - KeyLeftAlt = uv.KeyLeftAlt - KeyLeftCtrl = uv.KeyLeftCtrl - KeyLeftSuper = uv.KeyLeftSuper - KeyLeftHyper = uv.KeyLeftHyper - KeyLeftMeta = uv.KeyLeftMeta - KeyRightShift = uv.KeyRightShift - KeyRightAlt = uv.KeyRightAlt - KeyRightCtrl = uv.KeyRightCtrl - KeyRightSuper = uv.KeyRightSuper - KeyRightHyper = uv.KeyRightHyper - KeyRightMeta = uv.KeyRightMeta - KeyIsoLevel3Shift = uv.KeyIsoLevel3Shift - KeyIsoLevel5Shift = uv.KeyIsoLevel5Shift - - // Special names in C0. - - KeyBackspace = uv.KeyBackspace - KeyTab = uv.KeyTab - KeyEnter = uv.KeyEnter - KeyReturn = uv.KeyReturn - KeyEscape = uv.KeyEscape - KeyEsc = uv.KeyEsc - - // Special names in G0. - - KeySpace = uv.KeySpace -) - -// KeyPressMsg represents a key press message. -type KeyPressMsg Key - -// String implements [fmt.Stringer] and is quite useful for matching key -// events. For details, on what this returns see [Key.String]. -func (k KeyPressMsg) String() string { - return Key(k).String() -} - -// Keystroke returns the keystroke representation of the [Key]. While less type -// safe than looking at the individual fields, it will usually be more -// convenient and readable to use this method when matching against keys. -// -// Note that modifier keys are always printed in the following order: -// - ctrl -// - alt -// - shift -// - meta -// - hyper -// - super -// -// For example, you'll always see "ctrl+shift+alt+a" and never -// "shift+ctrl+alt+a". -func (k KeyPressMsg) Keystroke() string { - return uv.Key(k).Keystroke() -} - -// Key returns the underlying key event. This is a syntactic sugar for casting -// the key event to a [Key]. -func (k KeyPressMsg) Key() Key { - return Key(k) -} - -// KeyReleaseMsg represents a key release message. -type KeyReleaseMsg Key - -// String implements [fmt.Stringer] and is quite useful for matching key -// events. For details, on what this returns see [Key.String]. -func (k KeyReleaseMsg) String() string { - return Key(k).String() -} - -// Keystroke returns the keystroke representation of the [Key]. While less type -// safe than looking at the individual fields, it will usually be more -// convenient and readable to use this method when matching against keys. -// -// Note that modifier keys are always printed in the following order: -// - ctrl -// - alt -// - shift -// - meta -// - hyper -// - super -// -// For example, you'll always see "ctrl+shift+alt+a" and never -// "shift+ctrl+alt+a". -func (k KeyReleaseMsg) Keystroke() string { - return uv.Key(k).Keystroke() -} - -// Key returns the underlying key event. This is a convenience method and -// syntactic sugar to satisfy the [KeyMsg] interface, and cast the key event to -// [Key]. -func (k KeyReleaseMsg) Key() Key { - return Key(k) -} - -// KeyMsg represents a key event. This can be either a key press or a key -// release event. -type KeyMsg interface { - fmt.Stringer - - // Key returns the underlying key event. - Key() Key -} - -// Key represents a Key press or release event. It contains information about -// the Key pressed, like the runes, the type of Key, and the modifiers pressed. -// There are a couple general patterns you could use to check for key presses -// or releases: -// -// // Switch on the string representation of the key (shorter) -// switch msg := msg.(type) { -// case KeyPressMsg: -// switch msg.String() { -// case "enter": -// fmt.Println("you pressed enter!") -// case "a": -// fmt.Println("you pressed a!") -// } -// } -// -// // Switch on the key type (more foolproof) -// switch msg := msg.(type) { -// case KeyMsg: -// // catch both KeyPressMsg and KeyReleaseMsg -// switch key := msg.Key(); key.Code { -// case KeyEnter: -// fmt.Println("you pressed enter!") -// default: -// switch key.Text { -// case "a": -// fmt.Println("you pressed a!") -// } -// } -// } -// -// Note that [Key.Text] will be empty for special keys like [KeyEnter], -// [KeyTab], and for keys that don't represent printable characters like key -// combos with modifier keys. In other words, [Key.Text] is populated only for -// keys that represent printable characters shifted or unshifted (like 'a', -// 'A', '1', '!', etc.). -type Key struct { - // Text contains the actual characters received. This usually the same as - // [Key.Code]. When [Key.Text] is non-empty, it indicates that the key - // pressed represents printable character(s). - Text string - - // Mod represents modifier keys, like [ModCtrl], [ModAlt], and so on. - Mod KeyMod - - // Code represents the key pressed. This is usually a special key like - // [KeyTab], [KeyEnter], [KeyF1], or a printable character like 'a'. - Code rune - - // ShiftedCode is the actual, shifted key pressed by the user. For example, - // if the user presses shift+a, or caps lock is on, [Key.ShiftedCode] will - // be 'A' and [Key.Code] will be 'a'. - // - // In the case of non-latin keyboards, like Arabic, [Key.ShiftedCode] is the - // unshifted key on the keyboard. - // - // This is only available with the Kitty Keyboard Protocol or the Windows - // Console API. - ShiftedCode rune - - // BaseCode is the key pressed according to the standard PC-101 key layout. - // On international keyboards, this is the key that would be pressed if the - // keyboard was set to US PC-101 layout. - // - // For example, if the user presses 'q' on a French AZERTY keyboard, - // [Key.BaseCode] will be 'q'. - // - // This is only available with the Kitty Keyboard Protocol or the Windows - // Console API. - BaseCode rune - - // IsRepeat indicates whether the key is being held down and sending events - // repeatedly. - // - // This is only available with the Kitty Keyboard Protocol or the Windows - // Console API. - IsRepeat bool -} - -// String implements [fmt.Stringer] and is quite useful for matching key -// events. It will return the textual representation of the [Key] if there is -// one, otherwise, it will fallback to [Key.Keystroke]. -// -// For example, you'll always get "?" and instead of "shift+/" on a US ANSI -// keyboard. -func (k Key) String() string { - return uv.Key(k).String() -} - -// Keystroke returns the keystroke representation of the [Key]. While less type -// safe than looking at the individual fields, it will usually be more -// convenient and readable to use this method when matching against keys. -// -// Note that modifier keys are always printed in the following order: -// - ctrl -// - alt -// - shift -// - meta -// - hyper -// - super -// -// For example, you'll always see "ctrl+shift+alt+a" and never -// "shift+ctrl+alt+a". -func (k Key) Keystroke() string { - return uv.Key(k).Keystroke() -} diff --git a/patches/bubbletea-v2/keyboard.go b/patches/bubbletea-v2/keyboard.go deleted file mode 100644 index 33747a456..000000000 --- a/patches/bubbletea-v2/keyboard.go +++ /dev/null @@ -1,59 +0,0 @@ -package tea - -import ( - "github.com/charmbracelet/x/ansi" -) - -// KeyboardEnhancementsMsg is a message that gets sent when the terminal -// supports keyboard enhancements. -type KeyboardEnhancementsMsg struct { - // Flags is a bitmask of enabled keyboard enhancement features. A non-zero - // value indicates that at least we have key disambiguation support. - // - // See [ansi.KittyReportEventTypes] and other constants for details. - // - // Example: - // - // ```go - // // The hard way - // if msg.Flags&ansi.KittyReportEventTypes != 0 { - // // Terminal supports reporting different key event types - // } - // - // // The easy way - // if msg.SupportsEventTypes() { - // // Terminal supports reporting different key event types - // } - // ``` - Flags int -} - -// SupportsKeyDisambiguation returns whether the terminal supports key -// disambiguation (e.g., distinguishing between different modifier keys). -func (k KeyboardEnhancementsMsg) SupportsKeyDisambiguation() bool { - return k.Flags > 0 -} - -// SupportsEventTypes returns whether the terminal supports reporting -// different types of key events (press, release, and repeat). -func (k KeyboardEnhancementsMsg) SupportsEventTypes() bool { - return k.Flags&ansi.KittyReportEventTypes != 0 -} - -// SupportsAlternateKeys returns whether the terminal supports reporting -// alternate key codes. -func (k KeyboardEnhancementsMsg) SupportsAlternateKeys() bool { - return k.Flags&ansi.KittyReportAlternateKeys != 0 -} - -// SupportsAllKeysAsEscapeCodes returns whether the terminal supports reporting -// all keys as escape codes. -func (k KeyboardEnhancementsMsg) SupportsAllKeysAsEscapeCodes() bool { - return k.Flags&ansi.KittyReportAllKeysAsEscapeCodes != 0 -} - -// SupportsAssociatedText returns whether the terminal supports reporting -// associated text with key events. -func (k KeyboardEnhancementsMsg) SupportsAssociatedText() bool { - return k.Flags&ansi.KittyReportAssociatedKeys != 0 -} diff --git a/patches/bubbletea-v2/logging.go b/patches/bubbletea-v2/logging.go deleted file mode 100644 index 349758cbc..000000000 --- a/patches/bubbletea-v2/logging.go +++ /dev/null @@ -1,53 +0,0 @@ -package tea - -import ( - "fmt" - "io" - "log" - "os" - "unicode" -) - -// LogToFile sets up default logging to log to a file. This is helpful as we -// can't print to the terminal since our TUI is occupying it. If the file -// doesn't exist it will be created. -// -// Don't forget to close the file when you're done with it. -// -// f, err := LogToFile("debug.log", "debug") -// if err != nil { -// fmt.Println("fatal:", err) -// os.Exit(1) -// } -// defer f.Close() -func LogToFile(path string, prefix string) (*os.File, error) { - return LogToFileWith(path, prefix, log.Default()) -} - -// LogOptionsSetter is an interface implemented by stdlib's log and charm's log -// libraries. -type LogOptionsSetter interface { - SetOutput(io.Writer) - SetPrefix(string) -} - -// LogToFileWith does allows to call LogToFile with a custom LogOptionsSetter. -func LogToFileWith(path string, prefix string, log LogOptionsSetter) (*os.File, error) { - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:mnd - if err != nil { - return nil, fmt.Errorf("error opening file for logging: %w", err) - } - log.SetOutput(f) - - // Add a space after the prefix if a prefix is being specified and it - // doesn't already have a trailing space. - if len(prefix) > 0 { - finalChar := prefix[len(prefix)-1] - if !unicode.IsSpace(rune(finalChar)) { - prefix += " " - } - } - log.SetPrefix(prefix) - - return f, nil -} diff --git a/patches/bubbletea-v2/logging_test.go b/patches/bubbletea-v2/logging_test.go deleted file mode 100644 index 2c8cb68e9..000000000 --- a/patches/bubbletea-v2/logging_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package tea - -import ( - "log" - "os" - "path/filepath" - "testing" -) - -func TestLogToFile(t *testing.T) { - // LogToFile mutates the process-global default logger (output, prefix); - // SetFlags below mutates its flags too. Restore all three so a later - // test doesn't inherit this test's prefix/flags, or try to write into - // the file this test is about to close. - originalOutput := log.Writer() - originalPrefix := log.Prefix() - originalFlags := log.Flags() - t.Cleanup(func() { - log.SetOutput(originalOutput) - log.SetPrefix(originalPrefix) - log.SetFlags(originalFlags) - }) - - path := filepath.Join(t.TempDir(), "log.txt") - prefix := "logprefix" - f, err := LogToFile(path, prefix) - if err != nil { - t.Fatal(err) - } - log.SetFlags(log.Lmsgprefix) - log.Println("some test log") - if closeErr := f.Close(); closeErr != nil { - t.Error(closeErr) - } - out, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(out) != prefix+" some test log\n" { - t.Fatalf("wrong log msg: %q", string(out)) - } -} diff --git a/patches/bubbletea-v2/mod.go b/patches/bubbletea-v2/mod.go deleted file mode 100644 index f85a4c32c..000000000 --- a/patches/bubbletea-v2/mod.go +++ /dev/null @@ -1,27 +0,0 @@ -package tea - -import uv "github.com/charmbracelet/ultraviolet" - -// KeyMod represents modifier keys. -type KeyMod = uv.KeyMod - -// Modifier keys. -const ( - ModShift = uv.ModShift - ModAlt = uv.ModAlt - ModCtrl = uv.ModCtrl - ModMeta = uv.ModMeta - - // These modifiers are used with the Kitty protocol. - // XXX: Meta and Super are swapped in the Kitty protocol, - // this is to preserve compatibility with XTerm modifiers. - - ModHyper = uv.ModHyper - ModSuper = uv.ModSuper // Windows/Command keys - - // These are key lock states. - - ModCapsLock = uv.ModCapsLock - ModNumLock = uv.ModNumLock - ModScrollLock = uv.ModScrollLock // Defined in Windows API only -) diff --git a/patches/bubbletea-v2/mouse.go b/patches/bubbletea-v2/mouse.go deleted file mode 100644 index ee28c7fdc..000000000 --- a/patches/bubbletea-v2/mouse.go +++ /dev/null @@ -1,144 +0,0 @@ -package tea - -import ( - "fmt" - - uv "github.com/charmbracelet/ultraviolet" -) - -// MouseButton represents the button that was pressed during a mouse message. -type MouseButton = uv.MouseButton - -// Mouse event buttons -// -// This is based on X11 mouse button codes. -// -// 1 = left button -// 2 = middle button (pressing the scroll wheel) -// 3 = right button -// 4 = turn scroll wheel up -// 5 = turn scroll wheel down -// 6 = push scroll wheel left -// 7 = push scroll wheel right -// 8 = 4th button (aka browser backward button) -// 9 = 5th button (aka browser forward button) -// 10 -// 11 -// -// Other buttons are not supported. -const ( - MouseNone = uv.MouseNone - MouseLeft = uv.MouseLeft - MouseMiddle = uv.MouseMiddle - MouseRight = uv.MouseRight - MouseWheelUp = uv.MouseWheelUp - MouseWheelDown = uv.MouseWheelDown - MouseWheelLeft = uv.MouseWheelLeft - MouseWheelRight = uv.MouseWheelRight - MouseBackward = uv.MouseBackward - MouseForward = uv.MouseForward - MouseButton10 = uv.MouseButton10 - MouseButton11 = uv.MouseButton11 -) - -// MouseMsg represents a mouse message. This is a generic mouse message that -// can represent any kind of mouse event. -type MouseMsg interface { - fmt.Stringer - - // Mouse returns the underlying mouse event. - Mouse() Mouse -} - -// Mouse represents a Mouse message. Use [MouseMsg] to represent all mouse -// messages. -// -// The X and Y coordinates are zero-based, with (0,0) being the upper left -// corner of the terminal. -// -// // Catch all mouse events -// switch msg := msg.(type) { -// case MouseMsg: -// m := msg.Mouse() -// fmt.Println("Mouse event:", m.X, m.Y, m) -// } -// -// // Only catch mouse click events -// switch msg := msg.(type) { -// case MouseClickMsg: -// fmt.Println("Mouse click event:", msg.X, msg.Y, msg) -// } -type Mouse struct { - X, Y int - Button MouseButton - Mod KeyMod -} - -// String returns a string representation of the mouse message. -func (m Mouse) String() (s string) { - return uv.Mouse(m).String() -} - -// MouseClickMsg represents a mouse button click message. -type MouseClickMsg Mouse - -// String returns a string representation of the mouse click message. -func (e MouseClickMsg) String() string { - return Mouse(e).String() -} - -// Mouse returns the underlying mouse event. This is a convenience method and -// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse -// event to [Mouse]. -func (e MouseClickMsg) Mouse() Mouse { - return Mouse(e) -} - -// MouseReleaseMsg represents a mouse button release message. -type MouseReleaseMsg Mouse - -// String returns a string representation of the mouse release message. -func (e MouseReleaseMsg) String() string { - return Mouse(e).String() -} - -// Mouse returns the underlying mouse event. This is a convenience method and -// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse -// event to [Mouse]. -func (e MouseReleaseMsg) Mouse() Mouse { - return Mouse(e) -} - -// MouseWheelMsg represents a mouse wheel message event. -type MouseWheelMsg Mouse - -// String returns a string representation of the mouse wheel message. -func (e MouseWheelMsg) String() string { - return Mouse(e).String() -} - -// Mouse returns the underlying mouse event. This is a convenience method and -// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse -// event to [Mouse]. -func (e MouseWheelMsg) Mouse() Mouse { - return Mouse(e) -} - -// MouseMotionMsg represents a mouse motion message. -type MouseMotionMsg Mouse - -// String returns a string representation of the mouse motion message. -func (e MouseMotionMsg) String() string { - m := Mouse(e) - if m.Button != 0 { - return m.String() + "+motion" - } - return m.String() + "motion" -} - -// Mouse returns the underlying mouse event. This is a convenience method and -// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse -// event to [Mouse]. -func (e MouseMotionMsg) Mouse() Mouse { - return Mouse(e) -} diff --git a/patches/bubbletea-v2/nil_renderer.go b/patches/bubbletea-v2/nil_renderer.go deleted file mode 100644 index 1edafea7e..000000000 --- a/patches/bubbletea-v2/nil_renderer.go +++ /dev/null @@ -1,53 +0,0 @@ -package tea - -import ( - "github.com/charmbracelet/colorprofile" - "github.com/charmbracelet/x/ansi" -) - -// nilRenderer is a no-op renderer. It implements the Renderer interface but -// doesn't render anything to the terminal. -type nilRenderer struct{} - -var _ renderer = nilRenderer{} - -// start implements renderer. -func (n nilRenderer) start() {} - -// clearScreen implements renderer. -func (n nilRenderer) clearScreen() {} - -// insertAbove implements renderer. -func (n nilRenderer) insertAbove(string) error { return nil } - -// resize implements renderer. -func (n nilRenderer) resize(int, int) {} - -// setColorProfile implements renderer. -func (n nilRenderer) setColorProfile(colorprofile.Profile) {} - -// flush implements the Renderer interface. -func (nilRenderer) flush(bool) error { return nil } - -// close implements the Renderer interface. -func (nilRenderer) close() error { return nil } - -// render implements the Renderer interface. -func (nilRenderer) render(View) {} - -// reset implements the Renderer interface. -func (nilRenderer) reset() {} - -// writeString implements the Renderer interface. -func (nilRenderer) writeString(string) (int, error) { return 0, nil } - -// setSyncdUpdates implements the Renderer interface. -func (n nilRenderer) setSyncdUpdates(bool) {} - -// setWidthMethod implements the Renderer interface. -func (n nilRenderer) setWidthMethod(ansi.Method) {} - -// onMouse implements the Renderer interface. -func (n nilRenderer) onMouse(MouseMsg) Cmd { - return nil -} diff --git a/patches/bubbletea-v2/options.go b/patches/bubbletea-v2/options.go deleted file mode 100644 index dfd8cda4f..000000000 --- a/patches/bubbletea-v2/options.go +++ /dev/null @@ -1,168 +0,0 @@ -package tea - -import ( - "context" - "io" - "sync/atomic" - - "github.com/charmbracelet/colorprofile" -) - -// ProgramOption is used to set options when initializing a Program. Program can -// accept a variable number of options. -// -// Example usage: -// -// p := NewProgram(model, WithInput(someInput), WithOutput(someOutput)) -type ProgramOption func(*Program) - -// WithContext lets you specify a context in which to run the Program. This is -// useful if you want to cancel the execution from outside. When a Program gets -// cancelled it will exit with an error ErrProgramKilled. -func WithContext(ctx context.Context) ProgramOption { - return func(p *Program) { - p.externalCtx = ctx - } -} - -// WithOutput sets the output which, by default, is stdout. In most cases you -// won't need to use this. -func WithOutput(output io.Writer) ProgramOption { - return func(p *Program) { - p.output = output - } -} - -// WithInput sets the input which, by default, is stdin. In most cases you -// won't need to use this. To disable input entirely pass nil. -// -// p := NewProgram(model, WithInput(nil)) -func WithInput(input io.Reader) ProgramOption { - return func(p *Program) { - p.input = input - p.disableInput = input == nil - } -} - -// WithEnvironment sets the environment variables that the program will use. -// This useful when the program is running in a remote session (e.g. SSH) and -// you want to pass the environment variables from the remote session to the -// program. -// -// Example: -// -// var sess ssh.Session // ssh.Session is a type from the github.com/charmbracelet/ssh package -// pty, _, _ := sess.Pty() -// environ := append(sess.Environ(), "TERM="+pty.Term) -// p := tea.NewProgram(model, tea.WithEnvironment(environ) -func WithEnvironment(env []string) ProgramOption { - return func(p *Program) { - p.environ = env - } -} - -// WithoutSignalHandler disables the signal handler that Bubble Tea sets up for -// Programs. This is useful if you want to handle signals yourself. -func WithoutSignalHandler() ProgramOption { - return func(p *Program) { - p.disableSignalHandler = true - } -} - -// WithoutCatchPanics disables the panic catching that Bubble Tea does by -// default. If panic catching is disabled the terminal will be in a fairly -// unusable state after a panic because Bubble Tea will not perform its usual -// cleanup on exit. -func WithoutCatchPanics() ProgramOption { - return func(p *Program) { - p.disableCatchPanics = true - } -} - -// WithoutSignals will ignore OS signals. -// This is mainly useful for testing. -func WithoutSignals() ProgramOption { - return func(p *Program) { - atomic.StoreUint32(&p.ignoreSignals, 1) - } -} - -// WithoutRenderer disables the renderer. When this is set output and log -// statements will be plainly sent to stdout (or another output if one is set) -// without any rendering and redrawing logic. In other words, printing and -// logging will behave the same way it would in a non-TUI commandline tool. -// This can be useful if you want to use the Bubble Tea framework for a non-TUI -// application, or to provide an additional non-TUI mode to your Bubble Tea -// programs. For example, your program could behave like a daemon if output is -// not a TTY. -func WithoutRenderer() ProgramOption { - return func(p *Program) { - p.disableRenderer = true - } -} - -// WithFilter supplies an event filter that will be invoked before Bubble Tea -// processes a tea.Msg. The event filter can return any tea.Msg which will then -// get handled by Bubble Tea instead of the original event. If the event filter -// returns nil, the event will be ignored and Bubble Tea will not process it. -// -// As an example, this could be used to prevent a program from shutting down if -// there are unsaved changes. -// -// Example: -// -// func filter(m tea.Model, msg tea.Msg) tea.Msg { -// if _, ok := msg.(tea.QuitMsg); !ok { -// return msg -// } -// -// model := m.(myModel) -// if model.hasChanges { -// return nil -// } -// -// return msg -// } -// -// p := tea.NewProgram(Model{}, tea.WithFilter(filter)); -// -// if _,err := p.Run(); err != nil { -// fmt.Println("Error running program:", err) -// os.Exit(1) -// } -func WithFilter(filter func(Model, Msg) Msg) ProgramOption { - return func(p *Program) { - p.filter = filter - } -} - -// WithFPS sets a custom maximum FPS at which the renderer should run. If -// less than 1, the default value of 60 will be used. If over 120, the FPS -// will be capped at 120. -func WithFPS(fps int) ProgramOption { - return func(p *Program) { - p.fps = fps - } -} - -// WithColorProfile sets the color profile that the program will use. This is -// useful when you want to force a specific color profile. By default, Bubble -// Tea will try to detect the terminal's color profile from environment -// variables and terminfo capabilities. Use [tea.WithEnvironment] to set custom -// environment variables. -func WithColorProfile(profile colorprofile.Profile) ProgramOption { - return func(p *Program) { - p.profile = &profile - } -} - -// WithWindowSize sets the initial size of the terminal window. This is useful -// when you need to set the initial size of the terminal window, for example -// during testing or when you want to run your program in a non-interactive -// environment. -func WithWindowSize(width, height int) ProgramOption { - return func(p *Program) { - p.width = width - p.height = height - } -} diff --git a/patches/bubbletea-v2/options_test.go b/patches/bubbletea-v2/options_test.go deleted file mode 100644 index 423dc4c2f..000000000 --- a/patches/bubbletea-v2/options_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package tea - -import ( - "bytes" - "context" - "os" - "sync/atomic" - "testing" -) - -func TestOptions(t *testing.T) { - t.Run("output", func(t *testing.T) { - t.Parallel() - var b bytes.Buffer - p := NewProgram(nil, WithOutput(&b)) - if f, ok := p.output.(*os.File); ok { - t.Errorf("expected output to custom, got %v", f.Fd()) - } - }) - - t.Run("renderer", func(t *testing.T) { - t.Parallel() - p := NewProgram(nil, WithoutRenderer()) - if !p.disableRenderer { - t.Errorf("expected renderer to be a nilRenderer, got %v", p.renderer) - } - }) - - t.Run("without signals", func(t *testing.T) { - t.Parallel() - p := NewProgram(nil, WithoutSignals()) - if atomic.LoadUint32(&p.ignoreSignals) == 0 { - t.Errorf("ignore signals should have been set") - } - }) - - t.Run("filter", func(t *testing.T) { - t.Parallel() - p := NewProgram(nil, WithFilter(func(_ Model, msg Msg) Msg { return msg })) - if p.filter == nil { - t.Errorf("expected filter to be set") - } - }) - - t.Run("external context", func(t *testing.T) { - t.Parallel() - extCtx, extCancel := context.WithCancel(context.Background()) - defer extCancel() - - p := NewProgram(nil, WithContext(extCtx)) - if p.externalCtx != extCtx || p.externalCtx == context.Background() { - t.Errorf("expected passed in external context, got default") - } - }) - - t.Run("input options", func(t *testing.T) { - exercise := func(t *testing.T, opt ProgramOption, fn func(*Program)) { - p := NewProgram(nil, opt) - fn(p) - } - - t.Run("nil input", func(t *testing.T) { - t.Parallel() - exercise(t, WithInput(nil), func(p *Program) { - if !p.disableInput || p.input != nil { - t.Errorf("expected input to be disabled, got %v", p.input) - } - }) - }) - - t.Run("custom input", func(t *testing.T) { - t.Parallel() - var b bytes.Buffer - exercise(t, WithInput(&b), func(p *Program) { - if p.input != &b { - t.Errorf("expected input to be custom, got %v", p.input) - } - }) - }) - }) - - t.Run("startup options", func(t *testing.T) { - exercise := func(t *testing.T, opt ProgramOption, fn func(*Program)) { - p := NewProgram(nil, opt) - fn(p) - } - - t.Run("without catch panics", func(t *testing.T) { - t.Parallel() - exercise(t, WithoutCatchPanics(), func(p *Program) { - if !p.disableCatchPanics { - t.Errorf("expected catch panics to be disabled") - } - }) - }) - - t.Run("without signal handler", func(t *testing.T) { - t.Parallel() - exercise(t, WithoutSignalHandler(), func(p *Program) { - if !p.disableSignalHandler { - t.Errorf("expected signal handler to be disabled") - } - }) - }) - }) -} diff --git a/patches/bubbletea-v2/paste.go b/patches/bubbletea-v2/paste.go deleted file mode 100644 index 6a3f54c1f..000000000 --- a/patches/bubbletea-v2/paste.go +++ /dev/null @@ -1,20 +0,0 @@ -package tea - -// PasteMsg is an message that is emitted when a terminal receives pasted text -// using bracketed-paste. -type PasteMsg struct { - Content string -} - -// String returns the pasted content as a string. -func (p PasteMsg) String() string { - return p.Content -} - -// PasteStartMsg is an message that is emitted when the terminal starts the -// bracketed-paste text. -type PasteStartMsg struct{} - -// PasteEndMsg is an message that is emitted when the terminal ends the -// bracketed-paste text. -type PasteEndMsg struct{} diff --git a/patches/bubbletea-v2/profile.go b/patches/bubbletea-v2/profile.go deleted file mode 100644 index f67186636..000000000 --- a/patches/bubbletea-v2/profile.go +++ /dev/null @@ -1,15 +0,0 @@ -package tea - -import "github.com/charmbracelet/colorprofile" - -// ColorProfileMsg is a message that describes the terminal's color profile. -// This message is send to the program's update function when the program is -// started. -// -// To upgrade the terminal color profile, use the `tea.RequestCapability` -// command to request the `RGB` and `Tc` terminfo capabilities. Bubble Tea will -// then cache the terminal's color profile and send a `ColorProfileMsg` to the -// program's update function. -type ColorProfileMsg struct { - colorprofile.Profile -} diff --git a/patches/bubbletea-v2/raw.go b/patches/bubbletea-v2/raw.go deleted file mode 100644 index fb6ae44dd..000000000 --- a/patches/bubbletea-v2/raw.go +++ /dev/null @@ -1,37 +0,0 @@ -package tea - -// RawMsg is a message that contains a string to be printed to the terminal -// without any intermediate processing. -type RawMsg struct { - Msg any -} - -// Raw is a command that prints the given string to the terminal without any -// formatting. -// -// This is intended for advanced use cases where you need to query the terminal -// or send escape sequences directly. Don't use this unless you know what -// you're doing :) -// -// Example: -// -// func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { -// switch msg := msg.(type) { -// case input.PrimaryDeviceAttributesEvent: -// for _, attr := range msg { -// if attr == 4 { -// // We have Sixel graphics support! -// break -// } -// } -// } -// -// // Request the terminal primary device attributes to detect Sixel graphics -// // support. -// return m, tea.Raw(ansi.RequestPrimaryDeviceAttributes) -// } -func Raw(r any) Cmd { - return func() Msg { - return RawMsg{r} - } -} diff --git a/patches/bubbletea-v2/renderer.go b/patches/bubbletea-v2/renderer.go deleted file mode 100644 index 9d641b4fe..000000000 --- a/patches/bubbletea-v2/renderer.go +++ /dev/null @@ -1,104 +0,0 @@ -package tea - -import ( - "fmt" - - "github.com/charmbracelet/colorprofile" - "github.com/charmbracelet/x/ansi" -) - -const ( - // defaultFPS specifies the maximum interval at which we should - // update the view. - defaultFPS = 60 - maxFPS = 120 -) - -// renderer is the interface for Bubble Tea renderers. -type renderer interface { - // start starts the renderer. - start() - - // close closes the renderer and flushes any remaining data. - close() error - - // render renders a frame to the output. - render(View) - - // flush flushes the renderer's buffer to the output. - flush(closing bool) error - - // reset resets the renderer's state to its initial state. - reset() - - // insertAbove inserts unmanaged lines above the renderer. - insertAbove(string) error - - // setSyncdUpdates sets whether to use synchronized updates. - setSyncdUpdates(bool) - - // setWidthMethod sets the method for calculating the width of the terminal. - setWidthMethod(ansi.Method) - - // resize notify the renderer of a terminal resize. - resize(int, int) - - // setColorProfile sets the color profile. - setColorProfile(colorprofile.Profile) - - // clearScreen clears the screen. - clearScreen() - - // writeString writes a string to the renderer's output. - writeString(string) (int, error) - - // onMouse handles a mouse event. - onMouse(MouseMsg) Cmd -} - -type printLineMessage struct { - messageBody string -} - -// Println prints above the Program. This output is unmanaged by the program and -// will persist across renders by the Program. -// -// Unlike fmt.Println (but similar to log.Println) the message will be print on -// its own line. -// -// If the altscreen is active no output will be printed. -func Println(args ...any) Cmd { - return func() Msg { - return printLineMessage{ - messageBody: fmt.Sprint(args...), - } - } -} - -// Printf prints above the Program. It takes a format template followed by -// values similar to fmt.Printf. This output is unmanaged by the program and -// will persist across renders by the Program. -// -// Unlike fmt.Printf (but similar to log.Printf) the message will be print on -// its own line. -// -// If the altscreen is active no output will be printed. -func Printf(template string, args ...any) Cmd { - return func() Msg { - return printLineMessage{ - messageBody: fmt.Sprintf(template, args...), - } - } -} - -// encodeCursorStyle returns the integer value for the given cursor style and -// blink state. -func encodeCursorStyle(style CursorShape, blink bool) int { - // We're using the ANSI escape sequence values for cursor styles. - // We need to map both [style] and [steady] to the correct value. - style = (style * 2) + 1 //nolint:mnd - if !blink { - style++ - } - return int(style) -} diff --git a/patches/bubbletea-v2/screen.go b/patches/bubbletea-v2/screen.go deleted file mode 100644 index f835bb376..000000000 --- a/patches/bubbletea-v2/screen.go +++ /dev/null @@ -1,68 +0,0 @@ -package tea - -import "github.com/charmbracelet/x/ansi" - -// WindowSizeMsg is used to report the terminal size. It's sent to Update once -// initially and then on every terminal resize. Note that Windows does not -// have support for reporting when resizes occur as it does not support the -// SIGWINCH signal. -type WindowSizeMsg struct { - Width int - Height int -} - -// ClearScreen is a special command that tells the program to clear the screen -// before the next update. This can be used to move the cursor to the top left -// of the screen and clear visual clutter when the alt screen is not in use. -// -// Note that it should never be necessary to call ClearScreen() for regular -// redraws. -func ClearScreen() Msg { - return clearScreenMsg{} -} - -// clearScreenMsg is an internal message that signals to clear the screen. -// You can send a clearScreenMsg with ClearScreen. -type clearScreenMsg struct{} - -// ModeReportMsg is a message that represents a mode report event (DECRPM). -// -// This is sent by the terminal in response to a request for a terminal mode -// report (DECRQM). It indicates the current setting of a specific terminal -// mode like cursor visibility, mouse tracking, etc. -// -// Example: -// -// ```go -// func (m model) Init() tea.Cmd { -// // Does my terminal support reporting focus events? -// return tea.Raw(ansi.RequestModeFocusEvent) -// } -// -// func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { -// switch msg := msg.(type) { -// case tea.ModeReportMsg: -// if msg.Mode == ansi.ModeFocusEvent && !msg.Value.IsNotRecognized() { -// // Terminal supports focus events -// m.supportsFocus = true -// } -// } -// return m, nil -// } -// -// func (m model) View() tea.View { -// var view tea.View -// view.ReportFocus = m.supportsFocus -// view.SetContent(fmt.Sprintf("Terminal supports focus events: %v", m.supportsFocus)) -// return view -// } -// ``` -// -// See: https://vt100.net/docs/vt510-rm/DECRPM.html -type ModeReportMsg struct { - // Mode is the mode number. - Mode ansi.Mode - - // Value is the mode value. - Value ansi.ModeSetting -} diff --git a/patches/bubbletea-v2/screen_test.go b/patches/bubbletea-v2/screen_test.go deleted file mode 100644 index c3fe24ab5..000000000 --- a/patches/bubbletea-v2/screen_test.go +++ /dev/null @@ -1,207 +0,0 @@ -package tea - -import ( - "bytes" - "image/color" - "testing" - - "github.com/charmbracelet/colorprofile" - "github.com/charmbracelet/x/exp/golden" -) - -type testViewOpts struct { - altScreen bool - mouseMode MouseMode - showCursor bool - disableBp bool - keyReleases bool - bgColor color.Color -} - -func testViewOptsCmds(opts ...testViewOpts) []Cmd { - cmds := make([]Cmd, len(opts)) - for i, o := range opts { - o := o - cmds[i] = func() Msg { - return o - } - } - return cmds -} - -type testViewModel struct { - *testModel - opts testViewOpts -} - -func (m *testViewModel) Update(msg Msg) (Model, Cmd) { - switch msg := msg.(type) { - case testViewOpts: - m.opts = msg - return m, nil - } - tm, cmd := m.testModel.Update(msg) - m.testModel = tm.(*testModel) - return m, cmd -} - -func (m *testViewModel) View() View { - v := m.testModel.View() - v.AltScreen = m.opts.altScreen - v.MouseMode = m.opts.mouseMode - v.DisableBracketedPasteMode = m.opts.disableBp - v.KeyboardEnhancements.ReportEventTypes = m.opts.keyReleases - v.BackgroundColor = m.opts.bgColor - if m.opts.showCursor { - v.Cursor = NewCursor(0, 0) - } - return v -} - -func TestViewModel(t *testing.T) { - tests := []struct { - name string - opts []testViewOpts - }{ - { - name: "altscreen", - opts: []testViewOpts{ - {altScreen: true}, - {altScreen: false}, - }, - }, - { - name: "altscreen_autoexit", - opts: []testViewOpts{ - {altScreen: true}, - }, - }, - { - name: "mouse_cellmotion", - opts: []testViewOpts{ - {mouseMode: MouseModeCellMotion}, - }, - }, - { - name: "mouse_allmotion", - opts: []testViewOpts{ - {mouseMode: MouseModeAllMotion}, - }, - }, - { - name: "mouse_disable", - opts: []testViewOpts{ - {mouseMode: MouseModeAllMotion}, - {mouseMode: MouseModeNone}, - }, - }, - { - name: "cursor_hide", - opts: []testViewOpts{ - {}, - }, - }, - { - name: "cursor_hideshow", - opts: []testViewOpts{ - {showCursor: false}, - {showCursor: true}, - }, - }, - { - name: "bp_stop_start", - opts: []testViewOpts{ - {disableBp: true}, - {disableBp: false}, - }, - }, - { - name: "kitty_stop_startreleases", - opts: []testViewOpts{ - {}, - {keyReleases: true}, - }, - }, - { - name: "bg_set_color", - opts: []testViewOpts{ - {bgColor: color.RGBA{255, 255, 255, 255}}, - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - m := &testViewModel{testModel: &testModel{}} - p := NewProgram(m, - // Set the initial window size for the program. - WithWindowSize(80, 24), - // Use ANSI256 to increase test coverage. - WithColorProfile(colorprofile.ANSI256), - // always use xterm and 256 colors for tests - WithEnvironment([]string{"TERM=xterm-256color"}), - WithInput(&in), - WithOutput(&buf), - ) - - go p.Send(append(sequenceMsg(testViewOptsCmds(test.opts...)), Quit)) - - if _, err := p.Run(); err != nil { - t.Fatal(err) - } - golden.RequireEqual(t, buf.Bytes()) - }) - } -} - -func TestClearMsg(t *testing.T) { - type test struct { - name string - cmds sequenceMsg - } - tests := []test{ - { - name: "clear_screen", - cmds: []Cmd{ClearScreen}, - }, - { - name: "read_set_clipboard", - cmds: []Cmd{ReadClipboard, SetClipboard("success")}, - }, - { - name: "bg_fg_cur_color", - cmds: []Cmd{RequestForegroundColor, RequestBackgroundColor, RequestCursorColor}, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - m := &testModel{} - p := NewProgram(m, - // Set the initial window size for the program. - WithWindowSize(80, 24), - // Use ANSI256 to increase test coverage. - WithColorProfile(colorprofile.ANSI256), - // always use xterm and 256 colors for tests - WithEnvironment([]string{"TERM=xterm-256color"}), - WithInput(&in), - WithOutput(&buf), - ) - - go p.Send(append(test.cmds, Quit)) - - if _, err := p.Run(); err != nil { - t.Fatal(err) - } - golden.RequireEqual(t, buf.Bytes()) - }) - } -} diff --git a/patches/bubbletea-v2/signals_unix.go b/patches/bubbletea-v2/signals_unix.go deleted file mode 100644 index 409540385..000000000 --- a/patches/bubbletea-v2/signals_unix.go +++ /dev/null @@ -1,33 +0,0 @@ -//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix || zos -// +build darwin dragonfly freebsd linux netbsd openbsd solaris aix zos - -package tea - -import ( - "os" - "os/signal" - "syscall" -) - -// listenForResize sends messages (or errors) when the terminal resizes. -// Argument output should be the file descriptor for the terminal; usually -// os.Stdout. -func (p *Program) listenForResize(done chan struct{}) { - sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGWINCH) - - defer func() { - signal.Stop(sig) - close(done) - }() - - for { - select { - case <-p.ctx.Done(): - return - case <-sig: - } - - p.checkResize() - } -} diff --git a/patches/bubbletea-v2/signals_windows.go b/patches/bubbletea-v2/signals_windows.go deleted file mode 100644 index 2fc6f8ae7..000000000 --- a/patches/bubbletea-v2/signals_windows.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build windows -// +build windows - -package tea - -// listenForResize is not available on windows because windows does not -// implement syscall.SIGWINCH. -func (p *Program) listenForResize(done chan struct{}) { - close(done) -} diff --git a/patches/bubbletea-v2/tea.go b/patches/bubbletea-v2/tea.go deleted file mode 100644 index e249d71bb..000000000 --- a/patches/bubbletea-v2/tea.go +++ /dev/null @@ -1,1439 +0,0 @@ -// Package tea provides a framework for building rich terminal user interfaces -// based on the paradigms of The Elm Architecture. It's well-suited for simple -// and complex terminal applications, either inline, full-window, or a mix of -// both. It's been battle-tested in several large projects and is -// production-ready. -// -// A tutorial is available at https://github.com/charmbracelet/bubbletea/tree/master/tutorials -// -// Example programs can be found at https://github.com/charmbracelet/bubbletea/tree/master/examples -package tea - -import ( - "bytes" - "context" - "errors" - "fmt" - "image/color" - "io" - "log" - "os" - "os/signal" - "runtime" - "runtime/debug" - "strconv" - "strings" - "sync" - "sync/atomic" - "syscall" - "time" - - "github.com/charmbracelet/colorprofile" - uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/term" - "github.com/muesli/cancelreader" -) - -// ErrProgramPanic is returned by [Program.Run] when the program recovers from a panic. -var ErrProgramPanic = errors.New("program experienced a panic") - -// ErrProgramKilled is returned by [Program.Run] when the program gets killed. -var ErrProgramKilled = errors.New("program was killed") - -// ErrInterrupted is returned by [Program.Run] when the program get a SIGINT -// signal, or when it receives a [InterruptMsg]. -var ErrInterrupted = errors.New("program was interrupted") - -// Msg contain data from the result of a IO operation. Msgs trigger the update -// function and, henceforth, the UI. -type Msg = uv.Event - -// Model contains the program's state as well as its core functions. -type Model interface { - // Init is the first function that will be called. It returns an optional - // initial command. To not perform an initial command return nil. - Init() Cmd - - // Update is called when a message is received. Use it to inspect messages - // and, in response, update the model and/or send a command. - Update(Msg) (Model, Cmd) - - // View renders the program's UI, which can be a string or a [Layer]. The - // view is rendered after every Update. - View() View -} - -// NewView is a helper function to create a new [View] with the given styled -// string. A styled string represents text with styles and hyperlinks encoded -// as ANSI escape codes. -// -// Example: -// -// ```go -// v := tea.NewView("Hello, World!") -// ``` -func NewView(s string) View { - var view View - view.SetContent(s) - return view -} - -// View represents a terminal view that can be composed of multiple layers. -// It can also contain a cursor that will be rendered on top of the layers. -type View struct { - // Content is the screen content of the view. It holds styled strings that - // will be rendered to the terminal when the view is rendered. - // - // A styled string represents text with styles and hyperlinks encoded as - // ANSI escape codes. - // - // Example: - // - // ```go - // v := tea.NewView("Hello, World!") - // ``` - Content string - - // OnMouse is an optional mouse message handler that can be used to - // intercept mouse messages that depends on view content from last render. - // It can be useful for implementing view-specific behavior without - // breaking the unidirectional data flow of Bubble Tea. - // - // Example: - // - // ```go - // content := "Hello, World!" - // v := tea.NewView(content) - // v.OnMouse = func(msg tea.MouseMsg) tea.Cmd { - // return func() tea.Msg { - // m := msg.Mouse() - // // Check if the mouse is within the bounds of "World!" - // start := strings.Index(content, "World!") - // end := start + len("World!") - // if m.Y == 0 && m.X >= start && m.X < end { - // // Mouse is over "World!" - // return MyCustomMsg{ - // MouseMsg: msg, - // } - // } - // } - // } - // return nil - // } - // return v - // ``` - OnMouse func(msg MouseMsg) Cmd - - // Cursor represents the cursor position, style, and visibility on the - // screen. When not nil, the cursor will be shown at the specified - // position. - Cursor *Cursor - - // BackgroundColor when not nil, sets the terminal background color. Use - // nil to reset to the terminal's default background color. - BackgroundColor color.Color - - // ForegroundColor when not nil, sets the terminal foreground color. Use - // nil to reset to the terminal's default foreground color. - ForegroundColor color.Color - - // WindowTitle sets the terminal window title. Support depends on the - // terminal. - WindowTitle string - - // ProgressBar when not nil, shows a progress bar in the terminal's - // progress bar section. Support depends on the terminal. - ProgressBar *ProgressBar - - // AltScreen puts the program in the alternate screen buffer - // (i.e. the program goes into full window mode). Note that the altscreen will - // be automatically exited when the program quits. - // - // Example: - // - // func (m model) View() tea.View { - // v := tea.NewView("Hello, World!") - // v.AltScreen = true - // return v - // } - // - AltScreen bool - - // ReportFocus enables reporting when the terminal gains and loses focus. - // When this is enabled [FocusMsg] and [BlurMsg] messages will be sent to - // your Update method. - // - // Note that while most terminals and multiplexers support focus reporting, - // some do not. Also note that tmux needs to be configured to report focus - // events. - ReportFocus bool - - // DisableBracketedPasteMode disables bracketed paste mode for this view. - DisableBracketedPasteMode bool - - // MouseMode sets the mouse mode for this view. It can be one of - // [MouseModeNone], [MouseModeCellMotion], or [MouseModeAllMotion]. - MouseMode MouseMode - - // KeyboardEnhancements describes what keyboard enhancement features Bubble - // Tea should request from the terminal. - // - // Bubble Tea supports requesting the following keyboard enhancement features: - // - ReportEventTypes: requests the terminal to report key repeat and - // release events. - // - // If the terminal supports any of these features, your program will - // receive a [KeyboardEnhancementsMsg] that indicates which features are - // available. - KeyboardEnhancements KeyboardEnhancements -} - -// KeyboardEnhancements describes the requested keyboard enhancement features. -// If the terminal supports any of them, it will respond with a -// [KeyboardEnhancementsMsg] that indicates which features are supported. - -// KeyboardEnhancements defines different keyboard enhancement features that -// can be requested from the terminal. - -// KeyboardEnhancements defines different keyboard enhancement features that -// can be requested from the terminal. -// -// By default, Bubble Tea requests basic key disambiguation features from the -// terminal. If the terminal supports keyboard enhancements, or any of its -// additional features, it will respond with a [KeyboardEnhancementsMsg] that -// indicates which features are supported. -// -// Example: -// -// ```go -// func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { -// switch msg := msg.(type) { -// case tea.KeyboardEnhancementsMsg: -// // We have basic key disambiguation support. -// // We can handle "shift+enter", "ctrl+i", etc. -// m.keyboardEnhancements = msg -// if msg.ReportEventTypes { -// // Even better! We can now handle key repeat and release events. -// } -// case tea.KeyPressMsg: -// switch msg.String() { -// case "shift+enter": -// // Handle shift+enter -// // This would not be possible without keyboard enhancements. -// case "ctrl+j": -// // Handle ctrl+j -// } -// case tea.KeyReleaseMsg: -// // Whoa! A key was released! -// } -// -// return m, nil -// } -// -// func (m model) View() tea.View { -// v := tea.NewView("Press some keys!") -// // Request reporting key repeat and release events. -// v.KeyboardEnhancements.ReportEventTypes = true -// return v -// } -// ``` -type KeyboardEnhancements struct { - // ReportEventTypes requests the terminal to report key repeat and release - // events. - // If supported, your program will receive [KeyReleaseMsg]s and - // [KeyPressMsg] with the [Key.IsRepeat] field set indicating that this is - // a it's part of a key repeat sequence. - ReportEventTypes bool - - // ReportAlternateKeys requests the terminal to report alternate key values - // in addition to the main ones. - // Note that only key events represented as escape codes will affected by - // this enhancement. - ReportAlternateKeys bool - - // ReportAllKeysAsEscapeCodes requests the terminal to report all key - // events, including plain text keys, as escape codes. - // When this is enabled, text won't be sent as plain text but instead as - // escape codes that encode the key value and modifiers. - ReportAllKeysAsEscapeCodes bool - - // ReportAssociatedText requests the terminal to report the text associated - // with key events. - // Note that this is an enhancement to - // [KeyboardEnhancements.ReportAllKeysAsEscapeCodes] and only has an effect - // if that is enabled. - ReportAssociatedText bool -} - -// SetContent is a helper method to set the content of a [View] with a styled -// string. A styled string represents text with styles and hyperlinks encoded -// as ANSI escape codes. -// -// Example: -// -// ```go -// var v tea.View -// v.SetContent("Hello, World!") -// ``` -func (v *View) SetContent(s string) { - v.Content = s -} - -// MouseMode represents the mouse mode of a view. -type MouseMode int - -const ( - // MouseModeNone disables mouse events. - MouseModeNone MouseMode = iota - - // MouseModeCellMotion enables mouse click, release, and wheel events. - // Mouse movement events are also captured if a mouse button is pressed - // (i.e., drag events). Cell motion mode is better supported than all - // motion mode. - // - // This will try to enable the mouse in extended mode (SGR), if that is not - // supported by the terminal it will fall back to normal mode (X10). - MouseModeCellMotion - - // MouseModeAllMotion enables all mouse events, including click, release, - // wheel, and movement events. You will receive mouse movement events even - // when no buttons are pressed. - // - // This will try to enable the mouse in extended mode (SGR), if that is not - // supported by the terminal it will fall back to normal mode (X10). - MouseModeAllMotion -) - -// ProgressBarState represents the state of the progress bar. -type ProgressBarState int - -// Progress bar states. -const ( - ProgressBarNone ProgressBarState = iota - ProgressBarDefault - ProgressBarError - ProgressBarIndeterminate - ProgressBarWarning -) - -// String return a human-readable value for the given [ProgressBarState]. -func (s ProgressBarState) String() string { - return [...]string{ - "None", - "Default", - "Error", - "Indeterminate", - "Warning", - }[s] -} - -// ProgressBar represents the terminal progress bar. -// -// Support depends on the terminal. -// -// See https://learn.microsoft.com/en-us/windows/terminal/tutorials/progress-bar-sequences -type ProgressBar struct { - // State is the current state of the progress bar. It can be one of - // [ProgressBarNone], [ProgressBarDefault], [ProgressBarError], - // [ProgressBarIndeterminate], and [ProgressBarWarn]. - State ProgressBarState - // Value is the current value of the progress bar. It should be between - // 0 and 100. - Value int -} - -// NewProgressBar returns a new progress bar with the given state and value. -// The value is ignored if the state is [ProgressBarNone] or -// [ProgressBarIndeterminate]. -func NewProgressBar(state ProgressBarState, value int) *ProgressBar { - return &ProgressBar{ - State: state, - Value: min(max(value, 0), 100), - } -} - -// Cursor represents a cursor on the terminal screen. -type Cursor struct { - // Position is a [Position] that determines the cursor's position on the - // screen relative to the top left corner of the frame. - Position - - // Color is a [color.Color] that determines the cursor's color. - Color color.Color - - // Shape is a [CursorShape] that determines the cursor's shape. - Shape CursorShape - - // Blink is a boolean that determines whether the cursor should blink. - Blink bool -} - -// NewCursor returns a new cursor with the default settings and the given -// position. -func NewCursor(x, y int) *Cursor { - return &Cursor{ - Position: Position{X: x, Y: y}, - Color: nil, - Shape: CursorBlock, - Blink: true, - } -} - -// Cmd is an IO operation that returns a message when it's complete. If it's -// nil it's considered a no-op. Use it for things like HTTP requests, timers, -// saving and loading from disk, and so on. -// -// Note that there's almost never a reason to use a command to send a message -// to another part of your program. That can almost always be done in the -// update function. -type Cmd func() Msg - -// channelHandlers manages the series of channels returned by various processes. -// It allows us to wait for those processes to terminate before exiting the -// program. -type channelHandlers struct { - handlers []chan struct{} - mu sync.RWMutex -} - -// Adds a channel to the list of handlers. We wait for all handlers to terminate -// gracefully on shutdown. -func (h *channelHandlers) add(ch chan struct{}) { - h.mu.Lock() - h.handlers = append(h.handlers, ch) - h.mu.Unlock() -} - -// shutdown waits for all handlers to terminate. -func (h *channelHandlers) shutdown() { - var wg sync.WaitGroup - - h.mu.RLock() - defer h.mu.RUnlock() - - for _, ch := range h.handlers { - wg.Add(1) - go func(ch chan struct{}) { - <-ch - wg.Done() - }(ch) - } - wg.Wait() -} - -// Program is a terminal user interface. -type Program struct { - // disableInput disables all input. This is useful for programs that - // don't need input, like a progress bar or a spinner. - disableInput bool - - // disableSignalHandler disables the signal handler that Bubble Tea sets up - // for Programs. This is useful if you want to handle signals yourself. - disableSignalHandler bool - - // disableCatchPanics disables the panic catching that Bubble Tea does by - // default. If panic catching is disabled the terminal will be in a fairly - // unusable state after a panic because Bubble Tea will not perform its usual - // cleanup on exit. - disableCatchPanics bool - - // filter supplies an event filter that will be invoked before Bubble Tea - // processes a tea.Msg. The event filter can return any tea.Msg which will - // then get handled by Bubble Tea instead of the original event. If the - // event filter returns nil, the event will be ignored and Bubble Tea will - // not process it. - // - // As an example, this could be used to prevent a program from shutting - // down if there are unsaved changes. - // - // Example: - // - // func filter(m tea.Model, msg tea.Msg) tea.Msg { - // if _, ok := msg.(tea.QuitMsg); !ok { - // return msg - // } - // - // model := m.(myModel) - // if model.hasChanges { - // return nil - // } - // - // return msg - // } - // - // p := tea.NewProgram(Model{}); - // p.filter = filter - // - // if _,err := p.Run(context.Background()); err != nil { - // fmt.Println("Error running program:", err) - // os.Exit(1) - // } - filter func(Model, Msg) Msg - - // fps sets a custom maximum fps at which the renderer should run. If less - // than 1, the default value of 60 will be used. If over 120, the fps will - // be capped at 120. - fps int - - // initialModel is the initial model for the program and is the only - // required field when creating a new program. - initialModel Model - - // disableRenderer prevents the program from rendering to the terminal. - // This can be useful for running daemon-like programs that don't require a - // UI but still want to take advantage of Bubble Tea's architecture. - disableRenderer bool - - // handlers is a list of channels that need to be waited on before the - // program can exit. - handlers channelHandlers - - // ctx is the programs's internal context for signalling internal teardown. - // It is built and derived from the externalCtx in NewProgram(). - ctx context.Context - cancel context.CancelFunc - - // externalCtx is a context that was passed in via WithContext, otherwise defaulting - // to ctx.Background() (in case it was not), the internal context is derived from it. - externalCtx context.Context - - msgs chan Msg - errs chan error - finished chan struct{} - shutdownOnce sync.Once - - profile *colorprofile.Profile // the terminal color profile - - // where to send output, this will usually be os.Stdout. - output io.Writer - outputBuf bytes.Buffer // buffer used to queue commands to be sent to the output - - // ttyOutput is null if output is not a TTY. - ttyOutput term.File - previousOutputState *term.State - renderer renderer - - // the environment variables for the program, defaults to os.Environ(). - environ uv.Environ - // the program's logger for debugging. - logger uv.Logger - - // where to read inputs from, this will usually be os.Stdin. - input io.Reader - // ttyInput is null if input is not a TTY. - ttyInput term.File - previousTtyInputState *term.State - cancelReader cancelreader.CancelReader - inputScanner *uv.TerminalReader - readLoopDone chan struct{} - - // modes keeps track of terminal modes that have been enabled or disabled. - ignoreSignals uint32 - - // ticker is the ticker that will be used to write to the renderer. - ticker *time.Ticker - - // once is used to stop the renderer. - once sync.Once - - // rendererDone is used to stop the renderer. - rendererDone chan struct{} - - // Initial window size. Mainly used for testing. - width, height int - - // whether to use hard tabs to optimize cursor movements - useHardTabs bool - // whether to use backspace to optimize cursor movements - useBackspace bool - - mu sync.Mutex -} - -// Quit is a special command that tells the Bubble Tea program to exit. -func Quit() Msg { - return QuitMsg{} -} - -// QuitMsg signals that the program should quit. You can send a [QuitMsg] with -// [Quit]. -type QuitMsg struct{} - -// Suspend is a special command that tells the Bubble Tea program to suspend. -func Suspend() Msg { - return SuspendMsg{} -} - -// SuspendMsg signals the program should suspend. -// This usually happens when ctrl+z is pressed on common programs, but since -// bubbletea puts the terminal in raw mode, we need to handle it in a -// per-program basis. -// -// You can send this message with [Suspend()]. -type SuspendMsg struct{} - -// ResumeMsg can be listen to do something once a program is resumed back -// from a suspend state. -type ResumeMsg struct{} - -// InterruptMsg signals the program should suspend. -// This usually happens when ctrl+c is pressed on common programs, but since -// bubbletea puts the terminal in raw mode, we need to handle it in a -// per-program basis. -// -// You can send this message with [Interrupt()]. -type InterruptMsg struct{} - -// Interrupt is a special command that tells the Bubble Tea program to -// interrupt. -func Interrupt() Msg { - return InterruptMsg{} -} - -// NewProgram creates a new [Program]. -func NewProgram(model Model, opts ...ProgramOption) *Program { - p := &Program{ - initialModel: model, - msgs: make(chan Msg), - errs: make(chan error, 1), - rendererDone: make(chan struct{}), - } - - // Apply all options to the program. - for _, opt := range opts { - opt(p) - } - - // A context can be provided with a ProgramOption, but if none was provided - // we'll use the default background context. - if p.externalCtx == nil { - p.externalCtx = context.Background() - } - // Initialize context and teardown channel. - p.ctx, p.cancel = context.WithCancel(p.externalCtx) - - // if no output was set, set it to stdout - if p.output == nil { - p.output = os.Stdout - } - - // if no environment was set, set it to os.Environ() - if p.environ == nil { - p.environ = os.Environ() - } - - if p.fps < 1 { - p.fps = defaultFPS - } else if p.fps > maxFPS { - p.fps = maxFPS - } - - tracePath, traceOk := os.LookupEnv("TEA_TRACE") - if traceOk && len(tracePath) > 0 { - // We have a trace filepath. - if f, err := os.OpenFile(tracePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o600); err == nil { - p.logger = log.New(f, "bubbletea: ", log.LstdFlags|log.Lshortfile) - } - } - - return p -} - -func (p *Program) handleSignals() chan struct{} { - ch := make(chan struct{}) - - // Listen for SIGINT and SIGTERM. - // - // In most cases ^C will not send an interrupt because the terminal will be - // in raw mode and ^C will be captured as a keystroke and sent along to - // Program.Update as a KeyMsg. When input is not a TTY, however, ^C will be - // caught here. - // - // SIGTERM is sent by unix utilities (like kill) to terminate a process. - go func() { - sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) - defer func() { - signal.Stop(sig) - close(ch) - }() - - for { - select { - case <-p.ctx.Done(): - return - - case s := <-sig: - if atomic.LoadUint32(&p.ignoreSignals) == 0 { - switch s { - case syscall.SIGINT: - p.msgs <- InterruptMsg{} - default: - p.msgs <- QuitMsg{} - } - return - } - } - } - }() - - return ch -} - -// handleResize handles terminal resize events. -func (p *Program) handleResize() chan struct{} { - ch := make(chan struct{}) - - if p.ttyOutput != nil { - // Listen for window resizes. - go p.listenForResize(ch) - } else { - close(ch) - } - - return ch -} - -// handleCommands runs commands in a goroutine and sends the result to the -// program's message channel. -func (p *Program) handleCommands(cmds chan Cmd) chan struct{} { - ch := make(chan struct{}) - - go func() { - defer close(ch) - - for { - select { - case <-p.ctx.Done(): - return - - case cmd := <-cmds: - if cmd == nil { - continue - } - - // Don't wait on these goroutines, otherwise the shutdown - // latency would get too large as a Cmd can run for some time - // (e.g. tick commands that sleep for half a second). It's not - // possible to cancel them so we'll have to leak the goroutine - // until Cmd returns. - go func() { - // Recover from panics. - if !p.disableCatchPanics { - defer func() { - if r := recover(); r != nil { - p.recoverFromPanic(r) - } - }() - } - - msg := cmd() // this can be long. - p.Send(msg) - }() - } - } - }() - - return ch -} - -// eventLoop is the central message loop. It receives and handles the default -// Bubble Tea messages, update the model and triggers redraws. -func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { - for { - select { - case <-p.ctx.Done(): - return model, nil - - case err := <-p.errs: - return model, err - - case msg := <-p.msgs: - msg = p.translateInputEvent(msg) - - // Filter messages. - if p.filter != nil { - msg = p.filter(model, msg) - } - if msg == nil { - continue - } - - // Handle special internal messages. - switch msg := msg.(type) { - case QuitMsg: - return model, nil - - case InterruptMsg: - return model, ErrInterrupted - - case SuspendMsg: - if suspendSupported { - p.suspend() - } - - case CapabilityMsg: - switch msg.Content { - case "RGB", "Tc": - if *p.profile != colorprofile.TrueColor { - tc := colorprofile.TrueColor - p.profile = &tc - go p.Send(ColorProfileMsg{*p.profile}) - } - } - - case ModeReportMsg: - switch msg.Mode { - case ansi.ModeSynchronizedOutput: - if msg.Value == ansi.ModeReset { - // The terminal supports synchronized output and it's - // currently disabled, so we can enable it on the renderer. - p.renderer.setSyncdUpdates(true) - } - case ansi.ModeUnicodeCore: - if msg.Value == ansi.ModeReset || msg.Value == ansi.ModeSet || msg.Value == ansi.ModePermanentlySet { - p.renderer.setWidthMethod(ansi.GraphemeWidth) - } - } - - case MouseMsg: - switch msg.(type) { - case MouseClickMsg, MouseReleaseMsg, MouseWheelMsg, MouseMotionMsg: - // Only send mouse messages to the renderer if they are an - // actual mouse event. - if cmd := p.renderer.onMouse(msg); cmd != nil { - go p.Send(cmd()) - } - } - - case readClipboardMsg: - p.execute(ansi.RequestSystemClipboard) - - case setClipboardMsg: - p.execute(ansi.SetSystemClipboard(string(msg))) - - case readPrimaryClipboardMsg: - p.execute(ansi.RequestPrimaryClipboard) - - case setPrimaryClipboardMsg: - p.execute(ansi.SetPrimaryClipboard(string(msg))) - - case backgroundColorMsg: - p.execute(ansi.RequestBackgroundColor) - - case foregroundColorMsg: - p.execute(ansi.RequestForegroundColor) - - case cursorColorMsg: - p.execute(ansi.RequestCursorColor) - - case execMsg: - // NB: this blocks. - p.exec(msg.cmd, msg.fn) - - case terminalVersion: - p.execute(ansi.RequestNameVersion) - - case requestCapabilityMsg: - p.execute(ansi.RequestTermcap(string(msg))) - - case BatchMsg: - go p.execBatchMsg(msg) - continue - - case sequenceMsg: - go p.execSequenceMsg(msg) - continue - - case WindowSizeMsg: - p.renderer.resize(msg.Width, msg.Height) - - case windowSizeMsg: - go p.checkResize() - - case requestCursorPosMsg: - p.execute(ansi.RequestCursorPositionReport) - - case RawMsg: - p.execute(fmt.Sprint(msg.Msg)) - - case printLineMessage: - p.renderer.insertAbove(msg.messageBody) //nolint:errcheck,gosec - - case clearScreenMsg: - p.renderer.clearScreen() - - case ColorProfileMsg: - p.renderer.setColorProfile(msg.Profile) - } - - var cmd Cmd - model, cmd = model.Update(msg) // run update - - select { - case <-p.ctx.Done(): - return model, nil - case cmds <- cmd: // process command (if any) - } - - p.render(model) // render view - } - } -} - -// render renders the given view to the renderer. -func (p *Program) render(model Model) { - if p.renderer != nil { - p.renderer.render(model.View()) // send view to renderer - } -} - -func (p *Program) execSequenceMsg(msg sequenceMsg) { - if !p.disableCatchPanics { - defer func() { - if r := recover(); r != nil { - p.recoverFromGoPanic(r) - } - }() - } - - // Execute commands one at a time, in order. - for _, cmd := range msg { - if cmd == nil { - continue - } - msg := cmd() - switch msg := msg.(type) { - case BatchMsg: - p.execBatchMsg(msg) - case sequenceMsg: - p.execSequenceMsg(msg) - default: - p.Send(msg) - } - } -} - -func (p *Program) execBatchMsg(msg BatchMsg) { - if !p.disableCatchPanics { - defer func() { - if r := recover(); r != nil { - p.recoverFromGoPanic(r) - } - }() - } - - // Execute commands one at a time. - var wg sync.WaitGroup - for _, cmd := range msg { - if cmd == nil { - continue - } - wg.Add(1) - go func() { - defer wg.Done() - - if !p.disableCatchPanics { - defer func() { - if r := recover(); r != nil { - p.recoverFromGoPanic(r) - } - }() - } - - msg := cmd() - switch msg := msg.(type) { - case BatchMsg: - p.execBatchMsg(msg) - case sequenceMsg: - p.execSequenceMsg(msg) - default: - p.Send(msg) - } - }() - } - - wg.Wait() // wait for all commands from batch msg to finish -} - -// shouldQuerySynchronizedOutput determines whether the terminal should be -// queried for various capabilities. -// -// This function checks for terminals that are known to support mode 2026, -// while excluding SSH sessions which may be unreliable, unless it's a -// known-good terminal like Windows Terminal. -// -// The function returns true for: -// - Terminals without TERM_PROGRAM set and not in SSH sessions -// - Windows Terminal (WT_SESSION is set) -// - Terminals with TERM_PROGRAM set (except Apple Terminal) and not in SSH sessions -// - Specific terminal types: ghostty, wezterm, alacritty, kitty, rio -func shouldQuerySynchronizedOutput(environ uv.Environ) bool { - termType := environ.Getenv("TERM") - termProg, okTermProg := environ.LookupEnv("TERM_PROGRAM") - _, okSSHTTY := environ.LookupEnv("SSH_TTY") - _, okWTSession := environ.LookupEnv("WT_SESSION") - - return (!okTermProg && !okSSHTTY) || - okWTSession || - (okTermProg && !strings.Contains(termProg, "Apple") && !okSSHTTY) || - strings.Contains(termType, "ghostty") || - strings.Contains(termType, "wezterm") || - strings.Contains(termType, "alacritty") || - strings.Contains(termType, "kitty") || - strings.Contains(termType, "rio") -} - -// Run initializes the program and runs its event loops, blocking until it gets -// terminated by either [Program.Quit], [Program.Kill], or its signal handler. -// Returns the final model. -func (p *Program) Run() (returnModel Model, returnErr error) { - if p.initialModel == nil { - return nil, errors.New("bubbletea: InitialModel cannot be nil") - } - - // Initialize context and teardown channel. - p.handlers = channelHandlers{} - cmds := make(chan Cmd) - - p.finished = make(chan struct{}) - defer func() { - close(p.finished) - }() - - defer p.cancel() - - if p.disableInput { - p.input = nil - } else if p.input == nil { - p.input = os.Stdin - if !term.IsTerminal(os.Stdin.Fd()) { - ttyIn, _, err := OpenTTY() - if err != nil { - return p.initialModel, fmt.Errorf("bubbletea: error opening TTY: %w", err) - } - p.input = ttyIn - } - } - - // Handle signals. - if !p.disableSignalHandler { - p.handlers.add(p.handleSignals()) - } - - // Recover from panics. - if !p.disableCatchPanics { - defer func() { - if r := recover(); r != nil { - returnErr = fmt.Errorf("%w: %w", ErrProgramKilled, ErrProgramPanic) - p.recoverFromPanic(r) - } - }() - } - - // Check if output is a TTY before entering raw mode, hiding the cursor and - // so on. - if err := p.initTerminal(); err != nil { - return p.initialModel, err - } - - // Get the initial window size. - width, height := p.width, p.height - if p.ttyOutput != nil { - // Set the initial size of the terminal. - w, h, err := term.GetSize(p.ttyOutput.Fd()) - if err != nil { - return p.initialModel, fmt.Errorf("bubbletea: error getting terminal size: %w", err) - } - - width, height = w, h - } - - p.width, p.height = width, height - resizeMsg := WindowSizeMsg{Width: p.width, Height: p.height} - - if p.renderer == nil { - if p.disableRenderer { - p.renderer = &nilRenderer{} - } else { - // If no renderer is set use the cursed one. - r := newCursedRenderer( - p.output, - p.environ, - p.width, - p.height, - ) - r.setLogger(p.logger) - // XXX: This breaks many things especially when we want the output - // to be compatible with terminals that are not necessary a TTY. - // This was originally done to work around a Wish emulated-pty - // issue where when a PTY session is detected, and we don't - // allocate a real PTY, the terminal settings (Termios and WinCon) - // don't change and the we end up working in cooked mode instead of - // raw mode. See issue #1572. - mapNl := runtime.GOOS != "windows" && p.ttyInput == nil - r.setOptimizations(p.useHardTabs, p.useBackspace, mapNl) - p.renderer = r - } - } - - // Get the color profile and send it to the program. - if p.profile == nil { - cp := colorprofile.Detect(p.output, p.environ) - p.profile = &cp - } - - // Set the color profile on the renderer and send it to the program. - p.renderer.setColorProfile(*p.profile) - go p.Send(ColorProfileMsg{*p.profile}) - - // Send the initial size to the program. - go p.Send(resizeMsg) - p.renderer.resize(resizeMsg.Width, resizeMsg.Height) - - // Send the environment variables used by the program. - go p.Send(EnvMsg(p.environ)) - - // Init the input reader and initial model. - model := p.initialModel - if p.input != nil { - if err := p.initInputReader(false); err != nil { - return model, err - } - } - - // Start the renderer. - p.startRenderer() - - if !p.disableRenderer && shouldQuerySynchronizedOutput(p.environ) { - // Query for synchronized updates support (mode 2026) and unicode core - // (mode 2027). If the terminal supports it, the renderer will enable - // it once we get the response. - p.execute(ansi.RequestModeSynchronizedOutput + - ansi.RequestModeUnicodeCore) - } - - // Initialize the program. - initCmd := model.Init() - if initCmd != nil { - ch := make(chan struct{}) - p.handlers.add(ch) - - go func() { - defer close(ch) - - select { - case cmds <- initCmd: - case <-p.ctx.Done(): - } - }() - } - - // Render the initial view. - p.render(model) - - // Handle resize events. - p.handlers.add(p.handleResize()) - - // Process commands. - p.handlers.add(p.handleCommands(cmds)) - - // Run event loop, handle updates and draw. - var err error - model, err = p.eventLoop(model, cmds) - - if err == nil && len(p.errs) > 0 { - err = <-p.errs // Drain a leftover error in case eventLoop crashed. - } - - killed := p.externalCtx.Err() != nil || p.ctx.Err() != nil || err != nil - if killed { - if err == nil && p.externalCtx.Err() != nil { - // Return also as context error the cancellation of an external context. - // This is the context the user knows about and should be able to act on. - err = fmt.Errorf("%w: %w", ErrProgramKilled, p.externalCtx.Err()) - } else if err == nil && p.ctx.Err() != nil { - // Return only that the program was killed (not the internal mechanism). - // The user does not know or need to care about the internal program context. - err = ErrProgramKilled - } else { - // Return that the program was killed and also the error that caused it. - err = fmt.Errorf("%w: %w", ErrProgramKilled, err) - } - } else { - // Graceful shutdown of the program (not killed): - // Ensure we rendered the final state of the model. - p.render(model) - } - - // Restore terminal state. - p.shutdown(killed) - - return model, err -} - -// Send sends a message to the main update function, effectively allowing -// messages to be injected from outside the program for interoperability -// purposes. -// -// If the program hasn't started yet this will be a blocking operation. -// If the program has already been terminated this will be a no-op, so it's safe -// to send messages after the program has exited. -func (p *Program) Send(msg Msg) { - select { - case <-p.ctx.Done(): - case p.msgs <- msg: - } -} - -// Quit is a convenience function for quitting Bubble Tea programs. Use it -// when you need to shut down a Bubble Tea program from the outside. -// -// If you wish to quit from within a Bubble Tea program use the Quit command. -// -// If the program is not running this will be a no-op, so it's safe to call -// if the program is unstarted or has already exited. -func (p *Program) Quit() { - p.Send(Quit()) -} - -// Kill stops the program immediately and restores the former terminal state. -// The final render that you would normally see when quitting will be skipped. -// [program.Run] returns a [ErrProgramKilled] error. -func (p *Program) Kill() { - p.shutdown(true) -} - -// Wait waits/blocks until the underlying Program finished shutting down. -func (p *Program) Wait() { - <-p.finished -} - -// execute writes the given sequence to the program output. -func (p *Program) execute(seq string) { - p.mu.Lock() - _, _ = p.outputBuf.WriteString(seq) - p.mu.Unlock() -} - -// flush flushes the output buffer to the program output. -func (p *Program) flush() error { - p.mu.Lock() - defer p.mu.Unlock() - - if p.outputBuf.Len() == 0 { - return nil - } - if p.logger != nil { - p.logger.Printf("output: %q", p.outputBuf.String()) - } - _, err := p.output.Write(p.outputBuf.Bytes()) - p.outputBuf.Reset() - if err != nil { - return fmt.Errorf("error writing to output: %w", err) - } - return nil -} - -// shutdown performs operations to free up resources and restore the terminal -// to its original state. -func (p *Program) shutdown(kill bool) { - p.shutdownOnce.Do(func() { - p.cancel() - - // Wait for all handlers to finish. - p.handlers.shutdown() - - // Check if the cancel reader has been setup before waiting and closing. - if p.cancelReader != nil { - // Wait for input loop to finish. - if p.cancelReader.Cancel() { - if !kill { - p.waitForReadLoop() - } - } - _ = p.cancelReader.Close() - } - - if p.renderer != nil { - p.stopRenderer(kill) - } - - _ = p.restoreTerminalState() - }) -} - -// recoverFromPanic recovers from a panic, prints the stack trace, and restores -// the terminal to a usable state. -func (p *Program) recoverFromPanic(r interface{}) { - select { - case p.errs <- ErrProgramPanic: - default: - } - p.shutdown(true) // Ok to call here, p.Run() cannot do it anymore. - // We use "\r\n" to ensure the output is formatted even when restoring the - // terminal does not work or when raw mode is still active. - rec := strings.ReplaceAll(fmt.Sprintf("%s", r), "\n", "\r\n") - fmt.Fprintf(os.Stderr, "Caught panic:\r\n\r\n%s\r\n\r\nRestoring terminal...\r\n\r\n", rec) - stack := strings.ReplaceAll(fmt.Sprintf("%s\n", debug.Stack()), "\n", "\r\n") - fmt.Fprint(os.Stderr, stack) - if v, err := strconv.ParseBool(os.Getenv("TEA_DEBUG")); err == nil && v { - f, err := os.Create(fmt.Sprintf("bubbletea-panic-%d.log", time.Now().Unix())) - if err == nil { - defer f.Close() //nolint:errcheck - fmt.Fprintln(f, rec) //nolint:errcheck - fmt.Fprintln(f) //nolint:errcheck - fmt.Fprintln(f, stack) //nolint:errcheck - } - } -} - -// recoverFromGoPanic recovers from a goroutine panic, prints a stack trace and -// signals for the program to be killed and terminal restored to a usable state. -func (p *Program) recoverFromGoPanic(r interface{}) { - select { - case p.errs <- ErrProgramPanic: - default: - } - p.cancel() - // We use "\r\n" to ensure the output is formatted even when restoring the - // terminal does not work or when raw mode is still active. - rec := strings.ReplaceAll(fmt.Sprintf("%s", r), "\n", "\r\n") - fmt.Fprintf(os.Stderr, "Caught panic:\r\n\r\n%s\r\n\r\nRestoring terminal...\r\n\r\n", rec) - stack := strings.ReplaceAll(fmt.Sprintf("%s\n", debug.Stack()), "\n", "\r\n") - fmt.Fprint(os.Stderr, stack) - if v, err := strconv.ParseBool(os.Getenv("TEA_DEBUG")); err == nil && v { - f, err := os.Create(fmt.Sprintf("bubbletea-panic-%d.log", time.Now().Unix())) - if err == nil { - defer f.Close() //nolint:errcheck - fmt.Fprintln(f, rec) //nolint:errcheck - fmt.Fprintln(f) //nolint:errcheck - fmt.Fprintln(f, stack) //nolint:errcheck - } - } -} - -// ReleaseTerminal restores the original terminal state and cancels the input -// reader. You can return control to the Program with RestoreTerminal. -func (p *Program) ReleaseTerminal() error { - return p.releaseTerminal(false) -} - -func (p *Program) releaseTerminal(reset bool) error { - atomic.StoreUint32(&p.ignoreSignals, 1) - if p.cancelReader != nil { - p.cancelReader.Cancel() - } - - p.waitForReadLoop() - - if p.renderer != nil { - p.stopRenderer(false) - if reset { - p.renderer.reset() - } - } - - return p.restoreTerminalState() -} - -// RestoreTerminal reinitializes the Program's input reader, restores the -// terminal to the former state when the program was running, and repaints. -// Use it to reinitialize a Program after running ReleaseTerminal. -func (p *Program) RestoreTerminal() error { - atomic.StoreUint32(&p.ignoreSignals, 0) - - if err := p.initTerminal(); err != nil { - return err - } - if p.input != nil { - if err := p.initInputReader(false); err != nil { - return err - } - } - - p.startRenderer() - - // If the output is a terminal, it may have been resized while another - // process was at the foreground, in which case we may not have received - // SIGWINCH. Detect any size change now and propagate the new size as - // needed. - go p.checkResize() - - // Flush queued commands. - return p.flush() -} - -// Println prints above the Program. This output is unmanaged by the program -// and will persist across renders by the Program. -// -// If the altscreen is active no output will be printed. -func (p *Program) Println(args ...any) { - p.msgs <- printLineMessage{ - messageBody: fmt.Sprint(args...), - } -} - -// Printf prints above the Program. It takes a format template followed by -// values similar to fmt.Printf. This output is unmanaged by the program and -// will persist across renders by the Program. -// -// Unlike fmt.Printf (but similar to log.Printf) the message will be print on -// its own line. -// -// If the altscreen is active no output will be printed. -func (p *Program) Printf(template string, args ...any) { - p.msgs <- printLineMessage{ - messageBody: fmt.Sprintf(template, args...), - } -} - -// startRenderer starts the renderer. -func (p *Program) startRenderer() { - framerate := time.Second / time.Duration(p.fps) - if p.ticker == nil { - p.ticker = time.NewTicker(framerate) - } else { - // If the ticker already exists, it has been stopped and we need to - // reset it. - p.ticker.Reset(framerate) - } - - // Since the renderer can be restarted after a stop, we need to reset - // the done channel and its corresponding sync.Once. - p.once = sync.Once{} - - // Start the renderer. - p.renderer.start() - go func() { - for { - select { - case <-p.rendererDone: - p.ticker.Stop() - return - - case <-p.ticker.C: - _ = p.flush() - _ = p.renderer.flush(false) - } - } - }() -} - -// stopRenderer stops the renderer. -// If kill is true, the renderer will be stopped immediately without flushing -// the last frame. -func (p *Program) stopRenderer(kill bool) { - // Stop the renderer before acquiring the mutex to avoid a deadlock. - p.once.Do(func() { - p.rendererDone <- struct{}{} - }) - - if !kill { - // flush locks the mutex - _ = p.renderer.flush(true) - } - - _ = p.renderer.close() -} diff --git a/patches/bubbletea-v2/tea_test.go b/patches/bubbletea-v2/tea_test.go deleted file mode 100644 index f8be8b08a..000000000 --- a/patches/bubbletea-v2/tea_test.go +++ /dev/null @@ -1,682 +0,0 @@ -package tea - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "strings" - "sync" - "sync/atomic" - "testing" - "time" -) - -type ctxImplodeMsg struct { - cancel context.CancelFunc -} - -type incrementMsg struct{} - -type panicMsg struct{} - -func panicCmd() Msg { - panic("testing goroutine panic behavior") -} - -type testModel struct { - executed atomic.Value - counter atomic.Value -} - -func (m *testModel) Init() Cmd { - return nil -} - -func (m *testModel) Update(msg Msg) (Model, Cmd) { - switch msg := msg.(type) { - case ctxImplodeMsg: - msg.cancel() - time.Sleep(100 * time.Millisecond) - - case incrementMsg: - i := m.counter.Load() - if i == nil { - m.counter.Store(1) - } else { - m.counter.Store(i.(int) + 1) - } - - case KeyPressMsg: - switch msg.String() { - case "q", "ctrl+c": - return m, Quit - } - - case panicMsg: - panic("testing panic behavior") - } - - return m, nil -} - -func (m *testModel) View() View { - m.executed.Store(true) - return NewView("success") -} - -func TestTeaModel(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - in.Write([]byte("q")) - - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) - defer cancel() - - p := NewProgram(&testModel{}, - WithContext(ctx), - WithInput(&in), - WithOutput(&buf), - ) - if _, err := p.Run(); err != nil { - t.Fatal(err) - } - - if buf.Len() == 0 { - t.Fatal("no output") - } -} - -func TestTeaQuit(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - m := &testModel{} - p := NewProgram(m, - WithInput(&in), - WithOutput(&buf), - ) - go func() { - for { - time.Sleep(time.Millisecond) - if m.executed.Load() != nil { - p.Quit() - return - } - } - }() - - if _, err := p.Run(); err != nil { - t.Fatal(err) - } -} - -func TestTeaWaitQuit(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - progStarted := make(chan struct{}) - waitStarted := make(chan struct{}) - errChan := make(chan error, 1) - - m := &testModel{} - p := NewProgram(m, - WithInput(&in), - WithOutput(&buf), - ) - - go func() { - _, err := p.Run() - errChan <- err - }() - - go func() { - for { - time.Sleep(time.Millisecond) - if m.executed.Load() != nil { - close(progStarted) - - <-waitStarted - time.Sleep(50 * time.Millisecond) - p.Quit() - - return - } - } - }() - - <-progStarted - - var wg sync.WaitGroup - for range 5 { - wg.Add(1) - go func() { - p.Wait() - wg.Done() - }() - } - close(waitStarted) - wg.Wait() - - err := <-errChan - if err != nil { - t.Fatalf("Expected nil, got %v", err) - } -} - -func TestTeaWaitKill(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - progStarted := make(chan struct{}) - waitStarted := make(chan struct{}) - errChan := make(chan error, 1) - - m := &testModel{} - p := NewProgram(m, - WithInput(&in), - WithOutput(&buf), - ) - - go func() { - _, err := p.Run() - errChan <- err - }() - - go func() { - for { - time.Sleep(time.Millisecond) - if m.executed.Load() != nil { - close(progStarted) - - <-waitStarted - time.Sleep(50 * time.Millisecond) - p.Kill() - - return - } - } - }() - - <-progStarted - - var wg sync.WaitGroup - for range 5 { - wg.Add(1) - go func() { - p.Wait() - wg.Done() - }() - } - close(waitStarted) - wg.Wait() - - err := <-errChan - if !errors.Is(err, ErrProgramKilled) { - t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) - } -} - -func TestTeaWithFilter(t *testing.T) { - for _, preventCount := range []uint32{0, 1, 2} { - t.Run(fmt.Sprintf("prevent_%d", preventCount), func(t *testing.T) { - t.Parallel() - testTeaWithFilter(t, preventCount) - }) - } -} - -func testTeaWithFilter(t *testing.T, preventCount uint32) { - var buf bytes.Buffer - var in bytes.Buffer - - m := &testModel{} - shutdowns := uint32(0) - p := NewProgram(m, - WithInput(&in), - WithOutput(&buf), - ) - p.filter = func(_ Model, msg Msg) Msg { - if _, ok := msg.(QuitMsg); !ok { - return msg - } - if shutdowns < preventCount { - atomic.AddUint32(&shutdowns, 1) - return nil - } - return msg - } - - // The loop condition alone never turns false (shutdowns stays equal to - // preventCount after the final accepted Quit), so tie the goroutine to a - // channel closed when Run returns instead of leaking it until process - // exit. - done := make(chan struct{}) - go func() { - for atomic.LoadUint32(&shutdowns) <= preventCount { - select { - case <-done: - return - case <-time.After(time.Millisecond): - } - p.Quit() - } - }() - - _, err := p.Run() - close(done) - if err != nil { - t.Fatal(err) - } - if shutdowns != preventCount { - t.Errorf("Expected %d prevented shutdowns, got %d", preventCount, shutdowns) - } -} - -func TestTeaKill(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - m := &testModel{} - p := NewProgram(m, - WithInput(&in), - WithOutput(&buf), - ) - go func() { - for { - time.Sleep(time.Millisecond) - if m.executed.Load() != nil { - p.Kill() - return - } - } - }() - - _, err := p.Run() - - if !errors.Is(err, ErrProgramKilled) { - t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) - } - - if errors.Is(err, context.Canceled) { - // The end user should not know about the program's internal context state. - // The program should only report external context cancellation as a context error. - t.Fatalf("Internal context cancellation was reported as context error!") - } -} - -func TestTeaContext(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithCancel(t.Context()) - var buf bytes.Buffer - var in bytes.Buffer - - m := &testModel{} - p := NewProgram(m, - WithContext(ctx), - WithInput(&in), - WithOutput(&buf), - ) - go func() { - for { - time.Sleep(time.Millisecond) - if m.executed.Load() != nil { - cancel() - return - } - } - }() - - _, err := p.Run() - - if !errors.Is(err, ErrProgramKilled) { - t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) - } - - if !errors.Is(err, context.Canceled) { - // The end user should know that their passed in context caused the kill. - t.Fatalf("Expected %v, got %v", context.Canceled, err) - } -} - -func TestTeaContextImplodeDeadlock(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithCancel(t.Context()) - var buf bytes.Buffer - var in bytes.Buffer - - m := &testModel{} - p := NewProgram(m, - WithContext(ctx), - WithInput(&in), - WithOutput(&buf), - ) - go func() { - for { - time.Sleep(time.Millisecond) - if m.executed.Load() != nil { - p.Send(ctxImplodeMsg{cancel: cancel}) - return - } - } - }() - - if _, err := p.Run(); !errors.Is(err, ErrProgramKilled) { - t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) - } -} - -func TestTeaContextBatchDeadlock(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithCancel(t.Context()) - var buf bytes.Buffer - var in bytes.Buffer - - inc := func() Msg { - cancel() - return incrementMsg{} - } - - m := &testModel{} - p := NewProgram(m, - WithContext(ctx), - WithInput(&in), - WithOutput(&buf), - ) - go func() { - for { - time.Sleep(time.Millisecond) - if m.executed.Load() != nil { - batch := make(BatchMsg, 100) - for i := range batch { - batch[i] = inc - } - p.Send(batch) - return - } - } - }() - - if _, err := p.Run(); !errors.Is(err, ErrProgramKilled) { - t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) - } -} - -func TestTeaBatchMsg(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - inc := func() Msg { - return incrementMsg{} - } - - m := &testModel{} - p := NewProgram(m, - WithInput(&in), - WithOutput(&buf), - ) - go func() { - p.Send(BatchMsg{inc, inc}) - - for { - time.Sleep(time.Millisecond) - i := m.counter.Load() - if i != nil && i.(int) >= 2 { - p.Quit() - return - } - } - }() - - if _, err := p.Run(); err != nil { - t.Fatal(err) - } - - if m.counter.Load() != 2 { - t.Fatalf("counter should be 2, got %d", m.counter.Load()) - } -} - -func TestTeaSequenceMsg(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - inc := func() Msg { - return incrementMsg{} - } - - m := &testModel{} - p := NewProgram(m, - WithInput(&in), - WithOutput(&buf), - ) - go p.Send(sequenceMsg{inc, inc, Quit}) - - if _, err := p.Run(); err != nil { - t.Fatal(err) - } - - if m.counter.Load() != 2 { - t.Fatalf("counter should be 2, got %d", m.counter.Load()) - } -} - -func TestTeaSequenceMsgWithBatchMsg(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - inc := func() Msg { - return incrementMsg{} - } - batch := func() Msg { - return BatchMsg{inc, inc} - } - - m := &testModel{} - p := NewProgram(m, - WithInput(&in), - WithOutput(&buf), - ) - go p.Send(sequenceMsg{batch, inc, Quit}) - - if _, err := p.Run(); err != nil { - t.Fatal(err) - } - - if m.counter.Load() != 3 { - t.Fatalf("counter should be 3, got %d", m.counter.Load()) - } -} - -func TestTeaNestedSequenceMsg(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - inc := func() Msg { - return incrementMsg{} - } - - m := &testModel{} - p := NewProgram(m, - WithInput(&in), - WithOutput(&buf), - ) - go p.Send(sequenceMsg{inc, Sequence(inc, inc, Batch(inc, inc)), Quit}) - - if _, err := p.Run(); err != nil { - t.Fatal(err) - } - - if m.counter.Load() != 5 { - t.Fatalf("counter should be 5, got %d", m.counter.Load()) - } -} - -func TestTeaSend(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - m := &testModel{} - p := NewProgram(m, - WithInput(&in), - WithOutput(&buf), - ) - - // sending before the program is started is a blocking operation - go p.Send(Quit()) - - if _, err := p.Run(); err != nil { - t.Fatal(err) - } - - // sending a message after program has quit is a no-op - p.Send(Quit()) -} - -func TestTeaNoRun(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - m := &testModel{} - NewProgram(m, - WithInput(&in), - WithOutput(&buf), - ) -} - -func TestTeaPanic(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - m := &testModel{} - p := NewProgram(m, - WithInput(&in), - WithOutput(&buf), - ) - go func() { - for { - time.Sleep(time.Millisecond) - if m.executed.Load() != nil { - p.Send(panicMsg{}) - return - } - } - }() - - _, err := p.Run() - - if !errors.Is(err, ErrProgramPanic) { - t.Fatalf("Expected %v, got %v", ErrProgramPanic, err) - } - - if !errors.Is(err, ErrProgramKilled) { - t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) - } -} - -func TestTeaGoroutinePanic(t *testing.T) { - t.Parallel() - var buf bytes.Buffer - var in bytes.Buffer - - m := &testModel{} - p := NewProgram(m, - WithInput(&in), - WithOutput(&buf), - ) - go func() { - for { - time.Sleep(time.Millisecond) - if m.executed.Load() != nil { - batch := make(BatchMsg, 10) - for i := 0; i < len(batch); i += 2 { - batch[i] = Sequence(panicCmd) - batch[i+1] = Batch(panicCmd) - } - p.Send(batch) - return - } - } - }() - - _, err := p.Run() - - if !errors.Is(err, ErrProgramPanic) { - t.Fatalf("Expected %v, got %v", ErrProgramPanic, err) - } - - if !errors.Is(err, ErrProgramKilled) { - t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) - } -} - -type benchModel struct { - t testing.TB -} - -func (m benchModel) Init() Cmd { - return nil -} - -func (m benchModel) Update(msg Msg) (Model, Cmd) { - switch msg := msg.(type) { - case KeyPressMsg: - switch msg.String() { - case "q", "ctrl+c": - return m, Quit - } - } - - return m, nil -} - -func (m benchModel) View() View { - view := strings.Join([]string{ - " \x1b[38;5;63m╭─────────────────────────╮\x1b[m", - " \x1b[38;5;63m│\x1b[m\x1b[25X\x1b[28G\x1b[38;5;63m│\x1b[m", - " \x1b[38;5;63m│\x1b[m \x1b[38;5;231mHello There!\x1b[m \x1b[38;5;63m│\x1b[m", - " \x1b[38;5;63m│\x1b[m\x1b[25X\x1b[28G\x1b[38;5;63m│\x1b[m", - " \x1b[38;5;63m╰─────────────────────────╯\x1b[m", - }, "\n") - - return NewView(view) -} - -func BenchmarkTeaRun(b *testing.B) { - for i := 0; i < b.N; i++ { - var buf bytes.Buffer - - m := benchModel{b} - r, w := io.Pipe() - p := NewProgram(m, - WithInput(r), - WithOutput(&buf), - ) - - go func() { - for _, input := range "abcdefghijklmnopq" { - time.Sleep(10 * time.Millisecond) - w.Write([]byte(string(input))) - } - }() - - if _, err := p.Run(); err != nil { - b.Fatalf("Run failed: %v", err) - } - - _ = r.CloseWithError(io.EOF) - } -} diff --git a/patches/bubbletea-v2/termcap.go b/patches/bubbletea-v2/termcap.go deleted file mode 100644 index c66d95ae5..000000000 --- a/patches/bubbletea-v2/termcap.go +++ /dev/null @@ -1,48 +0,0 @@ -package tea - -// requestCapabilityMsg is an internal message that requests the terminal to -// send its Termcap/Terminfo response. -type requestCapabilityMsg string - -// RequestCapability is a command that requests the terminal to send its -// Termcap/Terminfo response for the given capability. -// -// Bubble Tea recognizes the following capabilities and will use them to -// upgrade the program's color profile: -// - "RGB" Xterm direct color -// - "Tc" True color support -// -// Note: that some terminal's like Apple's Terminal.app do not support this and -// will send the wrong response to the terminal breaking the program's output. -// -// When the Bubble Tea advertises a non-TrueColor profile, you can use this -// command to query the terminal for its color capabilities. Example: -// -// switch msg := msg.(type) { -// case tea.ColorProfileMsg: -// if msg.Profile != colorprofile.TrueColor { -// return m, tea.Batch( -// tea.RequestCapability("RGB"), -// tea.RequestCapability("Tc"), -// ) -// } -// } -func RequestCapability(s string) Cmd { - return func() Msg { - return requestCapabilityMsg(s) - } -} - -// CapabilityMsg represents a Termcap/Terminfo response event. Termcap -// responses are generated by the terminal in response to RequestTermcap -// (XTGETTCAP) requests. -// -// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands -type CapabilityMsg struct { - Content string -} - -// String returns the capability content as a string. -func (c CapabilityMsg) String() string { - return c.Content -} diff --git a/patches/bubbletea-v2/termios_bsd.go b/patches/bubbletea-v2/termios_bsd.go deleted file mode 100644 index cc716e585..000000000 --- a/patches/bubbletea-v2/termios_bsd.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build dragonfly || freebsd -// +build dragonfly freebsd - -package tea - -import ( - "github.com/charmbracelet/x/term" - "golang.org/x/sys/unix" -) - -func (p *Program) checkOptimizedMovements(s *term.State) { - p.useHardTabs = s.Oflag&unix.TABDLY == unix.TAB0 -} diff --git a/patches/bubbletea-v2/termios_other.go b/patches/bubbletea-v2/termios_other.go deleted file mode 100644 index d4ede6d04..000000000 --- a/patches/bubbletea-v2/termios_other.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build !windows && !darwin && !dragonfly && !freebsd && !linux && !solaris && !aix -// +build !windows,!darwin,!dragonfly,!freebsd,!linux,!solaris,!aix - -package tea - -import "github.com/charmbracelet/x/term" - -func (*Program) checkOptimizedMovements(*term.State) {} diff --git a/patches/bubbletea-v2/termios_unix.go b/patches/bubbletea-v2/termios_unix.go deleted file mode 100644 index e322141b7..000000000 --- a/patches/bubbletea-v2/termios_unix.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build darwin || linux || solaris || aix -// +build darwin linux solaris aix - -package tea - -import ( - "github.com/charmbracelet/x/term" - "golang.org/x/sys/unix" -) - -func (p *Program) checkOptimizedMovements(s *term.State) { - p.useHardTabs = s.Oflag&unix.TABDLY == unix.TAB0 - p.useBackspace = s.Oflag&unix.BSDLY == unix.BS0 -} diff --git a/patches/bubbletea-v2/termios_windows.go b/patches/bubbletea-v2/termios_windows.go deleted file mode 100644 index c743652c8..000000000 --- a/patches/bubbletea-v2/termios_windows.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build windows -// +build windows - -package tea - -import "github.com/charmbracelet/x/term" - -func (p *Program) checkOptimizedMovements(*term.State) { - p.useHardTabs = true - p.useBackspace = true -} diff --git a/patches/bubbletea-v2/testdata/TestClearMsg/bg_fg_cur_color.golden b/patches/bubbletea-v2/testdata/TestClearMsg/bg_fg_cur_color.golden deleted file mode 100644 index dd7ab7f1d..000000000 --- a/patches/bubbletea-v2/testdata/TestClearMsg/bg_fg_cur_color.golden +++ /dev/null @@ -1 +0,0 @@ -[?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p]10;?]11;?]12;? \ No newline at end of file diff --git a/patches/bubbletea-v2/testdata/TestClearMsg/clear_screen.golden b/patches/bubbletea-v2/testdata/TestClearMsg/clear_screen.golden deleted file mode 100644 index 7d97a1535..000000000 --- a/patches/bubbletea-v2/testdata/TestClearMsg/clear_screen.golden +++ /dev/null @@ -1 +0,0 @@ -[?25l [?2004h[>4;2m[=1;1usuccess[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/patches/bubbletea-v2/testdata/TestClearMsg/read_set_clipboard.golden b/patches/bubbletea-v2/testdata/TestClearMsg/read_set_clipboard.golden deleted file mode 100644 index e75f5c615..000000000 --- a/patches/bubbletea-v2/testdata/TestClearMsg/read_set_clipboard.golden +++ /dev/null @@ -1 +0,0 @@ -[?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p]52;c;?]52;c;c3VjY2Vzcw== \ No newline at end of file diff --git a/patches/bubbletea-v2/testdata/TestViewModel/altscreen.golden b/patches/bubbletea-v2/testdata/TestViewModel/altscreen.golden deleted file mode 100644 index 503fac477..000000000 --- a/patches/bubbletea-v2/testdata/TestViewModel/altscreen.golden +++ /dev/null @@ -1 +0,0 @@ -[?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/patches/bubbletea-v2/testdata/TestViewModel/altscreen_autoexit.golden b/patches/bubbletea-v2/testdata/TestViewModel/altscreen_autoexit.golden deleted file mode 100644 index 810564717..000000000 --- a/patches/bubbletea-v2/testdata/TestViewModel/altscreen_autoexit.golden +++ /dev/null @@ -1 +0,0 @@ -[>4m[=0;1u[?1049h[?25l[?2004h[>4;2m[=1;1usuccess[>4m[=0;1u[?1049l[?25h[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/patches/bubbletea-v2/testdata/TestViewModel/bg_set_color.golden b/patches/bubbletea-v2/testdata/TestViewModel/bg_set_color.golden deleted file mode 100644 index cf7e4c4ee..000000000 --- a/patches/bubbletea-v2/testdata/TestViewModel/bg_set_color.golden +++ /dev/null @@ -1 +0,0 @@ -[?25l[?2004h[>4;2m[=1;1u]11;#ffffff success[>4m[=0;1u [?25h[?2004l]111[?2026$p[?2027$p \ No newline at end of file diff --git a/patches/bubbletea-v2/testdata/TestViewModel/bp_stop_start.golden b/patches/bubbletea-v2/testdata/TestViewModel/bp_stop_start.golden deleted file mode 100644 index 503fac477..000000000 --- a/patches/bubbletea-v2/testdata/TestViewModel/bp_stop_start.golden +++ /dev/null @@ -1 +0,0 @@ -[?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/patches/bubbletea-v2/testdata/TestViewModel/cursor_hide.golden b/patches/bubbletea-v2/testdata/TestViewModel/cursor_hide.golden deleted file mode 100644 index 503fac477..000000000 --- a/patches/bubbletea-v2/testdata/TestViewModel/cursor_hide.golden +++ /dev/null @@ -1 +0,0 @@ -[?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/patches/bubbletea-v2/testdata/TestViewModel/cursor_hideshow.golden b/patches/bubbletea-v2/testdata/TestViewModel/cursor_hideshow.golden deleted file mode 100644 index 9fd5343c0..000000000 --- a/patches/bubbletea-v2/testdata/TestViewModel/cursor_hideshow.golden +++ /dev/null @@ -1 +0,0 @@ -[?2004h[>4;2m[=1;1u[1 q success [?25h[>4m[=0;1u[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/patches/bubbletea-v2/testdata/TestViewModel/kitty_stop_startreleases.golden b/patches/bubbletea-v2/testdata/TestViewModel/kitty_stop_startreleases.golden deleted file mode 100644 index 0af542ed6..000000000 --- a/patches/bubbletea-v2/testdata/TestViewModel/kitty_stop_startreleases.golden +++ /dev/null @@ -1 +0,0 @@ -[?25l[?2004h[>4;2m[=3;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/patches/bubbletea-v2/testdata/TestViewModel/mouse_allmotion.golden b/patches/bubbletea-v2/testdata/TestViewModel/mouse_allmotion.golden deleted file mode 100644 index da6639489..000000000 --- a/patches/bubbletea-v2/testdata/TestViewModel/mouse_allmotion.golden +++ /dev/null @@ -1 +0,0 @@ -[?25l[?2004h[?1003h[?1006h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?1002l[?1003l[?1006l[?2026$p[?2027$p \ No newline at end of file diff --git a/patches/bubbletea-v2/testdata/TestViewModel/mouse_cellmotion.golden b/patches/bubbletea-v2/testdata/TestViewModel/mouse_cellmotion.golden deleted file mode 100644 index 7206798e5..000000000 --- a/patches/bubbletea-v2/testdata/TestViewModel/mouse_cellmotion.golden +++ /dev/null @@ -1 +0,0 @@ -[?25l[?2004h[?1002h[?1006h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?1002l[?1003l[?1006l[?2026$p[?2027$p \ No newline at end of file diff --git a/patches/bubbletea-v2/testdata/TestViewModel/mouse_disable.golden b/patches/bubbletea-v2/testdata/TestViewModel/mouse_disable.golden deleted file mode 100644 index 503fac477..000000000 --- a/patches/bubbletea-v2/testdata/TestViewModel/mouse_disable.golden +++ /dev/null @@ -1 +0,0 @@ -[?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p \ No newline at end of file diff --git a/patches/bubbletea-v2/tty.go b/patches/bubbletea-v2/tty.go deleted file mode 100644 index 12b86493b..000000000 --- a/patches/bubbletea-v2/tty.go +++ /dev/null @@ -1,136 +0,0 @@ -package tea - -import ( - "fmt" - "os" - "time" - - uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/term" -) - -func (p *Program) suspend() { - if err := p.releaseTerminal(true); err != nil { - // If we can't release input, abort. - return - } - - suspendProcess() - - _ = p.RestoreTerminal() - go p.Send(ResumeMsg{}) -} - -func (p *Program) initTerminal() error { - if p.disableRenderer { - return nil - } - return p.initInput() -} - -// restoreTerminalState restores the terminal to the state prior to running the -// Bubble Tea program. -func (p *Program) restoreTerminalState() error { - // Flush queued commands. - _ = p.flush() - - return p.restoreInput() -} - -// restoreInput restores the tty input to its original state. -func (p *Program) restoreInput() error { - if p.ttyInput != nil && p.previousTtyInputState != nil { - if err := term.Restore(p.ttyInput.Fd(), p.previousTtyInputState); err != nil { - return fmt.Errorf("bubbletea: error restoring console: %w", err) - } - } - if p.ttyOutput != nil && p.previousOutputState != nil { - if err := term.Restore(p.ttyOutput.Fd(), p.previousOutputState); err != nil { - return fmt.Errorf("bubbletea: error restoring console: %w", err) - } - } - return nil -} - -// initInputReader (re)commences reading inputs. -func (p *Program) initInputReader(cancel bool) error { - if cancel && p.cancelReader != nil { - p.cancelReader.Cancel() - p.waitForReadLoop() - } - - term := p.environ.Getenv("TERM") - - // Initialize the input reader. - // This need to be done after the terminal has been initialized and set to - // raw mode. - - var err error - p.cancelReader, err = uv.NewCancelReader(p.input) - if err != nil { - return fmt.Errorf("bubbletea: could not create cancelable reader: %w", err) - } - - drv := uv.NewTerminalReader(p.cancelReader, term) - drv.SetLogger(p.logger) - p.inputScanner = drv - p.readLoopDone = make(chan struct{}) - - go p.readLoop() - - return nil -} - -func (p *Program) readLoop() { - defer close(p.readLoopDone) - - if err := p.inputScanner.StreamEvents(p.ctx, p.msgs); err != nil { - select { - case <-p.ctx.Done(): - return - case p.errs <- err: - } - } -} - -// waitForReadLoop waits for the cancelReader to finish its read loop. -func (p *Program) waitForReadLoop() { - select { - case <-p.readLoopDone: - case <-time.After(500 * time.Millisecond): //nolint:mnd - // The read loop hangs, which means the input - // cancelReader's cancel function has returned true even - // though it was not able to cancel the read. - } -} - -// checkResize detects the current size of the output and informs the program -// via a WindowSizeMsg. -func (p *Program) checkResize() { - if p.ttyOutput == nil { - // can't query window size - return - } - - w, h, err := term.GetSize(p.ttyOutput.Fd()) - if err != nil { - select { - case <-p.ctx.Done(): - case p.errs <- err: - } - - return - } - - p.width, p.height = w, h - p.Send(WindowSizeMsg{Width: w, Height: h}) -} - -// OpenTTY opens the running terminal's TTY for reading and writing. -func OpenTTY() (*os.File, *os.File, error) { - in, out, err := uv.OpenTTY() - if err != nil { - return nil, nil, fmt.Errorf("bubbletea: could not open TTY: %w", err) - } - return in, out, nil -} diff --git a/patches/bubbletea-v2/tty_unix.go b/patches/bubbletea-v2/tty_unix.go deleted file mode 100644 index 4981a560a..000000000 --- a/patches/bubbletea-v2/tty_unix.go +++ /dev/null @@ -1,47 +0,0 @@ -//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix || zos -// +build darwin dragonfly freebsd linux netbsd openbsd solaris aix zos - -package tea - -import ( - "fmt" - "os" - "os/signal" - "syscall" - - "github.com/charmbracelet/x/term" -) - -func (p *Program) initInput() (err error) { - // Check if input is a terminal - if f, ok := p.input.(term.File); ok && term.IsTerminal(f.Fd()) { - p.ttyInput = f - p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd()) - if err != nil { - return fmt.Errorf("error entering raw mode: %w", err) - } - - // OPTIM: We can use hard tabs and backspaces to optimize cursor - // movements. This is based on termios settings support and whether - // they exist and enabled. - p.checkOptimizedMovements(p.previousTtyInputState) - } - - if f, ok := p.output.(term.File); ok && term.IsTerminal(f.Fd()) { - p.ttyOutput = f - } - - return nil -} - -const suspendSupported = true - -// Send SIGTSTP to the entire process group. -func suspendProcess() { - c := make(chan os.Signal, 1) - signal.Notify(c, syscall.SIGCONT) - defer signal.Stop(c) - _ = syscall.Kill(0, syscall.SIGTSTP) - // blocks until a CONT happens... - <-c -} diff --git a/patches/bubbletea-v2/tty_windows.go b/patches/bubbletea-v2/tty_windows.go deleted file mode 100644 index 27b31182a..000000000 --- a/patches/bubbletea-v2/tty_windows.go +++ /dev/null @@ -1,64 +0,0 @@ -//go:build windows -// +build windows - -package tea - -import ( - "fmt" - - "github.com/charmbracelet/x/term" - "golang.org/x/sys/windows" -) - -func (p *Program) initInput() (err error) { - // Save stdin state and enable VT input - // We also need to enable VT - // input here. - if f, ok := p.input.(term.File); ok && term.IsTerminal(f.Fd()) { - p.ttyInput = f - p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd()) - if err != nil { - return fmt.Errorf("error making terminal raw: %w", err) - } - - // Enable VT input - var mode uint32 - if err := windows.GetConsoleMode(windows.Handle(p.ttyInput.Fd()), &mode); err != nil { - return fmt.Errorf("error getting console mode: %w", err) - } - - if err := windows.SetConsoleMode(windows.Handle(p.ttyInput.Fd()), mode|windows.ENABLE_VIRTUAL_TERMINAL_INPUT); err != nil { - return fmt.Errorf("error setting console mode: %w", err) - } - } - - // Save output screen buffer state and enable VT processing. - if f, ok := p.output.(term.File); ok && term.IsTerminal(f.Fd()) { - p.ttyOutput = f - p.previousOutputState, err = term.GetState(f.Fd()) - if err != nil { - return fmt.Errorf("error getting terminal state: %w", err) - } - - var mode uint32 - if err := windows.GetConsoleMode(windows.Handle(p.ttyOutput.Fd()), &mode); err != nil { - return fmt.Errorf("error getting console mode: %w", err) - } - - if err := windows.SetConsoleMode(windows.Handle(p.ttyOutput.Fd()), - mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING| - windows.DISABLE_NEWLINE_AUTO_RETURN); err != nil { - return fmt.Errorf("error setting console mode: %w", err) - } - - //nolint:godox - // TODO: check if we can optimize cursor movements on Windows. - p.checkOptimizedMovements(p.previousOutputState) - } - - return //nolint:nakedret -} - -const suspendSupported = false - -func suspendProcess() {} diff --git a/patches/bubbletea-v2/xterm.go b/patches/bubbletea-v2/xterm.go deleted file mode 100644 index 0ffcd08fe..000000000 --- a/patches/bubbletea-v2/xterm.go +++ /dev/null @@ -1,22 +0,0 @@ -package tea - -// TerminalVersionMsg is a message that represents the terminal version. -type TerminalVersionMsg struct { - Name string -} - -// String returns the terminal name as a string. -func (t TerminalVersionMsg) String() string { - return t.Name -} - -// terminalVersion is an internal message that queries the terminal for its -// version using XTVERSION. -type terminalVersion struct{} - -// RequestTerminalVersion is a command that queries the terminal for its -// version using XTVERSION. Note that some terminals may not support this -// command. -func RequestTerminalVersion() Msg { - return terminalVersion{} -}