diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 851f8a7..8d1a40e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,12 +6,51 @@ 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: Linux x64
+ runner: ubuntu-latest
+ platform_key: linux-x64
+ - name: Linux arm64
+ runner: ubuntu-24.04-arm
+ platform_key: linux-arm64
+ - 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..e1f2d27 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,33 @@ 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: ubuntu-latest
+ platform_key: linux-x64
+ - runner: ubuntu-24.04-arm
+ platform_key: linux-arm64
+ - 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 +115,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 a0f7517..7427ec1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,10 @@
.worktrees/
node_modules/
npm-debug.log*
+/npm/platforms/*/bin/
+/npm/platforms/darwin-*/GridBash.app/
+/npm/platforms/**/*.tgz
+/*.tgz
npm/bin/win32-x64/gridbash.exe
npm/bin/linux-x64/gridbash
npm/bin/linux-arm64/gridbash
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0c3494f..6b6f4b5 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, Linux, 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, a supported Linux distribution, 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, Linux, 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 55f7c33..07144f6 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 707d6fc..3b722e0 100644
--- a/README.md
+++ b/README.md
@@ -5,11 +5,11 @@
[](https://www.npmjs.com/package/gridbash)
[](https://github.com/jasonsuhari/gridbash/releases)
[](LICENSE)
-[](https://github.com/jasonsuhari/gridbash)
+[](https://github.com/jasonsuhari/gridbash)
**The sexiest way to tokenmaxx.**
-GridBash 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. Spawn manager agents that can do the prompting for you, or talk to your agents via Voice Mode.
+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. Spawn manager agents that can do the prompting for you, or talk to your agents via Voice Mode.
Official site: [jasonsuhari.github.io/gridbash](https://jasonsuhari.github.io/gridbash/)
@@ -21,7 +21,8 @@ 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, Linux x64/arm64, or macOS 13+
+(Apple Silicon or Intel):
```powershell
npm install -g gridbash
@@ -52,7 +53,10 @@ 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, Linux, 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 +68,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 +110,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 +122,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 +143,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
@@ -301,7 +313,7 @@ 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
+Voice mode uses modern Windows dictation on Windows and Apple Speech on macOS.
`Alt+Shift+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
@@ -313,6 +325,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.
@@ -350,16 +366,21 @@ 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
+Linux: zsh bash sh pwsh
```
+Agent profile keys remain available on every platform: `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:
@@ -406,9 +427,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.
+
GridBash keeps its own interface at normal Windows process priority, while pane
processes default to `below-normal`. Child workloads such as parallel compilers
normally inherit that priority, which keeps input and other desktop apps responsive
@@ -423,7 +448,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..7841919 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, Linux x64/arm64, and 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..137a667
--- /dev/null
+++ b/docs/devlogs/2026-07-10-macos.md
@@ -0,0 +1,43 @@
+# 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.
+- Integrated the existing Linux targets into the same exact-version native npm
+ package contract 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, Linux x64/arm64, 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 22c9098..4a2bba3 100644
--- a/npm/bin/gridbash.js
+++ b/npm/bin/gridbash.js
@@ -20,6 +20,34 @@ function packageRoot() {
return path.resolve(__dirname, "..", "..");
}
+function resolveNativeExecutable(
+ root = packageRoot(),
+ platform = process.platform,
+ arch = process.arch,
+) {
+ const target = targetFor(platform, arch);
+ if (!target) {
+ throw new Error(`unsupported platform: ${targetKey(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.executablePath);
+ if (!fs.existsSync(executable)) {
+ throw new Error(
+ `native package ${target.packageName} is missing ${target.executablePath.join("/")}`,
+ );
+ }
+ return executable;
+}
+
function readPackageVersion(root = packageRoot()) {
try {
return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version;
@@ -300,12 +328,7 @@ async function maybePrintUpdateNotice(args, env = process.env, stderr = process.
async function main() {
const args = process.argv.slice(2);
- const target = targetFor();
- if (!target) {
- fail(`unsupported platform: ${targetKey(process.platform, process.arch)}`);
- }
-
- const exe = path.join(__dirname, target.directory, target.executable);
+ const exe = resolveNativeExecutable();
const normalizedBinDir = path.resolve(__dirname).toLowerCase();
const sourceManifest = path.resolve(__dirname, "..", "..", "Cargo.toml");
@@ -319,10 +342,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, {
@@ -353,6 +372,7 @@ if (require.main === module) {
environmentForLaunch,
fetchLatestRelease,
formatUpdateNotice,
+ resolveNativeExecutable,
profileForProcessName,
shouldSkipUpdateCheck,
tasklistImageName,
diff --git a/npm/bin/platforms.js b/npm/bin/platforms.js
index 3d879e8..191dda1 100644
--- a/npm/bin/platforms.js
+++ b/npm/bin/platforms.js
@@ -3,23 +3,47 @@ const TARGETS = Object.freeze({
platform: "win32",
arch: "x64",
directory: "win32-x64",
+ packageName: "gridbash-win32-x64",
executable: "gridbash.exe",
+ executablePath: Object.freeze(["bin", "gridbash.exe"]),
cargoTarget: "x86_64-pc-windows-msvc",
}),
"linux-x64": Object.freeze({
platform: "linux",
arch: "x64",
directory: "linux-x64",
+ packageName: "gridbash-linux-x64",
executable: "gridbash",
+ executablePath: Object.freeze(["bin", "gridbash"]),
cargoTarget: "x86_64-unknown-linux-gnu",
}),
"linux-arm64": Object.freeze({
platform: "linux",
arch: "arm64",
directory: "linux-arm64",
+ packageName: "gridbash-linux-arm64",
executable: "gridbash",
+ executablePath: Object.freeze(["bin", "gridbash"]),
cargoTarget: "aarch64-unknown-linux-gnu",
}),
+ "darwin-arm64": Object.freeze({
+ platform: "darwin",
+ arch: "arm64",
+ directory: "darwin-arm64",
+ packageName: "gridbash-darwin-arm64",
+ executable: "gridbash",
+ executablePath: Object.freeze(["GridBash.app", "Contents", "MacOS", "gridbash"]),
+ cargoTarget: "aarch64-apple-darwin",
+ }),
+ "darwin-x64": Object.freeze({
+ platform: "darwin",
+ arch: "x64",
+ directory: "darwin-x64",
+ packageName: "gridbash-darwin-x64",
+ executable: "gridbash",
+ executablePath: Object.freeze(["GridBash.app", "Contents", "MacOS", "gridbash"]),
+ cargoTarget: "x86_64-apple-darwin",
+ }),
});
function targetKey(platform, arch) {
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/linux-arm64/package.json b/npm/platforms/linux-arm64/package.json
new file mode 100644
index 0000000..926ca53
--- /dev/null
+++ b/npm/platforms/linux-arm64/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "gridbash-linux-arm64",
+ "version": "0.1.6",
+ "description": "GridBash native binary for Linux arm64.",
+ "license": "MIT",
+ "repository": "github:jasonsuhari/gridbash",
+ "os": ["linux"],
+ "cpu": ["arm64"],
+ "files": ["bin/"]
+}
diff --git a/npm/platforms/linux-x64/package.json b/npm/platforms/linux-x64/package.json
new file mode 100644
index 0000000..f7d3aa0
--- /dev/null
+++ b/npm/platforms/linux-x64/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "gridbash-linux-x64",
+ "version": "0.1.6",
+ "description": "GridBash native binary for Linux x64.",
+ "license": "MIT",
+ "repository": "github:jasonsuhari/gridbash",
+ "os": ["linux"],
+ "cpu": ["x64"],
+ "files": ["bin/"]
+}
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 c607a1f..cc7dfc9 100644
--- a/npm/scripts/gridbash-launcher.test.js
+++ b/npm/scripts/gridbash-launcher.test.js
@@ -1,5 +1,8 @@
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 { supportedTargets, targetFor, targetKey } = require("../bin/platforms.js");
@@ -9,12 +12,42 @@ const {
detectInvokingProfile,
environmentForLaunch,
formatUpdateNotice,
+ resolveNativeExecutable,
profileForProcessName,
shouldSkipUpdateCheck,
tasklistImageName,
updateCheckTimeoutMs,
} = require("../bin/gridbash.js");
+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(
+ fs.realpathSync(resolveNativeExecutable(root, "darwin", "arm64")),
+ fs.realpathSync(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" });
@@ -40,22 +73,41 @@ test("compareVersions handles newer, older, and prerelease versions", () => {
assert.equal(compareVersions("1.0.0", "1.0.0-beta.1"), 1);
});
-test("platform target selection covers shipped Windows and Linux builds", () => {
+test("platform target selection covers all shipped native builds", () => {
assert.equal(targetKey("linux", "arm64"), "linux-arm64");
- assert.deepEqual(targetFor("win32", "x64"), {
- platform: "win32",
- arch: "x64",
- directory: "win32-x64",
- executable: "gridbash.exe",
- cargoTarget: "x86_64-pc-windows-msvc",
- });
+ assert.equal(targetFor("win32", "x64").packageName, "gridbash-win32-x64");
+ assert.equal(targetFor("linux", "x64").packageName, "gridbash-linux-x64");
+ assert.equal(targetFor("darwin", "arm64").packageName, "gridbash-darwin-arm64");
+ assert.deepEqual(targetFor("darwin", "x64").executablePath, [
+ "GridBash.app",
+ "Contents",
+ "MacOS",
+ "gridbash",
+ ]);
assert.equal(targetFor("linux", "ia32"), undefined);
assert.deepEqual(
supportedTargets().map((target) => `${target.platform}-${target.arch}`),
- ["win32-x64", "linux-x64", "linux-arm64"],
+ ["win32-x64", "linux-x64", "linux-arm64", "darwin-arm64", "darwin-x64"],
);
});
+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);
diff --git a/npm/scripts/install-local.js b/npm/scripts/install-local.js
index 06f5cb4..9428e6c 100644
--- a/npm/scripts/install-local.js
+++ b/npm/scripts/install-local.js
@@ -5,38 +5,17 @@ const { targetFor, targetKey } = require("../bin/platforms.js");
const root = path.resolve(__dirname, "..", "..");
const packageJson = require(path.join(root, "package.json"));
+const platformTarget = targetFor();
function fail(message) {
console.error(`install-local: ${message}`);
process.exit(1);
}
-function run(command, args, options = {}) {
- const invocation = commandInvocation(command, args);
- const result = spawnSync(invocation.command, invocation.args, {
- cwd: root,
- stdio: options.capture ? "pipe" : "inherit",
- encoding: "utf8",
- shell: false,
- });
-
- if (result.error) {
- throw result.error;
- }
-
- if (result.status !== 0 && !options.allowFailure) {
- const output = options.capture ? `\n${result.stderr || result.stdout}` : "";
- fail(`${command} ${args.join(" ")} failed with exit code ${result.status}${output}`);
- }
-
- return options.capture ? result.stdout.trim() : "";
-}
-
function commandInvocation(command, args) {
if (process.platform !== "win32" || !command.endsWith(".cmd")) {
return { command, args };
}
-
return {
command: process.env.ComSpec || "cmd.exe",
args: ["/d", "/s", "/c", [command, ...args].map(quoteCmd).join(" ")],
@@ -47,7 +26,6 @@ function quoteCmd(value) {
if (!/[ \t"&|<>^]/.test(value)) {
return value;
}
-
return `"${value.replace(/"/g, '\\"')}"`;
}
@@ -55,7 +33,25 @@ function npmCommand() {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
-function assertNotLinked() {
+function run(command, args, options = {}) {
+ const invocation = commandInvocation(command, args);
+ const result = spawnSync(invocation.command, invocation.args, {
+ cwd: options.cwd || root,
+ stdio: options.capture ? "pipe" : "inherit",
+ encoding: "utf8",
+ shell: false,
+ });
+ if (result.error) {
+ throw result.error;
+ }
+ if (result.status !== 0 && !options.allowFailure) {
+ const output = options.capture ? `\n${result.stderr || result.stdout}` : "";
+ fail(`${command} ${args.join(" ")} failed with exit code ${result.status}${output}`);
+ }
+ return options.capture ? result.stdout.trim() : "";
+}
+
+function assertNotLinked(nativePackageName) {
const globalRoot = run(npmCommand(), ["root", "-g"], { capture: true });
const packagePath = path.join(globalRoot, packageJson.name);
const stat = fs.lstatSync(packagePath);
@@ -71,23 +67,50 @@ 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 (!targetFor()) {
+if (!platformTarget) {
fail(`unsupported platform: ${targetKey(process.platform, process.arch)}`);
}
-run("node", ["npm/scripts/prepare.js"]);
+const nativePackageName = platformTarget.packageName;
+const nativePackageDir = path.join(root, "npm", "platforms", platformTarget.directory);
+if (!fs.existsSync(path.join(nativePackageDir, "package.json"))) {
+ fail(`missing local package for ${platformTarget.directory}`);
+}
-const packOutput = run(npmCommand(), ["pack", "--json", "--ignore-scripts"], { capture: true });
-const pack = JSON.parse(packOutput)[0];
-const tarball = path.join(root, pack.filename);
+run("node", ["npm/scripts/prepare.js"]);
+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"]);
- assertNotLinked();
+ run(npmCommand(), ["uninstall", "-g", nativePackageName], { allowFailure: true });
+ run(npmCommand(), [
+ "install",
+ "-g",
+ nativeTarball,
+ rootTarball,
+ "--omit=optional",
+ "--no-audit",
+ "--no-fund",
+ ]);
+ assertNotLinked(nativePackageName);
} 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 af5e5d0..3276b97 100644
--- a/npm/scripts/prepare.js
+++ b/npm/scripts/prepare.js
@@ -4,34 +4,93 @@ const path = require("node:path");
const { targetFor, targetKey } = require("../bin/platforms.js");
const root = path.resolve(__dirname, "..", "..");
+const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
+const platformTarget = targetFor();
-function run(command, args) {
- const result = spawnSync(command, args, {
- cwd: root,
- stdio: "inherit"
- });
+function fail(message) {
+ throw new Error(`gridbash prepare: ${message}`);
+}
+function run(command, args) {
+ const result = spawnSync(command, args, { cwd: root, 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}`);
}
}
-const platformTarget = targetFor();
-if (!platformTarget) {
- console.log(
- `gridbash prepare: skipping unsupported platform ${targetKey(process.platform, process.arch)}`,
+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 prepareBinary(packageDir) {
+ const source = path.join(root, "target", "release", platformTarget.executable);
+ const binDir = path.join(packageDir, "bin");
+ const packagedBinary = path.join(binDir, platformTarget.executable);
+ resetDirectory(binDir);
+ fs.copyFileSync(source, packagedBinary);
+ if (process.platform !== "win32") {
+ fs.chmodSync(packagedBinary, 0o755);
+ }
+}
+
+function prepareMacos(packageDir) {
+ 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"),
);
- process.exit(0);
+ 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);
}
-const executable = process.platform === "win32" ? "gridbash.exe" : "gridbash";
-const source = path.join(root, "target", "release", executable);
-const outDir = path.join(root, "npm", "bin", platformTarget.directory);
-const packagedBinary = path.join(outDir, platformTarget.executable);
+if (!platformTarget) {
+ fail(`unsupported platform architecture: ${targetKey(process.platform, process.arch)}`);
+}
+
+const packageDir = path.join(root, "npm", "platforms", platformTarget.directory);
+if (!fs.existsSync(path.join(packageDir, "package.json"))) {
+ fail(`missing native package manifest for ${platformTarget.directory}`);
+}
run(process.platform === "win32" ? "cargo.exe" : "cargo", [
"build",
@@ -39,9 +98,11 @@ run(process.platform === "win32" ? "cargo.exe" : "cargo", [
"--bin",
"gridbash",
]);
-fs.mkdirSync(outDir, { recursive: true });
-fs.copyFileSync(source, packagedBinary);
-if (process.platform !== "win32") {
- fs.chmodSync(packagedBinary, 0o755);
+
+if (process.platform === "darwin") {
+ prepareMacos(packageDir);
+} else {
+ prepareBinary(packageDir);
}
-console.log(`gridbash prepare: copied ${path.relative(root, packagedBinary)}`);
+
+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 dc302ee..7a0c726 100644
--- a/package.json
+++ b/package.json
@@ -55,6 +55,7 @@
"pty",
"conpty",
"windows",
+ "macos",
"linux",
"powershell",
"git-bash",
@@ -63,12 +64,11 @@
"engines": {
"node": ">=18"
},
- "os": [
- "win32",
- "linux"
- ],
- "cpu": [
- "x64",
- "arm64"
- ]
+ "optionalDependencies": {
+ "gridbash-win32-x64": "0.1.6",
+ "gridbash-linux-x64": "0.1.6",
+ "gridbash-linux-arm64": "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 70f1821..f19fac7 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},
@@ -5735,7 +5738,7 @@ fn resolve_profile_name_from(
.or(environment_profile)
.or(invoking_profile)
.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")?;
@@ -6292,6 +6295,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",
@@ -6305,6 +6313,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..78d796f 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> {
@@ -498,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);
@@ -512,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 })
);
}
diff --git a/src/onboarding.rs b/src/onboarding.rs
index fc96b05..9442d6f 100644
--- a/src/onboarding.rs
+++ b/src/onboarding.rs
@@ -66,9 +66,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 {
@@ -80,6 +78,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 c12d720..f69347b 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},
@@ -119,7 +119,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);
@@ -376,12 +376,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) {
@@ -393,6 +395,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) {
@@ -514,10 +546,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('\\', "/");
@@ -853,6 +887,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";
@@ -862,6 +897,7 @@ mod tests {
);
}
+ #[cfg(windows)]
#[test]
fn parses_osc7_msys_cwd() {
let payload = b"7;file://host/c/Users/Jason/gridbash";
@@ -871,6 +907,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");
@@ -939,6 +985,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() {
@@ -1000,4 +1047,57 @@ 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,
+ &[],
+ PaneProcessPriority::BelowNormal,
+ 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..61ff406 100644
--- a/src/voice.rs
+++ b/src/voice.rs
@@ -1,11 +1,17 @@
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};
+
#[cfg(windows)]
use std::os::windows::process::CommandExt;
@@ -14,6 +20,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 +184,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 +213,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 +304,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)
}