From 56cbbbb1b59bb6395fa70c56f7fa3f8fb75523f5 Mon Sep 17 00:00:00 2001 From: "unemployabot[bot]" <277198073+unemployabot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:30:29 +0000 Subject: [PATCH] Termux v1.17.14 (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(termux): add Android artifact support * fix(termux): make target fork friendly * chore: generate * sync release versions for v1.17.14 * feat(data): improved model usage section * fix(data): refine model trend charts * chore: generate * feat(app): align cmd k menu with v2 styles (#35152) Co-authored-by: Brendan Allan * fix(config): handle unavailable config directories (#35632) Co-authored-by: James Long * feat(app): visual improvements (#35134) * chore: generate * feat(app): reintroduce model context tooltip in model selector (#35087) * chore: generate * feat(app): change traffic light position, fix sequoia bug (#35081) * fix(app): apply settings themes on select (#35011) Co-authored-by: Jay <53023+jayair@users.noreply.github.com> * fix(app): persist session tab info (#34934) * chore: generate * fix(app): show unread for pending questions (#34684) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] Co-authored-by: Brendan Allan * fix(app): prioritize shortcuts in terminal (#35668) * chore: generate * fix(app): hide markdown task markers (#35670) * feat(data): redesign model efficiency * chore: generate * fix(core): expand home-relative permission paths (#35737) * chore: generate * fix(core): ensure relevant files survive compaction (#35636) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> * fix(llm): classify zai token limit overflow (#35671) * release: v1.17.15 * chore(termux): seed release automation * chore(termux): prepare v1.17.15 * fix(ci): disable upstream housekeeping on forks * chore(codegraph): Ignore generated data files Signed-off-by: wallentx * fix(termux): pin OpenTUI for Android build * chore(termux): prepare v1.17.14 --------- Signed-off-by: wallentx Co-authored-by: wallentx Co-authored-by: opencode-agent[bot] Co-authored-by: opencode Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan Co-authored-by: James Long Co-authored-by: James Long Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Dustin Deus Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: unemployabot[bot] <277198073+unemployabot[bot]@users.noreply.github.com> Co-authored-by: William Allen --- .codegraph/.gitignore | 5 + .github/actionlint.yaml | 12 + .github/actions/build-android-cli/action.yml | 119 + .../scripts/termux-cleanup-release-branch.sh | 175 + .../scripts}/termux-configure-git.sh | 0 .../termux-create-android-build-deployment.sh | 55 + .../scripts}/termux-create-checkpoint-pr.sh | 2 +- ...ermux-create-or-update-mirrored-release.sh | 0 .../scripts}/termux-create-release-pr.sh | 8 +- .../scripts}/termux-find-release-pr.sh | 0 .../termux-finish-android-build-deployment.sh | 31 + .../scripts}/termux-read-release-metadata.sh | 1 + .../scripts}/termux-release-paths.sh | 26 +- .../scripts}/termux-resolve-release-ref.sh | 0 .../scripts}/termux-select-release.sh | 0 .../scripts}/termux-validate-gh-env.sh | 1 - .github/termux-release.json | 2 +- .github/workflows/beta.yml | 1 + .github/workflows/close-issues.yml | 1 + .github/workflows/close-prs.yml | 1 + .github/workflows/compliance-close.yml | 1 + .github/workflows/containers.yml | 1 + .github/workflows/duplicate-issues.yml | 4 +- .github/workflows/generate.yml | 13 +- .github/workflows/nix-hashes.yml | 3 +- .github/workflows/notify-discord.yml | 1 + .github/workflows/opencode.yml | 11 +- .github/workflows/pr-management.yml | 2 + .github/workflows/pr-standards.yml | 2 + .github/workflows/publish-github-action.yml | 1 + .github/workflows/publish-vscode.yml | 1 + .github/workflows/publish.yml | 94 +- .github/workflows/release-github-action.yml | 1 + .github/workflows/review.yml | 1 + .../termux-release-branch-cleanup.yml | 97 + .../workflows/termux-release-checkpoint.yml | 19 +- .github/workflows/termux-release-deploy.yml | 55 +- .github/workflows/termux-release-watch.yml | 19 +- .github/workflows/triage.yml | 1 + bun.lock | 38 +- package.json | 6 +- packages/app/src/app.tsx | 6 +- .../app/src/components/command-palette.ts | 325 + .../components/dialog-command-palette-v2.css | 220 + .../components/dialog-command-palette-v2.tsx | 221 + .../app/src/components/dialog-select-file.tsx | 434 +- .../src/components/dialog-select-model.tsx | 55 +- packages/app/src/components/model-tooltip.tsx | 37 +- .../app/src/components/settings-general.tsx | 10 - .../src/components/settings-v2/general.tsx | 10 - .../app/src/components/titlebar-tab-nav.tsx | 17 +- .../app/src/components/titlebar-tab-strip.tsx | 9 +- packages/app/src/context/command.test.ts | 18 +- packages/app/src/context/command.tsx | 21 +- packages/app/src/context/tabs.tsx | 37 +- packages/app/src/i18n/en.ts | 5 + packages/app/src/index.css | 42 +- packages/app/src/pages/home.tsx | 2 +- .../src/pages/layout/project-avatar-state.ts | 17 +- .../pages/session/use-session-commands.tsx | 16 + packages/codemode/codemode.md | 22 +- packages/codemode/src/codemode.ts | 7 +- packages/codemode/src/interpreter/model.ts | 3 +- packages/codemode/src/openapi/runtime.ts | 26 +- packages/codemode/src/openapi/spec.ts | 10 +- packages/codemode/src/stdlib/value.ts | 6 +- .../test/fixtures/openapi-happy-path.json | 25 +- .../test/fixtures/opencode-v2-openapi.json | 5358 ++++------------- packages/codemode/test/openapi.test.ts | 46 +- packages/core/src/config/plugin/agent.ts | 45 +- packages/core/src/fs-util.ts | 7 +- packages/core/src/session/compaction.ts | 15 +- packages/core/test/config/agent.test.ts | 77 +- packages/core/test/session-compaction.test.ts | 9 + packages/desktop/src/main/windows.ts | 14 +- packages/desktop/src/preload/types.ts | 1 + packages/llm/src/provider-error.ts | 1 + packages/llm/test/provider-error.test.ts | 8 + packages/opencode/src/config/config.ts | 1 + packages/opencode/test/config/config.test.ts | 25 + .../opencode/test/session/message-v2.test.ts | 1 + packages/plugin/package.json | 6 +- .../session-ui/src/components/markdown.css | 14 + packages/stats/app/src/i18n.ts | 11 +- packages/stats/app/src/i18n/ar.ts | 5 + packages/stats/app/src/i18n/br.ts | 5 + packages/stats/app/src/i18n/da.ts | 5 + packages/stats/app/src/i18n/de.ts | 5 + packages/stats/app/src/i18n/es.ts | 5 + packages/stats/app/src/i18n/fr.ts | 5 + packages/stats/app/src/i18n/it.ts | 5 + packages/stats/app/src/i18n/ja.ts | 5 + packages/stats/app/src/i18n/ko.ts | 5 + packages/stats/app/src/i18n/no.ts | 5 + packages/stats/app/src/i18n/pl.ts | 5 + packages/stats/app/src/i18n/ru.ts | 5 + packages/stats/app/src/i18n/th.ts | 5 + packages/stats/app/src/i18n/tr.ts | 5 + packages/stats/app/src/i18n/uk.ts | 5 + packages/stats/app/src/i18n/zh.ts | 5 + packages/stats/app/src/i18n/zht.ts | 5 + .../stats/app/src/routes/[lab]/[model].tsx | 463 +- packages/stats/app/src/routes/index.css | 371 +- packages/ui/src/theme/context.tsx | 17 +- scripts/termux-dispatch-android-build.sh | 76 - sdks/vscode/package.json | 2 +- 106 files changed, 3977 insertions(+), 5088 deletions(-) create mode 100644 .codegraph/.gitignore create mode 100644 .github/actionlint.yaml create mode 100644 .github/actions/build-android-cli/action.yml create mode 100755 .github/scripts/termux-cleanup-release-branch.sh rename {scripts => .github/scripts}/termux-configure-git.sh (100%) create mode 100755 .github/scripts/termux-create-android-build-deployment.sh rename {scripts => .github/scripts}/termux-create-checkpoint-pr.sh (98%) rename {scripts => .github/scripts}/termux-create-or-update-mirrored-release.sh (100%) rename {scripts => .github/scripts}/termux-create-release-pr.sh (96%) rename {scripts => .github/scripts}/termux-find-release-pr.sh (100%) create mode 100755 .github/scripts/termux-finish-android-build-deployment.sh rename {scripts => .github/scripts}/termux-read-release-metadata.sh (97%) rename {scripts => .github/scripts}/termux-release-paths.sh (53%) rename {scripts => .github/scripts}/termux-resolve-release-ref.sh (100%) rename {scripts => .github/scripts}/termux-select-release.sh (100%) rename {scripts => .github/scripts}/termux-validate-gh-env.sh (88%) create mode 100644 .github/workflows/termux-release-branch-cleanup.yml create mode 100644 packages/app/src/components/command-palette.ts create mode 100644 packages/app/src/components/dialog-command-palette-v2.css create mode 100644 packages/app/src/components/dialog-command-palette-v2.tsx create mode 100644 packages/llm/test/provider-error.test.ts delete mode 100755 scripts/termux-dispatch-android-build.sh diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 000000000000..d20c0fe4bdba --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 000000000000..0c3172c605c9 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,12 @@ +self-hosted-runner: + labels: + - blacksmith-4vcpu-ubuntu-2404 + - blacksmith-4vcpu-ubuntu-2404-arm + - blacksmith-4vcpu-windows-2025 + +config-variables: null + +paths: + .github/workflows/docs-locale-sync.yml: + ignore: + - 'constant expression "false" in condition' diff --git a/.github/actions/build-android-cli/action.yml b/.github/actions/build-android-cli/action.yml new file mode 100644 index 000000000000..9c3dc94ba33c --- /dev/null +++ b/.github/actions/build-android-cli/action.yml @@ -0,0 +1,119 @@ +name: "Build Android CLI" +description: "Build and smoke-check the Android arm64 opencode artifact" +inputs: + version: + description: "Version to embed in the Android CLI" + required: true + release: + description: "Release flag passed to the opencode build" + required: false + default: "" + repo: + description: "Repository name passed to the opencode build" + required: true +runs: + using: "composite" + steps: + - uses: ./.github/actions/setup-bun + + - name: Setup Zig + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + with: + version: 0.15.2 + + - name: Install Bun build toolchain + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends wget gnupg software-properties-common ninja-build cmake file binutils + wget -qO "$RUNNER_TEMP/llvm.sh" https://apt.llvm.org/llvm.sh + chmod +x "$RUNNER_TEMP/llvm.sh" + sudo "$RUNNER_TEMP/llvm.sh" 21 + sudo apt-get install -y --no-install-recommends clang-21 lld-21 + LLVM_RESOURCE_DIR="$(clang-21 -print-resource-dir)" + NDK_PREBUILT="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64" + NDK_CLANG_VERSION="$(ls "$NDK_PREBUILT/lib/clang" | sort -V | tail -n 1)" + NDK_RUNTIME_DIR="$NDK_PREBUILT/lib/clang/$NDK_CLANG_VERSION/lib/linux" + sudo mkdir -p "$LLVM_RESOURCE_DIR/lib/aarch64-unknown-linux-android28" "$LLVM_RESOURCE_DIR/lib/linux/aarch64" + sudo ln -sf "$NDK_RUNTIME_DIR/libclang_rt.builtins-aarch64-android.a" "$LLVM_RESOURCE_DIR/lib/aarch64-unknown-linux-android28/libclang_rt.builtins.a" + sudo ln -sf "$NDK_RUNTIME_DIR/aarch64/libunwind.a" "$LLVM_RESOURCE_DIR/lib/aarch64-unknown-linux-android28/libunwind.a" + sudo ln -sf "$NDK_RUNTIME_DIR/libclang_rt.builtins-aarch64-android.a" "$LLVM_RESOURCE_DIR/lib/linux/libclang_rt.builtins-aarch64-android.a" + sudo ln -sf "$NDK_RUNTIME_DIR/aarch64/libunwind.a" "$LLVM_RESOURCE_DIR/lib/linux/aarch64/libunwind.a" + + - name: Build Android Bun runtime with FFI + shell: bash + run: | + export CC=clang-21 + export CXX=clang++-21 + export LD=ld.lld-21 + NDK_PREBUILT="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64" + mkdir -p "$RUNNER_TEMP/android-toolchain-bin" + ln -sf "$NDK_PREBUILT/bin/llvm-strip" "$RUNNER_TEMP/android-toolchain-bin/strip" + export PATH="$RUNNER_TEMP/android-toolchain-bin:$PATH" + BUN_VERSION="$(node -p "require('./package.json').packageManager.split('@')[1]")" + BUN_SOURCE="$RUNNER_TEMP/bun-src" + git clone --depth=1 --branch "bun-v${BUN_VERSION}" https://github.com/oven-sh/bun.git "$BUN_SOURCE" + BUN_BUILD="$BUN_SOURCE/build/android-ffi" + cd "$BUN_SOURCE" + bun install --frozen-lockfile + bun scripts/build.ts --profile=android-release --tinycc=on --build-dir="$BUN_BUILD" --target=bun -j"$(nproc)" + test -x "$BUN_BUILD/bun" + file "$BUN_BUILD/bun" + echo "OPENCODE_ANDROID_BUN=$BUN_BUILD/bun" >> "$GITHUB_ENV" + + - name: Build Android CLI + shell: bash + run: ./packages/opencode/script/build.ts --target=android-arm64 + env: + OPENCODE_VERSION: ${{ inputs.version }} + OPENCODE_RELEASE: ${{ inputs.release }} + GH_REPO: ${{ inputs.repo }} + + - name: Smoke Android CLI artifact + shell: bash + run: | + set -euo pipefail + artifact_dir="packages/opencode/dist/opencode-android-arm64" + binary="${artifact_dir}/bin/opencode" + opentui_lib="packages/opencode/node_modules/@opentui/core/android-arm64/libopentui.so" + smoke_dir="packages/opencode/dist/android-smoke" + mkdir -p "$smoke_dir" + + test -x "$binary" + file "$binary" | tee "$smoke_dir/file.txt" + readelf -h "$binary" | tee "$smoke_dir/readelf-header.txt" + readelf -l "$binary" | tee "$smoke_dir/readelf-program-headers.txt" + readelf -S "$binary" > "$smoke_dir/readelf-sections.txt" + readelf -d "$binary" | tee "$smoke_dir/readelf-dynamic.txt" + + grep -q "Machine:.*AArch64" "$smoke_dir/readelf-header.txt" + grep -q "Type:.*DYN" "$smoke_dir/readelf-header.txt" + if grep -E "Requesting program interpreter: .*(ld-linux|ld-musl)" "$smoke_dir/readelf-program-headers.txt"; then + echo "Android artifact unexpectedly references a Linux ELF interpreter." >&2 + exit 1 + fi + if grep -E "Shared library: \\[(libc\\.so\\.6|ld-linux|ld-musl)" "$smoke_dir/readelf-dynamic.txt"; then + echo "Android artifact unexpectedly links Linux libc/runtime libraries." >&2 + exit 1 + fi + + test -f "$opentui_lib" + file "$opentui_lib" | tee "$smoke_dir/opentui-file.txt" + readelf -d "$opentui_lib" | tee "$smoke_dir/opentui-readelf-dynamic.txt" + readelf -Ws "$opentui_lib" > "$smoke_dir/opentui-readelf-symbols.txt" + readelf -r "$opentui_lib" > "$smoke_dir/opentui-readelf-relocs.txt" + + if grep -E "Shared library: \\[(libc\\.so\\.6|ld-linux|ld-musl|libstdc\\+\\+|libgcc_s)" "$smoke_dir/opentui-readelf-dynamic.txt"; then + echo "OpenTUI Android library unexpectedly links non-Android runtime libraries." >&2 + exit 1 + fi + if grep -E "Shared library: \\[(libc\\+\\+|libc\\+\\+_shared|libunwind)\\.so" "$smoke_dir/opentui-readelf-dynamic.txt"; then + echo "OpenTUI Android library requires an unbundled C++/unwind runtime." >&2 + exit 1 + fi + grep -E " (YGNode|facebook4yoga|yogaNode)" "$smoke_dir/opentui-readelf-symbols.txt" | head -80 || true + + - name: Pack Android artifact + shell: bash + working-directory: packages/opencode/dist/opencode-android-arm64/bin + run: tar -czf ../../opencode-android-arm64.tar.gz * diff --git a/.github/scripts/termux-cleanup-release-branch.sh b/.github/scripts/termux-cleanup-release-branch.sh new file mode 100755 index 000000000000..4bc7d2566f2f --- /dev/null +++ b/.github/scripts/termux-cleanup-release-branch.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" + +cleanup_limit="${CLEANUP_LIMIT:-100}" +cleanup_matched_checkpoint=false + +append_cleanup_summary() { + local outcome="$1" + local release_branch="${2:-}" + local detail="${3:-}" + + [[ -n "${GITHUB_STEP_SUMMARY:-}" ]] || return 0 + + { + echo "## Termux release branch cleanup" + echo + echo "- Outcome: ${outcome}" + if [[ -n "${release_branch}" ]]; then + echo "- Release branch: \`${release_branch}\`" + fi + if [[ -n "${detail}" ]]; then + echo "- Detail: ${detail}" + fi + } >> "${GITHUB_STEP_SUMMARY}" +} + +checkpoint_source_branch() { + jq -r ' + [ + (.body // "") + | split("\n")[] + | try capture("^- Source branch: `(?[^`]+)`").branch catch empty + ][0] // "" + ' <<< "$1" +} + +checkpoint_has_label() { + local checkpoint_json="$1" + local label="$2" + + jq -e --arg label "${label}" '[.labels[]?.name] | index($label) != null' <<< "${checkpoint_json}" >/dev/null +} + +release_branch_is_valid() { + [[ "$1" =~ ^release-[A-Za-z0-9._-]+$ ]] +} + +open_prs_using_branch() { + local release_branch="$1" + + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --state open \ + --limit 200 \ + --json number,title,url,headRefName,baseRefName,headRepository \ + | jq \ + --arg branch "${release_branch}" \ + --arg repo "${GITHUB_REPOSITORY}" \ + ' + [ + .[] + | select( + .baseRefName == $branch + or ( + .headRefName == $branch + and ((.headRepository.nameWithOwner // "") == $repo) + ) + ) + ] + ' +} + +delete_release_branch_if_unused() { + local release_branch="$1" + local checkpoint_url="$2" + local open_prs + local branch_sha + + if ! release_branch_is_valid "${release_branch}"; then + echo "::warning::Refusing to delete unexpected release branch name: ${release_branch}" + append_cleanup_summary "skipped" "${release_branch}" "branch name did not match release-* cleanup policy" + return 0 + fi + + if ! branch_sha="$(gh api --method GET "repos/${GITHUB_REPOSITORY}/git/ref/heads/${release_branch}" --jq '.object.sha' 2>/dev/null)"; then + echo "${release_branch} no longer exists on ${GITHUB_REPOSITORY}." + append_cleanup_summary "already deleted" "${release_branch}" "remote branch was not found" + return 0 + fi + + open_prs="$(open_prs_using_branch "${release_branch}")" + if [[ "$(jq 'length' <<< "${open_prs}")" != "0" ]]; then + echo "Leaving ${release_branch} in place because open PRs still use it as a source or target:" + jq -r '.[] | "- #\(.number) \(.title) (\(.url))"' <<< "${open_prs}" + append_cleanup_summary "skipped" "${release_branch}" "open PRs still use this branch" + return 0 + fi + + gh api --silent --method DELETE "repos/${GITHUB_REPOSITORY}/git/refs/heads/${release_branch}" + echo "Deleted ${release_branch} at ${branch_sha} after merged checkpoint ${checkpoint_url}." + append_cleanup_summary "deleted" "${release_branch}" "checkpoint ${checkpoint_url} merged and no open PRs use the branch" +} + +cleanup_checkpoint_pr() { + local checkpoint_json="$1" + local checkpoint_url + local release_branch + + checkpoint_url="$(jq -r '.url // empty' <<< "${checkpoint_json}")" + + if [[ "$(jq -r '.mergedAt // empty' <<< "${checkpoint_json}")" == "" ]]; then + echo "Checkpoint PR ${checkpoint_url:-unknown} is not merged yet; release branch cleanup is deferred." + append_cleanup_summary "deferred" "" "checkpoint PR is not merged yet" + return 0 + fi + + if ! checkpoint_has_label "${checkpoint_json}" "checkpoint" || ! checkpoint_has_label "${checkpoint_json}" "termux-release"; then + echo "Skipping ${checkpoint_url:-checkpoint PR}; it is not labeled as a Termux checkpoint." + return 0 + fi + + release_branch="$(checkpoint_source_branch "${checkpoint_json}")" + if [[ -z "${release_branch}" ]]; then + echo "::warning::Could not find release source branch in checkpoint PR ${checkpoint_url}." + append_cleanup_summary "skipped" "" "checkpoint PR did not include a source branch" + return 0 + fi + + if [[ -n "${SOURCE_BRANCH:-}" && "${release_branch}" != "${SOURCE_BRANCH}" ]]; then + echo "Skipping ${checkpoint_url}; source branch ${release_branch} does not match requested ${SOURCE_BRANCH}." + return 0 + fi + + cleanup_matched_checkpoint=true + delete_release_branch_if_unused "${release_branch}" "${checkpoint_url}" +} + +if [[ -n "${CHECKPOINT_PR:-}" ]]; then + cleanup_checkpoint_pr "$( + gh pr view "${CHECKPOINT_PR}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json number,title,body,baseRefName,headRefName,labels,mergedAt,state,url + )" + exit 0 +fi + +while IFS= read -r checkpoint_json; do + cleanup_checkpoint_pr "${checkpoint_json}" +done < <( + gh pr list \ + --repo "${GITHUB_REPOSITORY}" \ + --state merged \ + --limit "${cleanup_limit}" \ + --json number,title,body,baseRefName,headRefName,labels,mergedAt,state,url \ + | jq -c ' + .[] + | select(([.labels[]?.name] | index("checkpoint") != null)) + | select(([.labels[]?.name] | index("termux-release") != null)) + ' +) + +if [[ "${cleanup_matched_checkpoint}" == "false" ]]; then + if [[ -n "${SOURCE_BRANCH:-}" ]]; then + echo "No merged Termux checkpoint PR found for ${SOURCE_BRANCH} in the most recent ${cleanup_limit} merged PRs." + append_cleanup_summary "skipped" "${SOURCE_BRANCH}" "no merged checkpoint PR found" + exit 0 + fi + + echo "No merged Termux checkpoint PRs found in the most recent ${cleanup_limit} merged PRs." + append_cleanup_summary "skipped" "" "no merged checkpoint PRs found" +fi diff --git a/scripts/termux-configure-git.sh b/.github/scripts/termux-configure-git.sh similarity index 100% rename from scripts/termux-configure-git.sh rename to .github/scripts/termux-configure-git.sh diff --git a/.github/scripts/termux-create-android-build-deployment.sh b/.github/scripts/termux-create-android-build-deployment.sh new file mode 100755 index 000000000000..e8161036d624 --- /dev/null +++ b/.github/scripts/termux-create-android-build-deployment.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" +: "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}" +: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${RELEASE_BRANCH:?RELEASE_BRANCH is required}" +: "${RELEASE_SHA:?RELEASE_SHA is required}" +: "${TERMUX_TAG:?TERMUX_TAG is required}" + +deployment_json="$( + jq -n \ + --arg ref "${RELEASE_SHA}" \ + --arg termux_tag "${TERMUX_TAG}" \ + --arg release_branch "${RELEASE_BRANCH}" \ + '{ + ref: $ref, + task: "build-android", + auto_merge: false, + required_contexts: [], + environment: "termux-android", + description: "Build Android artifact for " + $termux_tag, + transient_environment: true, + production_environment: false, + payload: { + termux_tag: $termux_tag, + release_branch: $release_branch + } + }' \ + | gh api --method POST "repos/${GITHUB_REPOSITORY}/deployments" --input - +)" + +deployment_id="$(jq -r '.id' <<< "${deployment_json}")" +if [[ -z "${deployment_id}" || "${deployment_id}" == "null" ]]; then + echo "GitHub did not return a deployment id." >&2 + echo "${deployment_json}" >&2 + exit 1 +fi + +gh api --method POST "repos/${GITHUB_REPOSITORY}/deployments/${deployment_id}/statuses" \ + --input - <<< "$( + jq -n \ + --arg log_url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + '{ + state: "in_progress", + environment: "termux-android", + log_url: $log_url, + description: "Building Android artifact" + }' + )" + +echo "deployment_id=${deployment_id}" >> "${GITHUB_OUTPUT}" diff --git a/scripts/termux-create-checkpoint-pr.sh b/.github/scripts/termux-create-checkpoint-pr.sh similarity index 98% rename from scripts/termux-create-checkpoint-pr.sh rename to .github/scripts/termux-create-checkpoint-pr.sh index ea4e2af37397..e088639bc057 100755 --- a/scripts/termux-create-checkpoint-pr.sh +++ b/.github/scripts/termux-create-checkpoint-pr.sh @@ -8,7 +8,7 @@ set -euo pipefail : "${RUNNER_TEMP:?RUNNER_TEMP is required}" script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/termux-release-paths.sh +# shellcheck source=.github/scripts/termux-release-paths.sh source "${script_dir}/termux-release-paths.sh" source_branch="${SOURCE_BRANCH:-${REQUESTED_SOURCE_BRANCH:-${GITHUB_REF_NAME:-}}}" diff --git a/scripts/termux-create-or-update-mirrored-release.sh b/.github/scripts/termux-create-or-update-mirrored-release.sh similarity index 100% rename from scripts/termux-create-or-update-mirrored-release.sh rename to .github/scripts/termux-create-or-update-mirrored-release.sh diff --git a/scripts/termux-create-release-pr.sh b/.github/scripts/termux-create-release-pr.sh similarity index 96% rename from scripts/termux-create-release-pr.sh rename to .github/scripts/termux-create-release-pr.sh index 66ad79c2830c..aad8dfb782b4 100755 --- a/scripts/termux-create-release-pr.sh +++ b/.github/scripts/termux-create-release-pr.sh @@ -21,8 +21,8 @@ set -euo pipefail : "${GH_TOKEN:?GH_TOKEN is required}" script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -automation_root="$(cd -- "${script_dir}/.." && pwd)" -# shellcheck source=scripts/termux-release-paths.sh +automation_root="$(cd -- "${script_dir}/../.." && pwd)" +# shellcheck source=.github/scripts/termux-release-paths.sh source "${script_dir}/termux-release-paths.sh" pr_title="Termux ${UPSTREAM_TAG}" @@ -44,7 +44,7 @@ seed_release_automation_files() { mkdir -p "$(dirname "${seeded_path}")" cp "${seed_dir}/${seeded_path}" "${seeded_path}" case "${seeded_path}" in - scripts/*.sh) + .github/scripts/*.sh) chmod +x "${seeded_path}" ;; esac @@ -250,7 +250,7 @@ body_path="${RUNNER_TEMP}/termux-release-pr.md" echo echo "This PR carries the Termux patch branch onto the upstream release tag. If GitHub reports conflicts, resolve them by keeping upstream release code and preserving the Termux compatibility fixes." echo - echo "After this PR merges, the Termux deploy workflow builds \`opencode-android-arm64\` through \`publish.yml\`, mirrors the release asset to \`${TERMUX_TAG}\`, and opens a checkpoint PR back to \`${PATCH_BRANCH}\`." + echo "After this PR merges, the Termux deploy workflow builds \`opencode-android-arm64\` under a GitHub Deployment, mirrors the release asset to \`${TERMUX_TAG}\`, and opens a checkpoint PR back to \`${PATCH_BRANCH}\`." echo echo "## Upstream notes" echo diff --git a/scripts/termux-find-release-pr.sh b/.github/scripts/termux-find-release-pr.sh similarity index 100% rename from scripts/termux-find-release-pr.sh rename to .github/scripts/termux-find-release-pr.sh diff --git a/.github/scripts/termux-finish-android-build-deployment.sh b/.github/scripts/termux-finish-android-build-deployment.sh new file mode 100755 index 000000000000..0cf33af079fe --- /dev/null +++ b/.github/scripts/termux-finish-android-build-deployment.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}" +: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}" +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${DEPLOYMENT_ID:?DEPLOYMENT_ID is required}" +: "${BUILD_RESULT:?BUILD_RESULT is required}" + +state="success" +description="Android artifact build completed" +if [[ "${BUILD_RESULT}" != "success" ]]; then + state="failure" + description="Android artifact build failed" +fi + +gh api --method POST "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOYMENT_ID}/statuses" \ + --input - <<< "$( + jq -n \ + --arg state "${state}" \ + --arg description "${description}" \ + --arg log_url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + '{ + state: $state, + environment: "termux-android", + log_url: $log_url, + description: $description + }' + )" diff --git a/scripts/termux-read-release-metadata.sh b/.github/scripts/termux-read-release-metadata.sh similarity index 97% rename from scripts/termux-read-release-metadata.sh rename to .github/scripts/termux-read-release-metadata.sh index 906d36c0b4e0..be17c87a9204 100755 --- a/scripts/termux-read-release-metadata.sh +++ b/.github/scripts/termux-read-release-metadata.sh @@ -44,6 +44,7 @@ fi echo "upstream_tag=${upstream_tag}" echo "upstream_name=${upstream_name}" echo "termux_tag=${termux_tag}" + echo "build_version=${termux_tag#v}" echo "upstream_prerelease=${upstream_prerelease}" echo "upstream_html_url=${upstream_html_url}" echo "upstream_repo=${upstream_repo}" diff --git a/scripts/termux-release-paths.sh b/.github/scripts/termux-release-paths.sh similarity index 53% rename from scripts/termux-release-paths.sh rename to .github/scripts/termux-release-paths.sh index 9855eabd4ed4..4a58e49e6164 100755 --- a/scripts/termux-release-paths.sh +++ b/.github/scripts/termux-release-paths.sh @@ -3,24 +3,28 @@ set -euo pipefail readonly -a TERMUX_RELEASE_WORKFLOW_PATHS=( + .github/actions/build-android-cli/action.yml .github/workflows/publish.yml + .github/workflows/termux-release-branch-cleanup.yml .github/workflows/termux-release-checkpoint.yml .github/workflows/termux-release-deploy.yml .github/workflows/termux-release-watch.yml ) readonly -a TERMUX_RELEASE_SCRIPT_PATHS=( - scripts/termux-configure-git.sh - scripts/termux-create-checkpoint-pr.sh - scripts/termux-create-or-update-mirrored-release.sh - scripts/termux-create-release-pr.sh - scripts/termux-dispatch-android-build.sh - scripts/termux-find-release-pr.sh - scripts/termux-read-release-metadata.sh - scripts/termux-release-paths.sh - scripts/termux-resolve-release-ref.sh - scripts/termux-select-release.sh - scripts/termux-validate-gh-env.sh + .github/scripts/termux-configure-git.sh + .github/scripts/termux-cleanup-release-branch.sh + .github/scripts/termux-create-checkpoint-pr.sh + .github/scripts/termux-create-android-build-deployment.sh + .github/scripts/termux-create-or-update-mirrored-release.sh + .github/scripts/termux-create-release-pr.sh + .github/scripts/termux-find-release-pr.sh + .github/scripts/termux-finish-android-build-deployment.sh + .github/scripts/termux-read-release-metadata.sh + .github/scripts/termux-release-paths.sh + .github/scripts/termux-resolve-release-ref.sh + .github/scripts/termux-select-release.sh + .github/scripts/termux-validate-gh-env.sh ) readonly -a TERMUX_RELEASE_AUTOMATION_PATHS=( diff --git a/scripts/termux-resolve-release-ref.sh b/.github/scripts/termux-resolve-release-ref.sh similarity index 100% rename from scripts/termux-resolve-release-ref.sh rename to .github/scripts/termux-resolve-release-ref.sh diff --git a/scripts/termux-select-release.sh b/.github/scripts/termux-select-release.sh similarity index 100% rename from scripts/termux-select-release.sh rename to .github/scripts/termux-select-release.sh diff --git a/scripts/termux-validate-gh-env.sh b/.github/scripts/termux-validate-gh-env.sh similarity index 88% rename from scripts/termux-validate-gh-env.sh rename to .github/scripts/termux-validate-gh-env.sh index ce8c27a5f0cc..c57a9774d11d 100755 --- a/scripts/termux-validate-gh-env.sh +++ b/.github/scripts/termux-validate-gh-env.sh @@ -7,7 +7,6 @@ set -euo pipefail command -v gh command -v jq -gh auth status --hostname github.com printf 'GITHUB_REPOSITORY=%s\n' "${GITHUB_REPOSITORY}" printf 'GITHUB_REF_NAME=%s\n' "${GITHUB_REF_NAME:-}" diff --git a/.github/termux-release.json b/.github/termux-release.json index be5b79b088c7..e362aba75a05 100644 --- a/.github/termux-release.json +++ b/.github/termux-release.json @@ -10,6 +10,6 @@ "release_branch": "release-1.17.14", "work_branch": "termux-1.17.14", "patch_branch": "termux-target", - "patch_source_sha": "88bc4987258e3cf2d6db08bc867c6f0722df9295", + "patch_source_sha": "7b75f81296e3b2066dca7ee145da96eca5af7265", "termux_tag": "v1.17.14-termux" } diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 2e49924a46d6..ff4ca381acef 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -7,6 +7,7 @@ on: jobs: sync: + if: github.repository == 'anomalyco/opencode' runs-on: ${{ github.repository == 'anomalyco/opencode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} permissions: contents: write diff --git a/.github/workflows/close-issues.yml b/.github/workflows/close-issues.yml index b8a2e3f575d8..99f020620b89 100644 --- a/.github/workflows/close-issues.yml +++ b/.github/workflows/close-issues.yml @@ -7,6 +7,7 @@ on: jobs: close: + if: github.repository == 'anomalyco/opencode' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/close-prs.yml b/.github/workflows/close-prs.yml index a1e603a88104..7030a5b4909d 100644 --- a/.github/workflows/close-prs.yml +++ b/.github/workflows/close-prs.yml @@ -17,6 +17,7 @@ on: jobs: close: + if: github.repository == 'anomalyco/opencode' runs-on: ubuntu-latest timeout-minutes: 240 permissions: diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml index a83824e5cf60..a89ef16f24ac 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/compliance-close.yml @@ -13,6 +13,7 @@ permissions: jobs: close-non-compliant: + if: github.repository == 'anomalyco/opencode' runs-on: ubuntu-latest steps: - name: Close non-compliant issues and PRs after 2 hours diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index ba168be4068c..270b2aabd468 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -16,6 +16,7 @@ permissions: jobs: build: + if: github.repository == 'anomalyco/opencode' runs-on: ${{ github.repository == 'anomalyco/opencode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} env: REGISTRY: ghcr.io/${{ github.repository_owner }} diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index e36beb438a8a..c2f36eb5dd40 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -6,7 +6,7 @@ on: jobs: check-duplicates: - if: github.event.action == 'opened' + if: github.repository == 'anomalyco/opencode' && github.event.action == 'opened' runs-on: ${{ github.repository == 'anomalyco/opencode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} permissions: contents: read @@ -140,7 +140,7 @@ jobs: Remember: post at most ONE comment combining all findings. If everything is fine, post nothing." recheck-compliance: - if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance') + if: github.repository == 'anomalyco/opencode' && github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance') runs-on: ${{ github.repository == 'anomalyco/opencode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} permissions: contents: read diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index e84bda5197ba..c2f9486b6bea 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -7,6 +7,7 @@ on: jobs: generate: + if: github.repository == 'anomalyco/opencode' runs-on: ${{ github.repository == 'anomalyco/opencode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} permissions: contents: write @@ -38,15 +39,3 @@ jobs: git add -A git commit -m "chore: generate" --allow-empty git push origin HEAD:${{ github.ref_name }} --no-verify - # if ! git push origin HEAD:${{ github.event.pull_request.head.ref || github.ref_name }} --no-verify; then - # echo "" - # echo "============================================" - # echo "Failed to push generated code." - # echo "Please run locally and push:" - # echo "" - # echo " ./script/generate.ts" - # echo " git add -A && git commit -m \"chore: generate\" && git push" - # echo "" - # echo "============================================" - # exit 1 - # fi diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index 3f7d83c49613..67abeb3d5a9c 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -25,6 +25,7 @@ jobs: # Native runners required: bun install cross-compilation flags (--os/--cpu) # do not produce byte-identical node_modules as native installs. compute-hash: + if: github.repository == 'anomalyco/opencode' strategy: fail-fast: false matrix: @@ -90,7 +91,7 @@ jobs: update-hashes: needs: compute-hash - if: github.event_name != 'pull_request' + if: github.repository == 'anomalyco/opencode' && github.event_name != 'pull_request' runs-on: blacksmith-4vcpu-ubuntu-2404 steps: diff --git a/.github/workflows/notify-discord.yml b/.github/workflows/notify-discord.yml index 98f8978cd7e6..7b5e53c5401e 100644 --- a/.github/workflows/notify-discord.yml +++ b/.github/workflows/notify-discord.yml @@ -6,6 +6,7 @@ on: jobs: notify: + if: github.repository == 'anomalyco/opencode' runs-on: ${{ github.repository == 'anomalyco/opencode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} steps: - name: Send nicely-formatted embed to Discord diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index d4c325f6b373..c0b077e69a22 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -9,10 +9,13 @@ on: jobs: opencode: if: | - contains(github.event.comment.body, ' /oc') || - startsWith(github.event.comment.body, '/oc') || - contains(github.event.comment.body, ' /opencode') || - startsWith(github.event.comment.body, '/opencode') + github.repository == 'anomalyco/opencode' && + ( + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') + ) runs-on: ${{ github.repository == 'anomalyco/opencode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} permissions: id-token: write diff --git a/.github/workflows/pr-management.yml b/.github/workflows/pr-management.yml index fc14d255bfb5..117d18a0c6ab 100644 --- a/.github/workflows/pr-management.yml +++ b/.github/workflows/pr-management.yml @@ -6,6 +6,7 @@ on: jobs: check-duplicates: + if: github.repository == 'anomalyco/opencode' runs-on: ${{ github.repository == 'anomalyco/opencode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} permissions: contents: read @@ -72,6 +73,7 @@ jobs: fi add-contributor-label: + if: github.repository == 'anomalyco/opencode' runs-on: ubuntu-latest permissions: pull-requests: write diff --git a/.github/workflows/pr-standards.yml b/.github/workflows/pr-standards.yml index 06838089d354..53d5a41c96fc 100644 --- a/.github/workflows/pr-standards.yml +++ b/.github/workflows/pr-standards.yml @@ -6,6 +6,7 @@ on: jobs: check-standards: + if: github.repository == 'anomalyco/opencode' runs-on: ubuntu-latest permissions: contents: read @@ -153,6 +154,7 @@ jobs: console.log('PR meets all standards'); check-compliance: + if: github.repository == 'anomalyco/opencode' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/publish-github-action.yml b/.github/workflows/publish-github-action.yml index 97fe6565dda4..9c6cb168af9f 100644 --- a/.github/workflows/publish-github-action.yml +++ b/.github/workflows/publish-github-action.yml @@ -14,6 +14,7 @@ permissions: jobs: publish: + if: github.repository == 'anomalyco/opencode' runs-on: ${{ github.repository == 'anomalyco/opencode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 diff --git a/.github/workflows/publish-vscode.yml b/.github/workflows/publish-vscode.yml index 0ae4e5080af1..1820d224e41f 100644 --- a/.github/workflows/publish-vscode.yml +++ b/.github/workflows/publish-vscode.yml @@ -13,6 +13,7 @@ permissions: jobs: publish: + if: github.repository == 'anomalyco/opencode' runs-on: ${{ github.repository == 'anomalyco/opencode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a287a983923f..cdd65000c479 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,9 +36,21 @@ on: concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.target || 'all' }}-${{ inputs.version || inputs.bump }} permissions: + actions: read + artifact-metadata: read + attestations: read + checks: read id-token: write contents: write + deployments: read + discussions: read + issues: read + models: read packages: write + pages: read + pull-requests: read + security-events: read + statuses: read jobs: version: @@ -141,61 +153,19 @@ jobs: with: fetch-tags: true - - uses: ./.github/actions/setup-bun - - - name: Setup Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - version: 0.15.2 - - - name: Install Bun build toolchain - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends wget gnupg software-properties-common ninja-build cmake - wget -qO "$RUNNER_TEMP/llvm.sh" https://apt.llvm.org/llvm.sh - chmod +x "$RUNNER_TEMP/llvm.sh" - sudo "$RUNNER_TEMP/llvm.sh" 21 - sudo apt-get install -y --no-install-recommends clang-21 lld-21 - LLVM_RESOURCE_DIR="$(clang-21 -print-resource-dir)" - NDK_PREBUILT="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64" - NDK_CLANG_VERSION="$(ls "$NDK_PREBUILT/lib/clang" | sort -V | tail -n 1)" - NDK_RUNTIME_DIR="$NDK_PREBUILT/lib/clang/$NDK_CLANG_VERSION/lib/linux" - sudo mkdir -p "$LLVM_RESOURCE_DIR/lib/aarch64-unknown-linux-android28" "$LLVM_RESOURCE_DIR/lib/linux/aarch64" - sudo ln -sf "$NDK_RUNTIME_DIR/libclang_rt.builtins-aarch64-android.a" "$LLVM_RESOURCE_DIR/lib/aarch64-unknown-linux-android28/libclang_rt.builtins.a" - sudo ln -sf "$NDK_RUNTIME_DIR/aarch64/libunwind.a" "$LLVM_RESOURCE_DIR/lib/aarch64-unknown-linux-android28/libunwind.a" - sudo ln -sf "$NDK_RUNTIME_DIR/libclang_rt.builtins-aarch64-android.a" "$LLVM_RESOURCE_DIR/lib/linux/libclang_rt.builtins-aarch64-android.a" - sudo ln -sf "$NDK_RUNTIME_DIR/aarch64/libunwind.a" "$LLVM_RESOURCE_DIR/lib/linux/aarch64/libunwind.a" - - - name: Build Android Bun runtime with FFI - run: | - export CC=clang-21 - export CXX=clang++-21 - export LD=ld.lld-21 - NDK_PREBUILT="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64" - mkdir -p "$RUNNER_TEMP/android-toolchain-bin" - ln -sf "$NDK_PREBUILT/bin/llvm-strip" "$RUNNER_TEMP/android-toolchain-bin/strip" - export PATH="$RUNNER_TEMP/android-toolchain-bin:$PATH" - BUN_VERSION="$(node -p "require('./package.json').packageManager.split('@')[1]")" - BUN_SOURCE="$RUNNER_TEMP/bun-src" - git clone --depth=1 --branch "bun-v${BUN_VERSION}" https://github.com/oven-sh/bun.git "$BUN_SOURCE" - BUN_BUILD="$BUN_SOURCE/build/android-ffi" - cd "$BUN_SOURCE" - bun install --frozen-lockfile - bun scripts/build.ts --profile=android-release --tinycc=on --build-dir="$BUN_BUILD" --target=bun -j"$(nproc)" - test -x "$BUN_BUILD/bun" - file "$BUN_BUILD/bun" - echo "OPENCODE_ANDROID_BUN=$BUN_BUILD/bun" >> "$GITHUB_ENV" + checkout: false - name: Build Android CLI - run: ./packages/opencode/script/build.ts --target=android-arm64 - env: - OPENCODE_VERSION: ${{ inputs.version || format('0.0.0-android.{0}', github.run_number) }} - OPENCODE_RELEASE: "" - GH_REPO: ${{ github.repository }} - - - name: Pack Android artifact - working-directory: packages/opencode/dist/opencode-android-arm64/bin - run: tar -czf ../../opencode-android-arm64.tar.gz * + uses: ./.github/actions/build-android-cli + with: + version: ${{ inputs.version || format('0.0.0-android.{0}', github.run_number) }} + release: "" + repo: ${{ github.repository }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: @@ -203,6 +173,19 @@ jobs: path: | packages/opencode/dist/opencode-android-arm64 packages/opencode/dist/opencode-android-arm64.tar.gz + packages/opencode/dist/android-smoke + + termux-android-smoke: + name: Smoke Android artifact in Termux + needs: build-android + if: github.event_name == 'workflow_dispatch' && inputs.target == 'android-arm64' + permissions: + actions: read + contents: read + uses: ./.github/workflows/termux-android-artifact-smoke.yml + with: + artifact_name: opencode-android-arm64 + artifact_run_id: ${{ github.run_id }} build-android-smoke: runs-on: ubuntu-24.04 @@ -210,6 +193,13 @@ jobs: steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + checkout: false + - uses: ./.github/actions/setup-bun - name: Build Android compile smoke diff --git a/.github/workflows/release-github-action.yml b/.github/workflows/release-github-action.yml index 7bc5b50128c3..ece440691684 100644 --- a/.github/workflows/release-github-action.yml +++ b/.github/workflows/release-github-action.yml @@ -14,6 +14,7 @@ permissions: jobs: release: + if: github.repository == 'anomalyco/opencode' runs-on: ${{ github.repository == 'anomalyco/opencode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 3a173dec2ed3..4197fc32c876 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -7,6 +7,7 @@ on: jobs: check-guidelines: if: | + github.repository == 'anomalyco/opencode' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/review') && contains(fromJson('["OWNER","MEMBER"]'), github.event.comment.author_association) diff --git a/.github/workflows/termux-release-branch-cleanup.yml b/.github/workflows/termux-release-branch-cleanup.yml new file mode 100644 index 000000000000..bfe9b4e06c6a --- /dev/null +++ b/.github/workflows/termux-release-branch-cleanup.yml @@ -0,0 +1,97 @@ +name: termux-release-branch-cleanup + +on: + workflow_dispatch: + inputs: + release_branch: + description: "Release branch to clean up, for example release-1.17.13" + required: false + type: string + default: "" + checkpoint_pr: + description: "Checkpoint PR number or URL to verify before cleanup" + required: false + type: string + default: "" + cleanup_limit: + description: "Number of recent merged checkpoint PRs to scan" + required: false + type: string + default: "100" + schedule: + - cron: "37 8 * * *" + +permissions: + actions: read + artifact-metadata: read + attestations: read + checks: read + contents: write + deployments: read + discussions: read + id-token: none + issues: read + models: read + packages: read + pages: read + pull-requests: read + security-events: read + statuses: read + +concurrency: + group: termux-release-branch-cleanup-${{ inputs.release_branch || inputs.checkpoint_pr || github.event_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + cleanup: + if: github.repository == 'wallentx/opencode-termux' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Generate GitHub App token + id: app-token + continue-on-error: true + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.APP_ID || vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Export GitHub token for gh + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + FALLBACK_TOKEN: ${{ github.token }} + run: | + if [[ -n "${APP_TOKEN}" ]]; then + echo "GH_TOKEN=${APP_TOKEN}" >> "${GITHUB_ENV}" + exit 0 + fi + echo "::warning::GitHub App token unavailable; falling back to GITHUB_TOKEN." + echo "GH_TOKEN=${FALLBACK_TOKEN}" >> "${GITHUB_ENV}" + + - name: Checkout automation branch + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 1 + ref: dev + token: ${{ steps.app-token.outputs.token || github.token }} + + - name: 🧰 Actions Toolbox + uses: wallentx/gh-actions/composite/actions-toolbox@main + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + checkout: false + + - name: Validate GitHub CLI environment + run: bash .github/scripts/termux-validate-gh-env.sh + + - name: Delete release branches after merged checkpoints + env: + CHECKPOINT_PR: ${{ inputs.checkpoint_pr }} + SOURCE_BRANCH: ${{ inputs.release_branch }} + CLEANUP_LIMIT: ${{ inputs.cleanup_limit || '100' }} + run: bash .github/scripts/termux-cleanup-release-branch.sh diff --git a/.github/workflows/termux-release-checkpoint.yml b/.github/workflows/termux-release-checkpoint.yml index 34017254739c..1153d3de31d7 100644 --- a/.github/workflows/termux-release-checkpoint.yml +++ b/.github/workflows/termux-release-checkpoint.yml @@ -28,7 +28,6 @@ permissions: artifact-metadata: read attestations: read checks: read - code-quality: read contents: write deployments: read discussions: read @@ -40,7 +39,6 @@ permissions: pull-requests: write security-events: read statuses: read - vulnerability-alerts: read concurrency: group: termux-release-checkpoint-${{ inputs.source_branch }}-${{ inputs.destination_branch }} @@ -52,6 +50,7 @@ defaults: jobs: checkpoint: + if: github.repository == 'wallentx/opencode-termux' runs-on: ubuntu-24.04 timeout-minutes: 30 steps: @@ -60,7 +59,7 @@ jobs: continue-on-error: true uses: actions/create-github-app-token@v3 with: - client-id: ${{ vars.APP_CLIENT_ID }} + app-id: ${{ vars.APP_ID || vars.APP_CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Export GitHub token for gh @@ -90,18 +89,26 @@ jobs: checkout: false - name: Validate GitHub CLI environment - run: bash scripts/termux-validate-gh-env.sh + run: bash .github/scripts/termux-validate-gh-env.sh - name: Configure git - run: bash scripts/termux-configure-git.sh --origin "${SOURCE_BRANCH}" "${DESTINATION_BRANCH}" + run: bash .github/scripts/termux-configure-git.sh --origin "${SOURCE_BRANCH}" "${DESTINATION_BRANCH}" env: SOURCE_BRANCH: ${{ inputs.source_branch }} DESTINATION_BRANCH: ${{ inputs.destination_branch || 'termux-target' }} - name: Create checkpoint PR + id: checkpoint-pr env: REQUESTED_SOURCE_BRANCH: ${{ inputs.source_branch }} REQUESTED_SOURCE_SHA: ${{ inputs.source_sha }} DESTINATION_BRANCH: ${{ inputs.destination_branch || 'termux-target' }} REVIEWER: ${{ inputs.reviewer || 'wallentx' }} - run: bash scripts/termux-create-checkpoint-pr.sh + run: bash .github/scripts/termux-create-checkpoint-pr.sh + + - name: Delete release branch after merged checkpoint + if: steps.checkpoint-pr.outputs.pr_url != '' + env: + CHECKPOINT_PR: ${{ steps.checkpoint-pr.outputs.pr_url }} + SOURCE_BRANCH: ${{ inputs.source_branch }} + run: bash .github/scripts/termux-cleanup-release-branch.sh diff --git a/.github/workflows/termux-release-deploy.yml b/.github/workflows/termux-release-deploy.yml index 55352c0d6e98..f8e17386c57b 100644 --- a/.github/workflows/termux-release-deploy.yml +++ b/.github/workflows/termux-release-deploy.yml @@ -41,7 +41,6 @@ permissions: artifact-metadata: read attestations: read checks: read - code-quality: read contents: write deployments: write discussions: read @@ -53,7 +52,6 @@ permissions: pull-requests: write security-events: read statuses: read - vulnerability-alerts: read concurrency: group: termux-release-deploy-${{ inputs.release_branch || github.ref_name }} @@ -65,6 +63,7 @@ defaults: jobs: deploy: + if: github.repository == 'wallentx/opencode-termux' runs-on: ubuntu-24.04 timeout-minutes: 180 steps: @@ -73,7 +72,7 @@ jobs: continue-on-error: true uses: actions/create-github-app-token@v3 with: - client-id: ${{ vars.APP_CLIENT_ID }} + app-id: ${{ vars.APP_ID || vars.APP_CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Export GitHub token for gh @@ -103,21 +102,21 @@ jobs: checkout: false - name: Validate GitHub CLI environment - run: bash scripts/termux-validate-gh-env.sh + run: bash .github/scripts/termux-validate-gh-env.sh - name: Configure git - run: bash scripts/termux-configure-git.sh + run: bash .github/scripts/termux-configure-git.sh - name: Resolve release ref id: release-ref env: INPUT_RELEASE_BRANCH: ${{ inputs.release_branch }} INPUT_RELEASE_SHA: ${{ inputs.release_sha }} - run: bash scripts/termux-resolve-release-ref.sh + run: bash .github/scripts/termux-resolve-release-ref.sh - name: Read release metadata id: metadata - run: bash scripts/termux-read-release-metadata.sh + run: bash .github/scripts/termux-read-release-metadata.sh - name: Find merged release PR id: pr @@ -127,15 +126,39 @@ jobs: RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} INPUT_PR_NUMBER: ${{ inputs.pr_number }} INPUT_PR_HEAD_SHA: ${{ inputs.pr_head_sha }} - run: bash scripts/termux-find-release-pr.sh + run: bash .github/scripts/termux-find-release-pr.sh - - name: Build Android artifact through publish workflow + - name: Create Android build deployment + id: android-deployment if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' env: RELEASE_BRANCH: ${{ steps.release-ref.outputs.branch }} RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} TERMUX_TAG: ${{ steps.metadata.outputs.termux_tag }} - run: bash scripts/termux-dispatch-android-build.sh + run: bash .github/scripts/termux-create-android-build-deployment.sh + + - name: Build Android artifact + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + uses: ./.github/actions/build-android-cli + with: + version: ${{ steps.metadata.outputs.build_version }} + release: "" + repo: ${{ github.repository }} + + - name: Promote built Android artifact + if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists != 'true' + run: | + set -euo pipefail + mkdir -p promoted + cp packages/opencode/dist/opencode-android-arm64.tar.gz promoted/ + sha256sum promoted/opencode-android-arm64.tar.gz + + - name: Finish Android build deployment + if: always() && steps.android-deployment.outputs.deployment_id != '' + env: + DEPLOYMENT_ID: ${{ steps.android-deployment.outputs.deployment_id }} + BUILD_RESULT: ${{ job.status }} + run: bash .github/scripts/termux-finish-android-build-deployment.sh - name: Download existing Android artifact if release needs repair if: steps.metadata.outputs.deploy == 'true' && steps.metadata.outputs.asset_exists == 'true' @@ -164,13 +187,21 @@ jobs: PR_NUMBER: ${{ steps.pr.outputs.number }} HEAD_SHA: ${{ steps.pr.outputs.head_sha }} RELEASE_SHA: ${{ steps.release-ref.outputs.sha }} - run: bash scripts/termux-create-or-update-mirrored-release.sh + run: bash .github/scripts/termux-create-or-update-mirrored-release.sh - name: Open checkpoint PR + id: checkpoint-pr if: steps.metadata.outputs.deploy == 'true' env: SOURCE_BRANCH: ${{ steps.release-ref.outputs.branch }} SOURCE_SHA: ${{ steps.release-ref.outputs.sha }} DESTINATION_BRANCH: ${{ inputs.destination_branch || 'termux-target' }} REVIEWER: ${{ inputs.reviewer || 'wallentx' }} - run: bash scripts/termux-create-checkpoint-pr.sh + run: bash .github/scripts/termux-create-checkpoint-pr.sh + + - name: Delete release branch after merged checkpoint + if: steps.metadata.outputs.deploy == 'true' && steps.checkpoint-pr.outputs.pr_url != '' + env: + CHECKPOINT_PR: ${{ steps.checkpoint-pr.outputs.pr_url }} + SOURCE_BRANCH: ${{ steps.release-ref.outputs.branch }} + run: bash .github/scripts/termux-cleanup-release-branch.sh diff --git a/.github/workflows/termux-release-watch.yml b/.github/workflows/termux-release-watch.yml index 89aeca8629d9..a03423d34be4 100644 --- a/.github/workflows/termux-release-watch.yml +++ b/.github/workflows/termux-release-watch.yml @@ -31,7 +31,6 @@ permissions: artifact-metadata: read attestations: read checks: read - code-quality: read contents: write deployments: read discussions: read @@ -43,7 +42,6 @@ permissions: pull-requests: write security-events: read statuses: read - vulnerability-alerts: read concurrency: group: termux-release-watch @@ -55,6 +53,7 @@ defaults: jobs: look-for-newer-release: + if: github.repository == 'wallentx/opencode-termux' runs-on: ubuntu-24.04 timeout-minutes: 15 outputs: @@ -78,7 +77,7 @@ jobs: continue-on-error: true uses: actions/create-github-app-token@v3 with: - client-id: ${{ vars.APP_CLIENT_ID }} + app-id: ${{ vars.APP_ID || vars.APP_CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Export GitHub token for gh @@ -107,18 +106,18 @@ jobs: checkout: false - name: Validate GitHub CLI environment - run: bash scripts/termux-validate-gh-env.sh + run: bash .github/scripts/termux-validate-gh-env.sh - name: Select upstream release id: upstream env: REQUESTED_TAG: ${{ inputs.upstream_tag }} BYPASS_PRIOR_RELEASE_TRAIN: ${{ inputs.bypass_prior_release_train || false }} - run: bash scripts/termux-select-release.sh + run: bash .github/scripts/termux-select-release.sh create-release-pr: needs: look-for-newer-release - if: needs.look-for-newer-release.outputs.selected == 'true' + if: github.repository == 'wallentx/opencode-termux' && needs.look-for-newer-release.outputs.selected == 'true' runs-on: ubuntu-24.04 timeout-minutes: 45 env: @@ -131,7 +130,7 @@ jobs: continue-on-error: true uses: actions/create-github-app-token@v3 with: - client-id: ${{ vars.APP_CLIENT_ID }} + app-id: ${{ vars.APP_ID || vars.APP_CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Export GitHub token for gh @@ -163,14 +162,14 @@ jobs: uses: ./.github/actions/setup-bun - name: Validate GitHub CLI environment - run: bash scripts/termux-validate-gh-env.sh + run: bash .github/scripts/termux-validate-gh-env.sh - name: Configure git env: UPSTREAM_TAG: ${{ needs.look-for-newer-release.outputs.upstream_tag }} run: | set -euo pipefail - bash scripts/termux-configure-git.sh \ + bash .github/scripts/termux-configure-git.sh \ --origin "${PATCH_BRANCH}" \ --upstream-tag "${UPSTREAM_REPO}" "${UPSTREAM_TAG}" @@ -188,4 +187,4 @@ jobs: WORK_BRANCH: ${{ needs.look-for-newer-release.outputs.work_branch }} TERMUX_TAG: ${{ needs.look-for-newer-release.outputs.termux_tag }} UPSTREAM_BODY: ${{ needs.look-for-newer-release.outputs.body }} - run: bash scripts/termux-create-release-pr.sh + run: bash .github/scripts/termux-create-release-pr.sh diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 29fa33a463cb..7749b394977f 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -6,6 +6,7 @@ on: jobs: triage: + if: github.repository == 'anomalyco/opencode' runs-on: ${{ github.repository == 'anomalyco/opencode' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} permissions: contents: read diff --git a/bun.lock b/bun.lock index 2e195e9bad3d..dbc02538c64d 100644 --- a/bun.lock +++ b/bun.lock @@ -705,9 +705,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.4.3", - "@opentui/keymap": ">=0.4.3", - "@opentui/solid": ">=0.4.3", + "@opentui/core": ">=0.3.4", + "@opentui/keymap": ">=0.3.4", + "@opentui/solid": ">=0.3.4", }, "optionalPeers": [ "@opentui/core", @@ -1097,9 +1097,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.4.3", - "@opentui/keymap": "0.4.3", - "@opentui/solid": "0.4.3", + "@opentui/core": "0.3.4", + "@opentui/keymap": "0.3.4", + "@opentui/solid": "0.3.4", "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -2032,27 +2032,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="], + "@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8p8g8/AEq/xFGpQ7XcIFKcAqjc0QwsZcv+Ll9RbCDpUA56FGH6jfLDir0KYTNTgYXJTIrBIENI9K46VuxMUMQA=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dXpJitiZdYE3hq2Pvx6e9I0uPQSOcnaLLp1pDgWAHv+3kvKSHEX//9Yr/pV/Ua6qqT7p+2D/K4vXNap/NKVo2w=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="], - "@opentui/keymap": ["@opentui/keymap@0.4.3", "", { "dependencies": { "@opentui/core": "0.4.3" }, "peerDependencies": { "@opentui/react": "0.4.3", "@opentui/solid": "0.4.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-sinX0pyQBRrEvo89PSSUbSUDIYpL3xWo81VEfec58VFoVRB5FG48/deAtvRTQfJ8w1kgbzN8hzdOXdSm61zBmw=="], + "@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="], - "@opentui/solid": ["@opentui/solid@0.4.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw=="], + "@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -3244,7 +3244,7 @@ "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], - "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], @@ -5592,6 +5592,8 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], diff --git a/package.json b/package.json index e5526b22ffb1..fd86ad3decd2 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,9 @@ "@octokit/rest": "22.0.0", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.4.3", - "@opentui/keymap": "0.4.3", - "@opentui/solid": "0.4.3", + "@opentui/core": "0.3.4", + "@opentui/keymap": "0.3.4", + "@opentui/solid": "0.3.4", "@tanstack/solid-virtual": "3.13.28", "@shikijs/stream": "4.2.0", "ulid": "3.0.1", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 9c2e7d6a1f0e..f4cf14518c4c 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -186,7 +186,7 @@ declare global { deepLinks?: string[] } api?: { - setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise + setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise exportDebugLogs?: () => Promise } } @@ -320,8 +320,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { { - void window.api?.setTitlebar?.({ mode }) + onThemeApplied={(_, mode, scheme) => { + void window.api?.setTitlebar?.({ mode, scheme }) }} > diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts new file mode 100644 index 000000000000..b61a61977eea --- /dev/null +++ b/packages/app/src/components/command-palette.ts @@ -0,0 +1,325 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { getFilename } from "@opencode-ai/core/util/path" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useNavigate } from "@solidjs/router" +import { createMemo, onCleanup } from "solid-js" +import { useCommand, type CommandOption } from "@/context/command" +import { useFile } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useServerSDK, type ServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { createSessionTabs } from "@/pages/session/helpers" +import { useSessionLayout } from "@/pages/session/session-layout" +import { decode64 } from "@/utils/base64" + +export type CommandPaletteEntry = { + id: string + type: "command" | "file" | "session" + title: string + description?: string + keybind?: string + category: string + option?: CommandOption + path?: string + directory?: string + sessionID?: string + archived?: number + updated?: number +} + +const ENTRY_LIMIT = 5 +const COMMON_COMMAND_IDS = [ + "session.new", + "workspace.new", + "session.previous", + "session.next", + "terminal.toggle", + "review.toggle", +] as const + +export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) { + const seen = new Set() + return items.filter((item) => { + if (seen.has(item.id)) return false + seen.add(item.id) + return true + }) +} + +export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry { + return { + id: "file:" + path, + type: "file", + title: path, + category, + path, + } +} + +export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) { + const file = useFile() + const layout = useLayout() + const { tabs, view } = useSessionLayout() + + return (path: string) => { + const value = file.tab(path) + void tabs().open(value) + void file.load(path) + if (!view().reviewPanel.opened()) view().reviewPanel.open() + layout.fileTree.setTab("all") + onOpenFile?.(path) + tabs().setActive(value) + } +} + +export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) { + const command = useCommand() + const language = useLanguage() + const layout = useLayout() + const file = useFile() + const dialog = useDialog() + const navigate = useNavigate() + const serverSDK = useServerSDK()() + const serverSync = useServerSync() + const { params, tabs } = useSessionLayout() + const openFile = createCommandPaletteFileOpener(props.onOpenFile) + const state = { cleanup: undefined as (() => void) | void, committed: false } + const filesOnly = () => props.filesOnly?.() ?? false + + const allowedCommands = createMemo(() => { + if (filesOnly()) return [] + return command.options.filter( + (option) => + !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", + ) + }) + const commandEntries = createMemo(() => { + const category = language.t("palette.group.commands") + return allowedCommands().map((option) => createCommandEntry(option, category)) + }) + const preferredCommandEntries = createMemo(() => { + const all = allowedCommands() + const order = new Map(COMMON_COMMAND_IDS.map((id, index) => [id, index])) + const picked = all.filter((option) => order.has(option.id)) + const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) + const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base + const category = language.t("palette.group.commands") + return sorted.map((option) => createCommandEntry(option, category)) + }) + + const tabState = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + }) + const recentFileEntries = createMemo(() => { + const all = tabState.openedTabs() + const active = tabState.activeFileTab() + const order = active ? [active, ...all.filter((item) => item !== active)] : all + const seen = new Set() + const category = language.t("palette.group.files") + return order + .map((item) => file.pathFromTab(item)) + .filter((path): path is string => { + if (!path || seen.has(path)) return false + seen.add(path) + return true + }) + .slice(0, ENTRY_LIMIT) + .map((path) => createCommandPaletteFileEntry(path, category)) + }) + const rootFileEntries = createMemo(() => { + const category = language.t("palette.group.files") + return file.tree + .children("") + .filter((node) => node.type === "file") + .map((node) => node.path) + .sort((a, b) => a.localeCompare(b)) + .slice(0, ENTRY_LIMIT) + .map((path) => createCommandPaletteFileEntry(path, category)) + }) + + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return undefined + return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + }) + const workspaces = createMemo(() => { + const directory = projectDirectory() + const current = project() + if (!current) return directory ? [directory] : [] + const dirs = [current.worktree, ...(current.sandboxes ?? [])] + if (directory && !dirs.includes(directory)) return [...dirs, directory] + return dirs + }) + const homedir = createMemo(() => serverSync().data.path.home) + const sessions = createSessionEntries({ + workspaces, + label: (directory) => { + const current = project() + const kind = + current && directory === current.worktree + ? language.t("workspace.type.local") + : language.t("workspace.type.sandbox") + const [store] = serverSync().child(directory, { bootstrap: false }) + const home = homedir() + const path = home ? directory.replace(home, "~") : directory + const name = store.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name || path}` + }, + load: (directory) => serverSDK.client.session.list({ directory, roots: true }), + untitled: () => language.t("command.session.new"), + category: () => language.t("command.category.session"), + }) + + const highlight = (item: CommandPaletteEntry | undefined) => { + state.cleanup?.() + state.cleanup = undefined + if (item?.type !== "command") return + state.cleanup = item.option?.onHighlight?.() + } + + const select = (item: CommandPaletteEntry | undefined) => { + if (!item) return + state.committed = true + state.cleanup = undefined + dialog.close() + if (item.type === "command") { + item.option?.onSelect?.("palette") + return + } + if (item.type === "session") { + if (!item.directory || !item.sessionID) return + navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`) + return + } + if (!item.path) return + openFile(item.path) + } + + onCleanup(() => { + if (state.committed) return + state.cleanup?.() + }) + + return { + language, + file, + commandEntries, + preferredCommandEntries, + recentFileEntries, + rootFileEntries, + sessions, + highlight, + select, + close: () => dialog.close(), + } +} + +function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { + return { + id: "command:" + option.id, + type: "command", + title: option.title, + description: option.description, + keybind: option.keybind, + category, + option, + } +} + +function createSessionEntries(props: { + workspaces: () => string[] + label: (directory: string) => string + load: (directory: string) => ReturnType + untitled: () => string + category: () => string +}) { + const state: { + token: number + inflight: Promise | undefined + cached: CommandPaletteEntry[] | undefined + } = { token: 0, inflight: undefined, cached: undefined } + + return (text: string) => { + if (!text.trim()) { + state.token += 1 + state.inflight = undefined + state.cached = undefined + return [] as CommandPaletteEntry[] + } + if (state.cached) return state.cached + if (state.inflight) return state.inflight + + const current = state.token + const dirs = props.workspaces() + if (dirs.length === 0) return [] as CommandPaletteEntry[] + + state.inflight = Promise.all( + dirs.map((directory) => { + const description = props.label(directory) + return props + .load(directory) + .then((result) => + (result.data ?? []) + .filter((session) => !!session?.id) + .map((session) => ({ + id: session.id, + title: session.title ?? props.untitled(), + description, + directory, + archived: session.time?.archived, + updated: session.time?.updated, + })), + ) + .catch(() => [] as SessionEntryInput[]) + }), + ) + .then((results) => { + if (state.token !== current) return [] as CommandPaletteEntry[] + const seen = new Set() + const next = results + .flat() + .filter((item) => { + const key = `${item.directory}:${item.id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + .map((item) => createSessionEntry(item, props.category())) + state.cached = next + return next + }) + .catch(() => [] as CommandPaletteEntry[]) + .finally(() => { + state.inflight = undefined + }) + + return state.inflight + } +} + +type SessionEntryInput = { + directory: string + id: string + title: string + description: string + archived?: number + updated?: number +} + +function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry { + return { + id: `session:${input.directory}:${input.id}`, + type: "session", + title: input.title, + description: input.description, + category, + directory: input.directory, + sessionID: input.id, + archived: input.archived, + updated: input.updated, + } +} diff --git a/packages/app/src/components/dialog-command-palette-v2.css b/packages/app/src/components/dialog-command-palette-v2.css new file mode 100644 index 000000000000..a4019928aa1e --- /dev/null +++ b/packages/app/src/components/dialog-command-palette-v2.css @@ -0,0 +1,220 @@ +.command-palette-v2 { + overflow: hidden; +} + +/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the + top stays put while the content-driven height grows and shrinks. */ +[data-component="dialog-v2"]:has(.command-palette-v2) { + align-items: flex-start; +} + +[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] { + width: min(calc(100vw - 24px), 640px); + height: auto; + min-height: 280px; + max-height: min(calc(100vh - 96px), 480px); + margin-top: max(48px, calc((100vh - 480px) / 2)); + border-radius: 12px; + background: var(--v2-background-bg-base); + box-shadow: var(--v2-elevation-floating); +} + +.command-palette-v2-body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 0; + padding: 0; +} + +.command-palette-v2-search { + flex-shrink: 0; + padding: 6px; +} + +.command-palette-v2-search [data-component="text-input-v2"] { + width: 100%; + height: 36px; + border-radius: 6px; + outline: 0; + background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent); + box-shadow: none; + transition: + background-color 120ms ease-in-out, + box-shadow 120ms ease-in-out; +} + +.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]), +.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) { + background: var(--v2-background-bg-layer-02); + box-shadow: none; +} + +.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] { + border-radius: 6px; + background: transparent; + gap: 8px; +} + +.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] { + padding-left: 12px; +} + +.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] { + border-radius: 6px; + background: transparent; +} + +.command-palette-v2-scroll { + min-height: 0; + flex: 1; +} + +.command-palette-v2-results { + display: flex; + flex-direction: column; + gap: 16px; + padding: 6px 6px 8px; +} + +.command-palette-v2-group { + display: flex; + flex-direction: column; + gap: 1px; +} + +.command-palette-v2-group-title { + margin: 6px 0; + padding: 0 12px; + color: var(--v2-text-text-muted); + font-size: 13px; + font-weight: 440; + line-height: 16px; + letter-spacing: -0.04px; + user-select: none; +} + +.command-palette-v2-row { + display: flex; + width: 100%; + height: 36px; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 12px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--v2-text-text-base); + text-align: left; + cursor: default; + scroll-margin: 6px 0; +} + +.command-palette-v2-row[data-active] { + background: var(--v2-overlay-simple-overlay-hover); +} + +.command-palette-v2-row:focus-visible { + background: var(--v2-overlay-simple-overlay-hover); + outline: none; +} + +.command-palette-v2-row-main { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 8px; +} + +.command-palette-v2-row-icon { + flex-shrink: 0; + color: var(--v2-icon-icon-muted); +} + +.command-palette-v2-row-text { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; +} + +.command-palette-v2-title { + min-width: 0; + overflow: hidden; + color: var(--v2-text-text-base); + font-size: 13px; + font-weight: 530; + line-height: 16px; + letter-spacing: -0.04px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette-v2-description, +.command-palette-v2-meta { + min-width: 0; + overflow: hidden; + color: var(--v2-text-text-muted); + font-size: 13px; + font-weight: 440; + line-height: 16px; + letter-spacing: -0.04px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette-v2-meta { + flex-shrink: 0; +} + +.command-palette-v2-file-path { + display: flex; + min-width: 0; + align-items: baseline; + font-size: 13px; + line-height: 16px; + letter-spacing: -0.04px; +} + +.command-palette-v2-file-dir { + min-width: 0; + overflow: hidden; + color: var(--v2-text-text-muted); + font-weight: 440; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette-v2-file-name { + flex-shrink: 0; + color: var(--v2-text-text-base); + font-weight: 530; + white-space: nowrap; +} + +.command-palette-v2-state { + display: grid; + min-height: 120px; + place-items: center; + color: var(--v2-text-text-muted); + font-size: 13px; + font-weight: 440; + line-height: 16px; + letter-spacing: -0.04px; +} + +@media (max-width: 640px) { + .command-palette-v2-row-text { + flex-direction: column; + align-items: flex-start; + gap: 1px; + } + + .command-palette-v2-description { + max-width: 100%; + } +} diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx new file mode 100644 index 000000000000..b1cafe1e7a48 --- /dev/null +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -0,0 +1,221 @@ +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js" +import { formatKeybindParts } from "@/context/command" +import { useLanguage } from "@/context/language" +import { getRelativeTime } from "@/utils/time" +import { + createCommandPaletteFileEntry, + createCommandPaletteModel, + uniqueCommandPaletteEntries, + type CommandPaletteEntry, +} from "./command-palette" +import "./dialog-command-palette-v2.css" + +function groups(entries: CommandPaletteEntry[]) { + const map = new Map() + for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry]) + return Array.from(map.entries()).map(([category, entries]) => ({ category, entries })) +} + +function matchesEntry(entry: CommandPaletteEntry, query: string) { + const value = query.toLowerCase() + return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value)) +} + +export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) { + const palette = createCommandPaletteModel(props) + const [query, setQuery] = createSignal("") + const [active, setActive] = createSignal(0) + + const loadItems = async (text: string) => { + const q = text.trim() + if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] + + const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))]) + const category = palette.language.t("palette.group.files") + return [ + ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)), + ...nextSessions.filter((entry) => matchesEntry(entry, q)), + ...files.map((path) => createCommandPaletteFileEntry(path, category)), + ] + } + + const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] }) + // Render stale results while a new query loads to avoid flashing "Loading" per keystroke. + const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? [])) + const groupedEntries = createMemo(() => groups(visibleEntries())) + const activeEntry = createMemo(() => visibleEntries()[active()]) + + createEffect(() => { + query() + visibleEntries() + setActive(0) + }) + + createEffect(() => { + palette.highlight(activeEntry()) + }) + + let resultsRef: HTMLDivElement | undefined + + const move = (delta: -1 | 1) => { + const count = visibleEntries().length + if (count === 0) return + setActive((index) => (index + delta + count) % count) + requestAnimationFrame(() => { + resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" }) + }) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault() + move(1) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + move(-1) + return + } + if (event.key === "Enter") { + event.preventDefault() + palette.select(activeEntry()) + return + } + if (event.key === "Escape") { + event.preventDefault() + palette.close() + } + } + + return ( + + + + (resultsRef = el)}> +
+ 0} + fallback={ +
+ {entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")} +
+ } + > + + {(group) => ( +
+ +
{group.category}
+
+ + {(item) => ( + setActive(visibleEntries().findIndex((entry) => entry.id === item.id))} + onSelect={() => palette.select(item)} + /> + )} + +
+ )} +
+
+
+
+
+
+ ) +} + +function PaletteRow(props: { + item: CommandPaletteEntry + active: boolean + language: ReturnType + onActive: () => void + onSelect: () => void +}) { + return ( + + ) +} diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 49a0d5961aa6..d7c6c2a84f47 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -1,329 +1,84 @@ -import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { Keybind } from "@opencode-ai/ui/keybind" import { List } from "@opencode-ai/ui/list" -import { base64Encode } from "@opencode-ai/core/util/encode" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" -import { useNavigate } from "@solidjs/router" -import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js" -import { formatKeybind, useCommand, type CommandOption } from "@/context/command" -import { useServerSDK, type ServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" -import { useLayout } from "@/context/layout" -import { useFile } from "@/context/file" +import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js" +import { formatKeybind } from "@/context/command" +import { useServerSDK } from "@/context/server-sdk" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { useSessionLayout } from "@/pages/session/session-layout" -import { createSessionTabs } from "@/pages/session/helpers" import { decode64 } from "@/utils/base64" import { getRelativeTime } from "@/utils/time" +import { + createCommandPaletteFileEntry, + createCommandPaletteFileOpener, + createCommandPaletteModel, + uniqueCommandPaletteEntries, + type CommandPaletteEntry, +} from "./command-palette" const DialogSelectFileV2 = lazy(() => import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })), ) - -type EntryType = "command" | "file" | "session" - -type Entry = { - id: string - type: EntryType - title: string - description?: string - keybind?: string - category: string - option?: CommandOption - path?: string - directory?: string - sessionID?: string - archived?: number - updated?: number -} +const DialogCommandPaletteV2 = lazy(() => + import("./dialog-command-palette-v2").then((module) => ({ default: module.DialogCommandPaletteV2 })), +) type DialogSelectFileMode = "all" | "files" -const ENTRY_LIMIT = 5 -const COMMON_COMMAND_IDS = [ - "session.new", - "workspace.new", - "session.previous", - "session.next", - "terminal.toggle", - "review.toggle", -] as const - -const uniqueEntries = (items: Entry[]) => { - const seen = new Set() - const out: Entry[] = [] - for (const item of items) { - if (seen.has(item.id)) continue - seen.add(item.id) - out.push(item) - } - return out -} - -const createCommandEntry = (option: CommandOption, category: string): Entry => ({ - id: "command:" + option.id, - type: "command", - title: option.title, - description: option.description, - keybind: option.keybind, - category, - option, -}) - -const createFileEntry = (path: string, category: string): Entry => ({ - id: "file:" + path, - type: "file", - title: path, - category, - path, -}) - -const createSessionEntry = ( - input: { - directory: string - id: string - title: string - description: string - archived?: number - updated?: number - }, - category: string, -): Entry => ({ - id: `session:${input.directory}:${input.id}`, - type: "session", - title: input.title, - description: input.description, - category, - directory: input.directory, - sessionID: input.id, - archived: input.archived, - updated: input.updated, -}) - -function createCommandEntries(props: { - filesOnly: () => boolean - command: ReturnType - language: ReturnType -}) { - const allowed = createMemo(() => { - if (props.filesOnly()) return [] - return props.command.options.filter( - (option) => - !option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open", - ) - }) - - const list = createMemo(() => { - const category = props.language.t("palette.group.commands") - return allowed().map((option) => createCommandEntry(option, category)) - }) - - const picks = createMemo(() => { - const all = allowed() - const order = new Map(COMMON_COMMAND_IDS.map((id, index) => [id, index])) - const picked = all.filter((option) => order.has(option.id)) - const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) - const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base - const category = props.language.t("palette.group.commands") - return sorted.map((option) => createCommandEntry(option, category)) - }) - - return { allowed, list, picks } -} - -function createFileEntries(props: { - file: ReturnType - tabs: () => ReturnType["tabs"]> - language: ReturnType -}) { - const tabState = createSessionTabs({ - tabs: props.tabs, - pathFromTab: props.file.pathFromTab, - normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab), - }) - const recent = createMemo(() => { - const all = tabState.openedTabs() - const active = tabState.activeFileTab() - const order = active ? [active, ...all.filter((item) => item !== active)] : all - const seen = new Set() - const category = props.language.t("palette.group.files") - const items: Entry[] = [] - - for (const item of order) { - const path = props.file.pathFromTab(item) - if (!path) continue - if (seen.has(path)) continue - seen.add(path) - items.push(createFileEntry(path, category)) - } - - return items.slice(0, ENTRY_LIMIT) - }) - - const root = createMemo(() => { - const category = props.language.t("palette.group.files") - const nodes = props.file.tree.children("") - const paths = nodes - .filter((node) => node.type === "file") - .map((node) => node.path) - .sort((a, b) => a.localeCompare(b)) - return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category)) - }) - - return { recent, root } -} +export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { + const platform = usePlatform() + const settings = useSettings() + const filesOnly = () => props.mode === "files" -function createSessionEntries(props: { - workspaces: () => string[] - label: (directory: string) => string - serverSDK: ServerSDK - language: ReturnType -}) { - const state: { - token: number - inflight: Promise | undefined - cached: Entry[] | undefined - } = { - token: 0, - inflight: undefined, - cached: undefined, + if (!filesOnly() && settings.general.newLayoutDesigns()) { + return } - const sessions = (text: string) => { - const query = text.trim() - if (!query) { - state.token += 1 - state.inflight = undefined - state.cached = undefined - return [] as Entry[] - } - - if (state.cached) return state.cached - if (state.inflight) return state.inflight - - const current = state.token - const dirs = props.workspaces() - if (dirs.length === 0) return [] as Entry[] - - state.inflight = Promise.all( - dirs.map((directory) => { - const description = props.label(directory) - return props.serverSDK.client.session - .list({ directory, roots: true }) - .then((x) => - (x.data ?? []) - .filter((s) => !!s?.id) - .map((s) => ({ - id: s.id, - title: s.title ?? props.language.t("command.session.new"), - description, - directory, - archived: s.time?.archived, - updated: s.time?.updated, - })), - ) - .catch( - () => - [] as { - id: string - title: string - description: string - directory: string - archived?: number - updated?: number - }[], - ) - }), - ) - .then((results) => { - if (state.token !== current) return [] as Entry[] - const seen = new Set() - const category = props.language.t("command.category.session") - const next = results - .flat() - .filter((item) => { - const key = `${item.directory}:${item.id}` - if (seen.has(key)) return false - seen.add(key) - return true - }) - .map((item) => createSessionEntry(item, category)) - state.cached = next - return next - }) - .catch(() => [] as Entry[]) - .finally(() => { - state.inflight = undefined - }) - - return state.inflight + if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) { + return } - return { sessions } + return } -export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { - const command = useCommand() +function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) { const language = useLanguage() - const platform = usePlatform() - const settings = useSettings() - const layout = useLayout() - const file = useFile() - const dialog = useDialog() - const navigate = useNavigate() const serverSDK = useServerSDK() - const serverSync = useServerSync() - const { params, tabs, view } = useSessionLayout() - const filesOnly = () => props.mode === "files" - const state = { cleanup: undefined as (() => void) | void, committed: false } - const [grouped, setGrouped] = createSignal(false) - const commandEntries = createCommandEntries({ filesOnly, command, language }) - const fileEntries = createFileEntries({ file, tabs, language }) - + const { params } = useSessionLayout() const projectDirectory = createMemo(() => decode64(params.dir) ?? "") - const project = createMemo(() => { - const directory = projectDirectory() - if (!directory) return - return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) - }) - const workspaces = createMemo(() => { - const directory = projectDirectory() - const current = project() - if (!current) return directory ? [directory] : [] + const openFile = createCommandPaletteFileOpener(props.onOpenFile) - const dirs = [current.worktree, ...(current.sandboxes ?? [])] - if (directory && !dirs.includes(directory)) return [...dirs, directory] - return dirs - }) - const homedir = createMemo(() => serverSync().data.path.home) - const label = (directory: string) => { - const current = project() - const kind = - current && directory === current.worktree - ? language.t("workspace.type.local") - : language.t("workspace.type.sandbox") - const [store] = serverSync().child(directory, { bootstrap: false }) - const home = homedir() - const path = home ? directory.replace(home, "~") : directory - const name = store.vcs?.branch ?? getFilename(directory) - return `${kind} : ${name || path}` - } + return ( + { + if (typeof result !== "string") return + openFile(result) + }} + /> + ) +} - const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language }) +function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) { + const palette = createCommandPaletteModel(props) + const [grouped, setGrouped] = createSignal(false) const items = async (text: string) => { const query = text.trim() setGrouped(query.length > 0) - if (!query && filesOnly()) { - const loaded = file.tree.state("")?.loaded - const pending = loaded ? Promise.resolve() : file.tree.list("") - const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) + if (!query && props.filesOnly()) { + const loaded = palette.file.tree.state("")?.loaded + const pending = loaded ? Promise.resolve() : palette.file.tree.list("") + const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()]) if (loaded || next.length > 0) { void pending @@ -331,79 +86,24 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil } await pending - return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) - } - - if (!query) return [...commandEntries.picks(), ...fileEntries.recent()] - - if (filesOnly()) { - const files = await file.searchFiles(query) - const category = language.t("palette.group.files") - return files.map((path) => createFileEntry(path, category)) + return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()]) } - const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))]) - const category = language.t("palette.group.files") - const entries = files.map((path) => createFileEntry(path, category)) - return [...commandEntries.list(), ...nextSessions, ...entries] - } - - const handleMove = (item: Entry | undefined) => { - state.cleanup?.() - if (!item) return - if (item.type !== "command") return - state.cleanup = item.option?.onHighlight?.() - } - - const open = (path: string) => { - const value = file.tab(path) - void tabs().open(value) - void file.load(path) - if (!view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.setTab("all") - props.onOpenFile?.(path) - tabs().setActive(value) - } - - const handleSelect = (item: Entry | undefined) => { - if (!item) return - state.committed = true - state.cleanup = undefined - dialog.close() + if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] - if (item.type === "command") { - item.option?.onSelect?.("palette") - return + if (props.filesOnly()) { + const files = await palette.file.searchFiles(query) + const category = palette.language.t("palette.group.files") + return files.map((path) => createCommandPaletteFileEntry(path, category)) } - if (item.type === "session") { - if (!item.directory || !item.sessionID) return - navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`) - return - } - - if (!item.path) return - open(item.path) - } - - onCleanup(() => { - if (state.committed) return - state.cleanup?.() - }) - - if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) { - return ( - { - if (typeof result !== "string") return - open(result) - }} - /> - ) + const [files, nextSessions] = await Promise.all([ + palette.file.searchFiles(query), + Promise.resolve(palette.sessions(query)), + ]) + const category = palette.language.t("palette.group.files") + const entries = files.map((path) => createCommandPaletteFileEntry(path, category)) + return [...palette.commandEntries(), ...nextSessions, ...entries] } return ( @@ -411,21 +111,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil item.id} filterKeys={["title", "description", "category"]} skipFilter={(item) => item.type === "file"} groupBy={grouped() ? (item) => item.category : () => ""} - onMove={handleMove} - onSelect={handleSelect} + onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)} + onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)} > {(item) => ( - {formatKeybind(item.keybind ?? "", language.t)} + {formatKeybind(item.keybind ?? "", palette.language.t)} @@ -479,7 +179,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil - {getRelativeTime(new Date(item.updated!).toISOString(), language.t)} + {getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)} diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index cea5f34d0d9c..23857946d0ea 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -24,6 +24,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { Icon } from "@opencode-ai/ui/v2/icon" import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" import { decode64 } from "@/utils/base64" @@ -92,6 +93,7 @@ const ModelList: Component<{ class="w-full" placement="right-start" gutter={12} + openDelay={0} value={} > {node} @@ -446,26 +448,41 @@ export function ModelSelectorPopoverV2(props: { {(item) => ( - { - setStore("active", modelKey(item)) - setTimeout(() => searchRef?.focus()) - }} - onSelect={() => selectModel(item)} + + } > - {item.name} - - {language.t("model.tag.free")} - - - {language.t("model.tag.latest")} - - + { + setStore("active", modelKey(item)) + setTimeout(() => searchRef?.focus()) + }} + onSelect={() => selectModel(item)} + > + {item.name} + + {language.t("model.tag.free")} + + + {language.t("model.tag.latest")} + + + )} diff --git a/packages/app/src/components/model-tooltip.tsx b/packages/app/src/components/model-tooltip.tsx index 53164dae85e2..7253c069173f 100644 --- a/packages/app/src/components/model-tooltip.tsx +++ b/packages/app/src/components/model-tooltip.tsx @@ -1,4 +1,4 @@ -import { Show, type Component } from "solid-js" +import { Show, type Component, type JSX } from "solid-js" import { useLanguage } from "@/context/language" type InputKey = "text" | "image" | "audio" | "video" | "pdf" @@ -23,7 +23,18 @@ type ModelInfo = { } } -export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => { +function ModelTooltipRow(props: { name: JSX.Element; value: JSX.Element }) { + return ( +
+ {props.name} + {props.value} +
+ ) +} + +export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean; v2?: boolean }> = ( + props, +) => { const language = useLanguage() const sourceName = (model: ModelInfo) => { const value = `${model.id} ${model.name}`.toLowerCase() @@ -51,6 +62,13 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free? const suffix = tags.length ? ` (${tags.join(", ")})` : "" return `${sourceName(props.model)} ${props.model.name}${suffix}` } + const name = () => { + const tags: Array = [] + if (props.latest) tags.push(language.t("model.tag.latest")) + if (props.free) tags.push(language.t("model.tag.free")) + const suffix = tags.length ? ` (${tags.join(", ")})` : "" + return `${props.model.name}${suffix}` + } const inputs = () => { if (props.model.capabilities) { const input = props.model.capabilities.input @@ -73,6 +91,21 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free? : language.t("model.tooltip.reasoning.none") } const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() }) + const contextLimit = () => props.model.limit.context.toLocaleString(language.intl()) + + if (props.v2) { + return ( +
+ + + + {(value) => } + + + +
+ ) + } return (
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index cbbf33d2b135..c424a62a89b0 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -440,11 +440,6 @@ export const SettingsGeneral: Component = () => { value={(o) => o.value} label={(o) => o.label} onSelect={(option) => option && theme.setColorScheme(option.value)} - onHighlight={(option) => { - if (!option) return - theme.previewColorScheme(option.value) - return () => theme.cancelPreview() - }} variant="secondary" size="small" triggerVariant="settings" @@ -471,11 +466,6 @@ export const SettingsGeneral: Component = () => { if (!option) return theme.setTheme(option.id) }} - onHighlight={(option) => { - if (!option) return - theme.previewTheme(option.id) - return () => theme.cancelPreview() - }} variant="secondary" size="small" triggerVariant="settings" diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 0dd48a5523cc..69dfb77fb0ea 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -422,11 +422,6 @@ export const SettingsGeneralV2: Component<{ value={(o) => o.value} label={(o) => o.label} onSelect={(option) => option && theme.setColorScheme(option.value)} - onHighlight={(option) => { - if (!option) return - theme.previewColorScheme(option.value) - return () => theme.cancelPreview() - }} /> @@ -454,11 +449,6 @@ export const SettingsGeneralV2: Component<{ if (!option) return theme.setTheme(option.id) }} - onHighlight={(option) => { - if (!option) return - theme.previewTheme(option.id) - return () => theme.cancelPreview() - }} /> diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 7f064932bcb1..a397046f9b3e 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -246,18 +246,23 @@ export function TabNavItem(props: { }} class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" > - - {(session) => ( - + + - )} - + )} + + { titleEl = el diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 6705ac1919f0..7ef53324cdb6 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -40,6 +40,7 @@ function SessionTabSlot(props: { let ref!: HTMLDivElement const sdk = createMemo(() => props.serverCtx()?.sdk ?? null) const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId)) + const persisted = createMemo(() => tabs.info[props.id]) const [loadedSession] = createResource( () => { const ctx = props.serverCtx() @@ -71,8 +72,10 @@ function SessionTabSlot(props: { createEffect(() => { const value = session() + if (!value) return + tabs.rememberSessionInfo(props.tab, value) const current = sdk() - if (!value || !current) return + if (!current) return createTabPromptState(tabs, props.tab, current.scope, { dir: base64Encode(value.directory), id: value.id, @@ -86,7 +89,7 @@ function SessionTabSlot(props: { data-tab-key={props.id} data-active={props.active()} class="relative flex w-56 min-w-7 max-w-56 flex-shrink" - classList={{ hidden: !session() && !missingSession() }} + classList={{ hidden: !session() && !missingSession() && !persisted()?.title }} > { @@ -95,7 +98,7 @@ function SessionTabSlot(props: { href={tabHref(props.tab)} server={props.tab.server} session={session} - fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined} + fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)} onTitleChange={(title) => { const value = session() const ctx = props.serverCtx() diff --git a/packages/app/src/context/command.test.ts b/packages/app/src/context/command.test.ts index 2b956287c549..4297dd89bd37 100644 --- a/packages/app/src/context/command.test.ts +++ b/packages/app/src/context/command.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { upsertCommandRegistration } from "./command" +import { resolveKeybindOption, upsertCommandRegistration } from "./command" describe("upsertCommandRegistration", () => { test("replaces keyed registrations", () => { @@ -23,3 +23,19 @@ describe("upsertCommandRegistration", () => { expect(next[1]?.options).toBe(one) }) }) + +describe("resolveKeybindOption", () => { + test("prefers a matching contextual command over the global fallback", () => { + const fallback = { id: "tab.close", title: "Close tab" } + const contextual = { id: "terminal.close", title: "Close terminal", when: () => true } + + expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(contextual) + }) + + test("uses the global fallback outside the command context", () => { + const fallback = { id: "tab.close", title: "Close tab" } + const contextual = { id: "terminal.close", title: "Close terminal", when: () => false } + + expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(fallback) + }) +}) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 0c935307ade1..467a1115b55f 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -82,10 +82,15 @@ export interface CommandOption { suggested?: boolean disabled?: boolean hidden?: boolean + when?: (event: KeyboardEvent) => boolean onSelect?: (source?: "palette" | "keybind" | "slash") => void onHighlight?: () => (() => void) | void } +export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) { + return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when) +} + type CommandSource = "palette" | "keybind" | "slash" export type CommandCatalogItem = { @@ -334,7 +339,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex }) const keymap = createMemo(() => { - const map = new Map() + const map = new Map() for (const option of options()) { if (option.id.startsWith(SUGGESTED_PREFIX)) continue if (option.disabled) continue @@ -344,8 +349,12 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex for (const kb of keybinds) { if (!kb.key) continue const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt) - if (map.has(sig)) continue - map.set(sig, option) + const existing = map.get(sig) + if (existing) { + existing.push(option) + continue + } + map.set(sig, [option]) } } return map @@ -374,7 +383,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const sig = signatureFromEvent(event) const isPalette = palette().has(sig) - const option = keymap().get(sig) + const option = resolveKeybindOption(keymap().get(sig), event) const modified = event.ctrlKey || event.metaKey || event.altKey const isTab = event.key === "Tab" @@ -383,17 +392,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex if (isPalette) { event.preventDefault() + event.stopPropagation() showPalette() return } if (!option) return event.preventDefault() + event.stopPropagation() option.onSelect?.("keybind") } onMount(() => { - makeEventListener(document, "keydown", handleKeyDown) + makeEventListener(document, "keydown", handleKeyDown, { capture: true }) }) function register(cb: () => CommandOption[]): void diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 11155d2ca479..47ed77e781e7 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -28,6 +28,11 @@ export type DraftTab = { export type Tab = SessionTab | DraftTab +export type TabInfo = { + title?: string + directory?: string +} + type RecentTab = { key?: string } @@ -64,6 +69,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ createStore([]), ) const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore({})) + const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore>({})) const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore([])) const params = useParams() @@ -102,6 +108,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) } + const removeInfo = (key: string) => { + if (!info[key]) return + setInfo( + produce((draft) => { + delete draft[key] + }), + ) + } + onCleanup(memory.dispose) createEffect(() => { @@ -110,11 +125,19 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const next = store.filter((tab) => servers.has(tab.server)) if (next.length !== store.length) { for (const tab of store) { - if (!servers.has(tab.server)) memory.remove(tabKey(tab)) + if (!servers.has(tab.server)) { + const key = tabKey(tab) + memory.remove(key) + removeInfo(key) + } } setStore(() => next) } if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined) + const keys = new Set(next.map(tabKey)) + for (const key of Object.keys(info)) { + if (!keys.has(key)) removeInfo(key) + } }) createEffect(() => { @@ -150,6 +173,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (nextTab) navigateTab(nextTab) }).finally(() => closing.delete(key)) memory.remove(key) + removeInfo(key) if (draftID) removeDraftPersisted(draftID) } @@ -264,6 +288,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const removed = store.filter((tab) => tab.server === key).map(tabKey) setStore((tabs) => tabs.filter((tab) => tab.server !== key)) for (const key of removed) memory.remove(key) + for (const key of removed) removeInfo(key) if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) for (const draftID of drafts) removeDraftPersisted(draftID) if (server.key === key) navigate("/") @@ -318,6 +343,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) }) for (const key of removed) memory.remove(key) + for (const key of removed) removeInfo(key) + }, + rememberSessionInfo(tab: SessionTab, session: Session) { + const key = tabKey(tab) + const next = { title: session.title, directory: session.directory } + const current = info[key] + if (current?.title === next.title && current.directory === next.directory) return + setInfo(key, next) }, select: navigateTab, remember(tab: Tab) { @@ -342,6 +375,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }, } - return { ...actions, store, ready, recentReady } + return { ...actions, store, info, ready, recentReady } }, }) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 66e60df30e09..3da8183bb7e7 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -212,6 +212,11 @@ export const dict = { "model.input.video": "video", "model.input.pdf": "pdf", "model.tooltip.allows": "Allows: {{inputs}}", + "model.tooltip.context.label": "Context", + "model.tooltip.inputs": "Inputs", + "model.tooltip.model": "Model", + "model.tooltip.provider": "Provider", + "model.tooltip.reasoning": "Reasoning", "model.tooltip.reasoning.allowed": "Allows reasoning", "model.tooltip.reasoning.none": "No reasoning", "model.tooltip.context": "Context limit {{limit}}", diff --git a/packages/app/src/index.css b/packages/app/src/index.css index bc6866f9767c..78a9e103a7f5 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -224,7 +224,26 @@ [data-slot="model-selector-scroll"]::before { top: 0; - background: linear-gradient(to bottom, var(--v2-background-bg-layer-01), transparent); + height: 12px; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--v2-background-bg-layer-01) 80%, transparent) 0%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 78.3336%, transparent) 4.7%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 76.4824%, transparent) 8.9%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 74.368%, transparent) 12.8%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 71.9123%, transparent) 16.56%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 69.037%, transparent) 20.37%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 65.664%, transparent) 24.4%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 61.715%, transparent) 28.83%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 57.1117%, transparent) 33.84%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 51.776%, transparent) 39.6%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 45.6296%, transparent) 46.3%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 38.5944%, transparent) 54.1%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 30.592%, transparent) 63.2%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 21.5443%, transparent) 73.76%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 11.373%, transparent) 85.97%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 0%, transparent) 100% + ); } [data-slot="manage-models-scroll"]::before { @@ -239,7 +258,26 @@ [data-slot="model-selector-scroll"]::after { bottom: 0; - background: linear-gradient(to top, var(--v2-background-bg-layer-01), transparent); + height: 12px; + background: linear-gradient( + 0deg, + color-mix(in srgb, var(--v2-background-bg-layer-01) 80%, transparent) 0%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 78.3336%, transparent) 4.7%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 76.4824%, transparent) 8.9%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 74.368%, transparent) 12.8%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 71.9123%, transparent) 16.56%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 69.037%, transparent) 20.37%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 65.664%, transparent) 24.4%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 61.715%, transparent) 28.83%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 57.1117%, transparent) 33.84%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 51.776%, transparent) 39.6%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 45.6296%, transparent) 46.3%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 38.5944%, transparent) 54.1%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 30.592%, transparent) 63.2%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 21.5443%, transparent) 73.76%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 11.373%, transparent) 85.97%, + color-mix(in srgb, var(--v2-background-bg-layer-01) 0%, transparent) 100% + ); } @supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) { diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index efbab7809ee9..762b8b2c8e02 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -1278,7 +1278,7 @@ function HomeSessionSearch(props: {
-