From 1a46e2c0ebd81f4d0df745d5e075ba6c5821a0b2 Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Fri, 10 Jul 2026 12:34:37 -0700 Subject: [PATCH 1/7] feat: add macOS platform support Signed-off-by: Jason Matthew Suhari --- .github/workflows/ci.yml | 39 ++- .github/workflows/release.yml | 332 +++++++++--------------- .gitignore | 4 + CONTRIBUTING.md | 17 +- Cargo.toml | 4 +- README.md | 50 ++-- docs/RELEASING.md | 19 +- docs/devlogs/2026-07-10-macos.md | 41 +++ native/macos/GridBash.Info.plist | 24 ++ native/macos/GridBashSpeech.Info.plist | 28 ++ native/macos/GridBashSpeech.swift | 139 ++++++++++ npm/bin/gridbash.js | 61 ++++- npm/platforms/darwin-arm64/package.json | 10 + npm/platforms/darwin-x64/package.json | 10 + npm/platforms/win32-x64/package.json | 10 + npm/scripts/gridbash-launcher.test.js | 38 +++ npm/scripts/install-local.js | 45 +++- npm/scripts/prepare.js | 92 ++++++- npm/scripts/release.js | 21 ++ package.json | 14 +- src/app.rs | 30 ++- src/auth.rs | 5 +- src/composer.rs | 9 +- src/onboarding.rs | 15 +- src/profiles.rs | 137 ++++++++-- src/pty.rs | 109 +++++++- src/voice.rs | 90 +++++-- 27 files changed, 1065 insertions(+), 328 deletions(-) create mode 100644 docs/devlogs/2026-07-10-macos.md create mode 100644 native/macos/GridBash.Info.plist create mode 100644 native/macos/GridBashSpeech.Info.plist create mode 100644 native/macos/GridBashSpeech.swift create mode 100644 npm/platforms/darwin-arm64/package.json create mode 100644 npm/platforms/darwin-x64/package.json create mode 100644 npm/platforms/win32-x64/package.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 851f8a7..68107d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,45 @@ on: pull_request: jobs: - windows: - runs-on: windows-latest + platform: + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - name: Windows x64 + runner: windows-latest + platform_key: win32-x64 + - name: macOS arm64 + runner: macos-latest + platform_key: darwin-arm64 + - name: macOS x64 + runner: macos-15-intel + platform_key: darwin-x64 + runs-on: ${{ matrix.runner }} + env: + MACOSX_DEPLOYMENT_TARGET: "13.0" steps: - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: cargo fmt --check + - run: cargo clippy -- -D warnings + - run: cargo test - - run: cargo build --release + + - run: node --test npm/scripts/gridbash-launcher.test.js + + - run: node npm/scripts/prepare.js + + - run: npm pack --dry-run --ignore-scripts + + - name: Pack native platform package + working-directory: npm/platforms/${{ matrix.platform_key }} + run: npm pack --dry-run --ignore-scripts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e560977..7da4118 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: "patch, minor, major, or an explicit x.y.z version" + description: "patch, minor, major, or an exact version; macOS previews use x.y.z-macos.N" required: true default: "patch" notes: @@ -42,6 +42,14 @@ jobs: with: node-version: 24 + - name: Require a prerelease until macOS signing is configured + shell: pwsh + run: | + $requested = "${{ inputs.version }}" + if ($requested -notmatch "^\d+\.\d+\.\d+-.+$") { + throw "macOS is preview-only. Use an exact prerelease such as 0.2.0-macos.1 until Developer ID signing and notarization are configured." + } + - name: Create release commit and tag id: release shell: pwsh @@ -50,23 +58,21 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" $requested = "${{ inputs.version }}" - if ($requested -match "^\d+\.\d+\.\d+(?:-.+)?$") { - $existingTag = "v$requested" - git fetch origin "refs/tags/${existingTag}:refs/tags/${existingTag}" --force 2>$null + $existingTag = "v$requested" + git fetch origin "refs/tags/${existingTag}:refs/tags/${existingTag}" --force 2>$null + $tagExists = $LASTEXITCODE -eq 0 + if (-not $tagExists) { + git rev-parse --verify $existingTag *> $null $tagExists = $LASTEXITCODE -eq 0 - if (-not $tagExists) { - git rev-parse --verify $existingTag *> $null - $tagExists = $LASTEXITCODE -eq 0 - } - - if ($tagExists) { - Write-Output "$existingTag already exists; skipping release preparation and publishing that tag." - "tag=$existingTag" >> $env:GITHUB_OUTPUT - exit 0 - } } - $releaseArgs = @("${{ inputs.version }}", "--push", "--yes") + if ($tagExists) { + Write-Output "$existingTag already exists; publishing that tag." + "tag=$existingTag" >> $env:GITHUB_OUTPUT + exit 0 + } + + $releaseArgs = @($requested, "--push", "--yes") if ("${{ inputs.notes }}") { $releaseArgs += @("--notes", "${{ inputs.notes }}") } @@ -75,18 +81,29 @@ jobs: } node npm/scripts/release.js @releaseArgs + "tag=v$requested" >> $env:GITHUB_OUTPUT - $version = (Get-Content package.json | ConvertFrom-Json).version - "tag=v$version" >> $env:GITHUB_OUTPUT - - publish-manual: - if: github.event_name == 'workflow_dispatch' + build-native: needs: prepare - runs-on: windows-latest + if: ${{ always() && (github.event_name == 'push' || needs.prepare.result == 'success') }} + strategy: + fail-fast: false + matrix: + include: + - runner: windows-latest + platform_key: win32-x64 + - runner: macos-latest + platform_key: darwin-arm64 + - runner: macos-15-intel + platform_key: darwin-x64 + runs-on: ${{ matrix.runner }} + env: + MACOSX_DEPLOYMENT_TARGET: "13.0" + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && needs.prepare.outputs.tag || github.ref_name }} steps: - uses: actions/checkout@v4 with: - ref: ${{ needs.prepare.outputs.tag }} + ref: ${{ env.RELEASE_TAG }} - uses: dtolnay/rust-toolchain@stable @@ -94,212 +111,119 @@ jobs: with: node-version: 24 + - name: Block unsigned stable macOS artifacts + if: startsWith(matrix.platform_key, 'darwin-') && !contains(env.RELEASE_TAG, '-') + run: | + echo "Stable macOS artifacts require Developer ID signing and notarization." + exit 1 + - run: cargo fmt --check - run: cargo clippy -- -D warnings - run: cargo test - - run: node npm/scripts/prepare.js - - - name: Pack npm package - id: pack - shell: pwsh - run: | - $raw = npm pack --json --ignore-scripts 2>&1 - if ($LASTEXITCODE -ne 0) { - $raw | Write-Output - exit $LASTEXITCODE - } - - $text = $raw | Out-String - $parsed = $text | ConvertFrom-Json - if ($parsed -is [array]) { - $pack = $parsed[0].filename - } elseif ($parsed.filename) { - $pack = $parsed.filename - } else { - foreach ($property in $parsed.PSObject.Properties) { - if ($property.Value.filename) { - $pack = $property.Value.filename - break - } - } - } - - if (-not $pack) { - $text | Write-Output - throw "Could not parse npm pack filename." - } - "tarball=$pack" >> $env:GITHUB_OUTPUT - - - name: Publish npm package - shell: pwsh - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - $package = Get-Content package.json | ConvertFrom-Json - $spec = "$($package.name)@$($package.version)" - $published = npm view $spec version 2>$null - if ($LASTEXITCODE -eq 0 -and $published.Trim() -eq $package.version) { - Write-Output "$spec is already published; skipping npm publish." - exit 0 - } - - if ($env:NODE_AUTH_TOKEN) { - "//registry.npmjs.org/:_authToken=$env:NODE_AUTH_TOKEN" | Set-Content -Path .npmrc - Write-Output "Publishing with NPM_TOKEN secret." - npm publish --access public --provenance - } else { - Remove-Item Env:\NODE_AUTH_TOKEN -ErrorAction SilentlyContinue - Write-Output "Publishing with npm trusted publishing/OIDC. Configure the package on npmjs.com if this fails with ENEEDAUTH." - npm publish --access public - } + - run: node --test npm/scripts/gridbash-launcher.test.js - - name: Create GitHub release - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - run: | - $tag = "${{ needs.prepare.outputs.tag }}" - $notes = "docs/releases/$tag.md" - if (-not (Test-Path $notes)) { - "# $tag`n`nAutomated release for $tag." | Set-Content -Path RELEASE_NOTES.md - $notes = "RELEASE_NOTES.md" - } + - run: node npm/scripts/prepare.js - $releaseExists = $false - gh release view $tag *> $null - if ($LASTEXITCODE -eq 0) { - $releaseExists = $true - } + - name: Pack native npm package + working-directory: npm/platforms/${{ matrix.platform_key }} + run: npm pack --ignore-scripts - if ($releaseExists) { - gh release upload $tag ` - "target/release/gridbash.exe" ` - "${{ steps.pack.outputs.tarball }}" ` - --clobber - gh release edit $tag --title $tag --notes-file $notes --latest - } else { - gh release create $tag ` - "target/release/gridbash.exe" ` - "${{ steps.pack.outputs.tarball }}" ` - --title $tag ` - --notes-file $notes ` - --verify-tag ` - --latest - } + - uses: actions/upload-artifact@v4 + with: + name: native-${{ matrix.platform_key }} + path: npm/platforms/${{ matrix.platform_key }}/*.tgz + if-no-files-found: error publish: - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - runs-on: windows-latest + needs: [prepare, build-native] + if: ${{ always() && needs.build-native.result == 'success' }} + runs-on: ubuntu-latest + env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && needs.prepare.outputs.tag || github.ref_name }} steps: - uses: actions/checkout@v4 - - - uses: dtolnay/rust-toolchain@stable + with: + ref: ${{ env.RELEASE_TAG }} - uses: actions/setup-node@v4 with: node-version: 24 - - run: cargo fmt --check - - - run: cargo clippy -- -D warnings - - - run: cargo test - - - run: node npm/scripts/prepare.js - - - name: Pack npm package - id: pack - shell: pwsh - run: | - $raw = npm pack --json --ignore-scripts 2>&1 - if ($LASTEXITCODE -ne 0) { - $raw | Write-Output - exit $LASTEXITCODE - } - - $text = $raw | Out-String - $parsed = $text | ConvertFrom-Json - if ($parsed -is [array]) { - $pack = $parsed[0].filename - } elseif ($parsed.filename) { - $pack = $parsed.filename - } else { - foreach ($property in $parsed.PSObject.Properties) { - if ($property.Value.filename) { - $pack = $property.Value.filename - break - } - } - } - - if (-not $pack) { - $text | Write-Output - throw "Could not parse npm pack filename." - } - "tarball=$pack" >> $env:GITHUB_OUTPUT + - uses: actions/download-artifact@v4 + with: + pattern: native-* + path: artifacts + merge-multiple: true - - name: Resolve release notes - id: notes - shell: pwsh + - name: Pack root npm launcher + id: root-pack + shell: bash run: | - $tag = "${{ github.ref_name }}" - $notes = "docs/releases/$tag.md" - if (-not (Test-Path $notes)) { - "# $tag`n`nAutomated release for $tag." | Set-Content -Path RELEASE_NOTES.md - $notes = "RELEASE_NOTES.md" - } - "path=$notes" >> $env:GITHUB_OUTPUT + tarball="$(npm pack --ignore-scripts | tail -n 1)" + echo "tarball=$tarball" >> "$GITHUB_OUTPUT" - - name: Publish npm package - shell: pwsh + - name: Publish native packages, then launcher + shell: bash env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + ROOT_TARBALL: ${{ steps.root-pack.outputs.tarball }} run: | - $package = Get-Content package.json | ConvertFrom-Json - $spec = "$($package.name)@$($package.version)" - $published = npm view $spec version 2>$null - if ($LASTEXITCODE -eq 0 -and $published.Trim() -eq $package.version) { - Write-Output "$spec is already published; skipping npm publish." - exit 0 - } - - if ($env:NODE_AUTH_TOKEN) { - "//registry.npmjs.org/:_authToken=$env:NODE_AUTH_TOKEN" | Set-Content -Path .npmrc - Write-Output "Publishing with NPM_TOKEN secret." - npm publish --access public --provenance - } else { - Remove-Item Env:\NODE_AUTH_TOKEN -ErrorAction SilentlyContinue - Write-Output "Publishing with npm trusted publishing/OIDC. Configure the package on npmjs.com if this fails with ENEEDAUTH." - npm publish --access public - } - - - name: Create GitHub release - shell: pwsh + set -euo pipefail + version="${RELEASE_TAG#v}" + dist_tag="latest" + if [[ "$version" == *-* ]]; then + dist_tag="next" + fi + + if [[ -n "${NODE_AUTH_TOKEN:-}" ]]; then + echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" > .npmrc + else + unset NODE_AUTH_TOKEN + fi + + publish_if_missing() { + local tarball="$1" + local manifest name package_version published + manifest="$(tar -xOf "$tarball" package/package.json)" + name="$(node -e 'const fs=require("fs"); console.log(JSON.parse(fs.readFileSync(0,"utf8")).name)' <<<"$manifest")" + package_version="$(node -e 'const fs=require("fs"); console.log(JSON.parse(fs.readFileSync(0,"utf8")).version)' <<<"$manifest")" + published="$(npm view "$name@$package_version" version 2>/dev/null || true)" + if [[ "$published" == "$package_version" ]]; then + echo "$name@$package_version already published; skipping." + return + fi + npm publish "$tarball" --access public --provenance --tag "$dist_tag" + } + + for tarball in artifacts/*.tgz; do + publish_if_missing "$tarball" + done + publish_if_missing "$ROOT_TARBALL" + + - name: Create or update GitHub release + shell: bash env: GH_TOKEN: ${{ github.token }} + ROOT_TARBALL: ${{ steps.root-pack.outputs.tarball }} run: | - $tag = "${{ github.ref_name }}" - $releaseExists = $false - gh release view $tag *> $null - if ($LASTEXITCODE -eq 0) { - $releaseExists = $true - } - - if ($releaseExists) { - gh release upload $tag ` - "target/release/gridbash.exe" ` - "${{ steps.pack.outputs.tarball }}" ` - --clobber - gh release edit $tag --title $tag --notes-file "${{ steps.notes.outputs.path }}" --latest - } else { - gh release create $tag ` - "target/release/gridbash.exe" ` - "${{ steps.pack.outputs.tarball }}" ` - --title $tag ` - --notes-file "${{ steps.notes.outputs.path }}" ` - --verify-tag ` - --latest - } + set -euo pipefail + notes="docs/releases/$RELEASE_TAG.md" + if [[ ! -f "$notes" ]]; then + printf '# %s\n\nAutomated release for %s.\n' "$RELEASE_TAG" "$RELEASE_TAG" > RELEASE_NOTES.md + notes="RELEASE_NOTES.md" + fi + + assets=(artifacts/*.tgz "$ROOT_TARBALL") + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release upload "$RELEASE_TAG" "${assets[@]}" --clobber + gh release edit "$RELEASE_TAG" --title "$RELEASE_TAG" --notes-file "$notes" --prerelease + else + gh release create "$RELEASE_TAG" "${assets[@]}" \ + --title "$RELEASE_TAG" \ + --notes-file "$notes" \ + --verify-tag \ + --prerelease + fi diff --git a/.gitignore b/.gitignore index 7c332f3..70bfa24 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ .worktrees/ node_modules/ npm-debug.log* +/npm/platforms/*/bin/ +/npm/platforms/darwin-*/GridBash.app/ +/npm/platforms/**/*.tgz +/*.tgz npm/bin/win32-x64/gridbash.exe diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c3494f..7398a68 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Thanks for helping improve GridBash. This guide is meant to get a useful pull request from idea to review with as little guessing as possible. -GridBash is a Windows-native Rust TUI, so changes should be tested on Windows whenever they affect terminal behavior, process handling, keyboard input, PTY IO, packaging, or user-facing commands. +GridBash is a cross-platform Rust TUI. Changes affecting terminal behavior, process handling, keyboard input, PTY IO, packaging, or user-facing commands should be tested on Windows and macOS when relevant. ## Fast Path @@ -20,17 +20,24 @@ Small fixes, documentation improvements, and focused tests can go straight to a Prerequisites: -- Windows 10 or newer. +- Windows 10 or newer, or macOS 13 or newer. - Rust stable through `rustup`. - Node.js 18 or newer for npm packaging checks. -- A terminal that supports normal Windows console behavior, such as Windows Terminal, PowerShell, Git Bash, or `cmd`. +- A compatible terminal such as Windows Terminal, Apple Terminal, iTerm2, PowerShell, Git Bash, zsh, or bash. -Install Rust: +Install Rust on Windows: ```powershell winget install --id Rustlang.Rustup -e ``` +On macOS, install the Xcode command-line tools and Rust: + +```bash +xcode-select --install +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + Clone and build: ```powershell @@ -123,7 +130,7 @@ Before opening a pull request: - Match the existing Rust style and module boundaries. - Prefer simple, legible code over speculative abstractions. - Avoid new dependencies unless the problem clearly needs one. -- Keep Windows behavior first-class. +- Keep Windows and macOS behavior first-class. - Treat existing config files and user workflows as compatibility surfaces. - Put important behavior in tests when the logic can be tested without a real terminal session. diff --git a/Cargo.toml b/Cargo.toml index 69f245d..c168214 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,11 +2,11 @@ name = "gridbash" version = "0.1.6" edition = "2024" -description = "Windows-native terminal grid for running Codex, Claude, Gemini, and other CLI agents side by side." +description = "Cross-platform terminal grid for running Codex, Claude, Gemini, and other CLI agents side by side." license = "MIT" repository = "https://github.com/jasonsuhari/gridbash" homepage = "https://jasonsuhari.github.io/gridbash/" -keywords = ["terminal", "tui", "agents", "windows", "conpty"] +keywords = ["terminal", "tui", "agents", "windows", "macos"] categories = ["command-line-utilities", "development-tools"] [dependencies] diff --git a/README.md b/README.md index 717403a..8d4fce2 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ [![npm downloads](https://img.shields.io/npm/dm/gridbash?label=npm%20downloads)](https://www.npmjs.com/package/gridbash) [![GitHub release](https://img.shields.io/github/v/release/jasonsuhari/gridbash?label=github)](https://github.com/jasonsuhari/gridbash/releases) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Platform: Windows x64](https://img.shields.io/badge/platform-Windows%20x64-0078D4.svg)](https://github.com/jasonsuhari/gridbash) +[![Platforms: Windows and macOS](https://img.shields.io/badge/platform-Windows%20x64%20%7C%20macOS-0078D4.svg)](https://github.com/jasonsuhari/gridbash) **Run every CLI coding agent in one fast terminal grid.** -GridBash by Jason Suhari is a Windows-native Rust TUI for agent-heavy development. Launch Codex, Claude, Gemini, Aider, OpenCode, Goose, Amp, Cursor, Copilot, Git Bash, PowerShell, or any custom command into a real PTY grid, then select exactly which panes receive your prompt. +GridBash by Jason Suhari is a cross-platform Rust TUI for agent-heavy development. Launch Codex, Claude, Gemini, Aider, OpenCode, Goose, Amp, Cursor, Copilot, your native shell, or any custom command into a real PTY grid, then select exactly which panes receive your prompt. Official site: [jasonsuhari.github.io/gridbash](https://jasonsuhari.github.io/gridbash/) @@ -21,7 +21,7 @@ GridBash is built for developers who want parallel CLI-agent work without juggli ## Quickstart -Install the published Windows x64 npm package: +Install the published package on Windows x64 or macOS 13+ (Apple Silicon or Intel): ```powershell npm install -g gridbash @@ -52,7 +52,7 @@ gridbash 2x3 --profile codex --worktrees GridBash is for CLI agent orchestration in the terminal: compare ideas from multiple coding agents, run review/build/test loops in parallel, keep shells visible, and send a prompt only to the panes that should receive it. -Its niche is Windows-native, PTY-backed, agent-first terminal grids. Traditional terminal multiplexers are still great; GridBash focuses on the workflows that appear when Codex, Claude, Gemini, Aider, and other CLI agents are all part of the same development session. +Its niche is PTY-backed, agent-first terminal grids on Windows and macOS. Traditional terminal multiplexers are still great; GridBash focuses on the workflows that appear when Codex, Claude, Gemini, Aider, and other CLI agents are all part of the same development session. ## Release Status & Devlogs @@ -64,7 +64,7 @@ Its niche is Windows-native, PTY-backed, agent-first terminal grids. Traditional ## Highlights -- Real PTY-backed panes through Windows ConPTY via `portable-pty`. +- Real PTY-backed panes through Windows ConPTY or Unix PTYs via `portable-pty`. - Up to 100 panes in one terminal process. - Multiple tabbed grids in one terminal process. - Configurable default terminal profile: Git Bash, PowerShell, cmd, agents, or custom. @@ -106,7 +106,7 @@ Build a publishable npm tarball: npm pack ``` -The package ships a Node command shim that launches the bundled Windows x64 `gridbash.exe`. +The package ships a small Node command shim and downloads only the native package for the current OS and architecture. Release automation and devlog workflow are documented in `docs/RELEASING.md`. @@ -118,12 +118,19 @@ Pull requests can be merged directly after they have been reviewed. Before mergi ## Install From Source -Install Rust first: +Install Rust first. On Windows: ```powershell winget install --id Rustlang.Rustup -e ``` +On macOS: + +```bash +xcode-select --install +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + Build GridBash: ```powershell @@ -132,7 +139,8 @@ cd gridbash cargo build --release ``` -The executable will be: +The executable is `target\release\gridbash.exe` on Windows or +`target/release/gridbash` on macOS. On Windows: ```text target\release\gridbash.exe @@ -299,8 +307,8 @@ The grid resizer uses the same row-and-column picker as startup, with active cel shown in blue. Shrinking removes live panes outside the retained upper-left rectangle. For example, changing 3x3 to 3x2 deactivates the full rightmost column. -Voice mode uses modern Windows online dictation and the default microphone. Press -`Alt+v` to listen for one utterance; GridBash waits up to 15 seconds for speech. +Voice mode uses modern Windows dictation on Windows and Apple Speech on macOS. +Press `Alt+v` to listen for one utterance; GridBash waits up to 15 seconds for speech. The transcript is inserted into the command bar or the panes that were targeted when listening started. GridBash never presses Enter for dictated text, so you can review or edit it before submitting. Press `Alt+v` while listening to cancel. @@ -311,6 +319,10 @@ allow desktop apps to access the microphone, and install the Windows speech language pack matching the desired dictation language. If any requirement is missing, GridBash reports the Windows dictation error instead of inserting text. +On macOS, GridBash asks for Speech Recognition and Microphone permission on +first use. It prefers on-device recognition and uses Apple's authorized speech +service when the current locale does not support local recognition. + Renamed pane headers replace the numeric prefix for the current session. Saving a blank name restores the default number. Settings includes a General tab for local runtime display controls and an Auth tab for GridBash-wide Claude/Codex auth defaults and launch policy. Pane Settings lets each Claude or Codex pane select its own compatible auth account; applying a different account restarts only that pane. @@ -348,16 +360,20 @@ Auth settings controls: For a Claude or Codex pane, open Pane Settings with `Alt+P`, use Left/Right to choose a compatible auth profile, and press Enter to apply it and restart that pane. Press `r` there to refresh the pane's history snapshot. -Usage status is best-effort. GridBash reads local auth metadata, masks account emails, and uses short-timeout `curl.exe` requests only while the Auth settings view is refreshed. +Usage status is best-effort. GridBash reads local auth metadata, masks account emails, and uses short-timeout `curl.exe` (Windows) or `curl` (macOS) requests only while the Auth settings view is refreshed. ## Profiles -Built-in profile keys: +Built-in terminal profile keys are platform-specific: ```text -git-bash pwsh powershell cmd codex claude gemini opencode aider amp goose copilot cursor +Windows: git-bash pwsh powershell cmd +macOS: zsh bash sh pwsh ``` +Agent profile keys remain available on both platforms: `codex`, `claude`, +`gemini`, `opencode`, `aider`, `amp`, `goose`, `copilot`, and `cursor`. + GridBash resolves Windows `.exe` and `.cmd` shims before extensionless npm shims, so common Node-based CLIs launch correctly. Optional config file: @@ -402,9 +418,13 @@ gridbash 2x4 --profile review Default profile resolution order: ```text ---profile > GRIDBASH_PROFILE > [defaults].profile > git-bash +--profile > GRIDBASH_PROFILE > [defaults].profile > platform default ``` +The platform default is Git Bash on Windows, zsh on macOS, and bash on other +Unix systems. In Apple Terminal or iTerm2, configure Option as the Meta/Alt key +so GridBash's Alt shortcuts reach the TUI. + Pane managers use the OpenAI-compatible chat-completions endpoint, model, and API key under `[manager]`. These values can also be edited from the Manager tab in GridBash settings; the key is masked in the UI and stored in the local GridBash @@ -414,7 +434,7 @@ moved between tabs. ## Design Goals -GridBash is inspired by agent-first multiplexers such as Mato and terminal workspaces such as Zellij, but V1 takes a different path: Windows-native, single binary, visual selection, scoped multi-pane input, and a hard bias toward fast multi-agent grids. +GridBash is inspired by agent-first multiplexers such as Mato and terminal workspaces such as Zellij, but takes a different path: native PTYs, visual selection, scoped multi-pane input, and a hard bias toward fast multi-agent grids. ## Community diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 615dc77..7e7a129 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -53,8 +53,14 @@ After the change is merged to `main`: The workflow runs `node npm/scripts/release.js` on `main`. That script creates and pushes the release commit and `vX.Y.Z` tag. A separate publish job in the -same workflow run then builds the Windows package, publishes npm, and creates -or updates the GitHub release. +same workflow run then builds Windows x64 plus macOS arm64/x64 native packages, +publishes those packages before the platform-neutral npm launcher, and creates +or updates one GitHub release. + +macOS releases are preview-only until real-hardware testing and Developer ID +signing/notarization are complete. Dispatch an exact prerelease such as +`0.2.0-macos.1`; prereleases publish under npm's `next` dist-tag. Stable release +requests fail before creating a tag while this gate is active. Before creating the release commit, the script fetches origin branch refs and fails if any unmerged task branches remain under `chore/`, `docs/`, `feat/`, @@ -104,7 +110,9 @@ The script will: - create tag `vX.Y.Z` - push the commit and tag when `--push --yes` is passed -When the tag reaches GitHub, `.github/workflows/release.yml` builds the Windows package, publishes npm, and creates or updates a GitHub release with the release notes. +When the tag reaches GitHub, `.github/workflows/release.yml` builds each native +package, publishes the native packages before `gridbash`, and creates or updates +one GitHub release with all artifacts and the release notes. ## Local Release Without Push @@ -125,7 +133,10 @@ Only do that for a local release experiment that has not been pushed. ## Common Blocker -`node npm/scripts/prepare.js` copies the freshly built exe into `npm/bin/win32-x64/gridbash.exe`. Close any running GridBash window before releasing, otherwise Windows can lock the target exe and the copy step will fail with `EBUSY`. +`node npm/scripts/prepare.js` assembles the native package for the current host. +On macOS it builds `GridBash.app` and the nested Apple Speech helper. On Windows, +close running GridBash windows before a local reinstall; Windows can lock the +currently installed executable and make npm fail with `EBUSY`. For local testing, use: diff --git a/docs/devlogs/2026-07-10-macos.md b/docs/devlogs/2026-07-10-macos.md new file mode 100644 index 0000000..1707f93 --- /dev/null +++ b/docs/devlogs/2026-07-10-macos.md @@ -0,0 +1,41 @@ +# macOS support + +Date: 2026-07-10 +Issue: [#140](https://github.com/jasonsuhari/gridbash/issues/140) +Release target: preview + +## Summary + +- Added macOS 13+ support for Apple Silicon and Intel while retaining the + existing Windows x64 behavior. + +## What Changed + +- Added zsh-first terminal profiles, Unix executable resolution, PTY cwd + reporting, Unix OSC-7 paths, and `pbcopy` clipboard integration. +- Added exact-version native npm packages behind a platform-neutral launcher. +- Added a bundled Apple Speech helper with microphone and speech permission + descriptions. +- Expanded CI and preview releases across Windows x64 and both Mac architectures. + +## Why It Matters + +- GridBash can use one source tree, one version, and one release line while each + user downloads only the native package for their platform. + +## Validation + +- `cargo fmt --check` +- `cargo clippy -- -D warnings` +- `cargo test` +- `node --test npm/scripts/gridbash-launcher.test.js` +- Windows native release package assembled and inspected with `npm pack --dry-run` +- macOS arm64/x64 native builds run in GitHub Actions + +## Release Notes + +- macOS packages remain preview-only until real-hardware interaction testing. +- Stable macOS publication is blocked until Developer ID signing and + notarization are configured. +- Local Windows reinstall was blocked during development by running GridBash + processes holding the installed executable open. diff --git a/native/macos/GridBash.Info.plist b/native/macos/GridBash.Info.plist new file mode 100644 index 0000000..1b94c3a --- /dev/null +++ b/native/macos/GridBash.Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDisplayName + GridBash + CFBundleExecutable + gridbash + CFBundleIdentifier + com.jasonsuhari.gridbash + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + GridBash + CFBundlePackageType + APPL + CFBundleShortVersionString + __GRIDBASH_VERSION__ + CFBundleVersion + __GRIDBASH_VERSION__ + LSMinimumSystemVersion + 13.0 + + diff --git a/native/macos/GridBashSpeech.Info.plist b/native/macos/GridBashSpeech.Info.plist new file mode 100644 index 0000000..5e5ef43 --- /dev/null +++ b/native/macos/GridBashSpeech.Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDisplayName + GridBash Speech + CFBundleExecutable + gridbash-speech + CFBundleIdentifier + com.jasonsuhari.gridbash.speech + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + GridBash Speech + CFBundlePackageType + APPL + CFBundleShortVersionString + __GRIDBASH_VERSION__ + CFBundleVersion + __GRIDBASH_VERSION__ + LSMinimumSystemVersion + 13.0 + NSMicrophoneUsageDescription + GridBash uses the microphone only while you dictate text with Alt+V. + NSSpeechRecognitionUsageDescription + GridBash converts an Alt+V voice utterance into editable terminal text. + + diff --git a/native/macos/GridBashSpeech.swift b/native/macos/GridBashSpeech.swift new file mode 100644 index 0000000..b92ce09 --- /dev/null +++ b/native/macos/GridBashSpeech.swift @@ -0,0 +1,139 @@ +import AVFoundation +import Darwin +import Foundation +import Speech + +private let noSpeechExitCode: Int32 = 2 + +private func writeStandardOutput(_ value: String) { + if let data = value.data(using: .utf8) { + FileHandle.standardOutput.write(data) + } +} + +private func writeStandardError(_ value: String) { + if let data = value.data(using: .utf8) { + FileHandle.standardError.write(data) + } +} + +private final class DictationSession { + private let audioEngine = AVAudioEngine() + private let request = SFSpeechAudioBufferRecognitionRequest() + private var task: SFSpeechRecognitionTask? + private var latestTranscript = "" + private var finished = false + + func start() { + guard let recognizer = SFSpeechRecognizer(), recognizer.isAvailable else { + finish(error: "speech recognition is unavailable for the current macOS locale") + return + } + + request.shouldReportPartialResults = true + request.taskHint = .dictation + request.addsPunctuation = true + request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition + + let input = audioEngine.inputNode + let format = input.outputFormat(forBus: 0) + guard format.sampleRate > 0, format.channelCount > 0 else { + finish(error: "no microphone input format is available") + return + } + + input.installTap(onBus: 0, bufferSize: 1_024, format: format) { [weak self] buffer, _ in + self?.request.append(buffer) + } + + task = recognizer.recognitionTask(with: request) { [weak self] result, error in + DispatchQueue.main.async { + guard let self, !self.finished else { return } + if let result { + self.latestTranscript = result.bestTranscription.formattedString + if result.isFinal { + self.finish(transcript: self.latestTranscript) + return + } + } + if let error { + if self.latestTranscript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.finish(error: "macOS speech recognition failed: \(error.localizedDescription)") + } else { + self.finish(transcript: self.latestTranscript) + } + } + } + } + + do { + audioEngine.prepare() + try audioEngine.start() + } catch { + finish(error: "could not start the microphone: \(error.localizedDescription)") + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 15) { [weak self] in + guard let self, !self.finished else { return } + self.audioEngine.stop() + self.audioEngine.inputNode.removeTap(onBus: 0) + self.request.endAudio() + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let self, !self.finished else { return } + self.finish(transcript: self.latestTranscript) + } + } + } + + private func finish(transcript: String? = nil, error: String? = nil) { + guard !finished else { return } + finished = true + + if audioEngine.isRunning { + audioEngine.stop() + audioEngine.inputNode.removeTap(onBus: 0) + } + request.endAudio() + task?.cancel() + + if let error { + writeStandardError(error) + Darwin.exit(1) + } + + let value = (transcript ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + Darwin.exit(noSpeechExitCode) + } + writeStandardOutput(value) + Darwin.exit(0) + } +} + +private var session: DictationSession? + +SFSpeechRecognizer.requestAuthorization { status in + DispatchQueue.main.async { + guard status == .authorized else { + writeStandardError("speech recognition permission is denied; enable GridBash Speech in System Settings > Privacy & Security > Speech Recognition") + Darwin.exit(1) + } + + AVCaptureDevice.requestAccess(for: .audio) { granted in + DispatchQueue.main.async { + guard granted else { + writeStandardError("microphone permission is denied; enable GridBash Speech in System Settings > Privacy & Security > Microphone") + Darwin.exit(1) + } + + let activeSession = DictationSession() + session = activeSession + activeSession.start() + } + } + } +} + +dispatchMain() diff --git a/npm/bin/gridbash.js b/npm/bin/gridbash.js index af62747..6979a85 100644 --- a/npm/bin/gridbash.js +++ b/npm/bin/gridbash.js @@ -9,6 +9,20 @@ const path = require("node:path"); const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 900; const DEFAULT_UPDATE_CHECK_URL = "https://api.github.com/repos/jasonsuhari/gridbash/releases/latest"; +const NATIVE_TARGETS = { + "win32-x64": { + packageName: "gridbash-win32-x64", + executable: ["bin", "gridbash.exe"], + }, + "darwin-arm64": { + packageName: "gridbash-darwin-arm64", + executable: ["GridBash.app", "Contents", "MacOS", "gridbash"], + }, + "darwin-x64": { + packageName: "gridbash-darwin-x64", + executable: ["GridBash.app", "Contents", "MacOS", "gridbash"], + }, +}; function fail(message) { console.error(`gridbash: ${message}`); @@ -19,6 +33,36 @@ function packageRoot() { return path.resolve(__dirname, "..", ".."); } +function nativeTarget(platform = process.platform, arch = process.arch) { + return NATIVE_TARGETS[`${platform}-${arch}`]; +} + +function resolveNativeExecutable( + root = packageRoot(), + platform = process.platform, + arch = process.arch, +) { + const target = nativeTarget(platform, arch); + if (!target) { + throw new Error(`unsupported platform: ${platform}-${arch}`); + } + + let manifest; + try { + manifest = require.resolve(`${target.packageName}/package.json`, { paths: [root] }); + } catch { + throw new Error( + `missing optional native package ${target.packageName}; reinstall gridbash without omitting optional dependencies`, + ); + } + + const executable = path.join(path.dirname(manifest), ...target.executable); + if (!fs.existsSync(executable)) { + throw new Error(`native package ${target.packageName} is missing ${target.executable.join("/")}`); + } + return executable; +} + function readPackageVersion(root = packageRoot()) { try { return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version; @@ -235,16 +279,7 @@ async function maybePrintUpdateNotice(args, env = process.env, stderr = process. async function main() { const args = process.argv.slice(2); - - if (process.platform !== "win32") { - fail("this npm package currently ships the Windows x64 build only"); - } - - if (process.arch !== "x64") { - fail(`unsupported architecture: ${process.arch}`); - } - - const exe = path.join(__dirname, "win32-x64", "gridbash.exe"); + const exe = resolveNativeExecutable(); const normalizedBinDir = path.resolve(__dirname).toLowerCase(); const sourceManifest = path.resolve(__dirname, "..", "..", "Cargo.toml"); @@ -258,10 +293,6 @@ async function main() { ); } - if (!fs.existsSync(exe)) { - fail(`missing packaged binary at ${exe}. Run "npm run build" from the gridbash repo.`); - } - await maybePrintUpdateNotice(args); const result = spawnSync(exe, args, { @@ -289,6 +320,8 @@ if (require.main === module) { compareVersions, fetchLatestRelease, formatUpdateNotice, + nativeTarget, + resolveNativeExecutable, shouldSkipUpdateCheck, updateCheckTimeoutMs, }; diff --git a/npm/platforms/darwin-arm64/package.json b/npm/platforms/darwin-arm64/package.json new file mode 100644 index 0000000..c33ff75 --- /dev/null +++ b/npm/platforms/darwin-arm64/package.json @@ -0,0 +1,10 @@ +{ + "name": "gridbash-darwin-arm64", + "version": "0.1.6", + "description": "GridBash native application for macOS Apple Silicon.", + "license": "MIT", + "repository": "github:jasonsuhari/gridbash", + "os": ["darwin"], + "cpu": ["arm64"], + "files": ["GridBash.app/"] +} diff --git a/npm/platforms/darwin-x64/package.json b/npm/platforms/darwin-x64/package.json new file mode 100644 index 0000000..39043e7 --- /dev/null +++ b/npm/platforms/darwin-x64/package.json @@ -0,0 +1,10 @@ +{ + "name": "gridbash-darwin-x64", + "version": "0.1.6", + "description": "GridBash native application for macOS Intel.", + "license": "MIT", + "repository": "github:jasonsuhari/gridbash", + "os": ["darwin"], + "cpu": ["x64"], + "files": ["GridBash.app/"] +} diff --git a/npm/platforms/win32-x64/package.json b/npm/platforms/win32-x64/package.json new file mode 100644 index 0000000..3e514f6 --- /dev/null +++ b/npm/platforms/win32-x64/package.json @@ -0,0 +1,10 @@ +{ + "name": "gridbash-win32-x64", + "version": "0.1.6", + "description": "GridBash native binary for Windows x64.", + "license": "MIT", + "repository": "github:jasonsuhari/gridbash", + "os": ["win32"], + "cpu": ["x64"], + "files": ["bin/"] +} diff --git a/npm/scripts/gridbash-launcher.test.js b/npm/scripts/gridbash-launcher.test.js index 6b1674a..8b9df8b 100644 --- a/npm/scripts/gridbash-launcher.test.js +++ b/npm/scripts/gridbash-launcher.test.js @@ -1,15 +1,53 @@ const assert = require("node:assert/strict"); +const fs = require("node:fs"); const http = require("node:http"); +const os = require("node:os"); +const path = require("node:path"); const { test } = require("node:test"); const { checkForUpdate, compareVersions, formatUpdateNotice, + nativeTarget, + resolveNativeExecutable, shouldSkipUpdateCheck, updateCheckTimeoutMs, } = require("../bin/gridbash.js"); +test("nativeTarget maps supported platform architectures", () => { + assert.equal(nativeTarget("win32", "x64").packageName, "gridbash-win32-x64"); + assert.equal(nativeTarget("darwin", "arm64").packageName, "gridbash-darwin-arm64"); + assert.equal(nativeTarget("darwin", "x64").packageName, "gridbash-darwin-x64"); + assert.equal(nativeTarget("linux", "x64"), undefined); +}); + +test("resolveNativeExecutable finds the installed optional package", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "gridbash-launcher-")); + const packageDir = path.join(root, "node_modules", "gridbash-darwin-arm64"); + const executable = path.join(packageDir, "GridBash.app", "Contents", "MacOS", "gridbash"); + fs.mkdirSync(path.dirname(executable), { recursive: true }); + fs.writeFileSync(path.join(packageDir, "package.json"), '{"name":"gridbash-darwin-arm64"}'); + fs.writeFileSync(executable, "test"); + + try { + assert.equal(resolveNativeExecutable(root, "darwin", "arm64"), executable); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("resolveNativeExecutable explains unsupported and omitted targets", () => { + assert.throws( + () => resolveNativeExecutable(process.cwd(), "freebsd", "x64"), + /unsupported platform: freebsd-x64/, + ); + assert.throws( + () => resolveNativeExecutable(process.cwd(), "darwin", "x64"), + /missing optional native package gridbash-darwin-x64/, + ); +}); + function serveJson(payload) { const server = http.createServer((_request, response) => { response.writeHead(200, { "Content-Type": "application/json" }); diff --git a/npm/scripts/install-local.js b/npm/scripts/install-local.js index c0b7d09..af4e2c1 100644 --- a/npm/scripts/install-local.js +++ b/npm/scripts/install-local.js @@ -4,6 +4,9 @@ const path = require("node:path"); const root = path.resolve(__dirname, "..", ".."); const packageJson = require(path.join(root, "package.json")); +const platformKey = `${process.platform}-${process.arch}`; +const nativePackageName = `gridbash-${platformKey}`; +const nativePackageDir = path.join(root, "npm", "platforms", platformKey); function fail(message) { console.error(`install-local: ${message}`); @@ -13,7 +16,7 @@ function fail(message) { function run(command, args, options = {}) { const invocation = commandInvocation(command, args); const result = spawnSync(invocation.command, invocation.args, { - cwd: root, + cwd: options.cwd || root, stdio: options.capture ? "pipe" : "inherit", encoding: "utf8", shell: false, @@ -70,23 +73,47 @@ function assertNotLinked() { fail(`global install still points at a source checkout: ${packagePath}`); } - console.log(`install-local: installed copy at ${packagePath}`); + + const nativePackagePath = path.join(globalRoot, nativePackageName); + if (!fs.existsSync(path.join(nativePackagePath, "package.json"))) { + fail(`native package was not installed at ${nativePackagePath}`); + } + + console.log(`install-local: installed ${packageJson.name} and ${nativePackageName}`); } -if (process.platform !== "win32" || process.arch !== "x64") { - fail("local packaged install currently supports win32-x64 only"); +if (!fs.existsSync(path.join(nativePackageDir, "package.json"))) { + fail(`local packaged install does not support ${platformKey}`); } run("node", ["npm/scripts/prepare.js"]); -const packOutput = run(npmCommand(), ["pack", "--json", "--ignore-scripts"], { capture: true }); -const pack = JSON.parse(packOutput)[0]; -const tarball = path.join(root, pack.filename); +const rootPack = JSON.parse( + run(npmCommand(), ["pack", "--json", "--ignore-scripts"], { capture: true }), +)[0]; +const nativePack = JSON.parse( + run(npmCommand(), ["pack", "--json", "--ignore-scripts"], { + capture: true, + cwd: nativePackageDir, + }), +)[0]; +const rootTarball = path.join(root, rootPack.filename); +const nativeTarball = path.join(nativePackageDir, nativePack.filename); try { run(npmCommand(), ["uninstall", "-g", packageJson.name], { allowFailure: true }); - run(npmCommand(), ["install", "-g", tarball, "--no-audit", "--no-fund"]); + run(npmCommand(), ["uninstall", "-g", nativePackageName], { allowFailure: true }); + run(npmCommand(), [ + "install", + "-g", + nativeTarball, + rootTarball, + "--omit=optional", + "--no-audit", + "--no-fund", + ]); assertNotLinked(); } finally { - fs.rmSync(tarball, { force: true }); + fs.rmSync(rootTarball, { force: true }); + fs.rmSync(nativeTarball, { force: true }); } diff --git a/npm/scripts/prepare.js b/npm/scripts/prepare.js index a287eff..db63658 100644 --- a/npm/scripts/prepare.js +++ b/npm/scripts/prepare.js @@ -3,31 +3,99 @@ const fs = require("node:fs"); const path = require("node:path"); const root = path.resolve(__dirname, "..", ".."); -const source = path.join(root, "target", "release", "gridbash.exe"); -const outDir = path.join(root, "npm", "bin", "win32-x64"); -const target = path.join(outDir, "gridbash.exe"); +const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); +const platformKey = `${process.platform}-${process.arch}`; +const packageDir = path.join(root, "npm", "platforms", platformKey); + +function fail(message) { + throw new Error(`gridbash prepare: ${message}`); +} function run(command, args) { const result = spawnSync(command, args, { cwd: root, - stdio: "inherit" + stdio: "inherit", }); if (result.error) { throw result.error; } - if (result.status !== 0) { - throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}`); + fail(`${command} ${args.join(" ")} failed with exit code ${result.status}`); } } -if (process.platform !== "win32" || process.arch !== "x64") { - console.log("gridbash prepare: skipping binary build for non-win32-x64 platform"); - process.exit(0); +function copyVersionedPlist(source, target) { + const raw = fs.readFileSync(source, "utf8"); + fs.writeFileSync(target, raw.replaceAll("__GRIDBASH_VERSION__", packageJson.version)); +} + +function resetDirectory(target) { + const relative = path.relative(root, target); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + fail(`refusing to reset path outside repository: ${target}`); + } + fs.rmSync(target, { recursive: true, force: true }); + fs.mkdirSync(target, { recursive: true }); +} + +function prepareWindows() { + const source = path.join(root, "target", "release", "gridbash.exe"); + const binDir = path.join(packageDir, "bin"); + resetDirectory(binDir); + fs.copyFileSync(source, path.join(binDir, "gridbash.exe")); +} + +function prepareMacos() { + const source = path.join(root, "target", "release", "gridbash"); + const app = path.join(packageDir, "GridBash.app"); + const contents = path.join(app, "Contents"); + const macosDir = path.join(contents, "MacOS"); + const helperContents = path.join(contents, "Helpers", "GridBashSpeech.app", "Contents"); + const helperMacosDir = path.join(helperContents, "MacOS"); + const helper = path.join(helperMacosDir, "gridbash-speech"); + const nativeSource = path.join(root, "native", "macos"); + const targetArch = process.arch === "arm64" ? "arm64" : "x86_64"; + + resetDirectory(app); + fs.mkdirSync(macosDir, { recursive: true }); + fs.mkdirSync(helperMacosDir, { recursive: true }); + fs.copyFileSync(source, path.join(macosDir, "gridbash")); + copyVersionedPlist(path.join(nativeSource, "GridBash.Info.plist"), path.join(contents, "Info.plist")); + copyVersionedPlist( + path.join(nativeSource, "GridBashSpeech.Info.plist"), + path.join(helperContents, "Info.plist"), + ); + + run("xcrun", [ + "swiftc", + path.join(nativeSource, "GridBashSpeech.swift"), + "-O", + "-target", + `${targetArch}-apple-macosx13.0`, + "-framework", + "Speech", + "-framework", + "AVFoundation", + "-o", + helper, + ]); + fs.chmodSync(path.join(macosDir, "gridbash"), 0o755); + fs.chmodSync(helper, 0o755); +} + +if (!fs.existsSync(path.join(packageDir, "package.json"))) { + fail(`unsupported platform architecture: ${platformKey}`); } run(process.platform === "win32" ? "cargo.exe" : "cargo", ["build", "--release"]); -fs.mkdirSync(outDir, { recursive: true }); -fs.copyFileSync(source, target); -console.log(`gridbash prepare: copied ${path.relative(root, target)}`); + +if (process.platform === "win32" && process.arch === "x64") { + prepareWindows(); +} else if (process.platform === "darwin" && ["arm64", "x64"].includes(process.arch)) { + prepareMacos(); +} else { + fail(`unsupported platform architecture: ${platformKey}`); +} + +console.log(`gridbash prepare: assembled ${path.relative(root, packageDir)}`); diff --git a/npm/scripts/release.js b/npm/scripts/release.js index ff64419..b5a7f72 100644 --- a/npm/scripts/release.js +++ b/npm/scripts/release.js @@ -128,6 +128,22 @@ function updateCargoVersion(version) { } } +function updateNativePackageVersions(version) { + const platformRoot = path.join(root, "npm", "platforms"); + const manifests = fs + .readdirSync(platformRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(platformRoot, entry.name, "package.json")) + .filter((manifest) => fs.existsSync(manifest)); + + for (const manifest of manifests) { + const value = JSON.parse(fs.readFileSync(manifest, "utf8")); + value.version = version; + fs.writeFileSync(manifest, `${JSON.stringify(value, null, 2)}\n`); + } + return manifests; +} + function latestDevlog() { const dir = path.join(root, "docs", "devlogs"); if (!fs.existsSync(dir)) { @@ -324,8 +340,12 @@ assertTagFree(tag); console.log(`release: ${packageJson.version} -> ${version}`); packageJson.version = version; +for (const dependency of Object.keys(packageJson.optionalDependencies || {})) { + packageJson.optionalDependencies[dependency] = version; +} writeJson("package.json", packageJson); updateCargoVersion(version); +const nativeManifests = updateNativePackageVersions(version); const notesPath = releaseNotesPath(version); run("cargo", ["check"]); @@ -343,6 +363,7 @@ run("git", [ "Cargo.toml", "Cargo.lock", "package.json", + ...nativeManifests.map((manifest) => path.relative(root, manifest)), path.relative(root, notesPath), ]); run("git", ["commit", "-m", `chore: release ${tag}`]); diff --git a/package.json b/package.json index afe1223..5a9ad66 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gridbash", "version": "0.1.6", - "description": "Windows-native terminal grid for running Codex, Claude, Gemini, and other CLI agents side by side.", + "description": "Cross-platform terminal grid for running Codex, Claude, Gemini, and other CLI agents side by side.", "license": "MIT", "homepage": "https://jasonsuhari.github.io/gridbash/", "repository": { @@ -55,6 +55,7 @@ "pty", "conpty", "windows", + "macos", "powershell", "git-bash", "developer-tools" @@ -62,10 +63,9 @@ "engines": { "node": ">=18" }, - "os": [ - "win32" - ], - "cpu": [ - "x64" - ] + "optionalDependencies": { + "gridbash-win32-x64": "0.1.6", + "gridbash-darwin-arm64": "0.1.6", + "gridbash-darwin-x64": "0.1.6" + } } diff --git a/src/app.rs b/src/app.rs index 223df44..178ce95 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,9 @@ use std::{ time::{Duration, Instant}, }; +#[cfg(target_os = "macos")] +use std::process::Stdio; + use anyhow::{Context, Result, anyhow}; use crossterm::{ event::{ @@ -34,7 +37,7 @@ use crate::{ image_preview::{self, ImagePreview}, layout::{GridLayout, GridSize, PaneId, pane_at}, manager::{self, ManagerDecision}, - profiles::find_profile, + profiles::{default_profile_name, find_profile}, pty::{PtyEvent, PtyPane, plain_terminal_text}, session::{SavedPaneHistory, SessionRecord, SessionRecorder}, setup::{LaunchPlan, PaneLaunchSpec}, @@ -5719,7 +5722,7 @@ fn resolve_profile_name(cli: &Cli, config: &Config) -> String { .clone() .or_else(|| env::var("GRIDBASH_PROFILE").ok()) .or_else(|| config.defaults.profile.clone()) - .unwrap_or_else(|| "git-bash".into()) + .unwrap_or_else(|| default_profile_name().into()) } fn resolved_current_dir() -> Result { let current = env::current_dir().context("failed to resolve current directory")?; @@ -6276,6 +6279,11 @@ fn extract_selection_text(screen: &vt100::Screen, selection: PaneSelection, widt } fn copy_to_clipboard(terminal: &mut Tui, text: &str) -> Result<()> { + #[cfg(target_os = "macos")] + if copy_with_pbcopy(text) { + return Ok(()); + } + write!( terminal.backend_mut(), "\x1b]52;c;{}\x07", @@ -6289,6 +6297,24 @@ fn copy_to_clipboard(terminal: &mut Tui, text: &str) -> Result<()> { Ok(()) } +#[cfg(target_os = "macos")] +fn copy_with_pbcopy(text: &str) -> bool { + let Ok(mut child) = Command::new("pbcopy") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + else { + return false; + }; + + let wrote = child + .stdin + .take() + .is_some_and(|mut stdin| stdin.write_all(text.as_bytes()).is_ok()); + wrote && child.wait().is_ok_and(|status| status.success()) +} + fn base64_encode(bytes: &[u8]) -> String { const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4); diff --git a/src/auth.rs b/src/auth.rs index c3b1f15..a50556a 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -537,10 +537,11 @@ fn curl_json(url: &str, headers: &[(&str, String)]) -> Result { } args.push(url.to_string()); - let output = Command::new("curl.exe") + let curl = if cfg!(windows) { "curl.exe" } else { "curl" }; + let output = Command::new(curl) .args(args) .output() - .context("failed to run curl.exe for usage status")?; + .with_context(|| format!("failed to run {curl} for usage status"))?; if !output.status.success() { return Ok(Value::Null); } diff --git a/src/composer.rs b/src/composer.rs index edb9032..116836e 100644 --- a/src/composer.rs +++ b/src/composer.rs @@ -17,7 +17,12 @@ use ratatui::{ }; use crate::worktrees::ManagedWorktreeOptions; -use crate::{config::Config, layout::GridSize, profiles::find_profile, setup::LaunchPlan}; +use crate::{ + config::Config, + layout::GridSize, + profiles::{default_profile_name, find_profile}, + setup::LaunchPlan, +}; type ComposerTerminal = Terminal>; @@ -374,7 +379,7 @@ fn startup_profile_name(config: &Config) -> String { env::var("GRIDBASH_PROFILE") .ok() .or_else(|| config.defaults.profile.clone()) - .unwrap_or_else(|| "git-bash".into()) + .unwrap_or_else(|| default_profile_name().into()) } fn control_box(active: bool, value: usize) -> Span<'static> { diff --git a/src/onboarding.rs b/src/onboarding.rs index 5de2bff..2c4f1aa 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -65,9 +65,7 @@ pub fn should_run(cli: &Cli, config: &Config) -> bool { pub fn run(config: &mut Config, config_path: Option<&Path>) -> Result { let choices = detected_terminal_choices(config); if choices.is_empty() { - return Err(anyhow!( - "no terminal profiles detected; install Git Bash, PowerShell, or cmd" - )); + return Err(anyhow!(missing_terminal_message())); } let Some(profile) = run_picker(&choices)? else { @@ -79,6 +77,17 @@ pub fn run(config: &mut Config, config_path: Option<&Path>) -> Result &'static str { + #[cfg(windows)] + { + "no terminal profiles detected; install Git Bash, PowerShell, or cmd" + } + #[cfg(not(windows))] + { + "no terminal profiles detected; install zsh, bash, sh, or PowerShell 7" + } +} + fn detected_terminal_choices(config: &Config) -> Vec { terminal_profiles(config) .into_iter() diff --git a/src/profiles.rs b/src/profiles.rs index 57ef5e4..ae3da79 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -26,7 +26,10 @@ pub struct LaunchCommand { pub args: Vec, } -pub const TERMINAL_PROFILE_NAMES: &[&str] = &["git-bash", "pwsh", "powershell", "cmd"]; +#[cfg(windows)] +const TERMINAL_PROFILE_NAMES: &[&str] = &["git-bash", "pwsh", "powershell", "cmd"]; +#[cfg(not(windows))] +const TERMINAL_PROFILE_NAMES: &[&str] = &["zsh", "bash", "sh", "pwsh"]; pub const AGENT_PROFILE_NAMES: &[&str] = &[ "codex", "claude", "gemini", "opencode", "aider", "amp", "goose", "copilot", "cursor", ]; @@ -36,7 +39,7 @@ impl Profile { let exe = self .resolved_executable() .ok_or_else(|| anyhow!("profile command not found on PATH: {}", self.command))?; - Ok(wrap_for_windows_script(exe, &self.args)) + Ok(wrap_launch_command(exe, &self.args)) } pub fn display_name(&self, key: &str) -> String { @@ -55,7 +58,8 @@ impl Profile { } } -fn wrap_for_windows_script(command: PathBuf, args: &[String]) -> LaunchCommand { +#[cfg(windows)] +fn wrap_launch_command(command: PathBuf, args: &[String]) -> LaunchCommand { let extension = command .extension() .and_then(|value| value.to_str()) @@ -91,6 +95,15 @@ fn wrap_for_windows_script(command: PathBuf, args: &[String]) -> LaunchCommand { } } +#[cfg(not(windows))] +fn wrap_launch_command(command: PathBuf, args: &[String]) -> LaunchCommand { + LaunchCommand { + command, + args: args.to_vec(), + } +} + +#[cfg(windows)] fn quote_cmd_command(command: &Path, args: &[String]) -> String { std::iter::once(command.to_string_lossy().to_string()) .chain(args.iter().cloned()) @@ -105,6 +118,21 @@ fn quote_cmd_command(command: &Path, args: &[String]) -> String { .join(" ") } +pub fn default_profile_name() -> &'static str { + #[cfg(windows)] + { + "git-bash" + } + #[cfg(target_os = "macos")] + { + "zsh" + } + #[cfg(all(not(windows), not(target_os = "macos")))] + { + "bash" + } +} + pub fn all_profiles(config: &Config) -> BTreeMap { let mut profiles = builtin_profiles(); profiles.extend(config.profiles.clone()); @@ -144,6 +172,25 @@ pub fn terminal_profiles(config: &Config) -> Vec<(String, Profile)> { fn builtin_profiles() -> BTreeMap { let mut profiles = BTreeMap::new(); + insert_terminal_profiles(&mut profiles); + + for agent in AGENT_PROFILE_NAMES { + profiles.insert( + (*agent).into(), + Profile { + command: (*agent).into(), + args: vec![], + title: Some((*agent).into()), + agent_kind: builtin_agent_kind(agent), + }, + ); + } + + profiles +} + +#[cfg(windows)] +fn insert_terminal_profiles(profiles: &mut BTreeMap) { profiles.insert( "git-bash".into(), Profile { @@ -180,20 +227,46 @@ fn builtin_profiles() -> BTreeMap { agent_kind: None, }, ); +} - for agent in AGENT_PROFILE_NAMES { - profiles.insert( - (*agent).into(), - Profile { - command: (*agent).into(), - args: vec![], - title: Some((*agent).into()), - agent_kind: builtin_agent_kind(agent), - }, - ); - } - - profiles +#[cfg(not(windows))] +fn insert_terminal_profiles(profiles: &mut BTreeMap) { + profiles.insert( + "zsh".into(), + Profile { + command: "zsh".into(), + args: vec!["-i".into()], + title: Some("Z shell".into()), + agent_kind: None, + }, + ); + profiles.insert( + "bash".into(), + Profile { + command: "bash".into(), + args: vec!["--login".into(), "-i".into()], + title: Some("Bash".into()), + agent_kind: None, + }, + ); + profiles.insert( + "sh".into(), + Profile { + command: "sh".into(), + args: vec!["-i".into()], + title: Some("POSIX shell".into()), + agent_kind: None, + }, + ); + profiles.insert( + "pwsh".into(), + Profile { + command: "pwsh".into(), + args: vec!["-NoLogo".into()], + title: Some("PowerShell 7".into()), + agent_kind: None, + }, + ); } fn builtin_agent_kind(agent: &str) -> Option { @@ -207,10 +280,11 @@ fn builtin_agent_kind(agent: &str) -> Option { pub fn resolve_executable(command: &str) -> Option { let command_path = Path::new(command); if command_path.is_absolute() || command_path.components().count() > 1 { - return command_path.exists().then(|| command_path.to_path_buf()); + return is_executable_file(command_path).then(|| command_path.to_path_buf()); } let path = env::var_os("PATH")?; + #[cfg(windows)] let pathext: Vec = env::var_os("PATHEXT") .map(|value| { env::split_paths(&value) @@ -219,24 +293,34 @@ pub fn resolve_executable(command: &str) -> Option { }) .unwrap_or_else(|| vec!["exe".into(), "cmd".into(), "bat".into(), "ps1".into()]); + #[cfg(windows)] let has_extension = Path::new(command).extension().is_some(); for dir in env::split_paths(&path) { + #[cfg(windows)] if has_extension { let direct = dir.join(command); - if direct.is_file() { + if is_executable_file(&direct) { return Some(direct); } } else { for ext in &pathext { let candidate = dir.join(format!("{command}.{ext}")); - if candidate.is_file() { + if is_executable_file(&candidate) { return Some(candidate); } } let direct = dir.join(command); - if direct.is_file() { + if is_executable_file(&direct) { + return Some(direct); + } + } + + #[cfg(not(windows))] + { + let direct = dir.join(command); + if is_executable_file(&direct) { return Some(direct); } } @@ -244,3 +328,16 @@ pub fn resolve_executable(command: &str) -> Option { None } + +#[cfg(unix)] +fn is_executable_file(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + path.metadata() + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn is_executable_file(path: &Path) -> bool { + path.is_file() +} diff --git a/src/pty.rs b/src/pty.rs index 5796d0d..52a9f71 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -1,6 +1,6 @@ use std::{ collections::BTreeMap, - env, + env, fs, io::{Read, Write}, path::{Path, PathBuf}, sync::{Arc, Mutex}, @@ -118,7 +118,7 @@ impl PtyPane { .context("failed to open PTY")?; let mut command_builder = CommandBuilder::new(command); - configure_cwd_reporting(profile_name, &mut command_builder); + configure_cwd_reporting(profile_name, &mut command_builder)?; let args = args_with_cwd_reporting(profile_name, args); for arg in &args { command_builder.arg(arg); @@ -370,12 +370,14 @@ impl Drop for PtyPane { } } -fn configure_cwd_reporting(profile_name: &str, command_builder: &mut CommandBuilder) { +fn configure_cwd_reporting(profile_name: &str, command_builder: &mut CommandBuilder) -> Result<()> { match profile_name { "git-bash" => configure_bash_cwd_reporting(command_builder), "cmd" => command_builder.env("PROMPT", "$E]7;file:///$P$E\\$P$G"), + "zsh" => configure_zsh_cwd_reporting(command_builder)?, _ => {} } + Ok(()) } fn configure_bash_cwd_reporting(command_builder: &mut CommandBuilder) { @@ -387,6 +389,36 @@ fn configure_bash_cwd_reporting(command_builder: &mut CommandBuilder) { command_builder.env("PROMPT_COMMAND", prompt_command); } +fn configure_zsh_cwd_reporting(command_builder: &mut CommandBuilder) -> Result<()> { + const ZSHRC: &str = r#"if [[ -n "$GRIDBASH_ORIGINAL_ZDOTDIR" && -r "$GRIDBASH_ORIGINAL_ZDOTDIR/.zshrc" ]]; then + _gridbash_zdotdir="$ZDOTDIR" + ZDOTDIR="$GRIDBASH_ORIGINAL_ZDOTDIR" + source "$GRIDBASH_ORIGINAL_ZDOTDIR/.zshrc" + ZDOTDIR="$_gridbash_zdotdir" + unset _gridbash_zdotdir +fi + +function _gridbash_report_cwd() { + printf '\033]7;file://%s%s\007' "${HOST:-localhost}" "$PWD" +} +autoload -Uz add-zsh-hook +add-zsh-hook precmd _gridbash_report_cwd +"#; + + let integration_dir = env::temp_dir().join("gridbash-zsh-integration-v1"); + fs::create_dir_all(&integration_dir) + .context("failed to create GridBash zsh integration directory")?; + fs::write(integration_dir.join(".zshrc"), ZSHRC) + .context("failed to write GridBash zsh integration")?; + + let original_zdotdir = env::var_os("ZDOTDIR") + .or_else(|| env::var_os("HOME")) + .unwrap_or_default(); + command_builder.env("GRIDBASH_ORIGINAL_ZDOTDIR", original_zdotdir); + command_builder.env("ZDOTDIR", integration_dir); + Ok(()) +} + fn args_with_cwd_reporting(profile_name: &str, args: &[String]) -> Vec { let mut args = args.to_vec(); if matches!(profile_name, "pwsh" | "powershell") && !has_powershell_entrypoint(&args) { @@ -508,10 +540,12 @@ fn cwd_from_osc7_payload(payload: &[u8]) -> Option { }; let decoded = percent_decode(uri_path); - let path = windows_path_from_uri_path(&decoded); - Some(PathBuf::from(path)) + #[cfg(windows)] + let decoded = windows_path_from_uri_path(&decoded); + Some(PathBuf::from(decoded)) } +#[cfg(windows)] fn windows_path_from_uri_path(path: &str) -> String { let path = path.replace('\\', "/"); @@ -847,6 +881,7 @@ mod tests { assert_eq!(count_sequence(&scan, CURSOR_POSITION_QUERY), 0); } + #[cfg(windows)] #[test] fn parses_osc7_windows_cwd() { let payload = b"7;file://localhost/C:/Users/Jason/My%20Repo"; @@ -856,6 +891,7 @@ mod tests { ); } + #[cfg(windows)] #[test] fn parses_osc7_msys_cwd() { let payload = b"7;file://host/c/Users/Jason/gridbash"; @@ -865,6 +901,16 @@ mod tests { ); } + #[cfg(not(windows))] + #[test] + fn parses_osc7_unix_cwd() { + let payload = b"7;file://localhost/Users/Jason/My%20Repo"; + assert_eq!( + cwd_from_osc7_payload(payload), + Some(PathBuf::from("/Users/Jason/My Repo")) + ); + } + #[test] fn finds_osc_payloads_with_bel_and_st_terminators() { let payloads = osc_payloads(b"a\x1b]7;file://localhost/C:/one\x07b\x1b]0;title\x1b\\c"); @@ -933,6 +979,7 @@ mod tests { assert_eq!(plain, "red\nok"); } + #[cfg(windows)] #[test] #[ignore = "Windows ConPTY smoke test requires an interactive console; run manually when debugging PTY I/O"] fn spawned_pty_receives_output_and_input() { @@ -993,4 +1040,56 @@ mod tests { pane.screen().contents() ); } + + #[cfg(unix)] + #[test] + fn spawned_unix_pty_receives_output_and_input() { + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let cwd = env::current_dir().expect("current dir"); + let mut pane = PtyPane::spawn( + "sh", + PaneId(0), + 0, + Path::new("/bin/sh"), + &[ + "-c".into(), + "read value; printf 'GRIDBASH_READY:%s\\n' \"$value\"".into(), + ], + &BTreeMap::new(), + &cwd, + &[], + event_tx, + ) + .expect("spawn Unix PTY"); + + pane.write(b"typed-input\n").expect("write input to PTY"); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut raw_output = Vec::new(); + while Instant::now() < deadline { + while let Ok(event) = event_rx.try_recv() { + match event { + PtyEvent::Output { bytes, .. } => { + pane.process_output(&bytes); + raw_output.extend(bytes); + } + PtyEvent::Exited { .. } => pane.exited = true, + } + } + + if String::from_utf8_lossy(&raw_output).contains("GRIDBASH_READY:typed-input") + && pane + .screen() + .contents() + .contains("GRIDBASH_READY:typed-input") + { + pane.terminate(); + return; + } + thread::sleep(Duration::from_millis(20)); + } + + pane.terminate(); + panic!("Unix PTY did not round-trip input/output"); + } } diff --git a/src/voice.rs b/src/voice.rs index 8e0e6da..6ea8e8e 100644 --- a/src/voice.rs +++ b/src/voice.rs @@ -6,6 +6,9 @@ use std::{ time::Duration, }; +#[cfg(target_os = "macos")] +use std::{env, path::PathBuf}; + #[cfg(windows)] use std::os::windows::process::CommandExt; @@ -14,6 +17,7 @@ const NO_SPEECH_EXIT_CODE: i32 = 2; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x0800_0000; +#[cfg(windows)] const RECOGNIZE_SCRIPT: &str = r#" $ErrorActionPreference = 'Stop' $recognizer = $null @@ -177,28 +181,16 @@ impl Drop for VoiceInput { } fn recognize(cancel_rx: Receiver<()>) -> Option { - let mut command = Command::new("powershell.exe"); - command - .args([ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - RECOGNIZE_SCRIPT, - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - #[cfg(windows)] - command.creation_flags(CREATE_NO_WINDOW); + let mut command = match recognition_command() { + Ok(command) => command, + Err(error) => return Some(VoiceOutcome::Error(error)), + }; let mut child = match command.spawn() { Ok(child) => child, Err(error) => { return Some(VoiceOutcome::Error(format!( - "could not start modern Windows dictation: {error}" + "could not start speech recognition: {error}" ))); } }; @@ -218,13 +210,73 @@ fn recognize(cancel_rx: Receiver<()>) -> Option { Err(error) => { terminate(&mut child); return Some(VoiceOutcome::Error(format!( - "modern Windows dictation failed: {error}" + "speech recognition failed: {error}" ))); } } } } +#[cfg(windows)] +fn recognition_command() -> Result { + let mut command = Command::new("powershell.exe"); + command + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + RECOGNIZE_SCRIPT, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command.creation_flags(CREATE_NO_WINDOW); + Ok(command) +} + +#[cfg(target_os = "macos")] +fn recognition_command() -> Result { + let helper = macos_speech_helper().ok_or_else(|| { + "macOS speech helper is missing; reinstall the packaged GridBash application".to_string() + })?; + let mut command = Command::new(helper); + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + Ok(command) +} + +#[cfg(target_os = "macos")] +fn macos_speech_helper() -> Option { + if let Some(path) = env::var_os("GRIDBASH_SPEECH_HELPER").map(PathBuf::from) { + return path.is_file().then_some(path); + } + + let executable = env::current_exe().ok()?; + let sibling = executable.with_file_name("gridbash-speech"); + if sibling.is_file() { + return Some(sibling); + } + + let contents = executable.parent()?.parent()?; + let bundled = contents + .join("Helpers") + .join("GridBashSpeech.app") + .join("Contents") + .join("MacOS") + .join("gridbash-speech"); + bundled.is_file().then_some(bundled) +} + +#[cfg(not(any(windows, target_os = "macos")))] +fn recognition_command() -> Result { + Err("voice input is not supported on this platform yet".into()) +} + fn terminate(child: &mut Child) { let _ = child.kill(); let _ = child.wait(); @@ -249,7 +301,7 @@ fn read_outcome(child: &mut Child, status: ExitStatus) -> VoiceOutcome { let detail = normalize_transcript(&stderr); if detail.is_empty() { - VoiceOutcome::Error(format!("modern Windows dictation exited with {status}")) + VoiceOutcome::Error(format!("speech recognition exited with {status}")) } else { VoiceOutcome::Error(detail) } From 30a3279478ccb4db3c415a1fa0a6c2f073a85df5 Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Fri, 10 Jul 2026 13:23:22 -0700 Subject: [PATCH 2/7] test: verify native package contract Signed-off-by: Jason Matthew Suhari --- npm/scripts/gridbash-launcher.test.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/npm/scripts/gridbash-launcher.test.js b/npm/scripts/gridbash-launcher.test.js index bf4322b..d1dffe6 100644 --- a/npm/scripts/gridbash-launcher.test.js +++ b/npm/scripts/gridbash-launcher.test.js @@ -88,6 +88,23 @@ test("platform target selection covers all shipped native builds", () => { ); }); +test("root optional dependencies match every native target version", () => { + const root = path.resolve(__dirname, "..", ".."); + const rootPackage = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); + + for (const target of supportedTargets()) { + const nativePackage = JSON.parse( + fs.readFileSync( + path.join(root, "npm", "platforms", target.directory, "package.json"), + "utf8", + ), + ); + assert.equal(nativePackage.name, target.packageName); + assert.equal(nativePackage.version, rootPackage.version); + assert.equal(rootPackage.optionalDependencies[target.packageName], rootPackage.version); + } +}); + test("shouldSkipUpdateCheck preserves help, version, MCP, and non-TTY paths", () => { assert.equal(shouldSkipUpdateCheck(["--version"], {}, { isTTY: true }), true); assert.equal(shouldSkipUpdateCheck(["--mcp"], {}, { isTTY: true }), true); From e4ed95fe2d62f6fa60fd1fcd7143995bf90cc36f Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Fri, 10 Jul 2026 13:40:37 -0700 Subject: [PATCH 3/7] fix: make Unix platform validation portable Signed-off-by: Jason Matthew Suhari --- src/pty.rs | 1 + src/voice.rs | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pty.rs b/src/pty.rs index ce2a840..f69347b 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -1065,6 +1065,7 @@ mod tests { &BTreeMap::new(), &cwd, &[], + PaneProcessPriority::BelowNormal, event_tx, ) .expect("spawn Unix PTY"); diff --git a/src/voice.rs b/src/voice.rs index 6ea8e8e..61ff406 100644 --- a/src/voice.rs +++ b/src/voice.rs @@ -1,11 +1,14 @@ use std::{ io::Read, - process::{Child, Command, ExitStatus, Stdio}, + process::{Child, Command, ExitStatus}, sync::mpsc::{self, Receiver, Sender, TryRecvError}, thread, time::Duration, }; +#[cfg(any(windows, target_os = "macos"))] +use std::process::Stdio; + #[cfg(target_os = "macos")] use std::{env, path::PathBuf}; From 62413ce5ac57a5d900f9d3dcf79a230074ac3239 Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Fri, 10 Jul 2026 13:55:49 -0700 Subject: [PATCH 4/7] fix: compile Unix platform checks Signed-off-by: Jason Matthew Suhari --- src/pty.rs | 1 + src/voice.rs | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pty.rs b/src/pty.rs index ce2a840..f69347b 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -1065,6 +1065,7 @@ mod tests { &BTreeMap::new(), &cwd, &[], + PaneProcessPriority::BelowNormal, event_tx, ) .expect("spawn Unix PTY"); diff --git a/src/voice.rs b/src/voice.rs index 6ea8e8e..61ff406 100644 --- a/src/voice.rs +++ b/src/voice.rs @@ -1,11 +1,14 @@ use std::{ io::Read, - process::{Child, Command, ExitStatus, Stdio}, + process::{Child, Command, ExitStatus}, sync::mpsc::{self, Receiver, Sender, TryRecvError}, thread, time::Duration, }; +#[cfg(any(windows, target_os = "macos"))] +use std::process::Stdio; + #[cfg(target_os = "macos")] use std::{env, path::PathBuf}; From 5f1beec2ea456c9401794d03a2f901e508628dc5 Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Fri, 10 Jul 2026 14:12:42 -0700 Subject: [PATCH 5/7] test: use platform startup profile Signed-off-by: Jason Matthew Suhari --- src/composer.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/composer.rs b/src/composer.rs index 116836e..78d796f 100644 --- a/src/composer.rs +++ b/src/composer.rs @@ -503,7 +503,8 @@ mod tests { } let mut config = Config::default(); - config.set_default_profile("powershell"); + let profile = default_profile_name(); + config.set_default_profile(profile); let current_dir = env::current_dir().expect("current dir"); let composer = Composer::new(current_dir.clone(), None); @@ -517,7 +518,7 @@ mod tests { assert!( plan.panes .iter() - .all(|pane| { pane.profile_name == "powershell" && pane.cwd == current_dir }) + .all(|pane| { pane.profile_name == profile && pane.cwd == current_dir }) ); } From 8c122c3b3339700cb8324d61d97e6bbae41e745a Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Fri, 10 Jul 2026 14:31:06 -0700 Subject: [PATCH 6/7] test: canonicalize macOS launcher paths Signed-off-by: Jason Matthew Suhari --- npm/scripts/gridbash-launcher.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/npm/scripts/gridbash-launcher.test.js b/npm/scripts/gridbash-launcher.test.js index d1dffe6..cc7dfc9 100644 --- a/npm/scripts/gridbash-launcher.test.js +++ b/npm/scripts/gridbash-launcher.test.js @@ -28,7 +28,10 @@ test("resolveNativeExecutable finds the installed optional package", () => { fs.writeFileSync(executable, "test"); try { - assert.equal(resolveNativeExecutable(root, "darwin", "arm64"), executable); + assert.equal( + fs.realpathSync(resolveNativeExecutable(root, "darwin", "arm64")), + fs.realpathSync(executable), + ); } finally { fs.rmSync(root, { recursive: true, force: true }); } From f4a15e07f50b43ca4a24631e1a9038b8164a5e8c Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Fri, 10 Jul 2026 14:35:06 -0700 Subject: [PATCH 7/7] test: compare canonical launcher paths Signed-off-by: Jason Matthew Suhari --- npm/scripts/gridbash-launcher.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npm/scripts/gridbash-launcher.test.js b/npm/scripts/gridbash-launcher.test.js index d1dffe6..49e29d9 100644 --- a/npm/scripts/gridbash-launcher.test.js +++ b/npm/scripts/gridbash-launcher.test.js @@ -28,7 +28,7 @@ test("resolveNativeExecutable finds the installed optional package", () => { fs.writeFileSync(executable, "test"); try { - assert.equal(resolveNativeExecutable(root, "darwin", "arm64"), executable); + assert.equal(resolveNativeExecutable(root, "darwin", "arm64"), fs.realpathSync(executable)); } finally { fs.rmSync(root, { recursive: true, force: true }); }