diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4b6334ce..3c1f0a9ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,22 @@ on: branches: [main] jobs: + workflow-lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check workflow files + run: bun run lint:workflow + ci: runs-on: macos-latest @@ -15,7 +31,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.11 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..82da58f67 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: macos-14 + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Test + run: bun test + + - name: Build + run: bun run build + + - name: Package release artifacts + env: + RELEASE_VERSION: ${{ github.ref_name }} + run: bun run scripts/package-release.ts + + - name: Upload release assets + id: upload_release + uses: softprops/action-gh-release@v2 + with: + files: | + release/*.tar.gz + release/SHA256SUMS + release/manifest.json + packaging/homebrew/claude-code-best.rb + + - name: Write release summary + if: always() + env: + RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }} + run: | + node <<'EOF' + const fs = require('fs') + const manifest = JSON.parse(fs.readFileSync('release/manifest.json', 'utf8')) + const lines = [ + '## Release published', + '', + `- tag: ${{ github.ref_name }}`, + `- release url: ${process.env.RELEASE_URL}`, + '- assets:', + ...manifest.artifacts.map(name => ` - ${name}`), + ` - ${manifest.checksumsFile}`, + ' - manifest.json', + ` - ${manifest.formulaFile}`, + '', + 'Homebrew tap sync will be triggered separately by the release published event.', + 'Check the Sync Homebrew Tap workflow for downstream PR, auto-merge, and recovery status.', + '', + ] + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${lines.join('\n')}\n`) + EOF diff --git a/.github/workflows/sync-homebrew-tap.yml b/.github/workflows/sync-homebrew-tap.yml new file mode 100644 index 000000000..160cd1863 --- /dev/null +++ b/.github/workflows/sync-homebrew-tap.yml @@ -0,0 +1,320 @@ +name: Sync Homebrew Tap + +on: + release: + types: + - published + workflow_dispatch: + inputs: + release_tag: + description: Release tag to sync (e.g. v1.1.0) + required: false + sync_mode: + description: Manual recovery mode + required: false + default: full + type: choice + options: + - full + - auto-merge-only + - summary-only + +concurrency: + group: sync-homebrew-tap-${{ github.event.inputs.release_tag || github.event.release.tag_name || github.ref_name }} + cancel-in-progress: false + +jobs: + sync-homebrew-tap: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + env: + TAP_REPOSITORY: claude-code-best/homebrew-claude-code-best + TAP_DEFAULT_BRANCH: main + TAP_FORMULA_PATH: Formula/claude-code-best.rb + TAP_BRANCH_PREFIX: sync/claude-code + SYNC_MODE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sync_mode || 'full' }} + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + + steps: + - name: Checkout source repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.release_tag || github.event.release.tag_name || github.ref }} + + - name: Resolve release version + id: version + run: | + tag="${{ github.event.inputs.release_tag || github.event.release.tag_name || github.ref_name }}" + version="${tag#v}" + { + echo "tag=$tag" + echo "version=$version" + echo "branch=${TAP_BRANCH_PREFIX}/${tag}" + } >> "$GITHUB_OUTPUT" + + - name: Download release manifest + if: env.SYNC_MODE == 'full' + id: manifest + timeout-minutes: 3 + run: | + gh release download "${{ steps.version.outputs.tag }}" \ + --repo "${{ github.repository }}" \ + --pattern manifest.json \ + --dir release-manifest + node <<'EOF' + const fs = require('fs') + const manifest = JSON.parse(fs.readFileSync('release-manifest/manifest.json', 'utf8')) + const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts.join(',') : '' + if (manifest.version !== '${{ steps.version.outputs.version }}') { + throw new Error(`Manifest version ${manifest.version} does not match release version ${{ steps.version.outputs.version }}`) + } + fs.appendFileSync(process.env.GITHUB_OUTPUT, `formula_file=${manifest.formulaFile}\n`) + fs.appendFileSync(process.env.GITHUB_OUTPUT, `checksums_file=${manifest.checksumsFile}\n`) + fs.appendFileSync(process.env.GITHUB_OUTPUT, `artifacts=${artifacts}\n`) + EOF + + - name: Validate formula exists + if: env.SYNC_MODE == 'full' + timeout-minutes: 2 + run: test -f "${{ steps.manifest.outputs.formula_file }}" + + - name: Checkout tap repository + timeout-minutes: 3 + uses: actions/checkout@v4 + with: + repository: ${{ env.TAP_REPOSITORY }} + ref: ${{ env.TAP_DEFAULT_BRANCH }} + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: tap-repo + + - name: Copy formula into tap repository + if: env.SYNC_MODE == 'full' + timeout-minutes: 2 + run: | + mkdir -p "tap-repo/$(dirname "${TAP_FORMULA_PATH}")" + cp "${{ steps.manifest.outputs.formula_file }}" "tap-repo/${TAP_FORMULA_PATH}" + + - name: Check for formula changes + if: env.SYNC_MODE == 'full' + id: diff + timeout-minutes: 2 + working-directory: tap-repo + run: | + if git diff --quiet -- "${TAP_FORMULA_PATH}"; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Configure git author + if: env.SYNC_MODE == 'full' && steps.diff.outputs.changed == 'true' + timeout-minutes: 2 + working-directory: tap-repo + run: | + git config user.name "claude-code-best-bot" + git config user.email "claude-code-best-bot@users.noreply.github.com" + + - name: Reuse or create sync branch + if: env.SYNC_MODE == 'full' && steps.diff.outputs.changed == 'true' + timeout-minutes: 3 + working-directory: tap-repo + run: | + branch="${{ steps.version.outputs.branch }}" + if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then + git fetch origin "$branch" + git checkout -B "$branch" "origin/$branch" + else + git checkout -b "$branch" + fi + + - name: Commit and push formula update + if: env.SYNC_MODE == 'full' && steps.diff.outputs.changed == 'true' + timeout-minutes: 4 + working-directory: tap-repo + run: | + git add "${TAP_FORMULA_PATH}" + git commit -m "chore(homebrew): sync claude-code-best to ${{ steps.version.outputs.tag }}" + git push --set-upstream origin "${{ steps.version.outputs.branch }}" + + - name: Detect existing pull request + if: env.SYNC_MODE != 'summary-only' && (env.SYNC_MODE == 'auto-merge-only' || steps.diff.outputs.changed == 'true') + id: existing_pr + timeout-minutes: 3 + working-directory: tap-repo + run: | + pr_number=$(gh pr list \ + --repo "${TAP_REPOSITORY}" \ + --head "${{ steps.version.outputs.branch }}" \ + --base "${TAP_DEFAULT_BRANCH}" \ + --state open \ + --json number \ + --jq '.[0].number // empty') + if [ -n "$pr_number" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "number=$pr_number" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create pull request + if: env.SYNC_MODE == 'full' && steps.diff.outputs.changed == 'true' && steps.existing_pr.outputs.exists != 'true' + id: create_pr + timeout-minutes: 4 + working-directory: tap-repo + run: | + pr_url=$(gh pr create \ + --repo "${TAP_REPOSITORY}" \ + --base "${TAP_DEFAULT_BRANCH}" \ + --head "${{ steps.version.outputs.branch }}" \ + --title "Sync claude-code-best to ${{ steps.version.outputs.tag }}" \ + --body "$(cat <> "$GITHUB_OUTPUT" + + - name: Update existing pull request metadata + if: env.SYNC_MODE == 'full' && steps.diff.outputs.changed == 'true' && steps.existing_pr.outputs.exists == 'true' + id: update_pr + timeout-minutes: 4 + working-directory: tap-repo + run: | + gh pr edit "${{ steps.existing_pr.outputs.number }}" \ + --repo "${TAP_REPOSITORY}" \ + --title "Sync claude-code-best to ${{ steps.version.outputs.tag }}" \ + --body "$(cat <> "$GITHUB_OUTPUT" + fi + + - name: Enable auto-merge for sync pull request + if: env.SYNC_MODE != 'summary-only' && steps.pr_number.outputs.number != '' + id: auto_merge + timeout-minutes: 3 + working-directory: tap-repo + run: | + gh pr merge "${{ steps.pr_number.outputs.number }}" \ + --repo "${TAP_REPOSITORY}" \ + --auto \ + --merge + + - name: Write workflow summary + if: always() + timeout-minutes: 2 + env: + TAG: ${{ steps.version.outputs.tag }} + BRANCH: ${{ steps.version.outputs.branch }} + RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }} + SYNC_MODE: ${{ env.SYNC_MODE }} + CHANGED: ${{ steps.diff.outputs.changed }} + FORMULA_FILE: ${{ steps.manifest.outputs.formula_file }} + ARTIFACTS: ${{ steps.manifest.outputs.artifacts }} + CHECKSUMS_FILE: ${{ steps.manifest.outputs.checksums_file }} + EXISTING_PR_NUMBER: ${{ steps.existing_pr.outputs.number }} + PR_NUMBER: ${{ steps.pr_number.outputs.number }} + PR_URL: ${{ steps.create_pr.outputs.url }} + CREATE_PR_OUTCOME: ${{ steps.create_pr.outcome }} + UPDATE_PR_OUTCOME: ${{ steps.update_pr.outcome }} + AUTO_MERGE_OUTCOME: ${{ steps.auto_merge.outcome }} + TAP_REPOSITORY: ${{ env.TAP_REPOSITORY }} + TAP_DEFAULT_BRANCH: ${{ env.TAP_DEFAULT_BRANCH }} + run: | + pr_number="${PR_NUMBER:-${EXISTING_PR_NUMBER:-}}" + workflow_pr_url="${PR_URL:-}" + pr_url="$workflow_pr_url" + if [ -z "$pr_url" ] && [ -n "$pr_number" ]; then + pr_url="https://github.com/${TAP_REPOSITORY}/pull/${pr_number}" + fi + + { + echo "## Homebrew tap sync" + echo + echo "- tag: ${TAG}" + echo "- branch: ${BRANCH}" + echo "- release url: ${RELEASE_URL}" + echo "- sync mode: ${SYNC_MODE}" + echo "- changed: ${CHANGED:-unknown}" + if [ -n "${FORMULA_FILE}" ]; then + echo "- formula source: ${FORMULA_FILE}" + fi + if [ -n "${ARTIFACTS}" ]; then + echo "- release artifacts: ${ARTIFACTS}" + fi + if [ -n "${CHECKSUMS_FILE}" ]; then + echo "- checksums file: ${CHECKSUMS_FILE}" + fi + if [ -n "$pr_number" ]; then + echo "- pull request: #${pr_number}" + else + echo "- pull request: not resolved" + fi + if [ -n "$pr_url" ]; then + echo "- pull request url: ${pr_url}" + fi + echo + + if [ "${SYNC_MODE}" = "summary-only" ]; then + echo "Summary-only recovery mode completed; no write operations were attempted." + elif [ "${SYNC_MODE}" = "auto-merge-only" ] && [ -z "$pr_number" ]; then + echo "Auto-merge-only recovery mode could not resolve an open PR for branch ${BRANCH}." + elif [ "${CHANGED}" != "true" ] && [ "${SYNC_MODE}" = "full" ]; then + echo "Formula unchanged; no PR action was required." + elif [ "${CREATE_PR_OUTCOME}" = "failure" ] || [ "${UPDATE_PR_OUTCOME}" = "failure" ] || [ "${AUTO_MERGE_OUTCOME}" = "failure" ]; then + echo "### Recovery" + echo "- Inspect the failed step in this workflow run." + echo "- Verify branch '${BRANCH}' still exists in ${TAP_REPOSITORY}." + if [ -n "$pr_url" ]; then + echo "- Re-open or continue from ${pr_url}." + else + echo "- If PR creation failed, open a PR manually from ${BRANCH} to ${TAP_DEFAULT_BRANCH}." + fi + echo "- After fixing the underlying issue, re-run this workflow for tag ${TAG}." + else + echo "Sync completed; PR creation/update path succeeded." + if [ -n "$pr_url" ]; then + echo "Auto-merge status is managed on ${pr_url}." + fi + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: No-op when formula is unchanged + if: env.SYNC_MODE == 'full' && steps.diff.outputs.changed != 'true' + timeout-minutes: 1 + run: echo "Formula unchanged; skipping tap PR creation." diff --git a/README.md b/README.md index c9d05bc77..7d4a6b8d3 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,36 @@ ccb # 直接打开 claude code DEFAULT_RELEASE_BASE=https://ghproxy.net/https://github.com/microsoft/ripgrep-prebuilt/releases/download/v15.0.1 ``` +## ⚡ GitHub Releases / Homebrew + +### GitHub Releases + +发布页会提供 macOS 的完整运行时压缩包: + +- `ccb-v-darwin-arm64.tar.gz` +- `ccb-v-darwin-x64.tar.gz` +- `SHA256SUMS` + +解压后可直接运行包内的 `bin/ccb`。 +包内若缺少 ripgrep,会在首次运行时自动补下载。 +也可以直接使用仓库中的 `bun run package:release` 生成 release tarball、`SHA256SUMS` 和 Homebrew formula。 + +### Homebrew + +第一阶段采用独立 tap 分发,主仓库会在 release 发布后自动向 tap 仓库创建 formula 更新 PR。 +安装方式示例: + +```bash +brew tap claude-code-best/homebrew-claude-code-best +brew install claude-code-best +``` + +> 当前 Homebrew / Release 方案仍依赖 Bun 运行时;formula 会声明 Bun 依赖。 +> 自动同步到 tap 仓库需要配置 `HOMEBREW_TAP_TOKEN`,并默认以 PR 形式更新,不会直接推送 tap 主分支。 +> 同一 release tag 的 tap 同步 workflow 会串行化执行;若 PR 创建、更新或 auto-merge 失败,可直接查看 workflow summary 获取恢复信息。 +> 也可通过手动触发 workflow_dispatch 并选择 `sync_mode=auto-merge-only` 或 `summary-only` 做恢复。 +> 仓库 CI 还会通过仓库内脚本校验 `.github/workflows/*.yml`;release workflow 会列出真实发布产物名并上传 `manifest.json`,tap sync workflow 会读取该 manifest 来生成同步说明、PR / auto-merge / 恢复 summary。 + ## ⚡ 快速开始(源码版) ### ⚙️ 环境要求 diff --git a/build.ts b/build.ts index 4e8e0d260..ff999d051 100644 --- a/build.ts +++ b/build.ts @@ -1,8 +1,10 @@ -import { readdir, readFile, writeFile, cp } from 'fs/promises' +import { readdir, readFile, writeFile, cp, chmod } from 'fs/promises' import { join } from 'path' import { getMacroDefines } from './scripts/defines.ts' const outdir = 'dist' +const cliEntrypoint = join(outdir, 'cli.js') +const CLI_SHEBANG = '#!/usr/bin/env bun' // Step 1: Clean output directory const { rmSync } = await import('fs') @@ -79,12 +81,26 @@ console.log( `Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for Node.js compat)`, ) -// Step 4: Copy native .node addon files (audio-capture) +// Step 4: Stabilize CLI entrypoint for npm global installs +const cliContent = await readFile(cliEntrypoint, 'utf-8') +const normalizedCliContent = cliContent.startsWith(`${CLI_SHEBANG}\n`) + ? cliContent + : cliContent.replace(/^#!.*\n/, '') +const nextCliContent = normalizedCliContent.startsWith(`${CLI_SHEBANG}\n`) + ? normalizedCliContent + : `${CLI_SHEBANG}\n${normalizedCliContent}` +if (nextCliContent !== cliContent) { + await writeFile(cliEntrypoint, nextCliContent) +} +await chmod(cliEntrypoint, 0o755) +console.log(`Stabilized CLI entrypoint at ${cliEntrypoint}`) + +// Step 5: Copy native .node addon files (audio-capture) const vendorDir = join(outdir, 'vendor', 'audio-capture') await cp('vendor/audio-capture', vendorDir, { recursive: true }) console.log(`Copied vendor/audio-capture/ → ${vendorDir}/`) -// Step 5: Bundle download-ripgrep script as standalone JS for postinstall +// Step 6: Bundle download-ripgrep script as standalone JS for postinstall const rgScript = await Bun.build({ entrypoints: ['scripts/download-ripgrep.ts'], outdir, diff --git a/docs/features/voice-mode.md b/docs/features/voice-mode.md index 6131bd8da..411d5a4bc 100644 --- a/docs/features/voice-mode.md +++ b/docs/features/voice-mode.md @@ -38,12 +38,12 @@ VOICE_MODE 实现"按键说话"(Push-to-Talk)语音输入。用户按住空 三层检查: ```ts -isVoiceModeEnabled() = hasVoiceAuth() && isVoiceGrowthBookEnabled() +isVoiceModeEnabled() = hasAvailableVoiceProvider() && isVoiceGrowthBookEnabled() ``` 1. **Feature Flag**:`feature('VOICE_MODE')` — 编译时/运行时开关 2. **GrowthBook Kill-Switch**:`!getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_quartz_disabled', false)` — 紧急关闭开关(默认 false = 未禁用) -3. **Auth 检查**:`hasVoiceAuth()` — 需要 Anthropic OAuth token(非 API key) +3. **Provider 可用性检查**:`hasAvailableVoiceProvider()` — 需要可用的 STT provider(Claude.ai OAuth 或受支持的 API key provider) ### 3.2 核心模块 diff --git a/package.json b/package.json index 586b78fbe..67af560ec 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,12 @@ ], "scripts": { "build": "bun run build.ts", + "package:release": "bun run scripts/package-release.ts", "dev": "bun run scripts/dev.ts", "dev:inspect": "bun run scripts/dev-debug.ts", - "prepublishOnly": "bun run build", + "prepack": "bun run build", "lint": "biome lint src/", + "lint:workflow": "bun run scripts/actionlint.ts", "lint:fix": "biome lint --fix src/", "format": "biome format --write src/", "prepare": "git config core.hooksPath .githooks", diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md new file mode 100644 index 000000000..1688ac5f9 --- /dev/null +++ b/packaging/homebrew/README.md @@ -0,0 +1,31 @@ +# Homebrew formula template + +这个目录保存主仓库内的 Homebrew 分发模板,真正发布时建议同步到独立 tap 仓库:`claude-code-best/homebrew-claude-code-best`。 + +## 发布流程 + +1. 运行 `bun run build` +2. 运行 `bun run package:release` +3. 主仓库 release workflow 会上传 formula 到 GitHub Release +4. `sync-homebrew-tap.yml` 会在 release published 后自动把 `packaging/homebrew/claude-code-best.rb` 同步到独立 tap 仓库分支并创建 PR +5. 如果同一 release tag 的 PR 已存在,workflow 会复用并更新该 PR,而不是重复创建 +6. 对受控的同步 PR,workflow 会自动开启 GitHub auto-merge;若 tap 仓库 checks 未通过,则会等待通过后再合并 +7. workflow 对同一 release tag 启用并发保护,避免重复同步任务互相覆盖 +8. 若创建 / 更新 / auto-merge 失败,workflow summary 会输出分支、PR 编号/链接与手动恢复指引,便于重试或人工接管 +9. 如需人工介入,仍可在 tap 仓库中手动审查该 PR +10. 主仓库 CI 会通过仓库内脚本额外校验 `.github/workflows/*.yml`;release workflow 会列出真实发布产物名并上传 `manifest.json`,tap sync workflow 会读取该 manifest 并汇总 PR / auto-merge / 恢复状态 + +## 所需 secrets / env + +- `HOMEBREW_TAP_TOKEN`:对独立 tap 仓库具备 contents / pull requests 写权限的 token +- `TAP_REPOSITORY`:目标 tap 仓库,例如 `claude-code-best/homebrew-claude-code-best` +- `TAP_DEFAULT_BRANCH`:tap 默认分支,默认 `main` +- `TAP_FORMULA_PATH`:formula 路径,默认 `Formula/claude-code-best.rb` + +## 手动恢复 + +可通过 `workflow_dispatch` 手动触发 `sync-homebrew-tap.yml`,并指定: + +- `sync_mode=full`:完整同步(默认) +- `sync_mode=auto-merge-only`:只为当前 tag 对应的 open PR 重新开启 auto-merge +- `sync_mode=summary-only`:只输出当前恢复信息到 workflow summary,不执行写操作 diff --git a/packaging/homebrew/claude-code-best.rb b/packaging/homebrew/claude-code-best.rb new file mode 100644 index 000000000..6cad037d3 --- /dev/null +++ b/packaging/homebrew/claude-code-best.rb @@ -0,0 +1,29 @@ +class ClaudeCodeBest < Formula + desc "Reverse-engineered Anthropic Claude Code CLI" + homepage "https://github.com/claude-code-best/claude-code" + version "1.1.0" + depends_on "bun" + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/claude-code-best/claude-code/releases/download/v1.1.0/ccb-v1.1.0-darwin-arm64.tar.gz" + sha256 "ced8a5031c1feaee92ea409ed12ea90879c1c4290820af9895a0971d71a5982f" + else + url "https://github.com/claude-code-best/claude-code/releases/download/v1.1.0/ccb-v1.1.0-darwin-x64.tar.gz" + sha256 "617e33cdb22b2d49466de0f12cc19ed6b7ba271652c3df0f0f9b8b87b93624b8" + end + end + + def install + libexec.install Dir["*"] + root = libexec.children.find(&:directory?) + raise "release root directory missing" unless root + bin.install_symlink root/"bin/ccb" + bin.install_symlink root/"bin/claude-code-best" + end + + test do + output = shell_output("#{bin}/ccb --version") + assert_match "Claude Code", output + end +end diff --git a/packaging/homebrew/claude-code-best.rb.template b/packaging/homebrew/claude-code-best.rb.template new file mode 100644 index 000000000..cee766246 --- /dev/null +++ b/packaging/homebrew/claude-code-best.rb.template @@ -0,0 +1,29 @@ +class ClaudeCodeBest < Formula + desc "Reverse-engineered Anthropic Claude Code CLI" + homepage "https://github.com/claude-code-best/claude-code" + version "__VERSION__" + depends_on "bun" + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/claude-code-best/claude-code/releases/download/v__VERSION__/ccb-v__VERSION__-darwin-arm64.tar.gz" + sha256 "__SHA256_DARWIN_ARM64__" + else + url "https://github.com/claude-code-best/claude-code/releases/download/v__VERSION__/ccb-v__VERSION__-darwin-x64.tar.gz" + sha256 "__SHA256_DARWIN_X64__" + end + end + + def install + libexec.install Dir["*"] + root = libexec.children.find(&:directory?) + raise "release root directory missing" unless root + bin.install_symlink root/"bin/ccb" + bin.install_symlink root/"bin/claude-code-best" + end + + test do + output = shell_output("#{bin}/ccb --version") + assert_match "Claude Code", output + end +end diff --git a/scripts/actionlint.ts b/scripts/actionlint.ts new file mode 100644 index 000000000..8cfe07d75 --- /dev/null +++ b/scripts/actionlint.ts @@ -0,0 +1,198 @@ +import { createHash } from 'node:crypto' +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs' +import { spawnSync } from 'child_process' +import { setDefaultResultOrder } from 'node:dns' +import { tmpdir } from 'os' +import path from 'path' + +try { + setDefaultResultOrder('ipv4first') +} catch { + // ignore +} + +const DEFAULT_ACTIONLINT_VERSION = '1.7.8' +const ACTIONLINT_VERSION = process.env.ACTIONLINT_VERSION?.trim() || DEFAULT_ACTIONLINT_VERSION +const DEFAULT_ACTIONLINT_DOWNLOAD_BASE = `https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}` +const ACTIONLINT_DOWNLOAD_BASE = + (process.env.ACTIONLINT_DOWNLOAD_BASE?.trim() || DEFAULT_ACTIONLINT_DOWNLOAD_BASE).replace(/\/$/, '') +const ACTIONLINT_SHA256 = process.env.ACTIONLINT_SHA256?.trim().toLowerCase() + +const DEFAULT_ACTIONLINT_SHA256: Record> = { + '1.7.8': { + 'darwin-arm64': 'ffb1f6c429a51dc9f37af9d11f96c16bd52f54b713bf7f8bd92f7fce9fd4284a', + 'darwin-x64': '16b85caf792b34bcc40f7437736c4347680da0a1b034353a85012debbd71a461', + 'linux-arm64': '4c65dbb2d59b409cdd75d47ffa8fa32af8f0eee573ac510468dc2275c48bf07c', + 'linux-x64': 'be92c2652ab7b6d08425428797ceabeb16e31a781c07bc388456b4e592f3e36a', + }, +} + +function getVersionTag() { + return ACTIONLINT_VERSION.replace(/^v/, '') +} + +function getPlatformKey() { + return `${process.platform}-${process.arch}` +} + +function getPlatformAsset() { + const versionTag = getVersionTag() + const platform = process.platform + const arch = process.arch + + if (platform === 'darwin') { + if (arch === 'arm64') return `actionlint_${versionTag}_darwin_arm64.tar.gz` + if (arch === 'x64') return `actionlint_${versionTag}_darwin_amd64.tar.gz` + } + + if (platform === 'linux') { + if (arch === 'arm64') return `actionlint_${versionTag}_linux_arm64.tar.gz` + if (arch === 'x64') return `actionlint_${versionTag}_linux_amd64.tar.gz` + } + + throw new Error(`Unsupported platform for actionlint: ${platform}-${arch}`) +} + +function getCacheDir() { + return path.resolve(process.cwd(), '.claude', 'bin', 'actionlint', getVersionTag()) +} + +function getExecutablePath() { + const exe = process.platform === 'win32' ? 'actionlint.exe' : 'actionlint' + return path.join(getCacheDir(), exe) +} + +function proxyEnvSet() { + const v = (s: string | undefined) => (s ?? '').trim() + return !!( + v(process.env.HTTPS_PROXY) || + v(process.env.HTTP_PROXY) || + v(process.env.ALL_PROXY) || + v(process.env.https_proxy) || + v(process.env.http_proxy) + ) +} + +async function fetchRelease(url: string): Promise { + if (proxyEnvSet()) { + const { EnvHttpProxyAgent, fetch: undiciFetch } = await import('undici') + return (await undiciFetch(url, { + redirect: 'follow', + dispatcher: new EnvHttpProxyAgent(), + })) as unknown as Response + } + return await fetch(url, { redirect: 'follow' }) +} + +function tryCurlDownload(url: string, dest: string): boolean { + const curl = process.platform === 'win32' ? 'curl.exe' : 'curl' + const result = spawnSync(curl, ['-fsSL', '-L', '--fail', '-o', dest, url], { + stdio: 'pipe', + windowsHide: true, + }) + return result.status === 0 && existsSync(dest) && statSync(dest).size > 0 +} + +async function downloadUrlToBuffer(url: string): Promise { + const response = await fetchRelease(url) + if (!response.ok) { + throw new Error(`Download failed: ${response.status} ${response.statusText}`) + } + return Buffer.from(await response.arrayBuffer()) +} + +async function downloadUrlToBufferWithFallback(url: string): Promise { + let firstError: unknown + try { + return await downloadUrlToBuffer(url) + } catch (error) { + firstError = error + } + + const tmpRoot = path.join(tmpdir(), `actionlint-dl-${process.pid}-${Date.now()}`) + const tmpFile = path.join(tmpRoot, 'archive.tar.gz') + mkdirSync(tmpRoot, { recursive: true }) + try { + if (tryCurlDownload(url, tmpFile)) { + return readFileSync(tmpFile) + } + } finally { + rmSync(tmpRoot, { recursive: true, force: true }) + } + + throw firstError +} + +function sha256Buffer(buffer: Buffer) { + return createHash('sha256').update(buffer).digest('hex') +} + +function getExpectedSha256() { + if (ACTIONLINT_SHA256) { + return ACTIONLINT_SHA256 + } + + const versionChecksums = DEFAULT_ACTIONLINT_SHA256[getVersionTag()] + if (!versionChecksums) { + throw new Error( + `No default SHA256 is defined for actionlint ${ACTIONLINT_VERSION}. Set ACTIONLINT_SHA256 to continue.`, + ) + } + + const checksum = versionChecksums[getPlatformKey()] + if (!checksum) { + throw new Error( + `No default SHA256 is defined for ${getPlatformKey()} on actionlint ${ACTIONLINT_VERSION}. Set ACTIONLINT_SHA256 to continue.`, + ) + } + + return checksum +} + +function ensureArchiveChecksum(buffer: Buffer) { + const expected = getExpectedSha256() + const actual = sha256Buffer(buffer) + if (actual !== expected) { + throw new Error(`Actionlint SHA256 mismatch: expected ${expected}, got ${actual}`) + } +} + +function ensureExtractedBinary(archivePath: string, destination: string) { + mkdirSync(path.dirname(destination), { recursive: true }) + const result = spawnSync('tar', ['-xzf', archivePath, '-C', path.dirname(destination)], { + stdio: 'pipe', + }) + if (result.status !== 0) { + throw new Error(result.stderr.toString() || 'Failed to extract actionlint archive') + } + chmodSync(destination, 0o755) +} + +async function ensureActionlint() { + const executablePath = getExecutablePath() + if (existsSync(executablePath) && statSync(executablePath).size > 0) { + return executablePath + } + + const asset = getPlatformAsset() + const url = `${ACTIONLINT_DOWNLOAD_BASE}/${asset}` + const cacheDir = getCacheDir() + mkdirSync(cacheDir, { recursive: true }) + + const archivePath = path.join(cacheDir, asset) + const buffer = await downloadUrlToBufferWithFallback(url) + ensureArchiveChecksum(buffer) + writeFileSync(archivePath, buffer) + ensureExtractedBinary(archivePath, executablePath) + return executablePath +} + +async function main() { + const executablePath = await ensureActionlint() + const result = spawnSync(executablePath, ['-color'], { + stdio: 'inherit', + }) + process.exit(result.status ?? 1) +} + +await main() diff --git a/scripts/package-release.ts b/scripts/package-release.ts new file mode 100644 index 000000000..450f78b36 --- /dev/null +++ b/scripts/package-release.ts @@ -0,0 +1,184 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rm, writeFile, chmod, copyFile } from 'fs/promises' +import { cpSync, createReadStream, existsSync } from 'fs' +import { basename, join, resolve } from 'path' +import { tmpdir } from 'os' +import { spawnSync } from 'child_process' + +const projectRoot = process.cwd() +const distDir = resolve(projectRoot, 'dist') +const releaseDir = resolve(projectRoot, 'release') +const packageJsonPath = resolve(projectRoot, 'package.json') +const readmePath = resolve(projectRoot, 'README.md') +const licensePath = resolve(projectRoot, 'LICENSE') +const homebrewDir = resolve(projectRoot, 'packaging', 'homebrew') +const releasePlatforms = [{ arch: 'arm64' }, { arch: 'x64' }] as const +const manifestPath = resolve(releaseDir, 'manifest.json') + +function run(command: string, args: string[], cwd = projectRoot) { + const result = spawnSync(command, args, { cwd, stdio: 'inherit' }) + if (result.status !== 0) { + throw new Error(`Command failed: ${command} ${args.join(' ')}`) + } +} + +async function ensureFile(path: string) { + if (!existsSync(path)) { + throw new Error(`Required file not found: ${path}`) + } +} + +async function getPackageMetadata() { + const raw = await readFile(packageJsonPath, 'utf-8') + return JSON.parse(raw) as { + version: string + repository?: { url?: string } + } +} + +async function sha256(filePath: string) { + const hash = createHash('sha256') + const stream = createReadStream(filePath) + for await (const chunk of stream) { + hash.update(chunk) + } + return hash.digest('hex') +} + +async function createArchive(sourceDir: string, outputPath: string) { + run('tar', ['-czf', outputPath, '-C', resolve(sourceDir, '..'), basename(sourceDir)]) +} + +function buildWrapper() { + return `#!/usr/bin/env bash +set -euo pipefail +if ! command -v bun >/dev/null 2>&1; then + echo "Bun is required to run claude-code-best. Install Bun from https://bun.sh/." >&2 + exit 1 +fi +ROOT_DIR="$(cd "$(dirname "${'$'}0")/.." && pwd)" +ARCH="${'$'}(uname -m)" +case "${'$'}ARCH" in + x86_64) ARCH="x64" ;; + aarch64|arm64) ARCH="arm64" ;; +esac +OS="${'$'}(uname -s | tr '[:upper:]' '[:lower:]')" +RG_PATH="${'$'}ROOT_DIR/dist/vendor/ripgrep/${'$'}ARCH-${'$'}OS/rg" +if [ ! -x "${'$'}RG_PATH" ]; then + bun "${'$'}ROOT_DIR/dist/download-ripgrep.js" >/dev/null 2>&1 || true +fi +exec bun "${'$'}ROOT_DIR/dist/cli.js" "${'$'}@" +` +} + +function buildFormula(repositoryUrl: string, version: string, armSha: string, x64Sha: string) { + return `class ClaudeCodeBest < Formula + desc "Reverse-engineered Anthropic Claude Code CLI" + homepage "${repositoryUrl}" + version "${version}" + depends_on "bun" + + on_macos do + if Hardware::CPU.arm? + url "${repositoryUrl}/releases/download/v${version}/ccb-v${version}-darwin-arm64.tar.gz" + sha256 "${armSha}" + else + url "${repositoryUrl}/releases/download/v${version}/ccb-v${version}-darwin-x64.tar.gz" + sha256 "${x64Sha}" + end + end + + def install + libexec.install Dir["*"] + root = libexec.children.find(&:directory?) + raise "release root directory missing" unless root + bin.install_symlink root/"bin/ccb" + bin.install_symlink root/"bin/claude-code-best" + end + + test do + output = shell_output("#{bin}/ccb --version") + assert_match "Claude Code", output + end +end +` +} + +async function main() { + await ensureFile(packageJsonPath) + await ensureFile(readmePath) + await ensureFile(resolve(distDir, 'cli.js')) + await ensureFile(resolve(distDir, 'download-ripgrep.js')) + + const pkg = await getPackageMetadata() + const version = process.env.RELEASE_VERSION?.replace(/^v/, '') || pkg.version + const repositoryUrl = pkg.repository?.url?.replace(/^git\+/, '').replace(/\.git$/, '') || '' + + await rm(releaseDir, { recursive: true, force: true }) + await mkdir(releaseDir, { recursive: true }) + await mkdir(homebrewDir, { recursive: true }) + + const checksums: Array<{ file: string; sha: string }> = [] + const wrapper = buildWrapper() + + for (const platform of releasePlatforms) { + const rootName = `ccb-v${version}-darwin-${platform.arch}` + const stagingBase = await mkdtemp(join(tmpdir(), 'ccb-release-')) + const stagingRoot = join(stagingBase, rootName) + + await mkdir(stagingRoot, { recursive: true }) + cpSync(distDir, join(stagingRoot, 'dist'), { recursive: true }) + await copyFile(packageJsonPath, join(stagingRoot, 'package.json')) + await copyFile(readmePath, join(stagingRoot, 'README.md')) + if (existsSync(licensePath)) { + await copyFile(licensePath, join(stagingRoot, 'LICENSE')) + } + + const binDir = join(stagingRoot, 'bin') + await mkdir(binDir, { recursive: true }) + const ccbPath = join(binDir, 'ccb') + const fullPath = join(binDir, 'claude-code-best') + await writeFile(ccbPath, wrapper) + await writeFile(fullPath, wrapper) + await chmod(ccbPath, 0o755) + await chmod(fullPath, 0o755) + + const archivePath = join(releaseDir, `${rootName}.tar.gz`) + await createArchive(stagingRoot, archivePath) + checksums.push({ file: `${rootName}.tar.gz`, sha: await sha256(archivePath) }) + + await rm(stagingBase, { recursive: true, force: true }) + } + + await writeFile( + join(releaseDir, 'SHA256SUMS'), + `${checksums.map(item => `${item.sha} ${item.file}`).join('\n')}\n`, + ) + + await writeFile( + manifestPath, + `${JSON.stringify( + { + version, + artifacts: checksums.map(item => item.file), + checksumsFile: 'SHA256SUMS', + formulaFile: 'packaging/homebrew/claude-code-best.rb', + }, + null, + 2, + )}\n`, + ) + + if (repositoryUrl) { + const armSha = checksums.find(item => item.file.includes('darwin-arm64'))?.sha ?? '__SHA256_DARWIN_ARM64__' + const x64Sha = checksums.find(item => item.file.includes('darwin-x64'))?.sha ?? '__SHA256_DARWIN_X64__' + await writeFile( + resolve(homebrewDir, 'claude-code-best.rb'), + buildFormula(repositoryUrl, version, armSha, x64Sha), + ) + } + + console.log(`Release artifacts written to ${releaseDir}`) +} + +await main() diff --git a/scripts/postinstall.cjs b/scripts/postinstall.cjs index 245b4f36d..c5b29808d 100644 --- a/scripts/postinstall.cjs +++ b/scripts/postinstall.cjs @@ -94,6 +94,28 @@ function getBinaryPath() { return path.resolve(dir, subdir, binary) } +function getCliEntrypoint() { + return path.resolve(projectRoot, "dist", "cli.js") +} + +function ensureCliEntrypointReady() { + const cliEntrypoint = getCliEntrypoint() + if (!existsSync(cliEntrypoint)) { + console.warn(`[postinstall] CLI entrypoint not found at ${cliEntrypoint}`) + return + } + + const bunCommand = process.platform === "win32" ? "bun.exe" : "bun" + const bunCheck = spawnSync(bunCommand, ["--version"], { + stdio: "ignore", + windowsHide: true, + }) + + if (bunCheck.status !== 0) { + console.warn("[postinstall] Bun runtime not found in PATH. Global install will finish, but the CLI requires Bun to run.") + } +} + // --- Download helpers --- function proxyEnvSet() { @@ -307,6 +329,7 @@ async function downloadAndExtract() { } async function main() { + ensureCliEntrypointReady() await downloadAndExtract() } diff --git a/src/commands/voice/index.ts b/src/commands/voice/index.ts index 61540d3ba..a56270f41 100644 --- a/src/commands/voice/index.ts +++ b/src/commands/voice/index.ts @@ -8,7 +8,6 @@ const voice = { type: 'local', name: 'voice', description: 'Toggle voice mode', - availability: ['claude-ai'], isEnabled: () => isVoiceGrowthBookEnabled(), get isHidden() { return !isVoiceModeEnabled() diff --git a/src/commands/voice/voice.ts b/src/commands/voice/voice.ts index f369891bb..b12192b0f 100644 --- a/src/commands/voice/voice.ts +++ b/src/commands/voice/voice.ts @@ -1,8 +1,11 @@ import { normalizeLanguageForSTT } from '../../hooks/useVoice.js' import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js' import { logEvent } from '../../services/analytics/index.js' +import { + getVoiceModeAvailability, + type VoiceSttProvider, +} from '../../services/voiceStreamSTT.js' import type { LocalCommandCall } from '../../types/command.js' -import { isAnthropicAuthEnabled } from '../../utils/auth.js' import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' import { settingsChangeDetector } from '../../utils/settings/changeDetector.js' import { @@ -13,21 +16,18 @@ import { isVoiceModeEnabled } from '../../voice/voiceModeEnabled.js' const LANG_HINT_MAX_SHOWS = 2 +function getVoiceProviderLabel(provider: VoiceSttProvider): string { + return provider === 'anthropic-oauth' ? 'Claude.ai OAuth' : 'API key STT' +} + export const call: LocalCommandCall = async () => { - // Check auth and kill-switch before allowing voice mode - if (!isVoiceModeEnabled()) { - // Differentiate: OAuth-less users get an auth hint, everyone else - // gets nothing (command shouldn't be reachable when the kill-switch is on). - if (!isAnthropicAuthEnabled()) { - return { - type: 'text' as const, - value: - 'Voice mode requires a Claude.ai account. Please run /login to sign in.', - } - } + // Check provider and kill-switch before allowing voice mode + const { provider, available } = getVoiceModeAvailability() + if (!isVoiceModeEnabled() || !available || !provider) { return { type: 'text' as const, - value: 'Voice mode is not available.', + value: + 'Voice mode is not available. Configure Claude.ai OAuth or a supported API key provider first.', } } @@ -55,9 +55,6 @@ export const call: LocalCommandCall = async () => { } // Toggle ON — run pre-flight checks first - const { isVoiceStreamAvailable } = await import( - '../../services/voiceStreamSTT.js' - ) const { checkRecordingAvailability } = await import('../../services/voice.js') // Check recording availability (microphone access) @@ -70,15 +67,6 @@ export const call: LocalCommandCall = async () => { } } - // Check for API key - if (!isVoiceStreamAvailable()) { - return { - type: 'text' as const, - value: - 'Voice mode requires a Claude.ai account. Please run /login to sign in.', - } - } - // Check for recording tools const { checkVoiceDependencies, requestMicrophonePermission } = await import( '../../services/voice.js' @@ -145,6 +133,6 @@ export const call: LocalCommandCall = async () => { } return { type: 'text' as const, - value: `Voice mode enabled. Hold ${key} to record.${langNote}`, + value: `Voice mode enabled (${getVoiceProviderLabel(provider)}). Hold ${key} to record.${langNote}`, } } diff --git a/src/components/AgentProgressLine.tsx b/src/components/AgentProgressLine.tsx index c15a765a4..61843ec13 100644 --- a/src/components/AgentProgressLine.tsx +++ b/src/components/AgentProgressLine.tsx @@ -40,8 +40,11 @@ export function AgentProgressLine({ }: Props): React.ReactNode { const treeChar = isLast ? '└─' : '├─' const isBackgrounded = isAsync && isResolved + const headerLabel = hideType ? (name ?? description ?? agentType) : agentType + const statsText = !isBackgrounded + ? `${toolUseCount} tool ${toolUseCount === 1 ? 'use' : 'uses'}${tokens !== null ? ` · ${formatNumber(tokens)} tokens` : ''}` + : null - // Determine the status text const getStatusText = (): string => { if (!isResolved) { return lastToolInfo || 'Initializing…' @@ -53,51 +56,58 @@ export function AgentProgressLine({ } return ( - - + + {treeChar} - - {hideType ? ( - <> - {name ?? description ?? agentType} - {name && description && : {description}} - - ) : ( - <> - - {agentType} + + + + {headerLabel} + + + {hideType && name && description ? ( + + + : {description} - {description && ( - <> - {' ('} - - {description} - - {')'} - - )} - - )} - {!isBackgrounded && ( - <> - {' · '} - {toolUseCount} tool {toolUseCount === 1 ? 'use' : 'uses'} - {tokens !== null && <> · {formatNumber(tokens)} tokens} - - )} - + + ) : !hideType && description ? ( + + + {' ('} + + {description} + + {')'} + + + ) : null} + {statsText ? ( + + + {' · '} + {statsText} + + + ) : null} + {!isBackgrounded && ( - + {isLast ? ' ⎿ ' : '│ ⎿ '} - {getStatusText()} + + + {getStatusText()} + + )} diff --git a/src/components/TaskListV2.tsx b/src/components/TaskListV2.tsx index 6ab77ccbb..6be4142af 100644 --- a/src/components/TaskListV2.tsx +++ b/src/components/TaskListV2.tsx @@ -1,116 +1,104 @@ -import figures from 'figures' -import * as React from 'react' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Box, Text, stringWidth } from '@anthropic/ink' -import { useAppState } from '../state/AppState.js' -import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js' -import { - AGENT_COLOR_TO_THEME_COLOR, - type AgentColorName, -} from '../tools/AgentTool/agentColorManager.js' -import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js' -import { count } from '../utils/array.js' -import { summarizeRecentActivities } from '../utils/collapseReadSearch.js' -import { truncateToWidth } from '../utils/format.js' -import { isTodoV2Enabled, type Task } from '../utils/tasks.js' -import type { Theme } from '../utils/theme.js' -import ThemedText from './design-system/ThemedText.js' +import figures from 'figures'; +import * as React from 'react'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Box, Text } from '@anthropic/ink'; +import { useAppState } from '../state/AppState.js'; +import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'; +import { AGENT_COLOR_TO_THEME_COLOR, type AgentColorName } from '../tools/AgentTool/agentColorManager.js'; +import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; +import { count } from '../utils/array.js'; +import { summarizeRecentActivities } from '../utils/collapseReadSearch.js'; +import { truncateToWidth } from '../utils/format.js'; +import { isTodoV2Enabled, type Task } from '../utils/tasks.js'; +import type { Theme } from '../utils/theme.js'; +import ThemedText from './design-system/ThemedText.js'; type Props = { - tasks: Task[] - isStandalone?: boolean -} + tasks: Task[]; + isStandalone?: boolean; +}; + +const RECENT_COMPLETED_TTL_MS = 30_000; -const RECENT_COMPLETED_TTL_MS = 30_000 +type RecentCompletionState = { + timestamps: Map; + completedIds: Set; +}; function byIdAsc(a: Task, b: Task): number { - const aNum = parseInt(a.id, 10) - const bNum = parseInt(b.id, 10) + const aNum = parseInt(a.id, 10); + const bNum = parseInt(b.id, 10); if (!isNaN(aNum) && !isNaN(bNum)) { - return aNum - bNum + return aNum - bNum; } - return a.id.localeCompare(b.id) + return a.id.localeCompare(b.id); } -export function TaskListV2({ - tasks, - isStandalone = false, -}: Props): React.ReactNode { - const teamContext = useAppState(s => s.teamContext) - const appStateTasks = useAppState(s => s.tasks) - const [, forceUpdate] = React.useState(0) - const { rows, columns } = useTerminalSize() +export function TaskListV2({ tasks, isStandalone = false }: Props): React.ReactNode { + const teamContext = useAppState(s => s.teamContext); + const appStateTasks = useAppState(s => s.tasks); + const [, forceUpdate] = React.useState(0); + const { rows, columns } = useTerminalSize(); + const recentCompletionStateRef = React.useRef({ + timestamps: new Map(), + completedIds: new Set(tasks.filter(t => t.status === 'completed').map(t => t.id)), + }); + const maxDisplay = rows <= 10 ? 0 : Math.min(10, Math.max(3, rows - 14)); - // Track when each task was last observed transitioning to completed - const completionTimestampsRef = React.useRef(new Map()) - const previousCompletedIdsRef = React.useRef | null>(null) - if (previousCompletedIdsRef.current === null) { - previousCompletedIdsRef.current = new Set( - tasks.filter(t => t.status === 'completed').map(t => t.id), - ) - } - const maxDisplay = rows <= 10 ? 0 : Math.min(10, Math.max(3, rows - 14)) - - // Update completion timestamps: reset when a task transitions to completed - const currentCompletedIds = new Set( - tasks.filter(t => t.status === 'completed').map(t => t.id), - ) - const now = Date.now() + const now = Date.now(); + const recentCompletionTimestamps = recentCompletionStateRef.current.timestamps; + const currentCompletedIds = new Set(tasks.filter(t => t.status === 'completed').map(t => t.id)); for (const id of currentCompletedIds) { - if (!previousCompletedIdsRef.current.has(id)) { - completionTimestampsRef.current.set(id, now) + if (!recentCompletionStateRef.current.completedIds.has(id)) { + recentCompletionTimestamps.set(id, now); } } - for (const id of completionTimestampsRef.current.keys()) { + for (const id of recentCompletionTimestamps.keys()) { if (!currentCompletedIds.has(id)) { - completionTimestampsRef.current.delete(id) + recentCompletionTimestamps.delete(id); } } - previousCompletedIdsRef.current = currentCompletedIds + recentCompletionStateRef.current.completedIds = currentCompletedIds; - // Schedule re-render when the next recent completion expires. - // Depend on `tasks` so the timer is only reset when the task list changes, - // not on every render (which was causing unnecessary work). React.useEffect(() => { - if (completionTimestampsRef.current.size === 0) { - return + if (recentCompletionTimestamps.size === 0) { + return; } - const currentNow = Date.now() - let earliestExpiry = Infinity - for (const ts of completionTimestampsRef.current.values()) { - const expiry = ts + RECENT_COMPLETED_TTL_MS + const currentNow = Date.now(); + let earliestExpiry = Infinity; + for (const ts of recentCompletionTimestamps.values()) { + const expiry = ts + RECENT_COMPLETED_TTL_MS; if (expiry > currentNow && expiry < earliestExpiry) { - earliestExpiry = expiry + earliestExpiry = expiry; } } if (earliestExpiry === Infinity) { - return + return; } const timer = setTimeout( forceUpdate => forceUpdate((n: number) => n + 1), earliestExpiry - currentNow, forceUpdate, - ) - return () => clearTimeout(timer) - }, [tasks]) + ); + return () => clearTimeout(timer); + }, [recentCompletionTimestamps, tasks]); if (!isTodoV2Enabled()) { - return null + return null; } if (tasks.length === 0) { - return null + return null; } // Build a map of teammate name -> theme color - const teammateColors: Record = {} + const teammateColors: Record = {}; if (isAgentSwarmsEnabled() && teamContext?.teammates) { for (const teammate of Object.values(teamContext.teammates)) { if (teammate.color) { - const themeColor = - AGENT_COLOR_TO_THEME_COLOR[teammate.color as AgentColorName] + const themeColor = AGENT_COLOR_TO_THEME_COLOR[teammate.color as AgentColorName]; if (themeColor) { - teammateColors[teammate.name] = themeColor + teammateColors[teammate.name] = themeColor; } } } @@ -121,98 +109,88 @@ export function TaskListV2({ // task owners match regardless of which format the model used. // Rolls up consecutive search/read tool uses into a compact summary. // Also track which teammates are still running (not shut down). - const teammateActivity: Record = {} - const activeTeammates = new Set() + const teammateActivity: Record = {}; + const activeTeammates = new Set(); if (isAgentSwarmsEnabled()) { for (const bgTask of Object.values(appStateTasks)) { if (isInProcessTeammateTask(bgTask) && bgTask.status === 'running') { - activeTeammates.add(bgTask.identity.agentName) - activeTeammates.add(bgTask.identity.agentId) - const activities = bgTask.progress?.recentActivities + activeTeammates.add(bgTask.identity.agentName); + activeTeammates.add(bgTask.identity.agentId); + const activities = bgTask.progress?.recentActivities; const desc = - (activities && summarizeRecentActivities(activities)) ?? - bgTask.progress?.lastActivity?.activityDescription + (activities && summarizeRecentActivities(activities)) ?? bgTask.progress?.lastActivity?.activityDescription; if (desc) { - teammateActivity[bgTask.identity.agentName] = desc - teammateActivity[bgTask.identity.agentId] = desc + teammateActivity[bgTask.identity.agentName] = desc; + teammateActivity[bgTask.identity.agentId] = desc; } } } } // Get task counts for display - const completedCount = count(tasks, t => t.status === 'completed') - const pendingCount = count(tasks, t => t.status === 'pending') - const inProgressCount = tasks.length - completedCount - pendingCount + const completedCount = count(tasks, t => t.status === 'completed'); + const pendingCount = count(tasks, t => t.status === 'pending'); + const inProgressCount = tasks.length - completedCount - pendingCount; // Unresolved tasks (open or in_progress) block dependent tasks - const unresolvedTaskIds = new Set( - tasks.filter(t => t.status !== 'completed').map(t => t.id), - ) + const unresolvedTaskIds = new Set(tasks.filter(t => t.status !== 'completed').map(t => t.id)); // Check if we need to truncate - const needsTruncation = tasks.length > maxDisplay + const needsTruncation = tasks.length > maxDisplay; - let visibleTasks: Task[] - let hiddenTasks: Task[] + let visibleTasks: Task[]; + let hiddenTasks: Task[]; if (needsTruncation) { // Prioritize: recently completed (within 30s), in-progress, pending, older completed - const recentCompleted: Task[] = [] - const olderCompleted: Task[] = [] + const recentCompleted: Task[] = []; + const olderCompleted: Task[] = []; for (const task of tasks.filter(t => t.status === 'completed')) { - const ts = completionTimestampsRef.current.get(task.id) + const ts = recentCompletionTimestamps.get(task.id); if (ts && now - ts < RECENT_COMPLETED_TTL_MS) { - recentCompleted.push(task) + recentCompleted.push(task); } else { - olderCompleted.push(task) + olderCompleted.push(task); } } - recentCompleted.sort(byIdAsc) - olderCompleted.sort(byIdAsc) - const inProgress = tasks - .filter(t => t.status === 'in_progress') - .sort(byIdAsc) + recentCompleted.sort(byIdAsc); + olderCompleted.sort(byIdAsc); + const inProgress = tasks.filter(t => t.status === 'in_progress').sort(byIdAsc); const pending = tasks .filter(t => t.status === 'pending') .sort((a, b) => { - const aBlocked = a.blockedBy.some(id => unresolvedTaskIds.has(id)) - const bBlocked = b.blockedBy.some(id => unresolvedTaskIds.has(id)) + const aBlocked = a.blockedBy.some(id => unresolvedTaskIds.has(id)); + const bBlocked = b.blockedBy.some(id => unresolvedTaskIds.has(id)); if (aBlocked !== bBlocked) { - return aBlocked ? 1 : -1 + return aBlocked ? 1 : -1; } - return byIdAsc(a, b) - }) + return byIdAsc(a, b); + }); - const prioritized = [ - ...recentCompleted, - ...inProgress, - ...pending, - ...olderCompleted, - ] - visibleTasks = prioritized.slice(0, maxDisplay) - hiddenTasks = prioritized.slice(maxDisplay) + const prioritized = [...recentCompleted, ...inProgress, ...pending, ...olderCompleted]; + visibleTasks = prioritized.slice(0, maxDisplay); + hiddenTasks = prioritized.slice(maxDisplay); } else { // No truncation needed — sort by ID for stable ordering - visibleTasks = [...tasks].sort(byIdAsc) - hiddenTasks = [] + visibleTasks = [...tasks].sort(byIdAsc); + hiddenTasks = []; } - let hiddenSummary = '' + let hiddenSummary = ''; if (hiddenTasks.length > 0) { - const parts: string[] = [] - const hiddenPending = count(hiddenTasks, t => t.status === 'pending') - const hiddenInProgress = count(hiddenTasks, t => t.status === 'in_progress') - const hiddenCompleted = count(hiddenTasks, t => t.status === 'completed') + const parts: string[] = []; + const hiddenPending = count(hiddenTasks, t => t.status === 'pending'); + const hiddenInProgress = count(hiddenTasks, t => t.status === 'in_progress'); + const hiddenCompleted = count(hiddenTasks, t => t.status === 'completed'); if (hiddenInProgress > 0) { - parts.push(`${hiddenInProgress} in progress`) + parts.push(`${hiddenInProgress} in progress`); } if (hiddenPending > 0) { - parts.push(`${hiddenPending} pending`) + parts.push(`${hiddenPending} pending`); } if (hiddenCompleted > 0) { - parts.push(`${hiddenCompleted} completed`) + parts.push(`${hiddenCompleted} completed`); } - hiddenSummary = ` … +${parts.join(', ')}` + hiddenSummary = ` … +${parts.join(', ')}`; } const content = ( @@ -230,7 +208,7 @@ export function TaskListV2({ ))} {maxDisplay > 0 && hiddenSummary && {hiddenSummary}} - ) + ); if (isStandalone) { return ( @@ -253,102 +231,90 @@ export function TaskListV2({ {content} - ) + ); } - return {content} + return {content}; } type TaskItemProps = { - task: Task - ownerColor?: keyof Theme - openBlockers: string[] - activity?: string - ownerActive: boolean - columns: number -} + task: Task; + ownerColor?: keyof Theme; + openBlockers: string[]; + activity?: string; + ownerActive: boolean; + columns: number; +}; function getTaskIcon(status: Task['status']): { - icon: string - color: keyof Theme | undefined + icon: string; + color: keyof Theme | undefined; } { switch (status) { case 'completed': - return { icon: figures.tick, color: 'success' } + return { icon: figures.tick, color: 'success' }; case 'in_progress': - return { icon: figures.squareSmallFilled, color: 'claude' } + return { icon: figures.squareSmallFilled, color: 'claude' }; case 'pending': - return { icon: figures.squareSmall, color: undefined } + return { icon: figures.squareSmall, color: undefined }; } } -function TaskItem({ - task, - ownerColor, - openBlockers, - activity, - ownerActive, - columns, -}: TaskItemProps): React.ReactNode { - const isCompleted = task.status === 'completed' - const isInProgress = task.status === 'in_progress' - const isBlocked = openBlockers.length > 0 +function TaskItem({ task, ownerColor, openBlockers, activity, ownerActive, columns }: TaskItemProps): React.ReactNode { + const isCompleted = task.status === 'completed'; + const isInProgress = task.status === 'in_progress'; + const isBlocked = openBlockers.length > 0; - const { icon, color } = getTaskIcon(task.status) + const { icon, color } = getTaskIcon(task.status); - const showActivity = isInProgress && !isBlocked && activity + const showActivity = isInProgress && !isBlocked && activity; // Responsive layout: hide owner on narrow screens (<60 cols) - // Truncate subject based on available space - const showOwner = columns >= 60 && task.owner && ownerActive - const ownerWidth = showOwner ? stringWidth(` (@${task.owner})`) : 0 - // Account for: icon(2) + indentation(~8 when nested under spinner) + owner + safety - // Use columns - 15 as a conservative estimate for nested layouts - const maxSubjectWidth = Math.max(15, columns - 15 - ownerWidth) - const displaySubject = truncateToWidth(task.subject, maxSubjectWidth) + const showOwner = columns >= 60 && task.owner && ownerActive; // Truncate activity for narrow screens - const maxActivityWidth = Math.max(15, columns - 15) - const displayActivity = activity - ? truncateToWidth(activity, maxActivityWidth) - : undefined + const maxActivityWidth = Math.max(15, columns - 15); + const displayActivity = activity ? truncateToWidth(activity, maxActivityWidth) : undefined; + + const blockedByText = isBlocked + ? [...openBlockers] + .sort((a, b) => parseInt(a, 10) - parseInt(b, 10)) + .map(id => `#${id}`) + .join(', ') + : null; return ( - - - {icon} - - {displaySubject} - - {showOwner && ( - - {' ('} - {ownerColor ? ( - @{task.owner} - ) : ( - `@${task.owner}` - )} - {')'} + + + + {icon} + + + + {task.subject} + + {showOwner && ( + + + {'('} + {ownerColor ? @{task.owner} : `@${task.owner}`} + {')'} + + )} - {isBlocked && ( + + {isBlocked && blockedByText && ( + - {' '} - {figures.pointerSmall} blocked by{' '} - {[...openBlockers] - .sort((a, b) => parseInt(a, 10) - parseInt(b, 10)) - .map(id => `#${id}`) - .join(', ')} + {' '} + {figures.pointerSmall} blocked by {blockedByText} - )} - + + )} {showActivity && displayActivity && ( - - + + {' '} {displayActivity} {figures.ellipsis} @@ -356,5 +322,5 @@ function TaskItem({ )} - ) + ); } diff --git a/src/components/messages/AssistantToolUseMessage.tsx b/src/components/messages/AssistantToolUseMessage.tsx index 8f2dd965b..5042a2b70 100644 --- a/src/components/messages/AssistantToolUseMessage.tsx +++ b/src/components/messages/AssistantToolUseMessage.tsx @@ -1,41 +1,38 @@ -import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' -import React, { useMemo } from 'react' -import { useTerminalSize } from 'src/hooks/useTerminalSize.js' -import type { ThemeName } from 'src/utils/theme.js' -import type { Command } from '../../commands.js' -import { BLACK_CIRCLE } from '../../constants/figures.js' -import { Box, Text, stringWidth, useTheme } from '@anthropic/ink' -import { useAppStateMaybeOutsideOfProvider } from '../../state/AppState.js' -import { - findToolByName, - type Tool, - type ToolProgressData, - type Tools, -} from '../../Tool.js' -import type { ProgressMessage } from '../../types/message.js' -import { useIsClassifierChecking } from '../../utils/classifierApprovalsHook.js' -import { logError } from '../../utils/log.js' -import type { buildMessageLookups } from '../../utils/messages.js' -import { MessageResponse } from '../MessageResponse.js' -import { useSelectedMessageBg } from '../messageActions.js' -import { SentryErrorBoundary } from '../SentryErrorBoundary.js' -import { ToolUseLoader } from '../ToolUseLoader.js' -import { HookProgressMessage } from './HookProgressMessage.js' +import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import React, { useMemo } from 'react'; +import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; +import type { ThemeName } from 'src/utils/theme.js'; +import type { Command } from '../../commands.js'; +import { BLACK_CIRCLE } from '../../constants/figures.js'; +import { Box, Text, stringWidth, useTheme } from '@anthropic/ink'; +import { useAppStateMaybeOutsideOfProvider } from '../../state/AppState.js'; +import { findToolByName, type Tool, type ToolProgressData, type Tools } from '../../Tool.js'; +import type { ProgressMessage } from '../../types/message.js'; +import { useIsClassifierChecking } from '../../utils/classifierApprovalsHook.js'; +import { logError } from '../../utils/log.js'; +import type { buildMessageLookups } from '../../utils/messages.js'; +import { MessageResponse } from '../MessageResponse.js'; +import { renderMultilineCommandLines } from '../../tools/BashTool/UI.js'; +import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'; +import { useSelectedMessageBg } from '../messageActions.js'; +import { SentryErrorBoundary } from '../SentryErrorBoundary.js'; +import { ToolUseLoader } from '../ToolUseLoader.js'; +import { HookProgressMessage } from './HookProgressMessage.js'; type Props = { - param: ToolUseBlockParam - addMargin: boolean - tools: Tools - commands: Command[] - verbose: boolean - inProgressToolUseIDs: Set - progressMessagesForMessage: ProgressMessage[] - shouldAnimate: boolean - shouldShowDot: boolean - inProgressToolCallCount?: number - lookups: ReturnType - isTranscriptMode?: boolean -} + param: ToolUseBlockParam; + addMargin: boolean; + tools: Tools; + commands: Command[]; + verbose: boolean; + inProgressToolUseIDs: Set; + progressMessagesForMessage: ProgressMessage[]; + shouldAnimate: boolean; + shouldShowDot: boolean; + inProgressToolCallCount?: number; + lookups: ReturnType; + isTranscriptMode?: boolean; +}; export function AssistantToolUseMessage({ param, @@ -51,29 +48,21 @@ export function AssistantToolUseMessage({ lookups, isTranscriptMode, }: Props): React.ReactNode { - const terminalSize = useTerminalSize() - const [theme] = useTheme() - const bg = useSelectedMessageBg() - const pendingWorkerRequest = useAppStateMaybeOutsideOfProvider( - state => state.pendingWorkerRequest, - ) - const isClassifierCheckingRaw = useIsClassifierChecking(param.id) - const permissionMode = useAppStateMaybeOutsideOfProvider( - state => state.toolPermissionContext.mode, - ) + const terminalSize = useTerminalSize(); + const [theme] = useTheme(); + const bg = useSelectedMessageBg(); + const pendingWorkerRequest = useAppStateMaybeOutsideOfProvider(state => state.pendingWorkerRequest); + const isClassifierCheckingRaw = useIsClassifierChecking(param.id); + const permissionMode = useAppStateMaybeOutsideOfProvider(state => state.toolPermissionContext.mode); // strippedDangerousRules is set by stripDangerousPermissionsForAutoMode // (even to {}) whenever auto is active, and cleared by restoreDangerousPermissions // on deactivation — a reliable proxy for isAutoModeActive() during plan. // prePlanMode would be stale after transitionPlanAutoMode deactivates mid-plan. const hasStrippedRules = useAppStateMaybeOutsideOfProvider( state => !!state.toolPermissionContext.strippedDangerousRules, - ) - const isAutoClassifier = - permissionMode === 'auto' || (permissionMode === 'plan' && hasStrippedRules) - const isClassifierChecking = - process.env.USER_TYPE === 'ant' && - isClassifierCheckingRaw && - permissionMode !== 'auto' + ); + const isAutoClassifier = permissionMode === 'auto' || (permissionMode === 'plan' && hasStrippedRules); + const isClassifierChecking = process.env.USER_TYPE === 'ant' && isClassifierCheckingRaw && permissionMode !== 'auto'; // Memoize on param identity (stable — from the persisted message object). // Zod safeParse allocates per call, and some tools' userFacingName() @@ -81,72 +70,63 @@ export function AssistantToolUseMessage({ // this, ~50 bash messages × shell-quote-per-render pushed transition // render past the shimmer tick → abort → infinite retry (#21605). const parsed = useMemo(() => { - if (!tools) return null - const tool = findToolByName(tools, param.name) - if (!tool) return null - const input = tool.inputSchema.safeParse(param.input) - const data = input.success ? input.data : undefined + if (!tools) return null; + const tool = findToolByName(tools, param.name); + if (!tool) return null; + const input = tool.inputSchema.safeParse(param.input); + const data = input.success ? input.data : undefined; return { tool, input, userFacingToolName: tool.userFacingName(data), - userFacingToolNameBackgroundColor: - tool.userFacingNameBackgroundColor?.(data), + userFacingToolNameBackgroundColor: tool.userFacingNameBackgroundColor?.(data), isTransparentWrapper: tool.isTransparentWrapper?.() ?? false, - } - }, [tools, param]) + }; + }, [tools, param]); if (!parsed) { // Guard against undefined tools (required prop) or unknown tool name - logError( - new Error( - tools - ? `Tool ${param.name} not found` - : `Tools array is undefined for tool ${param.name}`, - ), - ) - return null + logError(new Error(tools ? `Tool ${param.name} not found` : `Tools array is undefined for tool ${param.name}`)); + return null; } - const { - tool, - input, - userFacingToolName, - userFacingToolNameBackgroundColor, - isTransparentWrapper, - } = parsed + const { tool, input, userFacingToolName, userFacingToolNameBackgroundColor, isTransparentWrapper } = parsed; - const isResolved = lookups.resolvedToolUseIDs.has(param.id) - const isQueued = !inProgressToolUseIDs.has(param.id) && !isResolved - const isWaitingForPermission = pendingWorkerRequest?.toolUseId === param.id + const isResolved = lookups.resolvedToolUseIDs.has(param.id); + const isQueued = !inProgressToolUseIDs.has(param.id) && !isResolved; + const isWaitingForPermission = pendingWorkerRequest?.toolUseId === param.id; if (isTransparentWrapper) { - if (isQueued || isResolved) return null + if (isQueued || isResolved) return null; return ( - {renderToolUseProgressMessage( - tool, - tools, - lookups, - param.id, - progressMessagesForMessage, - { verbose, inProgressToolCallCount, isTranscriptMode }, - terminalSize, - )} + - ) + ); } if (userFacingToolName === '') { - return null + return null; } const renderedToolUseMessage = input.success ? renderToolUseMessage(tool, input.data, { theme, verbose, commands }) - : null + : null; if (renderedToolUseMessage === null) { - return null + return null; } + const renderedToolUseMessageIsMultiline = + tool.name === BASH_TOOL_NAME && typeof renderedToolUseMessage === 'string' && renderedToolUseMessage.includes('\n'); return ( - + {shouldShowDot && (isQueued ? ( @@ -182,31 +158,28 @@ export function AssistantToolUseMessage({ bold wrap="truncate-end" backgroundColor={userFacingToolNameBackgroundColor} - color={ - userFacingToolNameBackgroundColor ? 'inverseText' : undefined - } + color={userFacingToolNameBackgroundColor ? 'inverseText' : undefined} > {userFacingToolName} - {renderedToolUseMessage !== '' && ( + {renderedToolUseMessage !== '' && !renderedToolUseMessageIsMultiline && ( ({renderedToolUseMessage}) )} {/* Render tool-specific tags (timeout, model, resume ID, etc.) */} - {input.success && - tool.renderToolUseTag && - tool.renderToolUseTag(input.data)} + {input.success && tool.renderToolUseTag && tool.renderToolUseTag(input.data)} + {renderedToolUseMessage !== '' && renderedToolUseMessageIsMultiline && ( + {renderMultilineCommandLines(renderedToolUseMessage)} + )} {!isResolved && !isQueued && (isClassifierChecking ? ( - {isAutoClassifier - ? 'Auto classifier checking\u2026' - : 'Bash classifier checking\u2026'} + {isAutoClassifier ? 'Auto classifier checking\u2026' : 'Bash classifier checking\u2026'} ) : isWaitingForPermission ? ( @@ -214,112 +187,97 @@ export function AssistantToolUseMessage({ Waiting for permission… ) : ( - renderToolUseProgressMessage( - tool, - tools, - lookups, - param.id, - progressMessagesForMessage, - { - verbose, - inProgressToolCallCount, - isTranscriptMode, - }, - terminalSize, - ) + ))} {!isResolved && isQueued && renderToolUseQueuedMessage(tool)} - ) + ); } function renderToolUseMessage( tool: Tool, input: unknown, - { - theme, - verbose, - commands, - }: { theme: ThemeName; verbose: boolean; commands: Command[] }, + { theme, verbose, commands }: { theme: ThemeName; verbose: boolean; commands: Command[] }, ): React.ReactNode { try { - const parsed = tool.inputSchema.safeParse(input) - if (!parsed.success) { - return '' - } - return tool.renderToolUseMessage(parsed.data, { theme, verbose, commands }) + return tool.renderToolUseMessage(input as never, { theme, verbose, commands }); } catch (error) { - logError( - new Error(`Error rendering tool use message for ${tool.name}: ${error}`), - ) - return '' + logError(new Error(`Error rendering tool use message for ${tool.name}: ${error}`)); + return ''; } } -function renderToolUseProgressMessage( - tool: Tool, - tools: Tools, - lookups: ReturnType, - toolUseID: string, - progressMessagesForMessage: ProgressMessage[], - { - verbose, - inProgressToolCallCount, - isTranscriptMode, - }: { - verbose: boolean - inProgressToolCallCount?: number - isTranscriptMode?: boolean - }, - terminalSize: { columns: number; rows: number }, -): React.ReactNode { +function ToolUseProgressRenderer({ + tool, + tools, + lookups, + toolUseID, + progressMessagesForMessage, + verbose, + inProgressToolCallCount, + isTranscriptMode, + terminalSize, +}: { + tool: Tool; + tools: Tools; + lookups: ReturnType; + toolUseID: string; + progressMessagesForMessage: ProgressMessage[]; + verbose: boolean; + inProgressToolCallCount?: number; + isTranscriptMode?: boolean; + terminalSize: { columns: number; rows: number }; +}): React.ReactNode { const toolProgressMessages = progressMessagesForMessage.filter( - (msg): msg is ProgressMessage => - msg.data.type !== 'hook_progress', - ) + (msg): msg is ProgressMessage => msg.data.type !== 'hook_progress', + ); + let toolMessages: React.ReactNode = null; try { - const toolMessages = + toolMessages = tool.renderToolUseProgressMessage?.(toolProgressMessages, { tools, verbose, terminalSize, inProgressToolCallCount: inProgressToolCallCount ?? 1, isTranscriptMode, - }) ?? null - return ( - <> - - - - {toolMessages} - - ) + }) ?? null; } catch (error) { - logError( - new Error( - `Error rendering tool use progress message for ${tool.name}: ${error}`, - ), - ) - return null + logError(new Error(`Error rendering tool use progress message for ${tool.name}: ${error}`)); + toolMessages = null; } + + return ( + <> + + + + {toolMessages} + + ); } function renderToolUseQueuedMessage(tool: Tool): React.ReactNode { try { - return tool.renderToolUseQueuedMessage?.() + return tool.renderToolUseQueuedMessage?.(); } catch (error) { - logError( - new Error( - `Error rendering tool use queued message for ${tool.name}: ${error}`, - ), - ) - return null + logError(new Error(`Error rendering tool use queued message for ${tool.name}: ${error}`)); + return null; } } diff --git a/src/components/messages/GroupedToolUseContent.tsx b/src/components/messages/GroupedToolUseContent.tsx index 2376e377c..3c763d562 100644 --- a/src/components/messages/GroupedToolUseContent.tsx +++ b/src/components/messages/GroupedToolUseContent.tsx @@ -1,23 +1,17 @@ -import type { - ToolResultBlockParam, - ToolUseBlockParam, -} from '@anthropic-ai/sdk/resources/messages/messages.mjs' -import * as React from 'react' -import { - filterToolProgressMessages, - findToolByName, - type Tools, -} from '../../Tool.js' -import type { GroupedToolUseMessage } from '../../types/message.js' -import type { buildMessageLookups } from '../../utils/messages.js' +import type { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs'; +import * as React from 'react'; +import { filterToolProgressMessages, findToolByName, type Tools } from '../../Tool.js'; +import { parseToolResultOutput } from './UserToolResultMessage/parseToolResultOutput.js'; +import type { GroupedToolUseMessage } from '../../types/message.js'; +import type { buildMessageLookups } from '../../utils/messages.js'; type Props = { - message: GroupedToolUseMessage - tools: Tools - lookups: ReturnType - inProgressToolUseIDs: Set - shouldAnimate: boolean -} + message: GroupedToolUseMessage; + tools: Tools; + lookups: ReturnType; + inProgressToolUseIDs: Set; + shouldAnimate: boolean; +}; export function GroupedToolUseContent({ message, @@ -26,46 +20,41 @@ export function GroupedToolUseContent({ inProgressToolUseIDs, shouldAnimate, }: Props): React.ReactNode { - const tool = findToolByName(tools, message.toolName) + const tool = findToolByName(tools, message.toolName); if (!tool?.renderGroupedToolUse) { - return null + return null; } // Build a map from tool_use_id to result data - const resultsByToolUseId = new Map< - string, - { param: ToolResultBlockParam; output: unknown } - >() + const resultsByToolUseId = new Map(); for (const resultMsg of message.results) { for (const content of resultMsg.message.content) { if (content.type === 'tool_result') { resultsByToolUseId.set(content.tool_use_id, { param: content, - output: resultMsg.toolUseResult, - }) + output: parseToolResultOutput(tool, resultMsg.toolUseResult), + }); } } } const toolUsesData = message.messages.map(msg => { - const content = msg.message.content[0] - const result = resultsByToolUseId.get(content.id) + const content = msg.message.content[0]; + const result = resultsByToolUseId.get(content.id); return { param: content as ToolUseBlockParam, isResolved: lookups.resolvedToolUseIDs.has(content.id), isError: lookups.erroredToolUseIDs.has(content.id), isInProgress: inProgressToolUseIDs.has(content.id), - progressMessages: filterToolProgressMessages( - lookups.progressMessagesByToolUseID.get(content.id) ?? [], - ), + progressMessages: filterToolProgressMessages(lookups.progressMessagesByToolUseID.get(content.id) ?? []), result, - } - }) + }; + }); - const anyInProgress = toolUsesData.some(d => d.isInProgress) + const anyInProgress = toolUsesData.some(d => d.isInProgress); return tool.renderGroupedToolUse(toolUsesData, { shouldAnimate: shouldAnimate && anyInProgress, tools, - }) + }); } diff --git a/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx index ffb8e23d5..fc7a05c8e 100644 --- a/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx +++ b/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx @@ -1,39 +1,33 @@ -import { feature } from 'bun:bundle' -import figures from 'figures' -import * as React from 'react' -import { SentryErrorBoundary } from 'src/components/SentryErrorBoundary.js' -import { Box, Text, useTheme } from '@anthropic/ink' -import { useAppState } from '../../../state/AppState.js' -import { - filterToolProgressMessages, - type Tool, - type Tools, -} from '../../../Tool.js' -import type { - NormalizedUserMessage, - ProgressMessage, -} from '../../../types/message.js' +import { feature } from 'bun:bundle'; +import figures from 'figures'; +import * as React from 'react'; +import { SentryErrorBoundary } from 'src/components/SentryErrorBoundary.js'; +import { Box, Text, useTheme } from '@anthropic/ink'; +import { useAppState } from '../../../state/AppState.js'; +import { filterToolProgressMessages, type Tool, type Tools } from '../../../Tool.js'; +import type { NormalizedUserMessage, ProgressMessage } from '../../../types/message.js'; import { deleteClassifierApproval, getClassifierApproval, getYoloClassifierApproval, -} from '../../../utils/classifierApprovals.js' -import type { buildMessageLookups } from '../../../utils/messages.js' -import { MessageResponse } from '../../MessageResponse.js' -import { HookProgressMessage } from '../HookProgressMessage.js' +} from '../../../utils/classifierApprovals.js'; +import type { buildMessageLookups } from '../../../utils/messages.js'; +import { MessageResponse } from '../../MessageResponse.js'; +import { HookProgressMessage } from '../HookProgressMessage.js'; +import { validateToolResultOutput } from './parseToolResultOutput.js'; type Props = { - message: NormalizedUserMessage - lookups: ReturnType - toolUseID: string - progressMessagesForMessage: ProgressMessage[] - style?: 'condensed' - tool?: Tool - tools: Tools - verbose: boolean - width: number | string - isTranscriptMode?: boolean -} + message: NormalizedUserMessage; + lookups: ReturnType; + toolUseID: string; + progressMessagesForMessage: ProgressMessage[]; + style?: 'condensed'; + tool?: Tool; + tools: Tools; + verbose: boolean; + width: number | string; + isTranscriptMode?: boolean; +}; export function UserToolSuccessMessage({ message, @@ -47,74 +41,59 @@ export function UserToolSuccessMessage({ width, isTranscriptMode, }: Props): React.ReactNode { - const [theme] = useTheme() + const [theme] = useTheme(); // Hook stays inside feature() ternary so external builds don't pay a // per-scrollback-message store subscription — same pattern as // UserPromptMessage.tsx. - const isBriefOnly = - feature('KAIROS') || feature('KAIROS_BRIEF') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.isBriefOnly) - : false + const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false; // Capture classifier approval once on mount, then delete from Map to prevent linear growth. // useState lazy initializer ensures the value persists across re-renders. - const [classifierRule] = React.useState(() => - getClassifierApproval(toolUseID), - ) - const [yoloReason] = React.useState(() => - getYoloClassifierApproval(toolUseID), - ) + const [classifierRule] = React.useState(() => getClassifierApproval(toolUseID)); + const [yoloReason] = React.useState(() => getYoloClassifierApproval(toolUseID)); React.useEffect(() => { - deleteClassifierApproval(toolUseID) - }, [toolUseID]) + deleteClassifierApproval(toolUseID); + }, [toolUseID]); if (!message.toolUseResult || !tool) { - return null + return null; } // Resumed transcripts deserialize toolUseResult via raw JSON.parse with no // validation (parseJSONL). A partial/corrupt/old-format result crashes // renderToolResultMessage on first field access (anthropics/claude-code#39817). // Validate against outputSchema before rendering — mirrors CollapsedReadSearchContent. - const parsedOutput = tool.outputSchema?.safeParse(message.toolUseResult) - if (parsedOutput && !parsedOutput.success) { - return null + const validatedOutput = validateToolResultOutput(tool, message.toolUseResult); + if (!validatedOutput.success) { + return null; } - const toolResult = parsedOutput?.data ?? message.toolUseResult + const toolResult = validatedOutput.output; const renderedMessage = - tool.renderToolResultMessage?.( - toolResult as never, - filterToolProgressMessages(progressMessagesForMessage), - { - style, - theme, - tools, - verbose, - isTranscriptMode, - isBriefOnly, - input: lookups.toolUseByToolUseID.get(toolUseID)?.input, - }, - ) ?? null + tool.renderToolResultMessage?.(toolResult as never, filterToolProgressMessages(progressMessagesForMessage), { + style, + theme, + tools, + verbose, + isTranscriptMode, + isBriefOnly, + input: lookups.toolUseByToolUseID.get(toolUseID)?.input, + }) ?? null; // Don't render anything if the tool result message is null if (renderedMessage === null) { - return null + return null; } // Tools that return '' from userFacingName opt out of tool chrome and // render like plain assistant text. Skip the tool-result width constraint // so MarkdownTable's SAFETY_MARGIN=4 (tuned for the assistant-text 2-col // dot gutter) holds — otherwise tables wrap their box-drawing chars. - const rendersAsAssistantText = tool.userFacingName(undefined) === '' + const rendersAsAssistantText = tool.userFacingName(undefined) === ''; return ( - + {renderedMessage} {feature('BASH_CLASSIFIER') ? classifierRule && ( @@ -145,5 +124,5 @@ export function UserToolSuccessMessage({ /> - ) + ); } diff --git a/src/components/messages/UserToolResultMessage/parseToolResultOutput.ts b/src/components/messages/UserToolResultMessage/parseToolResultOutput.ts new file mode 100644 index 000000000..9e29654aa --- /dev/null +++ b/src/components/messages/UserToolResultMessage/parseToolResultOutput.ts @@ -0,0 +1,23 @@ +import type { Tool } from '../../../Tool.js' + +export function validateToolResultOutput( + tool: Tool, + toolUseResult: unknown, +): { success: true; output: unknown } | { success: false } { + const parsedOutput = tool.outputSchema?.safeParse(toolUseResult) + if (parsedOutput && !parsedOutput.success) { + return { success: false } + } + return { + success: true, + output: parsedOutput?.data ?? toolUseResult, + } +} + +export function parseToolResultOutput( + tool: Tool, + toolUseResult: unknown, +): unknown { + const validated = validateToolResultOutput(tool, toolUseResult) + return validated.success ? validated.output : toolUseResult +} diff --git a/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx b/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx index fb2c06da9..b91076db0 100644 --- a/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx +++ b/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx @@ -11,6 +11,7 @@ import { import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js' import { useAppState } from '../../../state/AppState.js' import { BashTool } from '../../../tools/BashTool/BashTool.js' +import { renderMultilineCommandLines } from '../../../tools/BashTool/UI.js' import { getFirstWordPrefix, getSimpleCommandPrefix, @@ -516,12 +517,7 @@ function BashPermissionRequestInner({ subtitle={classifierSubtitle} > - - {BashTool.renderToolUseMessage( - { command, description }, - { theme, verbose: true }, // always show the full command - )} - + {renderMultilineCommandLines(command, explainerState.visible)} {!explainerState.visible && ( {toolUseConfirm.description} )} diff --git a/src/components/shell/ShellProgressMessage.tsx b/src/components/shell/ShellProgressMessage.tsx index a99bdbd0d..cadab27ba 100644 --- a/src/components/shell/ShellProgressMessage.tsx +++ b/src/components/shell/ShellProgressMessage.tsx @@ -39,11 +39,13 @@ export function ShellProgressMessage({ return ( - Running… - + + Running… + + ) @@ -62,15 +64,16 @@ export function ShellProgressMessage({ return ( - + {displayLines} - + {lineStatus ? {lineStatus} : null} { + test("renders progress metadata on its own line", async () => { + const output = await renderToString( + , + 80, + ); + + const lines = output.split("\n").map(line => line.trimEnd()); + const passLine = lines.find(line => line.includes("3 pass")); + const metaLine = lines.find(line => line.includes("+3 lines")); + + expect(passLine).toBeString(); + expect(metaLine).toBeString(); + expect(passLine).not.toContain("+3 lines"); + }); +}); diff --git a/src/hooks/useVoice.ts b/src/hooks/useVoice.ts index 0ac154e37..0bf441c8f 100644 --- a/src/hooks/useVoice.ts +++ b/src/hooks/useVoice.ts @@ -984,11 +984,9 @@ export function useVoice({ return } if (!conn) { - logForDebugging( - '[voice] Failed to connect to voice_stream (no OAuth token?)', - ) + logForDebugging('[voice] Failed to connect to voice STT provider') onErrorRef.current?.( - 'Voice mode requires a Claude.ai account. Please run /login to sign in.', + 'Voice mode is unavailable. Configure Claude.ai OAuth or a supported API key provider and try again.', ) // Clear the audio buffer on failure audioBuffer.length = 0 diff --git a/src/hooks/useVoiceEnabled.ts b/src/hooks/useVoiceEnabled.ts index ece06913f..c43aa19b2 100644 --- a/src/hooks/useVoiceEnabled.ts +++ b/src/hooks/useVoiceEnabled.ts @@ -1,25 +1,26 @@ import { useMemo } from 'react' import { useAppState } from '../state/AppState.js' import { - hasVoiceAuth, + hasAvailableVoiceProvider, isVoiceGrowthBookEnabled, } from '../voice/voiceModeEnabled.js' /** - * Combines user intent (settings.voiceEnabled) with auth + GB kill-switch. - * Only the auth half is memoized on authVersion — it's the expensive one - * (cold getClaudeAIOAuthTokens memoize → sync `security` spawn, ~60ms/call, - * ~180ms total in profile v5 when token refresh cleared the cache mid-session). - * GB is a cheap cached-map lookup and stays outside the memo so a mid-session - * kill-switch flip still takes effect on the next render. + * Combines user intent (settings.voiceEnabled) with provider availability + GB + * kill-switch. Only the provider half is memoized on authVersion — it's the + * expensive one (cold getClaudeAIOAuthTokens memoize → sync `security` spawn, + * ~60ms/call, ~180ms total in profile v5 when token refresh cleared the cache + * mid-session). GB is a cheap cached-map lookup and stays outside the memo so a + * mid-session kill-switch flip still takes effect on the next render. * * authVersion bumps on /login only. Background token refresh leaves it alone - * (user is still authed), so the auth memo stays correct without re-eval. + * (the same provider availability still applies), so the memo stays correct + * without re-eval. */ export function useVoiceEnabled(): boolean { const userIntent = useAppState(s => s.settings.voiceEnabled === true) const authVersion = useAppState(s => s.authVersion) // eslint-disable-next-line react-hooks/exhaustive-deps - const authed = useMemo(hasVoiceAuth, [authVersion]) - return userIntent && authed && isVoiceGrowthBookEnabled() + const providerAvailable = useMemo(hasAvailableVoiceProvider, [authVersion]) + return userIntent && providerAvailable && isVoiceGrowthBookEnabled() } diff --git a/src/services/__tests__/voiceStreamSTT.test.ts b/src/services/__tests__/voiceStreamSTT.test.ts new file mode 100644 index 000000000..539f188f2 --- /dev/null +++ b/src/services/__tests__/voiceStreamSTT.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + connectVoiceStream, + getVoiceSttProvider, + isVoiceStreamAvailable, + voiceStreamSttInternals, +} from '../voiceStreamSTT.js' + +let mockedOpenAICalls: Array> = [] + +describe('voiceStreamSTT', () => { + const originalInternals = { + getAPIProvider: voiceStreamSttInternals.getAPIProvider, + isAnthropicAuthEnabled: voiceStreamSttInternals.isAnthropicAuthEnabled, + getClaudeAIOAuthTokens: voiceStreamSttInternals.getClaudeAIOAuthTokens, + getOpenAIApiKey: voiceStreamSttInternals.getOpenAIApiKey, + getOpenAIBaseURL: voiceStreamSttInternals.getOpenAIBaseURL, + getOpenAITranscriptionModel: voiceStreamSttInternals.getOpenAITranscriptionModel, + createOpenAIClient: voiceStreamSttInternals.createOpenAIClient, + toUploadFile: voiceStreamSttInternals.toUploadFile, + } + + beforeEach(() => { + mockedOpenAICalls = [] + voiceStreamSttInternals.getAPIProvider = () => 'firstParty' + voiceStreamSttInternals.isAnthropicAuthEnabled = () => false + voiceStreamSttInternals.getClaudeAIOAuthTokens = () => null + voiceStreamSttInternals.getOpenAIApiKey = () => '' + voiceStreamSttInternals.getOpenAIBaseURL = () => undefined + voiceStreamSttInternals.getOpenAITranscriptionModel = () => + 'gpt-4o-mini-transcribe' + voiceStreamSttInternals.createOpenAIClient = () => + ({ + audio: { + transcriptions: { + create: async (params: Record) => { + mockedOpenAICalls.push(params) + return { text: 'hello world' } + }, + }, + }, + }) as never + voiceStreamSttInternals.toUploadFile = async ( + data: Buffer, + name: string, + options?: Record, + ) => ({ + data, + name, + options, + }) as never + }) + + afterEach(() => { + voiceStreamSttInternals.getAPIProvider = originalInternals.getAPIProvider + voiceStreamSttInternals.isAnthropicAuthEnabled = + originalInternals.isAnthropicAuthEnabled + voiceStreamSttInternals.getClaudeAIOAuthTokens = + originalInternals.getClaudeAIOAuthTokens + voiceStreamSttInternals.getOpenAIApiKey = originalInternals.getOpenAIApiKey + voiceStreamSttInternals.getOpenAIBaseURL = + originalInternals.getOpenAIBaseURL + voiceStreamSttInternals.getOpenAITranscriptionModel = + originalInternals.getOpenAITranscriptionModel + voiceStreamSttInternals.createOpenAIClient = + originalInternals.createOpenAIClient + voiceStreamSttInternals.toUploadFile = originalInternals.toUploadFile + }) + + test.serial('returns openai when OPENAI_API_KEY is configured', () => { + voiceStreamSttInternals.getAPIProvider = () => 'openai' + voiceStreamSttInternals.getOpenAIApiKey = () => 'sk-test' + + expect(getVoiceSttProvider()).toBe('openai') + expect(isVoiceStreamAvailable()).toBe(true) + }) + + test.serial('returns null when no supported provider is configured', () => { + voiceStreamSttInternals.getAPIProvider = () => 'firstParty' + voiceStreamSttInternals.getOpenAIApiKey = () => '' + voiceStreamSttInternals.isAnthropicAuthEnabled = () => false + voiceStreamSttInternals.getClaudeAIOAuthTokens = () => null + + expect(getVoiceSttProvider()).toBeNull() + expect(isVoiceStreamAvailable()).toBe(false) + }) + + test.serial('creates transcription from buffered audio for api key provider', async () => { + voiceStreamSttInternals.getAPIProvider = () => 'openai' + voiceStreamSttInternals.getOpenAIApiKey = () => 'sk-test' + voiceStreamSttInternals.getOpenAITranscriptionModel = () => + 'gpt-4o-mini-transcribe' + + const transcripts: string[] = [] + let readyCalled = false + const connection = await connectVoiceStream( + { + onTranscript: (text, isFinal) => { + if (isFinal) transcripts.push(text) + }, + onError: message => { + throw new Error(message) + }, + onClose: () => {}, + onReady: () => { + readyCalled = true + }, + }, + { language: 'en', keyterms: ['claude'] }, + ) + + expect(connection).not.toBeNull() + expect(readyCalled).toBe(true) + + connection!.send(Buffer.from('pcm-audio')) + const source = await connection!.finalize() + + expect(source).toBe('post_closestream_endpoint') + expect(transcripts).toEqual(['hello world']) + expect(mockedOpenAICalls).toHaveLength(1) + expect(mockedOpenAICalls[0]?.model).toBe('gpt-4o-mini-transcribe') + expect(mockedOpenAICalls[0]?.language).toBe('en') + expect(mockedOpenAICalls[0]?.prompt).toContain('claude') + const uploadedFile = mockedOpenAICalls[0]?.file as { + data: Buffer + name: string + options?: { type?: string } + } + expect(uploadedFile.name).toBe('voice-input.wav') + expect(uploadedFile.options?.type).toBe('audio/wav') + expect(uploadedFile.data.subarray(0, 4).toString()).toBe('RIFF') + expect(uploadedFile.data.subarray(8, 12).toString()).toBe('WAVE') + }) +}) diff --git a/src/services/api/grok/__tests__/client.test.ts b/src/services/api/grok/__tests__/client.test.ts index 13f199fb7..2eea12d0d 100644 --- a/src/services/api/grok/__tests__/client.test.ts +++ b/src/services/api/grok/__tests__/client.test.ts @@ -15,17 +15,38 @@ describe('getGrokClient', () => { process.env = { ...originalEnv } }) - test('creates client with default base URL', () => { - const client = getGrokClient() + test('creates client with default base URL', async () => { + let requestedUrl = '' + const client = getGrokClient({ + fetchOverride: async input => { + requestedUrl = String(input) + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }, + }) expect(client).toBeDefined() - expect(client.baseURL).toBe('https://api.x.ai/v1') + + await client.models.list() + expect(requestedUrl).toStartWith('https://api.x.ai/v1/models') }) - test('uses GROK_BASE_URL when set', () => { + test('uses GROK_BASE_URL when set', async () => { process.env.GROK_BASE_URL = 'https://custom.grok.api/v1' - clearGrokClientCache() - const client = getGrokClient() - expect(client.baseURL).toBe('https://custom.grok.api/v1') + let requestedUrl = '' + const client = getGrokClient({ + fetchOverride: async input => { + requestedUrl = String(input) + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }, + }) + + await client.models.list() + expect(requestedUrl).toStartWith('https://custom.grok.api/v1/models') }) test('returns cached client on second call', () => { diff --git a/src/services/voiceStreamSTT.ts b/src/services/voiceStreamSTT.ts index e1c86f78f..0a20e69e8 100644 --- a/src/services/voiceStreamSTT.ts +++ b/src/services/voiceStreamSTT.ts @@ -13,6 +13,7 @@ import type { ClientRequest, IncomingMessage } from 'http' import WebSocket from 'ws' +import OpenAI, { toFile } from 'openai' import { getOauthConfig } from '../constants/oauth.js' import { checkAndRefreshOAuthTokenIfNeeded, @@ -22,6 +23,7 @@ import { import { logForDebugging } from '../utils/debug.js' import { getUserAgent } from '../utils/http.js' import { logError } from '../utils/log.js' +import { getAPIProvider } from '../utils/model/providers.js' import { getWebSocketTLSOptions } from '../utils/mtls.js' import { getWebSocketProxyAgent, getWebSocketProxyUrl } from '../utils/proxy.js' import { jsonParse, jsonStringify } from '../utils/slowOperations.js' @@ -71,6 +73,29 @@ export type VoiceStreamConnection = { isConnected: () => boolean } +export type VoiceSttProvider = 'anthropic-oauth' | 'openai' + +export const voiceStreamSttInternals = { + getAPIProvider, + isAnthropicAuthEnabled, + getClaudeAIOAuthTokens, + getOpenAIApiKey: () => process.env.OPENAI_API_KEY ?? '', + getOpenAIBaseURL: () => process.env.OPENAI_BASE_URL, + getOpenAITranscriptionModel: () => + process.env.OPENAI_TRANSCRIPTION_MODEL || + process.env.OPENAI_MODEL || + 'gpt-4o-mini-transcribe', + createOpenAIClient: (config: { apiKey: string; baseURL: string | undefined }) => + new OpenAI({ + apiKey: config.apiKey, + ...(config.baseURL && { baseURL: config.baseURL }), + maxRetries: 0, + timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10), + dangerouslyAllowBrowser: true, + }), + toUploadFile: toFile, +} + // The voice_stream endpoint returns transcript chunks and endpoint markers. type VoiceStreamTranscriptText = { type: 'TranscriptText' @@ -95,23 +120,164 @@ type VoiceStreamMessage = // ─── Availability ────────────────────────────────────────────────────── +export function getVoiceSttProvider(): VoiceSttProvider | null { + const provider = voiceStreamSttInternals.getAPIProvider() + if (provider === 'openai' && voiceStreamSttInternals.getOpenAIApiKey()) { + return 'openai' + } + + try { + if (voiceStreamSttInternals.isAnthropicAuthEnabled()) { + const tokens = voiceStreamSttInternals.getClaudeAIOAuthTokens() + if (tokens !== null && tokens.accessToken !== null) { + return 'anthropic-oauth' + } + } + } catch { + return null + } + + return null +} + export function isVoiceStreamAvailable(): boolean { - // voice_stream uses the same OAuth as Claude Code — available when the - // user is authenticated with Anthropic (Claude.ai subscriber or has - // valid OAuth tokens). - if (!isAnthropicAuthEnabled()) { - return false + return getVoiceSttProvider() !== null +} + +export function getVoiceModeAvailability(): { + provider: VoiceSttProvider | null + available: boolean +} { + const provider = getVoiceSttProvider() + return { + provider, + available: provider !== null, } - const tokens = getClaudeAIOAuthTokens() - return tokens !== null && tokens.accessToken !== null } // ─── Connection ──────────────────────────────────────────────────────── +function getVoiceOpenAIConfig(): { + apiKey: string + baseURL: string | undefined + model: string +} { + return { + apiKey: voiceStreamSttInternals.getOpenAIApiKey(), + baseURL: voiceStreamSttInternals.getOpenAIBaseURL(), + model: voiceStreamSttInternals.getOpenAITranscriptionModel(), + } +} + +function encodePcm16LeAsWav(pcm: Buffer): Buffer { + const sampleRate = 16_000 + const channels = 1 + const bitsPerSample = 16 + const byteRate = sampleRate * channels * (bitsPerSample / 8) + const blockAlign = channels * (bitsPerSample / 8) + const header = Buffer.alloc(44) + + header.write('RIFF', 0) + header.writeUInt32LE(36 + pcm.length, 4) + header.write('WAVE', 8) + header.write('fmt ', 12) + header.writeUInt32LE(16, 16) + header.writeUInt16LE(1, 20) + header.writeUInt16LE(channels, 22) + header.writeUInt32LE(sampleRate, 24) + header.writeUInt32LE(byteRate, 28) + header.writeUInt16LE(blockAlign, 32) + header.writeUInt16LE(bitsPerSample, 34) + header.write('data', 36) + header.writeUInt32LE(pcm.length, 40) + + return Buffer.concat([header, pcm]) +} + +function createOpenAITranscriptionConnection( + callbacks: VoiceStreamCallbacks, + options?: { language?: string; keyterms?: string[] }, +): VoiceStreamConnection { + const chunks: Buffer[] = [] + let connected = true + let finalized = false + + return { + send(audioChunk: Buffer): void { + if (finalized) return + chunks.push(Buffer.from(audioChunk)) + }, + async finalize(): Promise { + if (finalized) return 'ws_already_closed' + finalized = true + connected = false + + if (chunks.length === 0) { + callbacks.onClose() + return 'no_data_timeout' + } + + try { + const { apiKey, baseURL, model } = getVoiceOpenAIConfig() + + const client = voiceStreamSttInternals.createOpenAIClient({ + apiKey, + baseURL, + }) + + const audio = encodePcm16LeAsWav(Buffer.concat(chunks)) + const file = await voiceStreamSttInternals.toUploadFile(audio, 'voice-input.wav', { + type: 'audio/wav', + }) + const transcription = await client.audio.transcriptions.create({ + file, + model, + language: options?.language, + prompt: options?.keyterms?.length + ? `Key terms: ${options.keyterms.join(', ')}` + : undefined, + response_format: 'verbose_json', + }) + + const text = transcription.text?.trim() ?? '' + if (text) { + callbacks.onTranscript(text, true) + } + callbacks.onClose() + return text ? 'post_closestream_endpoint' : 'no_data_timeout' + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + callbacks.onError(`Transcription failed: ${message}`) + callbacks.onClose() + return 'safety_timeout' + } + }, + close(): void { + finalized = true + connected = false + callbacks.onClose() + }, + isConnected(): boolean { + return connected && !finalized + }, + } +} + export async function connectVoiceStream( callbacks: VoiceStreamCallbacks, options?: { language?: string; keyterms?: string[] }, ): Promise { + const provider = getVoiceSttProvider() + if (!provider) { + return null + } + + if (provider !== 'anthropic-oauth') { + const connection = createOpenAITranscriptionConnection(callbacks, options) + callbacks.onReady(connection) + return connection + } + // Ensure OAuth token is fresh before connecting await checkAndRefreshOAuthTokenIfNeeded() diff --git a/src/tools/AgentTool/AgentTool.tsx b/src/tools/AgentTool/AgentTool.tsx index 7fbed68a4..5028a4603 100644 --- a/src/tools/AgentTool/AgentTool.tsx +++ b/src/tools/AgentTool/AgentTool.tsx @@ -96,6 +96,7 @@ import { extractPartialResult, finalizeAgentTool, getLastToolUseName, + resolveAgentTotalTokens, runAsyncAgentLifecycle, } from './agentToolUtils.js' import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js' @@ -1280,6 +1281,7 @@ export const AgentTool = buildTool({ description, startTime, lastToolName, + agentMessages, ) } } @@ -1287,6 +1289,7 @@ export const AgentTool = buildTool({ agentMessages, backgroundedTaskId, metadata, + getTokenCountFromTracker(tracker), ) // Mark task completed FIRST so TaskOutput(block=true) @@ -1328,7 +1331,7 @@ export const AgentTool = buildTool({ setAppState: rootSetAppState, finalMessage, usage: { - totalTokens: getTokenCountFromTracker(tracker), + totalTokens: agentResult.totalTokens, toolUses: agentResult.totalToolUseCount, durationMs: agentResult.totalDurationMs, }, @@ -1438,6 +1441,7 @@ export const AgentTool = buildTool({ description, agentStartTime, lastToolName, + agentMessages, ) // Keep AppState task.progress in sync when SDK summaries are // enabled, so updateAgentSummary reads correct token/tool counts @@ -1552,6 +1556,10 @@ export const AgentTool = buildTool({ // NOT trigger the print.ts XML task_notification parser or the LLM loop. if (!wasBackgrounded) { const progress = getProgressUpdate(syncTracker) + const totalTokens = resolveAgentTotalTokens( + agentMessages, + progress.tokenCount, + ) enqueueSdkEvent({ type: 'system', subtype: 'task_notification', @@ -1565,7 +1573,7 @@ export const AgentTool = buildTool({ output_file: '', summary: description, usage: { - total_tokens: progress.tokenCount, + total_tokens: totalTokens, tool_uses: progress.toolUseCount, duration_ms: Date.now() - agentStartTime, }, @@ -1637,6 +1645,7 @@ export const AgentTool = buildTool({ agentMessages, syncAgentId, metadata, + getTokenCountFromTracker(syncTracker), ) if (feature('TRANSCRIPT_CLASSIFIER')) { diff --git a/src/tools/AgentTool/UI.tsx b/src/tools/AgentTool/UI.tsx index b6bc20da4..e7de6dc85 100644 --- a/src/tools/AgentTool/UI.tsx +++ b/src/tools/AgentTool/UI.tsx @@ -1,56 +1,36 @@ -import type { - ToolResultBlockParam, - ToolUseBlockParam, -} from '@anthropic-ai/sdk/resources/index.mjs' -import * as React from 'react' -import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHint.js' -import { - CtrlOToExpand, - SubAgentProvider, -} from 'src/components/CtrlOToExpand.js' -import { Byline, KeyboardShortcutHint } from '@anthropic/ink' -import type { z } from 'zod/v4' -import { AgentProgressLine } from '../../components/AgentProgressLine.js' -import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js' -import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage.js' -import { Markdown } from '../../components/Markdown.js' -import { Message as MessageComponent } from '../../components/Message.js' -import { MessageResponse } from '../../components/MessageResponse.js' -import { ToolUseLoader } from '../../components/ToolUseLoader.js' -import { Box, Text } from '@anthropic/ink' -import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js' -import { findToolByName, type Tools } from '../../Tool.js' -import type { Message, ProgressMessage } from '../../types/message.js' -import type { AgentToolProgress } from '../../types/tools.js' -import { count } from '../../utils/array.js' -import { - getSearchOrReadFromContent, - getSearchReadSummaryText, -} from '../../utils/collapseReadSearch.js' -import { getDisplayPath } from '../../utils/file.js' -import { formatDuration, formatNumber } from '../../utils/format.js' -import { - buildSubagentLookups, - createAssistantMessage, - EMPTY_LOOKUPS, -} from '../../utils/messages.js' -import type { ModelAlias } from '../../utils/model/aliases.js' -import { - getMainLoopModel, - parseUserSpecifiedModel, - renderModelName, -} from '../../utils/model/model.js' -import type { Theme, ThemeName } from '../../utils/theme.js' -import type { - outputSchema, - Progress, - RemoteLaunchedOutput, -} from './AgentTool.js' -import { inputSchema } from './AgentTool.js' -import { getAgentColor } from './agentColorManager.js' -import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js' - -const MAX_PROGRESS_MESSAGES_TO_SHOW = 3 +import type { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import * as React from 'react'; +import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHint.js'; +import { CtrlOToExpand, SubAgentProvider } from 'src/components/CtrlOToExpand.js'; +import { Byline, KeyboardShortcutHint } from '@anthropic/ink'; +import type { z } from 'zod/v4'; +import { AgentProgressLine } from '../../components/AgentProgressLine.js'; +import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; +import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage.js'; +import { Markdown } from '../../components/Markdown.js'; +import { Message as MessageComponent } from '../../components/Message.js'; +import { MessageResponse } from '../../components/MessageResponse.js'; +import { ToolUseLoader } from '../../components/ToolUseLoader.js'; +import { Box, Text } from '@anthropic/ink'; +import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js'; +import { findToolByName, type Tools } from '../../Tool.js'; +import type { Message, ProgressMessage } from '../../types/message.js'; +import type { AgentToolProgress } from '../../types/tools.js'; +import { count } from '../../utils/array.js'; +import { getSearchOrReadFromContent, getSearchReadSummaryText } from '../../utils/collapseReadSearch.js'; +import { getDisplayPath } from '../../utils/file.js'; +import { formatDuration, formatNumber } from '../../utils/format.js'; +import { buildSubagentLookups, createAssistantMessage, EMPTY_LOOKUPS } from '../../utils/messages.js'; +import { resolveAgentTotalTokens } from './agentToolUtils.js'; +import type { ModelAlias } from '../../utils/model/aliases.js'; +import { getMainLoopModel, parseUserSpecifiedModel, renderModelName } from '../../utils/model/model.js'; +import type { Theme, ThemeName } from '../../utils/theme.js'; +import type { outputSchema, Progress, RemoteLaunchedOutput } from './AgentTool.js'; +import { inputSchema } from './AgentTool.js'; +import { getAgentColor } from './agentColorManager.js'; +import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'; + +const MAX_PROGRESS_MESSAGES_TO_SHOW = 3; /** * Guard: checks if progress data has a `message` field (agent_progress or @@ -59,10 +39,10 @@ const MAX_PROGRESS_MESSAGES_TO_SHOW = 3 */ function hasProgressMessage(data: Progress): data is AgentToolProgress { if (!('message' in data)) { - return false + return false; } - const msg = (data as AgentToolProgress).message - return msg != null && typeof msg === 'object' && 'type' in msg + const msg = (data as AgentToolProgress).message; + return msg != null && typeof msg === 'object' && 'type' in msg; } /** @@ -78,41 +58,44 @@ function getSearchOrReadInfo( toolUseByID: Map, ): { isSearch: boolean; isRead: boolean; isREPL: boolean } | null { if (!hasProgressMessage(progressMessage.data)) { - return null + return null; } - const message = progressMessage.data.message + const message = progressMessage.data.message; // Check tool_use (assistant message) if (message.type === 'assistant') { - return getSearchOrReadFromContent(message.message.content[0], tools) + return getSearchOrReadFromContent(message.message.content[0], tools); } // Check tool_result (user message) - find corresponding tool use from the map if (message.type === 'user') { - const content = message.message.content[0] + const content = message.message.content[0]; if (content?.type === 'tool_result') { - const toolUse = toolUseByID.get(content.tool_use_id) + const toolUse = toolUseByID.get(content.tool_use_id); if (toolUse) { - return getSearchOrReadFromContent(toolUse, tools) + return getSearchOrReadFromContent(toolUse, tools); } } } - return null + return null; } type SummaryMessage = { - type: 'summary' - searchCount: number - readCount: number - replCount: number - uuid: string - isActive: boolean // true if still in progress (last message was tool_use, not tool_result) -} + type: 'summary'; + searchCount: number; + readCount: number; + replCount: number; + uuid: string; + isActive: boolean; // true if still in progress (last message was tool_use, not tool_result) +}; + +type ProcessedMessage = { type: 'original'; message: ProgressMessage } | SummaryMessage; -type ProcessedMessage = - | { type: 'original'; message: ProgressMessage } - | SummaryMessage +type AgentProgressStats = { + toolUseCount: number; + tokens: number | null; +}; /** * Process progress messages to group consecutive search/read operations into summaries. @@ -124,31 +107,24 @@ function processProgressMessages( tools: Tools, isAgentRunning: boolean, ): ProcessedMessage[] { - // Only process for ants - if ("external" !== 'ant') { + if (process.env.USER_TYPE !== 'ant') { return messages .filter( - (m): m is ProgressMessage => - hasProgressMessage(m.data) && m.data.message.type !== 'user', + (m): m is ProgressMessage => hasProgressMessage(m.data) && m.data.message.type !== 'user', ) - .map(m => ({ type: 'original', message: m })) + .map(m => ({ type: 'original', message: m })); } - const result: ProcessedMessage[] = [] + const result: ProcessedMessage[] = []; let currentGroup: { - searchCount: number - readCount: number - replCount: number - startUuid: string - } | null = null + searchCount: number; + readCount: number; + replCount: number; + startUuid: string; + } | null = null; function flushGroup(isActive: boolean): void { - if ( - currentGroup && - (currentGroup.searchCount > 0 || - currentGroup.readCount > 0 || - currentGroup.replCount > 0) - ) { + if (currentGroup && (currentGroup.searchCount > 0 || currentGroup.readCount > 0 || currentGroup.replCount > 0)) { result.push({ type: 'summary', searchCount: currentGroup.searchCount, @@ -156,27 +132,25 @@ function processProgressMessages( replCount: currentGroup.replCount, uuid: `summary-${currentGroup.startUuid}`, isActive, - }) + }); } - currentGroup = null + currentGroup = null; } - const agentMessages = messages.filter( - (m): m is ProgressMessage => hasProgressMessage(m.data), - ) + const agentMessages = messages.filter((m): m is ProgressMessage => hasProgressMessage(m.data)); // Build tool_use lookup incrementally as we iterate - const toolUseByID = new Map() + const toolUseByID = new Map(); for (const msg of agentMessages) { // Track tool_use blocks as we see them if (msg.data.message.type === 'assistant') { for (const c of msg.data.message.message.content) { if (c.type === 'tool_use') { - toolUseByID.set(c.id, c as ToolUseBlockParam) + toolUseByID.set(c.id, c as ToolUseBlockParam); } } } - const info = getSearchOrReadInfo(msg, tools, toolUseByID) + const info = getSearchOrReadInfo(msg, tools, toolUseByID); if (info && (info.isSearch || info.isRead || info.isREPL)) { // This is a search/read/REPL operation - add to current group @@ -186,48 +160,48 @@ function processProgressMessages( readCount: 0, replCount: 0, startUuid: msg.uuid, - } + }; } // Only count tool_result messages (not tool_use) to avoid double counting if (msg.data.message.type === 'user') { if (info.isSearch) { - currentGroup.searchCount++ + currentGroup.searchCount++; } else if (info.isREPL) { - currentGroup.replCount++ + currentGroup.replCount++; } else if (info.isRead) { - currentGroup.readCount++ + currentGroup.readCount++; } } } else { // Non-search/read/REPL message - flush current group (completed) and add this message - flushGroup(false) + flushGroup(false); // Skip user tool_result messages — subagent progress messages lack // toolUseResult, so UserToolSuccessMessage returns null and the // height=1 Box in renderToolUseProgressMessage shows as a blank line. if (msg.data.message.type !== 'user') { - result.push({ type: 'original', message: msg }) + result.push({ type: 'original', message: msg }); } } } // Flush any remaining group - it's active if the agent is still running - flushGroup(isAgentRunning) + flushGroup(isAgentRunning); - return result + return result; } -const ESTIMATED_LINES_PER_TOOL = 9 -const TERMINAL_BUFFER_LINES = 7 +const ESTIMATED_LINES_PER_TOOL = 9; +const TERMINAL_BUFFER_LINES = 7; -type Output = z.input> +type Output = z.input>; export function AgentPromptDisplay({ prompt, dim: _dim = false, }: { - prompt: string - theme?: ThemeName // deprecated, kept for compatibility - Markdown uses useTheme internally - dim?: boolean // deprecated, kept for compatibility - dimColor cannot be applied to Box (Markdown returns Box) + prompt: string; + theme?: ThemeName; // deprecated, kept for compatibility - Markdown uses useTheme internally + dim?: boolean; // deprecated, kept for compatibility - dimColor cannot be applied to Box (Markdown returns Box) }): React.ReactNode { return ( @@ -238,14 +212,14 @@ export function AgentPromptDisplay({ {prompt} - ) + ); } export function AgentResponseDisplay({ content, }: { - content: { type: string; text: string }[] - theme?: ThemeName // deprecated, kept for compatibility - Markdown uses useTheme internally + content: { type: string; text: string }[]; + theme?: ThemeName; // deprecated, kept for compatibility - Markdown uses useTheme internally }): React.ReactNode { return ( @@ -258,44 +232,36 @@ export function AgentResponseDisplay({ ))} - ) + ); } type VerboseAgentTranscriptProps = { - progressMessages: ProgressMessage[] - tools: Tools - verbose: boolean -} + progressMessages: ProgressMessage[]; + tools: Tools; + verbose: boolean; +}; -function VerboseAgentTranscript({ - progressMessages, - tools, - verbose, -}: VerboseAgentTranscriptProps): React.ReactNode { +function VerboseAgentTranscript({ progressMessages, tools, verbose }: VerboseAgentTranscriptProps): React.ReactNode { const { lookups: agentLookups, inProgressToolUseIDs } = buildSubagentLookups( progressMessages - .filter((pm): pm is ProgressMessage => - hasProgressMessage(pm.data), - ) + .filter((pm): pm is ProgressMessage => hasProgressMessage(pm.data)) .map(pm => pm.data), - ) + ); // Filter out user tool_result messages that lack toolUseResult. // Subagent progress messages don't carry the parsed tool output, // so UserToolSuccessMessage returns null and MessageResponse renders // a bare ⎿ with no content. - const filteredMessages = progressMessages.filter( - (pm): pm is ProgressMessage => { - if (!hasProgressMessage(pm.data)) { - return false - } - const msg = pm.data.message - if (msg.type === 'user' && msg.toolUseResult === undefined) { - return false - } - return true - }, - ) + const filteredMessages = progressMessages.filter((pm): pm is ProgressMessage => { + if (!hasProgressMessage(pm.data)) { + return false; + } + const msg = pm.data.message; + if (msg.type === 'user' && msg.toolUseResult === undefined) { + return false; + } + return true; + }); return ( <> @@ -318,7 +284,7 @@ function VerboseAgentTranscript({ ))} - ) + ); } export function renderToolResultMessage( @@ -330,15 +296,15 @@ export function renderToolResultMessage( theme, isTranscriptMode = false, }: { - tools: Tools - verbose: boolean - theme: ThemeName - isTranscriptMode?: boolean + tools: Tools; + verbose: boolean; + theme: ThemeName; + isTranscriptMode?: boolean; }, ): React.ReactNode { // Remote-launched agents (ant-only) use a private output type not in the // public schema. Narrow via the internal discriminant. - const internal = data as Output | RemoteLaunchedOutput + const internal = data as Output | RemoteLaunchedOutput; if (internal.status === 'remote_launched') { return ( @@ -351,10 +317,10 @@ export function renderToolResultMessage( - ) + ); } if (data.status === 'async_launched') { - const { prompt } = data + const { prompt } = data; return ( @@ -385,42 +351,32 @@ export function renderToolResultMessage( )} - ) + ); } if (data.status !== 'completed') { - return null + return null; } - const { - agentId, - totalDurationMs, - totalToolUseCount, - totalTokens, - usage, - content, - prompt, - } = data + const { agentId, totalDurationMs, totalToolUseCount, totalTokens, usage, content, prompt } = data; const result = [ totalToolUseCount === 1 ? '1 tool use' : `${totalToolUseCount} tool uses`, formatNumber(totalTokens) + ' tokens', formatDuration(totalDurationMs), - ] + ]; - const completionMessage = `Done (${result.join(' · ')})` + const completionMessage = `Done (${result.join(' · ')})`; const finalAssistantMessage = createAssistantMessage({ content: completionMessage, usage: { ...usage, inference_geo: null, iterations: null, speed: null }, - }) + }); return ( {process.env.USER_TYPE === 'ant' && ( - - [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} - + [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} )} {isTranscriptMode && prompt && ( @@ -430,11 +386,7 @@ export function renderToolResultMessage( )} {isTranscriptMode ? ( - + ) : null} {isTranscriptMode && content && content.length > 0 && ( @@ -465,52 +417,52 @@ export function renderToolResultMessage( )} - ) + ); } export function renderToolUseMessage({ description, prompt, }: Partial<{ - description: string - prompt: string + description: string; + prompt: string; }>): React.ReactNode { if (!description || !prompt) { - return null + return null; } - return description + return description; } export function renderToolUseTag( input: Partial<{ - description: string - prompt: string - subagent_type: string - model?: ModelAlias + description: string; + prompt: string; + subagent_type: string; + model?: ModelAlias; }>, ): React.ReactNode { - const tags: React.ReactNode[] = [] + const tags: React.ReactNode[] = []; if (input.model) { - const mainModel = getMainLoopModel() - const agentModel = parseUserSpecifiedModel(input.model) + const mainModel = getMainLoopModel(); + const agentModel = parseUserSpecifiedModel(input.model); if (agentModel !== mainModel) { tags.push( {renderModelName(agentModel)} , - ) + ); } } if (tags.length === 0) { - return null + return null; } - return <>{tags} + return <>{tags}; } -const INITIALIZING_TEXT = 'Initializing…' +const INITIALIZING_TEXT = 'Initializing…'; export function renderToolUseProgressMessage( progressMessages: ProgressMessage[], @@ -521,69 +473,52 @@ export function renderToolUseProgressMessage( inProgressToolCallCount, isTranscriptMode = false, }: { - tools: Tools - verbose: boolean - terminalSize?: { columns: number; rows: number } - inProgressToolCallCount?: number - isTranscriptMode?: boolean + tools: Tools; + verbose: boolean; + terminalSize?: { columns: number; rows: number }; + inProgressToolCallCount?: number; + isTranscriptMode?: boolean; }, ): React.ReactNode { + const toolUseCount = count(progressMessages, msg => { + if (!hasProgressMessage(msg.data)) { + return false; + } + const message = msg.data.message; + return message.message.content.some(content => content.type === 'tool_use'); + }); + + const agentMessages = progressMessages + .filter((msg): msg is ProgressMessage => hasProgressMessage(msg.data)) + .map(msg => msg.data.message); + + const tokens = agentMessages.length ? resolveAgentTotalTokens(agentMessages) : 0; + const progressStats: AgentProgressStats = { + toolUseCount, + tokens: tokens > 0 ? tokens : null, + }; + if (!progressMessages.length) { return ( {INITIALIZING_TEXT} - ) + ); } // Checks to see if we should show a super condensed progress message summary. // This prevents flickers when the terminal size is too small to render all the dynamic content - const toolToolRenderLinesEstimate = - (inProgressToolCallCount ?? 1) * ESTIMATED_LINES_PER_TOOL + - TERMINAL_BUFFER_LINES + const toolToolRenderLinesEstimate = (inProgressToolCallCount ?? 1) * ESTIMATED_LINES_PER_TOOL + TERMINAL_BUFFER_LINES; const shouldUseCondensedMode = - !isTranscriptMode && - terminalSize && - terminalSize.rows && - terminalSize.rows < toolToolRenderLinesEstimate - - const getProgressStats = () => { - const toolUseCount = count(progressMessages, msg => { - if (!hasProgressMessage(msg.data)) { - return false - } - const message = msg.data.message - return message.message.content.some( - content => content.type === 'tool_use', - ) - }) - - const latestAssistant = progressMessages.findLast( - (msg): msg is ProgressMessage => - hasProgressMessage(msg.data) && msg.data.message.type === 'assistant', - ) - - let tokens = null - if (latestAssistant?.data.message.type === 'assistant') { - const usage = latestAssistant.data.message.message.usage - tokens = - (usage.cache_creation_input_tokens ?? 0) + - (usage.cache_read_input_tokens ?? 0) + - usage.input_tokens + - usage.output_tokens - } - - return { toolUseCount, tokens } - } + !isTranscriptMode && terminalSize && terminalSize.rows && terminalSize.rows < toolToolRenderLinesEstimate; if (shouldUseCondensedMode) { - const { toolUseCount, tokens } = getProgressStats() + const { toolUseCount, tokens } = progressStats; return ( - In progress… · {toolUseCount} tool{' '} - {toolUseCount === 1 ? 'use' : 'uses'} + In progress… · {toolUseCount} tool {toolUseCount === 1 ? 'use' : 'uses'} {tokens && ` · ${formatNumber(tokens)} tokens`} ·{' '} - ) + ); } // Process messages to group consecutive search/read operations into summaries (ants only) // isAgentRunning=true since this is the progress view while the agent is still running - const processedMessages = processProgressMessages( - progressMessages, - tools, - true, - ) + const processedMessages = processProgressMessages(progressMessages, tools, true); // For display, take the last few processed messages const displayedMessages = isTranscriptMode ? processedMessages - : processedMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW) + : processedMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW); // Count hidden tool uses specifically (not all messages) to match the // final "Done (N tool uses)" count. Each tool use generates multiple @@ -616,26 +547,20 @@ export function renderToolUseProgressMessage( // hidden messages inflates the number shown to the user. const hiddenMessages = isTranscriptMode ? [] - : processedMessages.slice( - 0, - Math.max(0, processedMessages.length - MAX_PROGRESS_MESSAGES_TO_SHOW), - ) + : processedMessages.slice(0, Math.max(0, processedMessages.length - MAX_PROGRESS_MESSAGES_TO_SHOW)); const hiddenToolUseCount = count(hiddenMessages, m => { if (m.type === 'summary') { - return m.searchCount + m.readCount + m.replCount > 0 + return m.searchCount + m.readCount + m.replCount > 0; } - const data = m.message.data + const data = m.message.data; if (!hasProgressMessage(data)) { - return false + return false; } - return data.message.message.content.some( - content => content.type === 'tool_use', - ) - }) + return data.message.message.content.some(content => content.type === 'tool_use'); + }); - const firstData = progressMessages[0]?.data - const prompt = - firstData && hasProgressMessage(firstData) ? firstData.prompt : undefined + const firstData = progressMessages[0]?.data; + const prompt = firstData && hasProgressMessage(firstData) ? firstData.prompt : undefined; // After grouping, displayedMessages can be empty when the only progress so // far is an assistant tool_use for a search/read op (grouped but not yet @@ -646,19 +571,14 @@ export function renderToolUseProgressMessage( {INITIALIZING_TEXT} - ) + ); } - const { - lookups: subagentLookups, - inProgressToolUseIDs: collapsedInProgressIDs, - } = buildSubagentLookups( + const { lookups: subagentLookups, inProgressToolUseIDs: collapsedInProgressIDs } = buildSubagentLookups( progressMessages - .filter((pm): pm is ProgressMessage => - hasProgressMessage(pm.data), - ) + .filter((pm): pm is ProgressMessage => hasProgressMessage(pm.data)) .map(pm => pm.data), - ) + ); return ( @@ -677,12 +597,12 @@ export function renderToolUseProgressMessage( processed.readCount, processed.isActive, processed.replCount, - ) + ); return ( {summaryText} - ) + ); } // Render original message without height=1 wrapper so null // content (tool not found, renderToolUseMessage returns null) @@ -705,18 +625,17 @@ export function renderToolUseProgressMessage( isTranscriptMode={false} isStatic={true} /> - ) + ); })} {hiddenToolUseCount > 0 && ( - +{hiddenToolUseCount} more tool{' '} - {hiddenToolUseCount === 1 ? 'use' : 'uses'} + +{hiddenToolUseCount} more tool {hiddenToolUseCount === 1 ? 'use' : 'uses'} )} - ) + ); } export function renderToolUseRejectedMessage( @@ -727,28 +646,25 @@ export function renderToolUseRejectedMessage( verbose, isTranscriptMode, }: { - columns: number - messages: Message[] - style?: 'condensed' - theme: ThemeName - progressMessagesForMessage: ProgressMessage[] - tools: Tools - verbose: boolean - isTranscriptMode?: boolean + columns: number; + messages: Message[]; + style?: 'condensed'; + theme: ThemeName; + progressMessagesForMessage: ProgressMessage[]; + tools: Tools; + verbose: boolean; + isTranscriptMode?: boolean; }, ): React.ReactNode { // Get agentId from progress messages if available (agent was running before rejection) - const firstData = progressMessagesForMessage[0]?.data - const agentId = - firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined + const firstData = progressMessagesForMessage[0]?.data; + const agentId = firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined; return ( <> {process.env.USER_TYPE === 'ant' && agentId && ( - - [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} - + [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} )} {renderToolUseProgressMessage(progressMessagesForMessage, { @@ -758,7 +674,7 @@ export function renderToolUseRejectedMessage( })} - ) + ); } export function renderToolUseErrorMessage( @@ -769,10 +685,10 @@ export function renderToolUseErrorMessage( verbose, isTranscriptMode, }: { - progressMessagesForMessage: ProgressMessage[] - tools: Tools - verbose: boolean - isTranscriptMode?: boolean + progressMessagesForMessage: ProgressMessage[]; + tools: Tools; + verbose: boolean; + isTranscriptMode?: boolean; }, ): React.ReactNode { return ( @@ -784,159 +700,139 @@ export function renderToolUseErrorMessage( })} - ) + ); } -function calculateAgentStats(progressMessages: ProgressMessage[]): { - toolUseCount: number - tokens: number | null -} { +function calculateAgentStats( + progressMessages: ProgressMessage[], + completedTotalTokens?: number, +): AgentProgressStats { const toolUseCount = count(progressMessages, msg => { if (!hasProgressMessage(msg.data)) { - return false + return false; } - const message = msg.data.message - return ( - message.type === 'user' && - message.message.content.some(content => content.type === 'tool_result') - ) - }) - - const latestAssistant = progressMessages.findLast( - (msg): msg is ProgressMessage => - hasProgressMessage(msg.data) && msg.data.message.type === 'assistant', - ) - - let tokens = null - if (latestAssistant?.data.message.type === 'assistant') { - const usage = latestAssistant.data.message.message.usage - tokens = - (usage.cache_creation_input_tokens ?? 0) + - (usage.cache_read_input_tokens ?? 0) + - usage.input_tokens + - usage.output_tokens - } - - return { toolUseCount, tokens } + const message = msg.data.message; + return message.type === 'user' && message.message.content.some(content => content.type === 'tool_result'); + }); + + const agentMessages = progressMessages + .filter((msg): msg is ProgressMessage => hasProgressMessage(msg.data)) + .map(msg => msg.data.message); + + const resolvedTokens = agentMessages.length ? resolveAgentTotalTokens(agentMessages) : 0; + const tokens = + completedTotalTokens !== undefined + ? completedTotalTokens > 0 + ? completedTotalTokens + : null + : resolvedTokens > 0 + ? resolvedTokens + : null; + + return { toolUseCount, tokens }; } export function renderGroupedAgentToolUse( toolUses: Array<{ - param: ToolUseBlockParam - isResolved: boolean - isError: boolean - isInProgress: boolean - progressMessages: ProgressMessage[] + param: ToolUseBlockParam; + isResolved: boolean; + isError: boolean; + isInProgress: boolean; + progressMessages: ProgressMessage[]; result?: { - param: ToolResultBlockParam - output: Output - } + param: ToolResultBlockParam; + output: Output; + }; }>, options: { - shouldAnimate: boolean - tools: Tools + shouldAnimate: boolean; + tools: Tools; }, ): React.ReactNode | null { - const { shouldAnimate, tools } = options + const { shouldAnimate, tools } = options; // Calculate stats for each agent - const agentStats = toolUses.map( - ({ param, isResolved, isError, progressMessages, result }) => { - const stats = calculateAgentStats(progressMessages) - const lastToolInfo = extractLastToolInfo(progressMessages, tools) - const parsedInput = inputSchema().safeParse(param.input) - - // teammate_spawned is not part of the exported Output type (cast through unknown - // for dead code elimination), so check via string comparison on the raw value - const isTeammateSpawn = - (result?.output?.status as string) === 'teammate_spawned' - - // For teammate spawns, show @name with type in parens and description as status - let agentType: string - let description: string | undefined - let color: keyof Theme | undefined - let descriptionColor: keyof Theme | undefined - let taskDescription: string | undefined - if (isTeammateSpawn && parsedInput.success && parsedInput.data.name) { - agentType = `@${parsedInput.data.name}` - const subagentType = parsedInput.data.subagent_type - description = isCustomSubagentType(subagentType) - ? subagentType - : undefined - taskDescription = parsedInput.data.description - // Use the custom agent definition's color on the type, not the name - descriptionColor = isCustomSubagentType(subagentType) - ? (getAgentColor(subagentType) as keyof Theme | undefined) - : undefined - } else { - agentType = parsedInput.success - ? userFacingName(parsedInput.data) - : 'Agent' - description = parsedInput.success - ? parsedInput.data.description - : undefined - color = parsedInput.success - ? userFacingNameBackgroundColor(parsedInput.data) - : undefined - taskDescription = undefined - } - - // Check if this was launched as a background agent OR backgrounded mid-execution - const launchedAsAsync = - parsedInput.success && - 'run_in_background' in parsedInput.data && - parsedInput.data.run_in_background === true - const outputStatus = (result?.output as { status?: string } | undefined) - ?.status - const backgroundedMidExecution = - outputStatus === 'async_launched' || outputStatus === 'remote_launched' - const isAsync = - launchedAsAsync || backgroundedMidExecution || isTeammateSpawn - - const name = parsedInput.success ? parsedInput.data.name : undefined - - return { - id: param.id, - agentType, - description, - toolUseCount: stats.toolUseCount, - tokens: stats.tokens, - isResolved, - isError, - isAsync, - color, - descriptionColor, - lastToolInfo, - taskDescription, - name, - } - }, - ) + const agentStats = toolUses.map(({ param, isResolved, isError, progressMessages, result }) => { + const completedTotalTokens = + result?.output && + typeof result.output === 'object' && + 'status' in result.output && + 'totalTokens' in result.output && + (result.output as { status?: string; totalTokens?: number }).status === 'completed' && + typeof (result.output as { totalTokens?: number }).totalTokens === 'number' + ? (result.output as { totalTokens: number }).totalTokens + : undefined; + const stats = calculateAgentStats(progressMessages, completedTotalTokens); + const lastToolInfo = extractLastToolInfo(progressMessages, tools); + const parsedInput = inputSchema().safeParse(param.input); + + // teammate_spawned is not part of the exported Output type (cast through unknown + // for dead code elimination), so check via string comparison on the raw value + const isTeammateSpawn = (result?.output?.status as string) === 'teammate_spawned'; + + // For teammate spawns, show @name with type in parens and description as status + let agentType: string; + let description: string | undefined; + let color: keyof Theme | undefined; + let descriptionColor: keyof Theme | undefined; + let taskDescription: string | undefined; + if (isTeammateSpawn && parsedInput.success && parsedInput.data.name) { + agentType = `@${parsedInput.data.name}`; + const subagentType = parsedInput.data.subagent_type; + description = isCustomSubagentType(subagentType) ? subagentType : undefined; + taskDescription = parsedInput.data.description; + // Use the custom agent definition's color on the type, not the name + descriptionColor = isCustomSubagentType(subagentType) + ? (getAgentColor(subagentType) as keyof Theme | undefined) + : undefined; + } else { + agentType = parsedInput.success ? userFacingName(parsedInput.data) : 'Agent'; + description = parsedInput.success ? parsedInput.data.description : undefined; + color = parsedInput.success ? userFacingNameBackgroundColor(parsedInput.data) : undefined; + taskDescription = undefined; + } - const anyUnresolved = toolUses.some(t => !t.isResolved) - const anyError = toolUses.some(t => t.isError) - const allComplete = !anyUnresolved + // Check if this was launched as a background agent OR backgrounded mid-execution + const launchedAsAsync = + parsedInput.success && 'run_in_background' in parsedInput.data && parsedInput.data.run_in_background === true; + const outputStatus = (result?.output as { status?: string } | undefined)?.status; + const backgroundedMidExecution = outputStatus === 'async_launched' || outputStatus === 'remote_launched'; + const isAsync = launchedAsAsync || backgroundedMidExecution || isTeammateSpawn; + + const name = parsedInput.success ? parsedInput.data.name : undefined; + + return { + id: param.id, + agentType, + description, + toolUseCount: stats.toolUseCount, + tokens: stats.tokens, + isResolved, + isError, + isAsync, + color, + descriptionColor, + lastToolInfo, + taskDescription, + name, + }; + }); + + const anyUnresolved = toolUses.some(t => !t.isResolved); + const anyError = toolUses.some(t => t.isError); + const allComplete = !anyUnresolved; // Check if all agents are the same type - const allSameType = - agentStats.length > 0 && - agentStats.every(stat => stat.agentType === agentStats[0]?.agentType) - const commonType = - allSameType && agentStats[0]?.agentType !== 'Agent' - ? agentStats[0]?.agentType - : null + const allSameType = agentStats.length > 0 && agentStats.every(stat => stat.agentType === agentStats[0]?.agentType); + const commonType = allSameType && agentStats[0]?.agentType !== 'Agent' ? agentStats[0]?.agentType : null; // Check if all resolved agents are async (background) - const allAsync = agentStats.every(stat => stat.isAsync) + const allAsync = agentStats.every(stat => stat.isAsync); return ( - + {allComplete ? ( allAsync ? ( @@ -948,14 +844,12 @@ export function renderGroupedAgentToolUse( ) : ( <> - {toolUses.length}{' '} - {commonType ? `${commonType} agents` : 'agents'} finished + {toolUses.length} {commonType ? `${commonType} agents` : 'agents'} finished ) ) : ( <> - Running {toolUses.length}{' '} - {commonType ? `${commonType} agents` : 'agents'}… + Running {toolUses.length} {commonType ? `${commonType} agents` : 'agents'}… )}{' '} @@ -982,154 +876,129 @@ export function renderGroupedAgentToolUse( /> ))} - ) + ); } export function userFacingName( input: | Partial<{ - description: string - prompt: string - subagent_type: string - name: string - team_name: string + description: string; + prompt: string; + subagent_type: string; + name: string; + team_name: string; }> | undefined, ): string { - if ( - input?.subagent_type && - input.subagent_type !== GENERAL_PURPOSE_AGENT.agentType - ) { + if (input?.subagent_type && input.subagent_type !== GENERAL_PURPOSE_AGENT.agentType) { // Display "worker" agents as "Agent" for cleaner UI if (input.subagent_type === 'worker') { - return 'Agent' + return 'Agent'; } - return input.subagent_type + return input.subagent_type; } - return 'Agent' + return 'Agent'; } export function userFacingNameBackgroundColor( - input: - | Partial<{ description: string; prompt: string; subagent_type: string }> - | undefined, + input: Partial<{ description: string; prompt: string; subagent_type: string }> | undefined, ): keyof Theme | undefined { if (!input?.subagent_type) { - return undefined + return undefined; } // Get the color for this agent - return getAgentColor(input.subagent_type) as keyof Theme | undefined + return getAgentColor(input.subagent_type) as keyof Theme | undefined; } -export function extractLastToolInfo( - progressMessages: ProgressMessage[], - tools: Tools, -): string | null { +export function extractLastToolInfo(progressMessages: ProgressMessage[], tools: Tools): string | null { // Build tool_use lookup from all progress messages (needed for reverse iteration) - const toolUseByID = new Map() + const toolUseByID = new Map(); for (const pm of progressMessages) { if (!hasProgressMessage(pm.data)) { - continue + continue; } if (pm.data.message.type === 'assistant') { for (const c of pm.data.message.message.content) { if (c.type === 'tool_use') { - toolUseByID.set(c.id, c as ToolUseBlockParam) + toolUseByID.set(c.id, c as ToolUseBlockParam); } } } } // Count trailing consecutive search/read operations from the end - let searchCount = 0 - let readCount = 0 + let searchCount = 0; + let readCount = 0; for (let i = progressMessages.length - 1; i >= 0; i--) { - const msg = progressMessages[i]! + const msg = progressMessages[i]!; if (!hasProgressMessage(msg.data)) { - continue + continue; } - const info = getSearchOrReadInfo(msg, tools, toolUseByID) + const info = getSearchOrReadInfo(msg, tools, toolUseByID); if (info && (info.isSearch || info.isRead)) { // Only count tool_result messages to avoid double counting if (msg.data.message.type === 'user') { if (info.isSearch) { - searchCount++ + searchCount++; } else if (info.isRead) { - readCount++ + readCount++; } } } else { - break + break; } } if (searchCount + readCount >= 2) { - return getSearchReadSummaryText(searchCount, readCount, true) + return getSearchReadSummaryText(searchCount, readCount, true); } // Find the last tool_result message - const lastToolResult = progressMessages.findLast( - (msg): msg is ProgressMessage => { - if (!hasProgressMessage(msg.data)) { - return false - } - const message = msg.data.message - return ( - message.type === 'user' && - message.message.content.some(c => c.type === 'tool_result') - ) - }, - ) + const lastToolResult = progressMessages.findLast((msg): msg is ProgressMessage => { + if (!hasProgressMessage(msg.data)) { + return false; + } + const message = msg.data.message; + return message.type === 'user' && message.message.content.some(c => c.type === 'tool_result'); + }); if (lastToolResult?.data.message.type === 'user') { - const toolResultBlock = lastToolResult.data.message.message.content.find( - c => c.type === 'tool_result', - ) + const toolResultBlock = lastToolResult.data.message.message.content.find(c => c.type === 'tool_result'); if (toolResultBlock?.type === 'tool_result') { // Look up the corresponding tool_use — already indexed above - const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id) + const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id); if (toolUseBlock) { - const tool = findToolByName(tools, toolUseBlock.name) + const tool = findToolByName(tools, toolUseBlock.name); if (!tool) { - return toolUseBlock.name // Fallback to raw name + return toolUseBlock.name; // Fallback to raw name } - const input = toolUseBlock.input as Record - const parsedInput = tool.inputSchema.safeParse(input) + const input = toolUseBlock.input as Record; + const parsedInput = tool.inputSchema.safeParse(input); // Get user-facing tool name - const userFacingToolName = tool.userFacingName( - parsedInput.success ? parsedInput.data : undefined, - ) + const userFacingToolName = tool.userFacingName(parsedInput.success ? parsedInput.data : undefined); // Try to get summary from the tool itself if (tool.getToolUseSummary) { - const summary = tool.getToolUseSummary( - parsedInput.success ? parsedInput.data : undefined, - ) + const summary = tool.getToolUseSummary(parsedInput.success ? parsedInput.data : undefined); if (summary) { - return `${userFacingToolName}: ${summary}` + return `${userFacingToolName}: ${summary}`; } } // Default: just show user-facing tool name - return userFacingToolName + return userFacingToolName; } } } - return null + return null; } -function isCustomSubagentType( - subagentType: string | undefined, -): subagentType is string { - return ( - !!subagentType && - subagentType !== GENERAL_PURPOSE_AGENT.agentType && - subagentType !== 'worker' - ) +function isCustomSubagentType(subagentType: string | undefined): subagentType is string { + return !!subagentType && subagentType !== GENERAL_PURPOSE_AGENT.agentType && subagentType !== 'worker'; } diff --git a/src/tools/AgentTool/__tests__/agentToolUtils.test.ts b/src/tools/AgentTool/__tests__/agentToolUtils.test.ts index d0335b387..779633de5 100644 --- a/src/tools/AgentTool/__tests__/agentToolUtils.test.ts +++ b/src/tools/AgentTool/__tests__/agentToolUtils.test.ts @@ -5,6 +5,7 @@ import { mock, describe, expect, test } from "bun:test"; // Do NOT mock common/shared modules (zod/v4, bootstrap/state, etc.) to avoid // corrupting the module cache for other test files in the same Bun process. +const emittedProgressEvents: any[] = []; const noop = () => {}; mock.module("bun:bundle", () => ({ feature: () => false })); @@ -130,11 +131,18 @@ mock.module("src/utils/permissions/yoloClassifier.js", () => ({ })); mock.module("src/utils/task/sdkProgress.js", () => ({ - emitTaskProgress: noop, + emitTaskProgress: (event: any) => { + emittedProgressEvents.push(event); + }, })); mock.module("src/utils/tokens.js", () => ({ - getTokenCountFromUsage: () => 0, + getTokenUsage: (message: any) => message?.message?.usage, + getTokenCountFromUsage: (usage: any) => + (usage?.input_tokens ?? 0) + + (usage?.cache_creation_input_tokens ?? 0) + + (usage?.cache_read_input_tokens ?? 0) + + (usage?.output_tokens ?? 0), })); mock.module("src/tools/ExitPlanModeTool/constants.js", () => ({ @@ -164,7 +172,9 @@ mock.module("src/tools/AgentTool/AgentTool.tsx", () => ({ const { countToolUses, + emitTaskProgress, getLastToolUseName, + resolveAgentTotalTokens, } = await import("../agentToolUtils"); function makeAssistantMessage(content: any[]): any { @@ -218,6 +228,93 @@ describe("countToolUses", () => { }); }); +describe("resolveAgentTotalTokens", () => { + test("emits progress with resolved token fallback when final usage is zero", () => { + emittedProgressEvents.length = 0; + const tracker = { + toolUseCount: 2, + latestInputTokens: 0, + cumulativeOutputTokens: 21, + recentActivities: [], + }; + const messages = [ + { + type: "assistant", + message: { + content: [{ type: "text", text: "step 1" }], + usage: { input_tokens: 8, output_tokens: 4 }, + }, + }, + { + type: "assistant", + message: { + content: [{ type: "tool_use", name: "Read" }], + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }, + ]; + + emitTaskProgress( + tracker, + "task-1", + undefined, + "desc", + 0, + "Read", + messages, + ); + + expect(emittedProgressEvents).toHaveLength(1); + expect(emittedProgressEvents[0].totalTokens).toBe(12); + }); + + test("prefers final assistant usage when it is positive", () => { + const messages = [ + { + type: "assistant", + message: { + content: [{ type: "text", text: "done" }], + usage: { input_tokens: 10, output_tokens: 5 }, + }, + }, + ]; + expect(resolveAgentTotalTokens(messages, 3)).toBe(15); + }); + + test("falls back to earlier positive assistant usage when final usage is zero", () => { + const messages = [ + { + type: "assistant", + message: { + content: [{ type: "text", text: "step 1" }], + usage: { input_tokens: 8, output_tokens: 4 }, + }, + }, + { + type: "assistant", + message: { + content: [{ type: "text", text: "done" }], + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }, + ]; + expect(resolveAgentTotalTokens(messages, 3)).toBe(12); + }); + + test("falls back to tracker token count when all assistant usage is zero", () => { + const messages = [ + { + type: "assistant", + message: { + content: [{ type: "text", text: "done" }], + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }, + ]; + expect(resolveAgentTotalTokens(messages, 21)).toBe(21); + }); +}); + describe("getLastToolUseName", () => { test("returns last tool name from assistant message", () => { const msg = makeAssistantMessage([ diff --git a/src/tools/AgentTool/agentToolUtils.ts b/src/tools/AgentTool/agentToolUtils.ts index 084ac6a85..d9ea72135 100644 --- a/src/tools/AgentTool/agentToolUtils.ts +++ b/src/tools/AgentTool/agentToolUtils.ts @@ -55,7 +55,7 @@ import { } from '../../utils/permissions/yoloClassifier.js' import { emitTaskProgress as emitTaskProgressEvent } from '../../utils/task/sdkProgress.js' import { isInProcessTeammate } from '../../utils/teammateContext.js' -import { getTokenCountFromUsage } from '../../utils/tokens.js' +import { getTokenCountFromUsage, getTokenUsage } from '../../utils/tokens.js' import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../ExitPlanModeTool/constants.js' import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME } from './constants.js' import type { AgentDefinition } from './loadAgentsDir.js' @@ -274,6 +274,43 @@ export function countToolUses(messages: MessageType[]): number { return count } +export function resolveAgentTotalTokens( + agentMessages: MessageType[], + trackerTokenCount?: number, +): number { + const lastAssistantMessage = getLastAssistantMessage(agentMessages) + const lastAssistantUsage = lastAssistantMessage + ? getTokenUsage(lastAssistantMessage) + : undefined + const lastAssistantTokens = lastAssistantUsage + ? getTokenCountFromUsage(lastAssistantUsage) + : undefined + + if (typeof lastAssistantTokens === 'number' && lastAssistantTokens > 0) { + return lastAssistantTokens + } + + let latestPositiveUsageTokens: number | undefined + for (let i = agentMessages.length - 1; i >= 0; i--) { + const message = agentMessages[i] + const usage = message ? getTokenUsage(message) : undefined + if (!usage) { + continue + } + const tokenCount = getTokenCountFromUsage(usage) + if (tokenCount > 0) { + latestPositiveUsageTokens = tokenCount + break + } + } + + return Math.max( + lastAssistantTokens ?? 0, + latestPositiveUsageTokens ?? 0, + trackerTokenCount ?? 0, + ) +} + export function finalizeAgentTool( agentMessages: MessageType[], agentId: string, @@ -285,6 +322,7 @@ export function finalizeAgentTool( agentType: string isAsync: boolean }, + trackerTokenCount?: number, ): AgentToolResult { const { prompt, @@ -317,7 +355,7 @@ export function finalizeAgentTool( } } - const totalTokens = getTokenCountFromUsage(lastAssistantMessage.message?.usage as Parameters[0]) + const totalTokens = resolveAgentTotalTokens(agentMessages, trackerTokenCount) const totalToolUseCount = countToolUses(agentMessages) logEvent('tengu_agent_tool_completed', { @@ -374,14 +412,18 @@ export function emitTaskProgress( description: string, startTime: number, lastToolName: string, + agentMessages?: MessageType[], ): void { const progress = getProgressUpdate(tracker) + const totalTokens = agentMessages + ? resolveAgentTotalTokens(agentMessages, progress.tokenCount) + : progress.tokenCount emitTaskProgressEvent({ taskId, toolUseId, description: progress.lastActivity?.activityDescription ?? description, startTime, - totalTokens: progress.tokenCount, + totalTokens, toolUses: progress.toolUseCount, lastToolName, }) @@ -589,13 +631,19 @@ export async function runAsyncAgentLifecycle({ description, metadata.startTime, lastToolName, + agentMessages, ) } } stopSummarization?.() - const agentResult = finalizeAgentTool(agentMessages, taskId, metadata) + const agentResult = finalizeAgentTool( + agentMessages, + taskId, + metadata, + getTokenCountFromTracker(tracker), + ) // Mark task completed FIRST so TaskOutput(block=true) unblocks // immediately. classifyHandoffIfNeeded (API call) and getWorktreeResult @@ -629,7 +677,7 @@ export async function runAsyncAgentLifecycle({ setAppState: rootSetAppState, finalMessage, usage: { - totalTokens: getTokenCountFromTracker(tracker), + totalTokens: agentResult.totalTokens, toolUses: agentResult.totalToolUseCount, durationMs: agentResult.totalDurationMs, }, diff --git a/src/tools/AgentTool/built-in/claudeCodeGuideAgent.ts b/src/tools/AgentTool/built-in/claudeCodeGuideAgent.ts index 30b446dc1..abe64b0e4 100644 --- a/src/tools/AgentTool/built-in/claudeCodeGuideAgent.ts +++ b/src/tools/AgentTool/built-in/claudeCodeGuideAgent.ts @@ -116,7 +116,7 @@ export const CLAUDE_CODE_GUIDE_AGENT: BuiltInAgentDefinition = { ], source: 'built-in', baseDir: 'built-in', - model: 'haiku', + model: 'inherit', permissionMode: 'dontAsk', getSystemPrompt({ toolUseContext }) { const commands = toolUseContext.options.commands diff --git a/src/tools/AgentTool/built-in/exploreAgent.ts b/src/tools/AgentTool/built-in/exploreAgent.ts index f508dc62f..00b4a3a4e 100644 --- a/src/tools/AgentTool/built-in/exploreAgent.ts +++ b/src/tools/AgentTool/built-in/exploreAgent.ts @@ -73,9 +73,7 @@ export const EXPLORE_AGENT: BuiltInAgentDefinition = { ], source: 'built-in', baseDir: 'built-in', - // Ants get inherit to use the main agent's model; external users get haiku for speed - // Note: For ants, getAgentModel() checks tengu_explore_agent GrowthBook flag at runtime - model: process.env.USER_TYPE === 'ant' ? 'inherit' : 'haiku', + model: 'inherit', // Explore is a fast read-only search agent — it doesn't need commit/PR/lint // rules from CLAUDE.md. The main agent has full context and interprets results. omitClaudeMd: true, diff --git a/src/tools/BashTool/BashTool.tsx b/src/tools/BashTool/BashTool.tsx index 24499566e..a5bb38a34 100644 --- a/src/tools/BashTool/BashTool.tsx +++ b/src/tools/BashTool/BashTool.tsx @@ -66,7 +66,10 @@ import { semanticNumber } from '../../utils/semanticNumber.js' import { EndTruncatingAccumulator } from '../../utils/stringUtils.js' import { getTaskOutputPath } from '../../utils/task/diskOutput.js' import { TaskOutput } from '../../utils/task/TaskOutput.js' -import { isOutputLineTruncated } from '../../utils/terminal.js' +import { + getTruncationTerminalWidth, + isOutputLineTruncated, +} from '../../utils/terminal.js' import { buildLargeToolResultMessage, ensureToolResultsDir, @@ -1093,10 +1096,11 @@ export const BashTool = buildTool({ } }, renderToolUseErrorMessage, - isResultTruncated(output: Out): boolean { + isResultTruncated(output: Out, { terminalSize }): boolean { + const columns = getTruncationTerminalWidth(terminalSize?.columns) return ( - isOutputLineTruncated(output.stdout) || - isOutputLineTruncated(output.stderr) + isOutputLineTruncated(output.stdout, columns) || + isOutputLineTruncated(output.stderr, columns) ) }, } satisfies ToolDef) diff --git a/src/tools/BashTool/UI.tsx b/src/tools/BashTool/UI.tsx index 2b0b3bfd3..4cc9d1b79 100644 --- a/src/tools/BashTool/UI.tsx +++ b/src/tools/BashTool/UI.tsx @@ -73,6 +73,21 @@ export function BackgroundHint({ ) } +export function renderMultilineCommandLines( + command: string, + dimColor = false, +): React.ReactNode { + return ( + + {command.split('\n').map((line, index) => ( + + {line || ' '} + + ))} + + ) +} + export function renderToolUseMessage( input: Partial, { verbose, theme: _theme }: { verbose: boolean; theme: ThemeName }, @@ -103,21 +118,24 @@ export function renderToolUseMessage( const needsLineTruncation = lines.length > MAX_COMMAND_DISPLAY_LINES const needsCharTruncation = command.length > MAX_COMMAND_DISPLAY_CHARS - if (needsLineTruncation || needsCharTruncation) { - let truncated = command + let display = command + let wasTruncated = false - // First truncate by lines if needed - if (needsLineTruncation) { - truncated = lines.slice(0, MAX_COMMAND_DISPLAY_LINES).join('\n') - } + if (needsLineTruncation) { + display = lines.slice(0, MAX_COMMAND_DISPLAY_LINES).join('\n') + wasTruncated = true + } - // Then truncate by chars if still too long - if (truncated.length > MAX_COMMAND_DISPLAY_CHARS) { - truncated = truncated.slice(0, MAX_COMMAND_DISPLAY_CHARS) - } + if (display.length > MAX_COMMAND_DISPLAY_CHARS) { + display = display.slice(0, MAX_COMMAND_DISPLAY_CHARS) + wasTruncated = true + } - return {truncated.trim()}… + if (wasTruncated) { + display = `${display.trim()}…` } + + return display } return command diff --git a/src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts b/src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts index 6f3f087af..8df9854e0 100644 --- a/src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts +++ b/src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts @@ -8,7 +8,10 @@ import { errorMessage } from '../../utils/errors.js' import { lazySchema } from '../../utils/lazySchema.js' import { logMCPError } from '../../utils/log.js' import { jsonStringify } from '../../utils/slowOperations.js' -import { isOutputLineTruncated } from '../../utils/terminal.js' +import { + getTruncationTerminalWidth, + isOutputLineTruncated, +} from '../../utils/terminal.js' import { DESCRIPTION, LIST_MCP_RESOURCES_TOOL_NAME, PROMPT } from './prompt.js' import { renderToolResultMessage, renderToolUseMessage } from './UI.js' @@ -103,7 +106,7 @@ export const ListMcpResourcesTool = buildTool({ userFacingName: () => 'listMcpResources', renderToolResultMessage, isResultTruncated(output: Output): boolean { - return isOutputLineTruncated(jsonStringify(output)) + return isOutputLineTruncated(jsonStringify(output), getTruncationTerminalWidth()) }, mapToolResultToToolResultBlockParam(content, toolUseID) { if (!content || content.length === 0) { diff --git a/src/tools/MCPTool/MCPTool.ts b/src/tools/MCPTool/MCPTool.ts index 3896868b3..cccf81fef 100644 --- a/src/tools/MCPTool/MCPTool.ts +++ b/src/tools/MCPTool/MCPTool.ts @@ -2,7 +2,10 @@ import { z } from 'zod/v4' import { buildTool, type ToolDef } from '../../Tool.js' import { lazySchema } from '../../utils/lazySchema.js' import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' -import { isOutputLineTruncated } from '../../utils/terminal.js' +import { + getTruncationTerminalWidth, + isOutputLineTruncated, +} from '../../utils/terminal.js' import { DESCRIPTION, PROMPT } from './prompt.js' import { renderToolResultMessage, @@ -65,7 +68,7 @@ export const MCPTool = buildTool({ renderToolUseProgressMessage, renderToolResultMessage, isResultTruncated(output: Output): boolean { - return isOutputLineTruncated(output) + return isOutputLineTruncated(output, getTruncationTerminalWidth()) }, mapToolResultToToolResultBlockParam(content, toolUseID) { return { diff --git a/src/tools/PowerShellTool/PowerShellTool.tsx b/src/tools/PowerShellTool/PowerShellTool.tsx index d5d49614f..83d4c0f63 100644 --- a/src/tools/PowerShellTool/PowerShellTool.tsx +++ b/src/tools/PowerShellTool/PowerShellTool.tsx @@ -53,7 +53,10 @@ import { getCachedPowerShellPath } from '../../utils/shell/powershellDetection.j import { EndTruncatingAccumulator } from '../../utils/stringUtils.js' import { getTaskOutputPath } from '../../utils/task/diskOutput.js' import { TaskOutput } from '../../utils/task/TaskOutput.js' -import { isOutputLineTruncated } from '../../utils/terminal.js' +import { + getTruncationTerminalWidth, + isOutputLineTruncated, +} from '../../utils/terminal.js' import { buildLargeToolResultMessage, ensureToolResultsDir, @@ -852,10 +855,11 @@ export const PowerShellTool = buildTool({ if (setToolJSX) setToolJSX(null) } }, - isResultTruncated(output: Out): boolean { + isResultTruncated(output: Out, { terminalSize }): boolean { + const columns = getTruncationTerminalWidth(terminalSize?.columns) return ( - isOutputLineTruncated(output.stdout) || - isOutputLineTruncated(output.stderr) + isOutputLineTruncated(output.stdout, columns) || + isOutputLineTruncated(output.stderr, columns) ) }, } satisfies ToolDef) diff --git a/src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts b/src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts index 593131e74..932198729 100644 --- a/src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts +++ b/src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts @@ -11,7 +11,10 @@ import { persistBinaryContent, } from '../../utils/mcpOutputStorage.js' import { jsonStringify } from '../../utils/slowOperations.js' -import { isOutputLineTruncated } from '../../utils/terminal.js' +import { + getTruncationTerminalWidth, + isOutputLineTruncated, +} from '../../utils/terminal.js' import { DESCRIPTION, PROMPT } from './prompt.js' import { renderToolResultMessage, @@ -146,7 +149,7 @@ export const ReadMcpResourceTool = buildTool({ userFacingName, renderToolResultMessage, isResultTruncated(output: Output): boolean { - return isOutputLineTruncated(jsonStringify(output)) + return isOutputLineTruncated(jsonStringify(output), getTruncationTerminalWidth()) }, mapToolResultToToolResultBlockParam(content, toolUseID) { return { diff --git a/src/tools/WebFetchTool/utils.ts b/src/tools/WebFetchTool/utils.ts index f75e358b4..e44cacfdd 100644 --- a/src/tools/WebFetchTool/utils.ts +++ b/src/tools/WebFetchTool/utils.ts @@ -12,28 +12,10 @@ import { isBinaryContentType, persistBinaryContent, } from '../../utils/mcpOutputStorage.js' -import { getSettings_DEPRECATED } from '../../utils/settings/settings.js' import { asSystemPrompt } from '../../utils/systemPromptType.js' import { isPreapprovedHost } from './preapproved.js' import { makeSecondaryModelPrompt } from './prompt.js' -// Custom error classes for domain blocking -class DomainBlockedError extends Error { - constructor(domain: string) { - super(`Claude Code is unable to fetch from ${domain}`) - this.name = 'DomainBlockedError' - } -} - -class DomainCheckFailedError extends Error { - constructor(domain: string) { - super( - `Unable to verify if domain ${domain} is safe to fetch. This may be due to network restrictions or enterprise security policies blocking claude.ai.`, - ) - this.name = 'DomainCheckFailedError' - } -} - class EgressBlockedError extends Error { constructor(public readonly domain: string) { super( @@ -68,18 +50,8 @@ const URL_CACHE = new LRUCache({ ttl: CACHE_TTL_MS, }) -// Separate cache for preflight domain checks. URL_CACHE is URL-keyed, so -// fetching two paths on the same domain triggers two identical preflight -// HTTP round-trips to api.anthropic.com. This hostname-keyed cache avoids -// that. Only 'allowed' is cached — blocked/failed re-check on next attempt. -const DOMAIN_CHECK_CACHE = new LRUCache({ - max: 128, - ttl: 5 * 60 * 1000, // 5 minutes — shorter than URL_CACHE TTL -}) - export function clearWebFetchCache(): void { URL_CACHE.clear() - DOMAIN_CHECK_CACHE.clear() } // Lazy singleton — defers the turndown → @mixmark-io/domino import (~1.4MB @@ -115,9 +87,6 @@ const MAX_HTTP_CONTENT_LENGTH = 10 * 1024 * 1024 // Prevents hanging indefinitely on slow/unresponsive servers. const FETCH_TIMEOUT_MS = 60_000 -// Timeout for the domain blocklist preflight check (10 seconds). -const DOMAIN_CHECK_TIMEOUT_MS = 10_000 - // Cap same-host redirect hops. Without this a malicious server can return // a redirect loop (/a → /b → /a …) and the per-request FETCH_TIMEOUT_MS // resets on every hop, hanging the tool until user interrupt. 10 matches @@ -168,40 +137,6 @@ export function validateURL(url: string): boolean { return true } -type DomainCheckResult = - | { status: 'allowed' } - | { status: 'blocked' } - | { status: 'check_failed'; error: Error } - -export async function checkDomainBlocklist( - domain: string, -): Promise { - if (DOMAIN_CHECK_CACHE.has(domain)) { - return { status: 'allowed' } - } - try { - const response = await axios.get( - `https://api.anthropic.com/api/web/domain_info?domain=${encodeURIComponent(domain)}`, - { timeout: DOMAIN_CHECK_TIMEOUT_MS }, - ) - if (response.status === 200) { - if (response.data.can_fetch === true) { - DOMAIN_CHECK_CACHE.set(domain, true) - return { status: 'allowed' } - } - return { status: 'blocked' } - } - // Non-200 status but didn't throw - return { - status: 'check_failed', - error: new Error(`Domain check returned status ${response.status}`), - } - } catch (e) { - logError(e) - return { status: 'check_failed', error: e as Error } - } -} - /** * Check if a redirect is safe to follow * Allows redirects that: @@ -380,23 +315,6 @@ export async function getURLMarkdownContent( const hostname = parsedUrl.hostname - // Check if the user has opted to skip the blocklist check - // This is for enterprise customers with restrictive security policies - // that prevent outbound connections to claude.ai - const settings = getSettings_DEPRECATED() - if (settings.skipWebFetchPreflight === false) { - const checkResult = await checkDomainBlocklist(hostname) - switch (checkResult.status) { - case 'allowed': - // Continue with the fetch - break - case 'blocked': - throw new DomainBlockedError(hostname) - case 'check_failed': - throw new DomainCheckFailedError(hostname) - } - } - if (process.env.USER_TYPE === 'ant') { logEvent('tengu_web_fetch_host', { hostname: @@ -404,13 +322,6 @@ export async function getURLMarkdownContent( }) } } catch (e) { - if ( - e instanceof DomainBlockedError || - e instanceof DomainCheckFailedError - ) { - // Expected user-facing failures - re-throw without logging as internal error - throw e - } logError(e) } diff --git a/src/utils/__tests__/formatBriefTimestamp.test.ts b/src/utils/__tests__/formatBriefTimestamp.test.ts index 8dafb7b0a..535a6b5ce 100644 --- a/src/utils/__tests__/formatBriefTimestamp.test.ts +++ b/src/utils/__tests__/formatBriefTimestamp.test.ts @@ -1,9 +1,36 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { formatBriefTimestamp } from "../formatBriefTimestamp"; describe("formatBriefTimestamp", () => { // Fixed "now" for deterministic tests: 2026-04-02T14:00:00Z (Thursday) const now = new Date("2026-04-02T14:00:00Z"); + const originalLcAll = process.env.LC_ALL; + const originalLcTime = process.env.LC_TIME; + const originalLang = process.env.LANG; + + beforeEach(() => { + process.env.LC_ALL = "en_US.UTF-8"; + process.env.LC_TIME = "en_US.UTF-8"; + process.env.LANG = "en_US.UTF-8"; + }); + + afterEach(() => { + if (originalLcAll !== undefined) { + process.env.LC_ALL = originalLcAll; + } else { + delete process.env.LC_ALL; + } + if (originalLcTime !== undefined) { + process.env.LC_TIME = originalLcTime; + } else { + delete process.env.LC_TIME; + } + if (originalLang !== undefined) { + process.env.LANG = originalLang; + } else { + delete process.env.LANG; + } + }); test("same day timestamp returns time only (contains colon)", () => { const result = formatBriefTimestamp("2026-04-02T10:30:00Z", now); diff --git a/src/utils/__tests__/terminal.test.ts b/src/utils/__tests__/terminal.test.ts new file mode 100644 index 000000000..921ee38d7 --- /dev/null +++ b/src/utils/__tests__/terminal.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { + isOutputLineTruncated, + renderTruncatedContent, + sanitizeCapturedTerminalOutput, +} from "../terminal"; + +describe("sanitizeCapturedTerminalOutput", () => { + test("keeps only the latest carriage-return segment on a line", () => { + expect(sanitizeCapturedTerminalOutput("old status\rfinal line")).toBe( + "final line", + ); + }); + + test("normalizes CRLF while preserving line breaks", () => { + expect(sanitizeCapturedTerminalOutput("a\r\nb\r\nc")).toBe("a\nb\nc"); + }); + + test("removes OSC and non-SGR CSI control sequences", () => { + const input = + "prefix\u001B]0;title\u0007\u001B[2K\u001B[1Gvisible\u001B[31m red\u001B[0m"; + expect(sanitizeCapturedTerminalOutput(input)).toBe( + "prefixvisible\u001B[31m red\u001B[0m", + ); + }); +}); + +describe("renderTruncatedContent", () => { + test("does not leak overwritten status text into folded output", () => { + const content = [ + "line 1", + "line 2", + "line 3", + "status: searching\rfinal error line", + ].join("\n"); + + const rendered = renderTruncatedContent(content, 80, true); + + expect(rendered).toContain("final error line"); + expect(rendered).not.toContain("status: searching"); + }); +}); + +describe("isOutputLineTruncated", () => { + test("uses sanitized content for carriage-return overwritten lines", () => { + const content = [ + "line 1", + "line 2", + "line 3", + "progress\roverwritten line", + "line 5", + ].join("\n"); + + expect(isOutputLineTruncated(content, 80)).toBe(true); + }); + + test("matches visual wrapping for a long single line", () => { + expect(isOutputLineTruncated("x".repeat(160), 40)).toBe(true); + }); + + test("ignores trailing newline after sanitization", () => { + expect(isOutputLineTruncated("a\nb\nc\n", 80)).toBe(false); + }); +}); diff --git a/src/utils/model/__tests__/providers.test.ts b/src/utils/model/__tests__/providers.test.ts index 0ed816f9e..c8e21fdb0 100644 --- a/src/utils/model/__tests__/providers.test.ts +++ b/src/utils/model/__tests__/providers.test.ts @@ -1,15 +1,23 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mock } from "bun:test"; let mockedModelType: "gemini" | undefined; - -mock.module("../../settings/settings.js", () => ({ - getInitialSettings: () => - mockedModelType ? { modelType: mockedModelType } : {}, -})); - -const { getAPIProvider, isFirstPartyAnthropicBaseUrl } = - await import("../providers"); +let providersModule: typeof import("../providers") | null = null; + +function installSettingsMock() { + mock.module("../../settings/settings.js", () => ({ + getInitialSettings: () => + mockedModelType ? { modelType: mockedModelType } : {}, + })); +} + +async function getProvidersModule() { + if (providersModule === null) { + installSettingsMock(); + providersModule = await import("../providers"); + } + return providersModule; +} describe("getAPIProvider", () => { const envKeys = [ @@ -18,12 +26,11 @@ describe("getAPIProvider", () => { "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", "CLAUDE_CODE_USE_OPENAI", + "CLAUDE_CODE_USE_GROK", ] as const; const savedEnv: Record = {}; - beforeEach(() => { - // Save and clear environment variables mockedModelType = undefined; for (const key of envKeys) { savedEnv[key] = process.env[key]; @@ -32,7 +39,6 @@ describe("getAPIProvider", () => { }); afterEach(() => { - // Restore environment variables mockedModelType = undefined; for (const key of envKeys) { if (savedEnv[key] !== undefined) { @@ -43,72 +49,89 @@ describe("getAPIProvider", () => { } }); - test('returns "firstParty" by default', () => { + afterAll(() => { + mock.restore(); + }); + + test('returns "firstParty" by default', async () => { + const { getAPIProvider } = await getProvidersModule(); expect(getAPIProvider()).toBe("firstParty"); }); - test('returns "gemini" when modelType is gemini', () => { + test('returns "gemini" when modelType is gemini', async () => { mockedModelType = "gemini"; + const { getAPIProvider } = await getProvidersModule(); expect(getAPIProvider()).toBe("gemini"); }); - test("modelType takes precedence over environment variables", () => { + test("modelType takes precedence over environment variables", async () => { mockedModelType = "gemini"; process.env.CLAUDE_CODE_USE_BEDROCK = "1"; + const { getAPIProvider } = await getProvidersModule(); expect(getAPIProvider()).toBe("gemini"); }); - test('returns "gemini" when CLAUDE_CODE_USE_GEMINI is set', () => { + test('returns "gemini" when CLAUDE_CODE_USE_GEMINI is set', async () => { process.env.CLAUDE_CODE_USE_GEMINI = "1"; + const { getAPIProvider } = await getProvidersModule(); expect(getAPIProvider()).toBe("gemini"); }); - test('returns "bedrock" when CLAUDE_CODE_USE_BEDROCK is set', () => { + test('returns "bedrock" when CLAUDE_CODE_USE_BEDROCK is set', async () => { process.env.CLAUDE_CODE_USE_BEDROCK = "1"; + const { getAPIProvider } = await getProvidersModule(); expect(getAPIProvider()).toBe("bedrock"); }); - test('returns "vertex" when CLAUDE_CODE_USE_VERTEX is set', () => { + test('returns "vertex" when CLAUDE_CODE_USE_VERTEX is set', async () => { process.env.CLAUDE_CODE_USE_VERTEX = "1"; + const { getAPIProvider } = await getProvidersModule(); expect(getAPIProvider()).toBe("vertex"); }); - test('returns "foundry" when CLAUDE_CODE_USE_FOUNDRY is set', () => { + test('returns "foundry" when CLAUDE_CODE_USE_FOUNDRY is set', async () => { process.env.CLAUDE_CODE_USE_FOUNDRY = "1"; + const { getAPIProvider } = await getProvidersModule(); expect(getAPIProvider()).toBe("foundry"); }); - test("bedrock takes precedence over gemini", () => { + test("bedrock takes precedence over gemini", async () => { process.env.CLAUDE_CODE_USE_BEDROCK = "1"; process.env.CLAUDE_CODE_USE_GEMINI = "1"; + const { getAPIProvider } = await getProvidersModule(); expect(getAPIProvider()).toBe("bedrock"); }); - test("bedrock takes precedence over vertex", () => { + test("bedrock takes precedence over vertex", async () => { process.env.CLAUDE_CODE_USE_BEDROCK = "1"; process.env.CLAUDE_CODE_USE_VERTEX = "1"; + const { getAPIProvider } = await getProvidersModule(); expect(getAPIProvider()).toBe("bedrock"); }); - test("bedrock wins when all three env vars are set", () => { + test("bedrock wins when all three env vars are set", async () => { process.env.CLAUDE_CODE_USE_BEDROCK = "1"; process.env.CLAUDE_CODE_USE_VERTEX = "1"; process.env.CLAUDE_CODE_USE_FOUNDRY = "1"; + const { getAPIProvider } = await getProvidersModule(); expect(getAPIProvider()).toBe("bedrock"); }); - test('"true" is truthy', () => { + test('"true" is truthy', async () => { process.env.CLAUDE_CODE_USE_BEDROCK = "true"; + const { getAPIProvider } = await getProvidersModule(); expect(getAPIProvider()).toBe("bedrock"); }); - test('"0" is not truthy', () => { + test('"0" is not truthy', async () => { process.env.CLAUDE_CODE_USE_BEDROCK = "0"; + const { getAPIProvider } = await getProvidersModule(); expect(getAPIProvider()).toBe("firstParty"); }); - test('empty string is not truthy', () => { + test('empty string is not truthy', async () => { process.env.CLAUDE_CODE_USE_BEDROCK = ""; + const { getAPIProvider } = await getProvidersModule(); expect(getAPIProvider()).toBe("firstParty"); }); }); @@ -130,44 +153,56 @@ describe("isFirstPartyAnthropicBaseUrl", () => { } }); - test("returns true when ANTHROPIC_BASE_URL is not set", () => { + afterAll(() => { + mock.restore(); + }); + + test("returns true when ANTHROPIC_BASE_URL is not set", async () => { delete process.env.ANTHROPIC_BASE_URL; + const { isFirstPartyAnthropicBaseUrl } = await getProvidersModule(); expect(isFirstPartyAnthropicBaseUrl()).toBe(true); }); - test("returns true for api.anthropic.com", () => { + test("returns true for api.anthropic.com", async () => { process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com"; + const { isFirstPartyAnthropicBaseUrl } = await getProvidersModule(); expect(isFirstPartyAnthropicBaseUrl()).toBe(true); }); - test("returns false for custom URL", () => { + test("returns false for custom URL", async () => { process.env.ANTHROPIC_BASE_URL = "https://my-proxy.com"; + const { isFirstPartyAnthropicBaseUrl } = await getProvidersModule(); expect(isFirstPartyAnthropicBaseUrl()).toBe(false); }); - test("returns false for invalid URL", () => { + test("returns false for invalid URL", async () => { process.env.ANTHROPIC_BASE_URL = "not-a-url"; + const { isFirstPartyAnthropicBaseUrl } = await getProvidersModule(); expect(isFirstPartyAnthropicBaseUrl()).toBe(false); }); - test("returns true for staging URL when USER_TYPE is ant", () => { + test("returns true for staging URL when USER_TYPE is ant", async () => { process.env.ANTHROPIC_BASE_URL = "https://api-staging.anthropic.com"; process.env.USER_TYPE = "ant"; + const { isFirstPartyAnthropicBaseUrl } = await getProvidersModule(); expect(isFirstPartyAnthropicBaseUrl()).toBe(true); }); - test("returns true for URL with path", () => { + test("returns true for URL with path", async () => { process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"; + const { isFirstPartyAnthropicBaseUrl } = await getProvidersModule(); expect(isFirstPartyAnthropicBaseUrl()).toBe(true); }); - test("returns true for trailing slash", () => { + test("returns true for trailing slash", async () => { process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com/"; + const { isFirstPartyAnthropicBaseUrl } = await getProvidersModule(); expect(isFirstPartyAnthropicBaseUrl()).toBe(true); }); - test("returns false for subdomain attack", () => { + test("returns false for subdomain attack", async () => { process.env.ANTHROPIC_BASE_URL = "https://evil-api.anthropic.com"; + const { isFirstPartyAnthropicBaseUrl } = await getProvidersModule(); expect(isFirstPartyAnthropicBaseUrl()).toBe(false); }); }); diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index edeecb190..51db1d7cc 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -653,12 +653,6 @@ export const SettingsSchema = lazySchema(() => .describe( 'Preferred language for Claude responses and voice dictation (e.g., "japanese", "spanish")', ), - skipWebFetchPreflight: z - .boolean() - .optional() - .describe( - 'Skip the WebFetch blocklist check for enterprise environments with restrictive security policies', - ), sandbox: SandboxSettingsSchema().optional(), feedbackSurveyRate: z .number() diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index a935f700c..650395df2 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -3,11 +3,29 @@ import { ctrlOToExpand } from '../components/CtrlOToExpand.js' import { stringWidth } from '@anthropic/ink' import sliceAnsi from './sliceAnsi.js' +const OSC_SEQUENCE = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g +const NON_SGR_CSI_SEQUENCE = /\u001B\[(?![0-9;]*m)[0-9;?]*[ -/]*[@-~]/g + +export function sanitizeCapturedTerminalOutput(content: string): string { + const withoutOsc = content.replace(OSC_SEQUENCE, '') + const withoutCursorControl = withoutOsc.replace(NON_SGR_CSI_SEQUENCE, '') + const normalizedNewlines = withoutCursorControl.replace(/\r\n/g, '\n') + + return normalizedNewlines + .split('\n') + .map(line => { + const carriageReturnSegments = line.split('\r') + return carriageReturnSegments[carriageReturnSegments.length - 1] ?? '' + }) + .join('\n') +} + // Text rendering utilities for terminal display const MAX_LINES_TO_SHOW = 3 // Account for MessageResponse prefix (" ⎿ " = 5 chars) + parent width // reduction (columns - 5 in tool result rendering) const PADDING_TO_PREVENT_OVERFLOW = 10 +const DEFAULT_TERMINAL_WIDTH = 80 /** * Inserts newlines in a string to wrap it at the specified width. @@ -16,10 +34,31 @@ const PADDING_TO_PREVENT_OVERFLOW = 10 * @param wrapWidth The width at which to wrap lines (in visible characters). * @returns The wrapped text. */ +type WrappedText = { + aboveTheFold: string + remainingLines: number + wrappedLineCount: number +} + +type PreparedTruncation = { + trimmedContent: string + wrapWidth: number + preTruncated: boolean + wrapped: WrappedText +} + +function getTerminalWrapWidth(terminalWidth: number): number { + return Math.max(terminalWidth - PADDING_TO_PREVENT_OVERFLOW, 10) +} + +export function getTruncationTerminalWidth(terminalWidth?: number): number { + return terminalWidth ?? process.stdout.columns ?? DEFAULT_TERMINAL_WIDTH +} + function wrapText( text: string, wrapWidth: number, -): { aboveTheFold: string; remainingLines: number } { +): WrappedText { const lines = text.split('\n') const wrappedLines: string[] = [] @@ -50,6 +89,7 @@ function wrapText( .join('\n') .trimEnd(), remainingLines: 0, // All lines are shown, nothing remaining + wrappedLineCount: wrappedLines.length, } } @@ -57,6 +97,34 @@ function wrapText( return { aboveTheFold: wrappedLines.slice(0, MAX_LINES_TO_SHOW).join('\n').trimEnd(), remainingLines: Math.max(0, remainingLines), + wrappedLineCount: wrappedLines.length, + } +} + +function prepareTruncatedContent( + content: string, + terminalWidth: number, +): PreparedTruncation | null { + const trimmedContent = sanitizeCapturedTerminalOutput(content).trimEnd() + if (!trimmedContent) { + return null + } + + const wrapWidth = getTerminalWrapWidth(terminalWidth) + + // Only process enough content for the visible lines. Avoids O(n) wrapping + // on huge outputs (e.g. 64MB binary dumps that cause 382K-row screens). + const maxChars = MAX_LINES_TO_SHOW * wrapWidth * 4 + const preTruncated = trimmedContent.length > maxChars + const contentForWrapping = preTruncated + ? trimmedContent.slice(0, maxChars) + : trimmedContent + + return { + trimmedContent, + wrapWidth, + preTruncated, + wrapped: wrapText(contentForWrapping, wrapWidth), } } @@ -73,25 +141,13 @@ export function renderTruncatedContent( terminalWidth: number, suppressExpandHint = false, ): string { - const trimmedContent = content.trimEnd() - if (!trimmedContent) { + const prepared = prepareTruncatedContent(content, terminalWidth) + if (!prepared) { return '' } - const wrapWidth = Math.max(terminalWidth - PADDING_TO_PREVENT_OVERFLOW, 10) - - // Only process enough content for the visible lines. Avoids O(n) wrapping - // on huge outputs (e.g. 64MB binary dumps that cause 382K-row screens). - const maxChars = MAX_LINES_TO_SHOW * wrapWidth * 4 - const preTruncated = trimmedContent.length > maxChars - const contentForWrapping = preTruncated - ? trimmedContent.slice(0, maxChars) - : trimmedContent - - const { aboveTheFold, remainingLines } = wrapText( - contentForWrapping, - wrapWidth, - ) + const { trimmedContent, wrapWidth, preTruncated, wrapped } = prepared + const { aboveTheFold, remainingLines } = wrapped const estimatedRemaining = preTruncated ? Math.max( @@ -112,20 +168,22 @@ export function renderTruncatedContent( .join('\n') } -/** Fast check: would OutputLine truncate this content? Counts raw newlines - * only (ignores terminal-width wrapping), so it may return false for a single - * very long line that wraps past 3 visual rows — acceptable, since the common - * case is multi-line output. */ -export function isOutputLineTruncated(content: string): boolean { - let pos = 0 - // Need more than MAX_LINES_TO_SHOW newlines (content fills > 3 lines). - // The +1 accounts for wrapText showing an extra line when remainingLines==1. - for (let i = 0; i <= MAX_LINES_TO_SHOW; i++) { - pos = content.indexOf('\n', pos) - if (pos === -1) return false - pos++ +/** Fast check: would OutputLine truncate this content for the given width? + * Mirrors renderTruncatedContent's sanitize + wrap behavior so truncation + * detection matches what the user actually sees. */ +export function isOutputLineTruncated( + content: string, + terminalWidth: number, +): boolean { + const prepared = prepareTruncatedContent(content, terminalWidth) + if (!prepared) { + return false } - // A trailing newline is a terminator, not a new line — match - // renderTruncatedContent's trimEnd() behavior. - return pos < content.length + + const { + preTruncated, + wrapped: { remainingLines, wrappedLineCount }, + } = prepared + + return preTruncated || remainingLines > 0 || wrappedLineCount > MAX_LINES_TO_SHOW + 1 } diff --git a/src/voice/__tests__/voiceModeEnabled.test.ts b/src/voice/__tests__/voiceModeEnabled.test.ts new file mode 100644 index 000000000..2bad0b087 --- /dev/null +++ b/src/voice/__tests__/voiceModeEnabled.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +const growthbookState = { + disabled: false, +}; +const providerState = { + available: false, +}; + +mock.module("../../services/analytics/growthbook.js", () => ({ + getFeatureValue_CACHED_MAY_BE_STALE: (_key: string, fallback: boolean) => + growthbookState.disabled ?? fallback, +})); + +mock.module("../../services/voiceStreamSTT.js", () => ({ + getVoiceModeAvailability: () => ({ + provider: providerState.available ? "openai" : null, + available: providerState.available, + }), +})); + +const { + hasAvailableVoiceProvider, + isVoiceGrowthBookEnabled, + isVoiceModeEnabled, +} = await import("../voiceModeEnabled.js"); + +describe("voiceModeEnabled", () => { + const originalVoiceModeFeature = process.env.FEATURE_VOICE_MODE; + + beforeEach(() => { + process.env.FEATURE_VOICE_MODE = "1"; + growthbookState.disabled = false; + providerState.available = false; + }); + + afterEach(() => { + if (originalVoiceModeFeature === undefined) { + delete process.env.FEATURE_VOICE_MODE; + } else { + process.env.FEATURE_VOICE_MODE = originalVoiceModeFeature; + } + }); + + test("reports provider availability from voice mode availability", () => { + providerState.available = true; + expect(hasAvailableVoiceProvider()).toBe(true); + }); + + test("disables voice mode when no provider is available", () => { + providerState.available = false; + expect(isVoiceModeEnabled()).toBe(false); + }); + + test("disables voice mode when growthbook kill switch is on", () => { + providerState.available = true; + growthbookState.disabled = true; + + expect(isVoiceGrowthBookEnabled()).toBe(false); + expect(isVoiceModeEnabled()).toBe(false); + }); +}); diff --git a/src/voice/voiceModeEnabled.ts b/src/voice/voiceModeEnabled.ts index 1d8867c35..5c061325f 100644 --- a/src/voice/voiceModeEnabled.ts +++ b/src/voice/voiceModeEnabled.ts @@ -1,9 +1,6 @@ import { feature } from 'bun:bundle' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' -import { - getClaudeAIOAuthTokens, - isAnthropicAuthEnabled, -} from '../utils/auth.js' +import { getVoiceModeAvailability } from '../services/voiceStreamSTT.js' /** * Kill-switch check for voice mode. Returns true unless the @@ -23,32 +20,19 @@ export function isVoiceGrowthBookEnabled(): boolean { } /** - * Auth-only check for voice mode. Returns true when the user has a valid - * Anthropic OAuth token. Backed by the memoized getClaudeAIOAuthTokens — - * first call spawns `security` on macOS (~20-50ms), subsequent calls are - * cache hits. The memoize clears on token refresh (~once/hour), so one - * cold spawn per refresh is expected. Cheap enough for usage-time checks. + * Availability check for voice mode. Returns true when there is a usable + * speech-to-text provider for the current auth/provider configuration. */ -export function hasVoiceAuth(): boolean { - // Voice mode requires Anthropic OAuth — it uses the voice_stream - // endpoint on claude.ai which is not available with API keys, - // Bedrock, Vertex, or Foundry. - if (!isAnthropicAuthEnabled()) { - return false - } - // isAnthropicAuthEnabled only checks the auth *provider*, not whether - // a token exists. Without this check, the voice UI renders but - // connectVoiceStream fails silently when the user isn't logged in. - const tokens = getClaudeAIOAuthTokens() - return Boolean(tokens?.accessToken) +export function hasAvailableVoiceProvider(): boolean { + return getVoiceModeAvailability().available } /** - * Full runtime check: auth + GrowthBook kill-switch. Callers: `/voice` - * (voice.ts, voice/index.ts), ConfigTool, VoiceModeNotice — command-time - * paths where a fresh keychain read is acceptable. For React render - * paths use useVoiceEnabled() instead (memoizes the auth half). + * Full runtime check: provider availability + GrowthBook kill-switch. + * Callers: `/voice` (voice.ts, voice/index.ts), ConfigTool, VoiceModeNotice + * — command-time paths where a fresh keychain read is acceptable. For React + * render paths use useVoiceEnabled() instead (memoizes the provider half). */ export function isVoiceModeEnabled(): boolean { - return hasVoiceAuth() && isVoiceGrowthBookEnabled() + return hasAvailableVoiceProvider() && isVoiceGrowthBookEnabled() }