diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca19a317..0313e2e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ on: - beta - 'cto/**' - 'nikita/**' + - 'repair/**' workflow_dispatch: concurrency: @@ -95,7 +96,7 @@ jobs: - name: Install build tools (Ubuntu) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep + run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep tmux - name: Install dependencies run: bun install --frozen-lockfile @@ -131,7 +132,7 @@ jobs: bun-version-file: package.json - name: Install build tools (Ubuntu) - run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep + run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep tmux - name: Install dependencies run: bun install --frozen-lockfile @@ -139,6 +140,9 @@ jobs: - name: Build CLI for terminal tests run: bun run build + - name: Prepare pinned Zit for tmux isolation tests + run: node --input-type=module -e "import { installZit } from './scripts/install-zit.mjs'; await installZit({ force:true });" + - name: Run built terminal tests run: bun run test:tuistory -- --shard=${{ matrix.shard }}/4 @@ -164,7 +168,7 @@ jobs: - name: Install build tools (Ubuntu) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep + run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep tmux - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/full-proof.yml b/.github/workflows/full-proof.yml index 4746168c..891a0b9f 100644 --- a/.github/workflows/full-proof.yml +++ b/.github/workflows/full-proof.yml @@ -34,7 +34,7 @@ jobs: bun-version-file: package.json - name: Install native build tools - run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep + run: sudo apt-get update && sudo apt-get install -y build-essential python3 make g++ ripgrep tmux - name: Install dependencies run: bun install --frozen-lockfile @@ -42,5 +42,8 @@ jobs: - name: Prepare built CLI fixtures run: bun run build + - name: Prepare pinned Zit for tmux isolation tests + run: node --input-type=module -e "import { installZit } from './scripts/install-zit.mjs'; await installZit({ force:true });" + - name: Run complete proof run: bun run proof diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d886a1b..74ed4c4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -202,7 +202,7 @@ jobs: bun-version-file: package.json - name: Install ripgrep - run: sudo apt-get update && sudo apt-get install -y ripgrep + run: sudo apt-get update && sudo apt-get install -y ripgrep tmux - name: Install dependencies run: bun install --frozen-lockfile @@ -240,7 +240,7 @@ jobs: bun-version-file: package.json - name: Install ripgrep - run: sudo apt-get update && sudo apt-get install -y ripgrep + run: sudo apt-get update && sudo apt-get install -y ripgrep tmux - name: Install dependencies run: bun install --frozen-lockfile @@ -248,6 +248,9 @@ jobs: - name: Build CLI for terminal tests run: bun run build + - name: Prepare pinned Zit for tmux isolation tests + run: node --input-type=module -e "import { installZit } from './scripts/install-zit.mjs'; await installZit({ force:true });" + - name: Run built terminal tests run: bun run test:tuistory -- --shard=${{ matrix.shard }}/4 diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml new file mode 100644 index 00000000..14a0fdc5 --- /dev/null +++ b/.github/workflows/terminal-compatibility.yml @@ -0,0 +1,120 @@ +name: Terminal compatibility + +on: + pull_request: + paths: + - 'src/ui/**' + - 'src/testing/**' + - 'src/utils/tmux.ts' + - 'src/startup/cliOptions.ts' + - 'src/index.ts' + - 'package.json' + - 'bun.lock' + - 'tests/tuistory/*terminal*' + - 'tests/tuistory/*resize*' + - 'tests/tuistory/*tmux*' + - '.github/workflows/terminal-compatibility.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: terminal-compatibility-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + tmux: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: + node-version: 24 + package-manager-cache: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + - run: sudo apt-get update && sudo apt-get install -y build-essential python3 ripgrep tmux + - run: bun install --frozen-lockfile + - run: bun run build + - name: Prepare pinned Zit for real isolation tests + run: node --input-type=module -e "import { installZit } from './scripts/install-zit.mjs'; await installZit({ force:true });" + - name: Test compiled startup and rapid resize through real tmux + run: bun run test:tuistory tests/tuistory/tmux-startup.tuistory.test.ts tests/tuistory/resize-flicker.tuistory.test.ts + + native-macos: + runs-on: macos-26 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + terminal: [iterm2, ghostty, cmux, vscode] + env: + AUTOHAND_NATIVE_TERMINAL: ${{ matrix.terminal }} + HOMEBREW_NO_AUTO_UPDATE: '1' + AUTOHAND_SKIP_COMPUTER_CONTROL_INSTALL: '1' + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: + node-version: 24 + package-manager-cache: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + - name: Install native terminal and UI test build tools + shell: bash + run: | + set -euo pipefail + brew install xcodegen ripgrep tmux + case "$AUTOHAND_NATIVE_TERMINAL" in + iterm2) brew install --cask iterm2; terminal_app=/Applications/iTerm.app ;; + ghostty) brew install --cask ghostty; terminal_app=/Applications/Ghostty.app ;; + cmux) brew install --cask cmux; terminal_app=/Applications/cmux.app ;; + vscode) brew install --cask visual-studio-code; terminal_app='/Applications/Visual Studio Code.app' ;; + esac + codesign --verify --deep --strict "$terminal_app" + spctl --assess --type execute --verbose "$terminal_app" + # Approve only the verified app on this disposable VM for unattended launch. + xattr -dr com.apple.quarantine "$terminal_app" + /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$terminal_app" + defaults read "$terminal_app/Contents/Info" CFBundleIdentifier + system_profiler SPDisplaysDataType + stat -f 'Console user: %Su' /dev/console + mkdir -p .artifacts/native-terminal + brew info --json=v2 --installed > .artifacts/native-terminal/installed-versions.json + # These preferences belong only to this disposable Actions VM. + defaults write com.googlecode.iterm2 EnableAPIServer -bool false + defaults write com.googlecode.iterm2 PromptOnQuit -bool false + defaults write com.googlecode.iterm2 SUEnableAutomaticChecks -bool false + defaults write com.googlecode.iterm2 NSQuitAlwaysKeepsWindows -bool false + defaults write com.googlecode.iterm2 ApplePersistenceIgnoreState -bool true + - run: bun install --frozen-lockfile + - run: bun run build + - name: Run Tuistory with native XCTest window and accessibility checks + run: bun run test:tuistory tests/tuistory/native-terminal.tuistory.test.ts + - name: Export native screen captures + if: always() + shell: bash + run: | + result=".artifacts/native-terminal/$AUTOHAND_NATIVE_TERMINAL/NativeTerminal.xcresult" + if [ -d "$result" ]; then + xcrun xcresulttool export attachments --path "$result" --output-path ".artifacts/native-terminal/$AUTOHAND_NATIVE_TERMINAL/screenshots" + fi + - name: Preserve native screenshots and XCTest results + if: always() + uses: actions/upload-artifact@v7 + with: + name: native-terminal-${{ matrix.terminal }} + path: | + .artifacts/native-terminal/installed-versions.json + .artifacts/native-terminal/**/*.xcresult + .artifacts/native-terminal/**/screenshots + if-no-files-found: error + retention-days: 7 diff --git a/docs/config-reference.md b/docs/config-reference.md index 4f0a679a..9936c4dd 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -3092,7 +3092,7 @@ When a noninteractive task needs approval, it stops at the first missing authori | Flag | Description | | ----------------------------- | ---------------------------------------------------------------------------------------------- | | `--worktree [name]` | Run session in isolated git worktree (optional worktree/branch name) | -| `--tmux` | Launch in a dedicated tmux session (implies `--worktree`; cannot be used with `--no-worktree`) | +| `--tmux` | Launch in a dedicated tmux session in the current directory; supports `--worktree` or `--zit` | | `--zit [intent]` | Run session in a disposable Zit workspace, recorded as a Zit change on exit (optional intent) | | `--no-worktree` | Disable git worktree isolation in auto-mode | | `-c, --continue` | Resume the latest session in this folder, including with `-p`; start fresh when none exists | @@ -3128,7 +3128,18 @@ What happens: Inspect the result with `zit status` and `zit show `; accepting the change into the current state is left to you (`zit accept`). -`--zit` cannot be combined with `--worktree`, `--tmux`, `--patch`, `--mode rpc|acp`, or a standalone `--auto-mode` loop. +`--zit` cannot be combined with `--worktree`, `--patch`, `--mode rpc|acp`, or a standalone `--auto-mode` loop. + +`--tmux` uses the current directory by default, including directories outside Git repositories. Workspace isolation is optional: + +```bash +autohand --tmux +autohand --tmux --worktree my-task +autohand --tmux --zit +``` + +Use `--worktree` or `--zit` when isolation is needed; `--tmux --no-worktree` is also supported. + ### Auto-Mode diff --git a/docs/config-reference_cs.md b/docs/config-reference_cs.md index 618dd1ee..2a94fca0 100644 --- a/docs/config-reference_cs.md +++ b/docs/config-reference_cs.md @@ -1931,7 +1931,7 @@ Tyto příznaky přepisují nastavení konfiguračního souboru: | Vlajka | Popis | | ------------------------------ | ---------------------------------------------------------------------------------------------- | | `--worktree [name]` | Spustit relaci v izolovaném pracovním stromu git (volitelný název pracovního stromu/větve) | -| `--tmux` | Spustit ve vyhrazené relaci tmux (předpokládá `--worktree`; nelze použít s `--no-worktree`) | +| `--tmux` | Spustit ve vyhrazené relaci tmux v aktuálním adresáři; podporuje `--worktree` nebo `--zit` | | `--no-worktree` | Zakázat izolaci pracovního stromu git v automatickém režimu | | `--auto-commit` | Automatické potvrzení změn po dokončení úkolů | | `--patch` | Vygenerujte git patch bez použití změn | diff --git a/docs/config-reference_de.md b/docs/config-reference_de.md index 9ab7e1d1..0299ac4a 100644 --- a/docs/config-reference_de.md +++ b/docs/config-reference_de.md @@ -2054,7 +2054,7 @@ Diese Flags überschreiben Konfigurationsdatei-Einstellungen: | Flag | Beschreibung | | ----------------------------- | ---------------------------------------------------------------------------------------------- | | `--worktree [name]` | Sitzung in isoliertem Git-Worktree ausführen (optionaler Worktree-/Branch-Name) | -| `--tmux` | In dedizierter tmux-Sitzung starten (impliziert `--worktree`; kann nicht mit `--no-worktree` verwendet werden) | +| `--tmux` | In dedizierter tmux-Sitzung im aktuellen Verzeichnis starten; unterstützt `--worktree` oder `--zit` | | `--no-worktree` | Git-Worktree-Isolierung im Auto-Modus deaktivieren | | `--auto-commit` | Änderungen nach Abschluss der Aufgaben automatisch committen | | `--patch` | Git-Patch generieren, ohne Änderungen anzuwenden | diff --git a/docs/config-reference_es.md b/docs/config-reference_es.md index 0b088f5e..2ec753e7 100644 --- a/docs/config-reference_es.md +++ b/docs/config-reference_es.md @@ -1396,7 +1396,7 @@ Estos flags sobrescriben la configuración del archivo: | `--model ` | Sobrescribir modelo | | `--path ` | Sobrescribir raíz del espacio de trabajo | | `--worktree [nombre]` | Ejecutar sesión en un git worktree aislado (nombre opcional de worktree/rama) | -| `--tmux` | Iniciar en una sesión tmux dedicada (implica `--worktree`; no se puede usar con `--no-worktree`) | +| `--tmux` | Iniciar una sesión tmux dedicada en el directorio actual; admite `--worktree` o `--zit` | | `--add-dir ` | Agregar directorios adicionales al alcance del espacio de trabajo (se puede usar múltiples veces) | | `--config ` | Usar archivo de configuración personalizado | | `--temperature ` | Establecer temperatura (0-1) | diff --git a/docs/config-reference_fr.md b/docs/config-reference_fr.md index bc938ade..a57d3f20 100644 --- a/docs/config-reference_fr.md +++ b/docs/config-reference_fr.md @@ -1913,7 +1913,7 @@ Ces indicateurs remplacent les paramètres du fichier de configuration : | Drapeau | Descriptif | | ----------------------------- | ------------------------------------------------------------------------------------------------------------- | | `--worktree [name]` | Exécuter la session dans un arbre de travail git isolé (nom de l'arbre de travail/de la branche facultatif) | -| `--tmux` | Lancer dans une session tmux dédiée (implique `--worktree` ; ne peut pas être utilisé avec `--no-worktree`) | +| `--tmux` | Lancer une session tmux dédiée dans le répertoire courant ; accepte `--worktree` ou `--zit` | | `--no-worktree` | Désactiver l'isolation de git worktree en mode automatique | | `--auto-commit` | Valider automatiquement les modifications après avoir terminé les tâches | | `--patch` | Générer le patch git sans appliquer les modifications | diff --git a/docs/config-reference_hi.md b/docs/config-reference_hi.md index 997ed534..2e310d7e 100644 --- a/docs/config-reference_hi.md +++ b/docs/config-reference_hi.md @@ -1309,7 +1309,7 @@ Autohand `~/.autohand/` (या `$AUTOHAND_HOME`) में डेटा स् | `--model ` | मॉडल ओवरराइड करें | | `--path ` | वर्कस्पेस रूट ओवरराइड करें | | `--worktree [name]` | सेशन को अलग git worktree में चलाएँ (वैकल्पिक worktree/branch नाम) | -| `--tmux` | समर्पित tmux सेशन में शुरू करें (`--worktree` निहित; `--no-worktree` के साथ उपयोग नहीं कर सकते) | +| `--tmux` | वर्तमान निर्देशिका में समर्पित tmux सेशन शुरू करें; `--worktree` या `--zit` समर्थित हैं | | `--add-dir ` | वर्कस्पेस स्कोप में अतिरिक्त डायरेक्टरी जोड़ें (कई बार उपयोग किया जा सकता है) | | `--config ` | कस्टम कॉन्फिग फाइल का उपयोग करें | | `--temperature ` | टेम्परेचर सेट करें (0-1) | diff --git a/docs/config-reference_hu.md b/docs/config-reference_hu.md index 79d36139..da619f30 100644 --- a/docs/config-reference_hu.md +++ b/docs/config-reference_hu.md @@ -1913,7 +1913,7 @@ Ezek a jelzők felülírják a konfigurációs fájl beállításait: | zászló | Leírás | | ------------------------------ | ---------------------------------------------------------------------------------------------- | | `--worktree [name]` | Munkamenet futtatása elszigetelt git-munkafán (opcionális munkafa/ág neve) | -| `--tmux` | Indítás egy dedikált tmux munkamenetben (az `--worktree`-t jelenti; nem használható a `--no-worktree` kóddal) | +| `--tmux` | Indítás dedikált tmux munkamenetben az aktuális könyvtárban; támogatja a `--worktree` és `--zit` kapcsolókat | | `--no-worktree` | A git munkafa elkülönítésének letiltása automatikus módban | | `--auto-commit` | Változások automatikus véglegesítése a feladatok elvégzése után | | `--patch` | Git javítás generálása változtatások alkalmazása nélkül | diff --git a/docs/config-reference_id.md b/docs/config-reference_id.md index 541177ca..c71d8fcc 100644 --- a/docs/config-reference_id.md +++ b/docs/config-reference_id.md @@ -1367,7 +1367,7 @@ Flag-flag ini mengganti pengaturan file konfigurasi: | `--model ` | Ganti model | | `--path ` | Ganti root workspace | | `--worktree [nama]` | Jalankan sesi di git worktree terisolasi (nama worktree/branch opsional) | -| `--tmux` | Jalankan dalam sesi tmux khusus (mengimplikasikan `--worktree`; tidak bisa dipakai dengan `--no-worktree`) | +| `--tmux` | Jalankan sesi tmux khusus di direktori saat ini; mendukung `--worktree` atau `--zit` | | `--add-dir ` | Tambahkan direktori tambahan ke lingkup workspace (dapat digunakan beberapa kali) | | `--config ` | Gunakan file konfigurasi kustom | | `--temperature ` | Atur temperature (0-1) | diff --git a/docs/config-reference_it.md b/docs/config-reference_it.md index 9690ad48..af1e206f 100644 --- a/docs/config-reference_it.md +++ b/docs/config-reference_it.md @@ -1913,7 +1913,7 @@ Questi flag sovrascrivono le impostazioni del file di configurazione: | Bandiera | Descrizione | | ----------------------- | ---------------------------------------------------------------------------------------- | | `--worktree [name]` | Esegui la sessione in un albero di lavoro git isolato (nome albero di lavoro/ramo opzionale) | -| `--tmux` | Avvia in una sessione tmux dedicata (implica `--worktree`; non può essere utilizzato con `--no-worktree`) | +| `--tmux` | Avvia una sessione tmux dedicata nella directory corrente; supporta `--worktree` o `--zit` | | `--no-worktree` | Disabilita l'isolamento di git worktree in modalità automatica | | `--auto-commit` | Effettua il commit automatico delle modifiche dopo aver completato le attività | | `--patch` | Genera patch git senza applicare modifiche | diff --git a/docs/config-reference_ja.md b/docs/config-reference_ja.md index 3a54d2fc..81d1b3d7 100644 --- a/docs/config-reference_ja.md +++ b/docs/config-reference_ja.md @@ -1361,7 +1361,7 @@ Autohandは `~/.autohand/`(または `$AUTOHAND_HOME`)にデータを保存 | `--model ` | モデルをオーバーライド | | `--path ` | ワークスペースルートをオーバーライド | | `--worktree [name]` | セッションを分離されたgit worktreeで実行(worktree/ブランチ名は任意) | -| `--tmux` | 専用のtmuxセッションで起動(`--worktree`を含意。`--no-worktree`とは併用不可) | +| `--tmux` | 現在のディレクトリで専用の tmux セッションを起動。`--worktree` または `--zit` に対応 | | `--add-dir ` | ワークスペーススコープに追加ディレクトリを追加(複数回使用可能) | | `--config ` | カスタム設定ファイルを使用 | | `--temperature ` | 温度を設定(0-1) | diff --git a/docs/config-reference_ko.md b/docs/config-reference_ko.md index 40140e00..da122334 100644 --- a/docs/config-reference_ko.md +++ b/docs/config-reference_ko.md @@ -892,7 +892,7 @@ Autohand는 `~/.autohand/` (또는 `$AUTOHAND_HOME`)에 데이터를 저장합 | `--model ` | 모델 덮어쓰기 | | `--path ` | 워크스페이스 루트 덮어쓰기 | | `--worktree [name]` | 세션을 격리된 git worktree에서 실행 (선택적으로 worktree/브랜치 이름 지정) | -| `--tmux` | 전용 tmux 세션에서 실행 (`--worktree` 포함, `--no-worktree`와 함께 사용 불가) | +| `--tmux` | 현재 디렉터리에서 전용 tmux 세션 실행; `--worktree` 또는 `--zit` 지원 | | `--add-dir ` | 워크스페이스 범위에 추가 디렉토리 추가 (여러 번 사용 가능) | | `--config ` | 사용자 지정 설정 파일 사용 | | `--temperature ` | 온도 설정 (0-1) | diff --git a/docs/config-reference_pl.md b/docs/config-reference_pl.md index 4a574db1..d2f34175 100644 --- a/docs/config-reference_pl.md +++ b/docs/config-reference_pl.md @@ -1912,7 +1912,7 @@ Te flagi zastępują ustawienia pliku konfiguracyjnego: | Flaga | Opis | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | | __AH_KOD_20__ | Uruchom sesję w izolowanym drzewie roboczym git (opcjonalna nazwa drzewa roboczego/oddziału) | -| __AH_KOD_21__ | Uruchom w dedykowanej sesji tmux (oznacza `--worktree`; nie można używać z `--no-worktree`) | +| `--tmux` | Uruchom dedykowaną sesję tmux w bieżącym katalogu; obsługuje `--worktree` lub `--zit` | | __AH_KOD_24__ | Wyłącz izolację drzewa roboczego git w trybie automatycznym | | __AH_KOD_25__ | Automatyczne zatwierdzanie zmian po ukończeniu zadań | | __AH_KOD_26__ | Wygeneruj łatkę git bez stosowania zmian | diff --git a/docs/config-reference_ptBR.md b/docs/config-reference_ptBR.md index 3a0bca20..3ee868a0 100644 --- a/docs/config-reference_ptBR.md +++ b/docs/config-reference_ptBR.md @@ -1411,7 +1411,7 @@ Estas flags sobrescrevem as configurações do arquivo: | `--model ` | Sobrescrever modelo | | `--path ` | Sobrescrever raiz do workspace | | `--worktree [nome]` | Executar sessão em git worktree isolado (nome opcional do worktree/branch) | -| `--tmux` | Iniciar em uma sessão tmux dedicada (implica `--worktree`; não pode ser usado com `--no-worktree`) | +| `--tmux` | Iniciar uma sessão tmux dedicada no diretório atual; aceita `--worktree` ou `--zit` | | `--add-dir ` | Adicionar diretórios adicionais ao escopo do workspace (pode ser usado múltiplas vezes) | | `--config ` | Usar arquivo de configuração personalizado | | `--temperature ` | Definir temperatura (0-1) | diff --git a/docs/config-reference_ru.md b/docs/config-reference_ru.md index 0111fc54..f9caeaeb 100644 --- a/docs/config-reference_ru.md +++ b/docs/config-reference_ru.md @@ -1913,7 +1913,7 @@ Autohand хранит данные в `~/.autohand/` (или `$AUTOHAND_HOME`): | Флаг | Описание | | ----------------------------- | --------------------------------------------------------------------------------------------- | | `--worktree [name]` | Запустить сеанс в изолированном рабочем дереве git (необязательное рабочее дерево/имя ветки) | -| `--tmux` | Запуск в выделенном сеансе tmux (подразумевается `--worktree`; нельзя использовать с `--no-worktree`) | +| `--tmux` | Запуск выделенного сеанса tmux в текущем каталоге; поддерживает `--worktree` или `--zit` | | `--no-worktree` | Отключить изоляцию рабочего дерева git в автоматическом режиме | | `--auto-commit` | Автоматическое подтверждение изменений после выполнения задач | | `--patch` | Создать патч git без применения изменений | diff --git a/docs/config-reference_tr.md b/docs/config-reference_tr.md index d8f459e5..79ef66ba 100644 --- a/docs/config-reference_tr.md +++ b/docs/config-reference_tr.md @@ -1913,7 +1913,7 @@ Bu bayraklar yapılandırma dosyası ayarlarını geçersiz kılar: | Bayrak | Açıklama | | ----------------------------- | ---------------------------------------------------------------------------------------------- | | `--worktree [name]` | Oturumu yalıtılmış git çalışma ağacında çalıştırın (isteğe bağlı çalışma ağacı/dal adı) | -| `--tmux` | Özel bir tmux oturumunda başlat (`--worktree` anlamına gelir; `--no-worktree` ile kullanılamaz) | +| `--tmux` | Geçerli dizinde özel bir tmux oturumu başlat; `--worktree` veya `--zit` desteklenir | | `--no-worktree` | Otomatik modda git çalışma ağacı izolasyonunu devre dışı bırakın | | `--auto-commit` | Görevleri tamamladıktan sonra değişiklikleri otomatik olarak uygula | | `--patch` | Değişiklikleri uygulamadan git yamasını oluşturun | diff --git a/docs/config-reference_zh-tw.md b/docs/config-reference_zh-tw.md index 52cca8ed..4b4c8263 100644 --- a/docs/config-reference_zh-tw.md +++ b/docs/config-reference_zh-tw.md @@ -1913,7 +1913,7 @@ Autohand 將資料儲存在 `~/.autohand/` (或 `$AUTOHAND_HOME`): |旗幟|描述 | | -------------------------------------- |---------------------------------------------------------------------------------------------------------------- | | `--worktree [name]` |在隔離的 git 工作樹中執行會話(可選工作樹/分支名稱)| -| `--tmux` |在專用 tmux 會話中啟動(意味著 `--worktree`;不能與 `--no-worktree` 一起使用)| +| `--tmux` | 在目前目錄中啟動專用 tmux 工作階段;支援 `--worktree` 或 `--zit` | | `--no-worktree` |在自動模式下停用 git worktree 隔離 | | `--auto-commit` |完成任務後自動提交變更 | | `--patch` |產生 git 補丁而不套用變更 | diff --git a/docs/config-reference_zh.md b/docs/config-reference_zh.md index cce42f9f..29bf586c 100644 --- a/docs/config-reference_zh.md +++ b/docs/config-reference_zh.md @@ -1395,7 +1395,7 @@ Autohand 将数据存储在 `~/.autohand/`(或 `$AUTOHAND_HOME`): | `--model ` | 覆盖模型 | | `--path ` | 覆盖工作区根目录 | | `--worktree [name]` | 在隔离的 git worktree 中运行会话(可选 worktree/分支名称) | -| `--tmux` | 在专用 tmux 会话中启动(隐含 `--worktree`;不能与 `--no-worktree` 一起使用) | +| `--tmux` | 在当前目录中启动专用 tmux 会话;支持 `--worktree` 或 `--zit` | | `--add-dir ` | 添加额外目录到工作区范围(可多次使用) | | `--config ` | 使用自定义配置文件 | | `--temperature ` | 设置温度(0-1) | diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md new file mode 100644 index 00000000..9328ccfc --- /dev/null +++ b/docs/terminal-compatibility.md @@ -0,0 +1,44 @@ +# Terminal compatibility + +Autohand uses Ink 7 and React 19. Full-screen output owns the alternate screen; inline output retains the terminal's normal scrollback. Terminal transport and workspace isolation are independent: + +```bash +autohand --tmux +autohand --tmux --no-worktree +autohand --tmux --worktree my-task +autohand --tmux --zit +``` + +Plain `--tmux` runs in the current directory and does not require Git. `--worktree` and `--zit` opt into their respective workspace isolation. `--no-daemon` remains incompatible with `--tmux`. + +## Resize regression + +iTerm2 archives an erase-display operation into scrollback. Sending `CSI 2 J` before a full-screen repaint therefore accumulated entire old frames. Moving the cursor home without erasing rows prevented that accumulation but left stale composer rows when the window grew. The renderer now clears each visible row with cursor-addressed erase-line operations, then returns home and paints in the same synchronized write. It also translates Ink overflow resets into row erases and advances full-screen lines with cursor movement that clamps at the bottom row. This prevents a frame arriving before SIGWINCH has settled from scrolling into history. See [iTerm2's erase-display implementation](https://github.com/gnachman/iTerm2/blob/master/sources/VT100Screen/VT100ScreenMutableState.m). + +The regression was reproduced in native iTerm2 3.7.7 with its Python API disabled: resizing increased the header, history marker, and draft counts from one to four. With the corrected renderer, each remained exactly one through four window sizes, including shrinking and growing. Screenshots and accessibility text were checked separately from the headless tests. + +Compiled Bun executables also need to relaunch their real `process.execPath` inside tmux. Their virtual entrypoint (`/$bunfs/...` or `~BUN/...`) is internal to the executable and cannot be passed to a separate Bun process. Startup tests exercise a real compiled CLI rather than mocking this argument layout alone. + +Startup authentication and Console connector synchronization can save configuration concurrently. Those writes wait up to five seconds for the configuration lock, so a brief competing write does not discard connector updates. Connector startup and later removal are checked through the running CLI even when file synchronization is unavailable. + +## Automated coverage + +Install tmux locally, build the CLI, and run: + +```bash +bun run build +node --input-type=module -e "import { installZit } from './scripts/install-zit.mjs'; await installZit({ force: true });" +bun run test:tuistory tests/tuistory/resize-flicker.tuistory.test.ts tests/tuistory/tmux-startup.tuistory.test.ts +``` + +The resize suite checks published terminal frames, idle and streaming output, full-screen and inline operation, multiline drafts, cursor retention, navigation, and clean Ctrl+C exits. Its terminal-name matrix checks protocol compatibility; it does not claim to run the named native application. + +The tmux suite launches a compiled CLI through a real tmux server on a unique test socket. It checks current-directory, worktree, and Zit startup, retained input during resize, pane contents and scrollback size, and clean exit. It never uses or stops the user's tmux server. The suite is skipped on Windows, where tmux is not native. `AUTOHAND_TUISTORY_EXECUTABLE` can select an already compiled candidate; otherwise the test compiles the current source. + +`.github/workflows/terminal-compatibility.yml` adds a separate macOS matrix for actual iTerm2, Ghostty, cmux, and VS Code applications. Tuistory runs an XCTest UI scenario against the shared renderer fixture. It resizes real windows, checks accessible terminal contents for duplicate transcript and composer markers, retains screenshots in an `.xcresult` bundle, and checks idle and streaming execution. The workflow records installed application versions alongside those results. Native GUI cases run only on disposable GitHub Actions macOS runners; the driver refuses to launch or terminate applications on a developer machine. iTerm2's Python API remains disabled. + +Native CI availability and per-application results must be assessed from that workflow's completed jobs. A passing PTY suite or a successful XCTest build alone does not prove a native application passed. Failures in app launch, accessibility access, or window resizing fail the native job and preserve its XCTest evidence; they are not silently converted to headless passes. + +Development postinstall intentionally skips Zit downloads. The command above prepares its pinned, checksum-verified binary for the isolation tests; an existing installation can also be selected with `AUTOHAND_TUISTORY_ZIT_BIN`. + +The regular CI, full proof, and release terminal-test jobs install tmux and prepare Zit explicitly. The native matrix registers the installed applications with Launch Services and targets their actual application URLs. It runs for terminal-related pull requests and can also be invoked manually. It is separate from package publication. diff --git a/src/config.ts b/src/config.ts index b55627e1..b6388b97 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2196,13 +2196,13 @@ export async function saveValidatedAuth(config: LoadedConfig, validatedToken: st if (config.auth) persisted.auth = config.auth; else delete persisted.auth; await writeConfigData(config.configPath, persisted as Record, {}, writePath); - }); + }, { waitTimeoutMs: 5_000 }); } export async function saveConfig(config: LoadedConfig, options: SaveConfigOptions = {}): Promise { const writePath = await resolveConfigWritePath(config.configPath); await fs.ensureDir(path.dirname(writePath)); - await withFileLock(`${writePath}.lock`, () => saveConfigSnapshot(config, options, writePath)); + await withFileLock(`${writePath}.lock`, () => saveConfigSnapshot(config, options, writePath), { waitTimeoutMs: 5_000 }); } async function saveConfigSnapshot( diff --git a/src/index.ts b/src/index.ts index 764141cd..394f6146 100644 --- a/src/index.ts +++ b/src/index.ts @@ -480,7 +480,7 @@ program .addOption(new Option('--sdk-resume-last').hideHelp()) .option('--teammate-mode ', 'Legacy team display preference; live team view stays in the lead terminal') .option('--worktree [name]', 'Run session in isolated git worktree (optional name)') - .option('--tmux', 'Launch in a dedicated tmux session (implies --worktree)') + .option('--tmux', 'Launch in a dedicated tmux session (supports --worktree or --zit)') .option('--zit [intent]', 'Run session in a disposable Zit workspace, recorded as a Zit change on exit (optional intent)') // Auto-mode options .option('--auto-mode [prompt]', 'Enable interactive auto-mode, or start a standalone loop with an inline task') @@ -621,8 +621,6 @@ program process.exit(1); } - // tmux sessions are intended to run with isolated worktrees by default. - // Respect explicit --no-worktree (opts.worktree === false) as invalid with --tmux. const tmuxOptionError = normalizeTmuxWorktreeOption(opts); if (tmuxOptionError) { console.error(chalk.red(tmuxOptionError)); diff --git a/src/startup/cliOptions.ts b/src/startup/cliOptions.ts index 468ff715..95cc35fb 100644 --- a/src/startup/cliOptions.ts +++ b/src/startup/cliOptions.ts @@ -135,12 +135,6 @@ export function normalizeTmuxWorktreeOption(options: RootCliOptions): string | n return null; } if (options.daemon === false) return '--tmux cannot be used with --no-daemon'; - if (options.worktree === false) { - return '--tmux cannot be used with --no-worktree'; - } - if (options.worktree === undefined) { - options.worktree = true; - } return null; } @@ -151,9 +145,6 @@ export function validateZitOption(options: RootCliOptions): string | null { if (options.worktree !== undefined && options.worktree !== false) { return '--zit cannot be used with --worktree; choose one isolation mode'; } - if (isTmuxEnabled(options.tmux)) { - return '--zit cannot be used with --tmux (--tmux implies --worktree)'; - } if (options.patch) { return '--zit cannot be used with --patch'; } diff --git a/src/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts new file mode 100644 index 00000000..7af853e4 --- /dev/null +++ b/src/testing/drivers/native-terminal-driver.ts @@ -0,0 +1,52 @@ +/** @license Apache-2.0 */ +import { execFileSync } from 'node:child_process'; +import { copyFile, mkdir, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +export async function prepareNativeTerminalTest(): Promise<{ args: string[]; resultBundle: string }> { + if (process.platform !== 'darwin' || process.env.GITHUB_ACTIONS !== 'true' || process.env.CI !== 'true') { + throw new Error('Native terminal UI automation runs only on an isolated macOS GitHub Actions runner.'); + } + const terminal = process.env.AUTOHAND_NATIVE_TERMINAL; + const applications: Record = { iterm2: '/Applications/iTerm.app', ghostty: '/Applications/Ghostty.app', + cmux: '/Applications/cmux.app', vscode: '/Applications/Visual Studio Code.app' }; + const application = terminal ? applications[terminal] : undefined; + if (!terminal || !application) { + throw new Error('Choose iterm2, ghostty, cmux, or vscode for AUTOHAND_NATIVE_TERMINAL.'); + } + const root = process.cwd(); + const output = path.resolve('.artifacts', 'native-terminal', terminal); + await mkdir(output, { recursive: true }); + const fixture = path.join(output, 'Autohand Terminal Fixture.command'); + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + const bun = execFileSync('/bin/sh', ['-c', 'command -v bun'], { encoding: 'utf8' }).trim(); + await writeFile(fixture, `#!/bin/sh\nset -eu\ncd ${quote(root)}\nexport AUTOHAND_HOME=${quote(path.join(output, 'home'))}\nexport CI=false\nexec ${quote(bun)} src/testing/scenarios/resizeFlickerScenario.tsx --full-screen --multiline "$@"\n`, { mode: 0o755 }); + const profile = path.join(output, 'vscode-profile'); + await mkdir(path.join(profile, 'User'), { recursive: true }); + await writeFile(path.join(profile, 'User', 'settings.json'), JSON.stringify({ + 'editor.accessibilitySupport': 'on', 'workbench.startupEditor': 'none', 'window.restoreWindows': 'none', + 'workbench.panel.opensMaximized': 'always', + 'update.mode': 'none', 'security.workspace.trust.enabled': false, + 'terminal.integrated.enablePersistentSessions': false, 'terminal.integrated.scrollback': 5000, + })); + const project = path.resolve('src/testing/drivers/native-terminal-lab'); + for (const source of ['Host.swift', 'TerminalCompatibilityUITests.swift']) { + await copyFile(path.join(project, source), path.join(output, source)); + } + execFileSync('xcodegen', ['generate', '--spec', path.join(project, 'project.yml'), '--project', output], { stdio: 'pipe', timeout: 30_000 }); + const derived = path.join(output, 'DerivedData'); + execFileSync('/usr/bin/xcodebuild', ['build-for-testing', '-project', path.join(output, 'TerminalLab.xcodeproj'), + '-scheme', 'TerminalLab', '-destination', 'platform=macOS', '-derivedDataPath', derived, 'CODE_SIGNING_ALLOWED=NO'], + { stdio: 'pipe', timeout: 120_000 }); + const products = path.join(derived, 'Build', 'Products'); + const runFile = (await readdir(products)).find(file => file.endsWith('.xctestrun')); + if (!runFile) throw new Error('Xcode did not produce the native UI test configuration.'); + const testRun = path.join(products, runFile); + execFileSync('/usr/bin/python3', ['-c', + 'import json,plistlib,sys\np=sys.argv[1]\nd=plistlib.load(open(p,"rb"))\ne=json.loads(sys.argv[2])\ntargets=[t for c in d.get("TestConfigurations",[]) for t in c["TestTargets"]] if "TestConfigurations" in d else [v for k,v in d.items() if k!="__xctestrun_metadata__" and isinstance(v,dict)]\nfor t in targets: t.setdefault("EnvironmentVariables",{}).update(e)\nplistlib.dump(d,open(p,"wb"))', + testRun, JSON.stringify({ AUTOHAND_NATIVE_TERMINAL: terminal, AUTOHAND_NATIVE_FIXTURE: fixture, + AUTOHAND_NATIVE_PROFILE: profile, AUTOHAND_NATIVE_APPLICATION: application, GITHUB_ACTIONS: 'true' })], { timeout: 10_000 }); + const resultBundle = path.join(output, 'NativeTerminal.xcresult'); + return { resultBundle, args: ['test-without-building', '-xctestrun', testRun, '-destination', 'platform=macOS', + '-parallel-testing-enabled', 'NO', '-resultBundlePath', resultBundle] }; +} diff --git a/src/testing/drivers/native-terminal-lab/Host.swift b/src/testing/drivers/native-terminal-lab/Host.swift new file mode 100644 index 00000000..23d70ec9 --- /dev/null +++ b/src/testing/drivers/native-terminal-lab/Host.swift @@ -0,0 +1,8 @@ +import SwiftUI + +@main +struct TerminalLab: App { + var body: some Scene { + WindowGroup { Text("Autohand terminal compatibility test host") } + } +} diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift new file mode 100644 index 00000000..cb0110db --- /dev/null +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -0,0 +1,121 @@ +import AppKit +import XCTest + +final class TerminalCompatibilityUITests: XCTestCase { + private var environment: [String: String] { ProcessInfo.processInfo.environment } + + @MainActor func testIdleResize() throws { try resizeScenario(streaming: false) } + @MainActor func testStreamingResize() throws { try resizeScenario(streaming: true) } + + @MainActor + private func resizeScenario(streaming: Bool) throws { + guard environment["GITHUB_ACTIONS"] == "true", + let terminal = environment["AUTOHAND_NATIVE_TERMINAL"], + let application = environment["AUTOHAND_NATIVE_APPLICATION"], + let fixture = environment["AUTOHAND_NATIVE_FIXTURE"], + let profile = environment["AUTOHAND_NATIVE_PROFILE"] else { + XCTFail("This scenario requires an isolated GitHub Actions macOS runner") + return + } + continueAfterFailure = false + let app = XCUIApplication(url: URL(fileURLWithPath: application)) + defer { if app.state != .notRunning { app.terminate() } } + let command = "'" + fixture.replacingOccurrences(of: "'", with: "'\\''") + "'" + (streaming ? " --streaming" : "") + if terminal == "ghostty" { + app.launchArguments = ["--config-default-files=false", "--confirm-close-surface=false"] + } else if terminal == "vscode" { + app.launchArguments = ["--user-data-dir", profile, "--extensions-dir", profile + "/extensions", "--disable-extensions", + "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes", "--new-window"] + } + let launch = expectation(description: "Launch the installed terminal through macOS") + let configuration = NSWorkspace.OpenConfiguration() + configuration.arguments = app.launchArguments + configuration.environment = app.launchEnvironment + configuration.createsNewApplicationInstance = true + var launchError: Error? + NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: application), configuration: configuration) { _, error in + launchError = error + launch.fulfill() + } + wait(for: [launch], timeout: 30) + let desktop = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + desktop.name = "\(terminal)-launch" + desktop.lifetime = .keepAlways + add(desktop) + XCTAssertNil(launchError) + app.activate() + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 30), app.debugDescription) + if terminal == "vscode" { app.typeKey("`", modifierFlags: .control) } + let terminalView = app.textViews.firstMatch + XCTAssertTrue(terminalView.waitForExistence(timeout: 20), app.debugDescription) + NSPasteboard.general.clearContents() + XCTAssertTrue(NSPasteboard.general.writeObjects([command as NSString])) + app.typeKey("v", modifierFlags: .command) + app.typeKey(.return, modifierFlags: []) + var startupValue = "" + let ready = NSPredicate { _, _ in + startupValue = self.terminalValue(app) + return startupValue.contains("RESIZE_HISTORY retained text") + } + guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: app)], timeout: 30) == .completed else { + let text = XCTAttachment(string: String(startupValue.suffix(16_000))) + text.name = "\(terminal)-startup-terminal" + text.lifetime = .keepAlways + add(text) + let screen = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + screen.name = "\(terminal)-startup-screen" + screen.lifetime = .keepAlways + add(screen) + XCTFail("The native terminal did not display the fixture history marker") + return + } + for (index, delta) in [CGVector(dx: -160, dy: -100), CGVector(dx: 240, dy: 160), + CGVector(dx: -200, dy: -120), CGVector(dx: 160, dy: 100)].enumerated() { + let frame = window.frame + let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) + corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(delta)) + XCTAssertNotEqual(window.frame.size, frame.size, "The native window did not resize") + let settled = NSPredicate { _, _ in + let value = self.terminalValue(app) + return value.contains("RESIZE_HISTORY retained text") && value.contains("preserved draft") + } + expectation(for: settled, evaluatedWith: app) + waitForExpectations(timeout: 10) + let content = terminalValue(app) + XCTAssertEqual(content.components(separatedBy: "Autohand Code").count - 1, 1, content) + XCTAssertEqual(content.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 1, content) + XCTAssertEqual(content.components(separatedBy: "preserved draft").count - 1, 1, content) + if streaming { XCTAssertTrue(content.contains("RESIZE_STREAM "), content) } + let image = XCTAttachment(screenshot: window.screenshot()) + image.name = "\(terminal)-\(streaming ? "streaming" : "idle")-\(index)" + image.lifetime = .keepAlways + add(image) + } + app.typeKey(.leftArrow, modifierFlags: []) + app.typeKey(.leftArrow, modifierFlags: []) + app.typeKey("x", modifierFlags: .shift) + let edited = NSPredicate { _, _ in self.terminalValue(app).contains("taXil") } + expectation(for: edited, evaluatedWith: app) + waitForExpectations(timeout: 10) + app.typeKey(.escape, modifierFlags: []) + app.typeKey("c", modifierFlags: .control) + app.typeKey("c", modifierFlags: .control) + app.typeKey("c", modifierFlags: .control) + let exit = NSPredicate { _, _ in self.terminalValue(app).contains("RESIZE_PTY_EXIT") || app.state == .notRunning } + expectation(for: exit, evaluatedWith: app) + waitForExpectations(timeout: 15) + print(streaming ? "NATIVE_TERMINAL_STREAMING_PASS" : "NATIVE_TERMINAL_IDLE_PASS") + } + + private func terminalValue(_ app: XCUIApplication) -> String { + let values = app.textViews.allElementsBoundByIndex.compactMap { $0.value as? String } + if let terminal = values.filter({ $0.contains("RESIZE_HISTORY retained text") }).max(by: { $0.count < $1.count }) { + return terminal + } + if environment["AUTOHAND_NATIVE_TERMINAL"] == "vscode" { + return app.staticTexts.allElementsBoundByIndex.map(\.label).joined(separator: "\n") + } + return values.max(by: { $0.count < $1.count }) ?? "" + } +} diff --git a/src/testing/drivers/native-terminal-lab/project.yml b/src/testing/drivers/native-terminal-lab/project.yml new file mode 100644 index 00000000..e3bbebf4 --- /dev/null +++ b/src/testing/drivers/native-terminal-lab/project.yml @@ -0,0 +1,32 @@ +name: TerminalLab +options: + bundleIdPrefix: ai.autohand.testing + deploymentTarget: + macOS: '14.0' +settings: + CODE_SIGNING_ALLOWED: NO + SWIFT_VERSION: '5.0' +targets: + TerminalLab: + type: application + platform: macOS + sources: [Host.swift] + settings: + GENERATE_INFOPLIST_FILE: YES + TerminalLabUITests: + type: bundle.ui-testing + platform: macOS + sources: [TerminalCompatibilityUITests.swift] + dependencies: + - target: TerminalLab + settings: + GENERATE_INFOPLIST_FILE: YES + TEST_TARGET_NAME: TerminalLab +schemes: + TerminalLab: + build: + targets: + TerminalLab: all + TerminalLabUITests: [test] + test: + targets: [TerminalLabUITests] diff --git a/src/testing/drivers/pty-driver.ts b/src/testing/drivers/pty-driver.ts index a6ebbd62..839a0f7f 100644 --- a/src/testing/drivers/pty-driver.ts +++ b/src/testing/drivers/pty-driver.ts @@ -20,7 +20,7 @@ export class PtyDriver { down(): void { this.type('\x1b[B'); } ctrlC(): void { this.type('\x03'); } resize(cols: number, rows: number): void { this.terminal?.resize(cols, rows); } - snapshot(): string { return stripVTControlCharacters(this.output); } + snapshot(): string { return stripVTControlCharacters(this.output.replaceAll('\x1b[1E', '\n')); } close(): void { this.terminal?.kill(); } async waitFor(expected: string | RegExp, timeout = 10_000, after = 0): Promise { diff --git a/src/testing/drivers/tmux-driver.ts b/src/testing/drivers/tmux-driver.ts new file mode 100644 index 00000000..9ee98556 --- /dev/null +++ b/src/testing/drivers/tmux-driver.ts @@ -0,0 +1,39 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +export class TmuxTestServer { + private readonly socketName = `autohand-test-${randomUUID()}`; + private readonly executable: string; + + constructor() { + const found = spawnSync('/bin/sh', ['-c', 'command -v tmux'], { encoding: 'utf8' }); + if (found.status !== 0 || !found.stdout.trim()) throw new Error('Terminal integration tests require tmux on PATH.'); + this.executable = found.stdout.trim(); + } + + async prepare(directory: string): Promise { + await mkdir(directory, { recursive: true }); + const quote = (text: string) => `'${text.replaceAll("'", "'\\''")}'`; + const config = path.join(directory, 'tmux.conf'); + await writeFile(config, 'set -g remain-on-exit failed\n'); + await writeFile(path.join(directory, 'tmux'), + `#!/bin/sh\nexec ${quote(this.executable)} -L ${quote(this.socketName)} -f ${quote(config)} "$@"\n`, { mode: 0o755 }); + return `${directory}${path.delimiter}${process.env.PATH ?? ''}`; + } + + stop(): void { + spawnSync(this.executable, ['-L', this.socketName, 'kill-server'], { stdio: 'ignore', timeout: 5_000 }); + } + + snapshot(): { text: string; historySize: number } { + const capture = (args: string[]) => { + const result = spawnSync(this.executable, ['-L', this.socketName, ...args], { encoding: 'utf8', timeout: 5_000 }); + if (result.status !== 0) throw new Error(`Could not inspect the test tmux pane: ${result.stderr}`); + return result.stdout; + }; + return { text: capture(['capture-pane', '-p', '-S', '-']), + historySize: Number(capture(['display-message', '-p', '#{history_size}']).trim()) }; + } +} diff --git a/src/testing/scenarios/fullScreenScenario.ts b/src/testing/scenarios/fullScreenScenario.ts index 569415d9..bb200156 100644 --- a/src/testing/scenarios/fullScreenScenario.ts +++ b/src/testing/scenarios/fullScreenScenario.ts @@ -25,6 +25,10 @@ export async function requestFullScreenStartupWrite(session: TerminalSession): P await session.type('Create the requested startup-check file.'); await session.press('enter'); await session.waitForText('AUTO_STARTUP_COMPLETE', { timeout: 20_000 }); + await session.text({ timeout: 5_000, waitFor: () => { + const viewport = outputViewport(session); + return viewport.includes('AUTO_STARTUP_COMPLETE') && viewport.includes('Completed in') && viewport.includes('● AUTO'); + } }); } export async function startFullScreenHeaderProbe(session: TerminalSession): Promise { diff --git a/src/ui/ink/InkRenderer.tsx b/src/ui/ink/InkRenderer.tsx index 7ec91dc4..2e4c5f41 100644 --- a/src/ui/ink/InkRenderer.tsx +++ b/src/ui/ink/InkRenderer.tsx @@ -401,7 +401,7 @@ const AgentUIWrapper = forwardRef( * On unsupported terminals the CSI sequences are silently ignored, so this * is safe to enable unconditionally. */ -function patchStdoutForSyncOutput(framePrefix: (paint: string) => string, shouldBatchOutput: () => boolean): () => void { +function patchStdoutForSyncOutput(preparePaint: (paint: string) => string, shouldBatchOutput: () => boolean): () => void { const originalWrite = process.stdout.write; const chunks: Buffer[] = []; let pendingBytes = 0; @@ -438,15 +438,10 @@ function patchStdoutForSyncOutput(framePrefix: (paint: string) => string, should if (str === '\x1b[?2026h') inkFrameActive = true; else if (str === '\x1b[?2026l') inkFrameActive = false; else { - const prefix = inkFrameActive ? framePrefix(str) : ''; - const content = typeof chunk === 'string' - ? Buffer.from(chunk, typeof encodingOrCallback === 'string' ? encodingOrCallback : 'utf8') + const prepared = inkFrameActive ? preparePaint(str) : str; + const content = prepared !== str || typeof chunk === 'string' + ? Buffer.from(prepared, typeof encodingOrCallback === 'string' ? encodingOrCallback : 'utf8') : Buffer.from(chunk); - if (prefix) { - const origin = Buffer.from(prefix); - chunks.push(origin); - pendingBytes += origin.length; - } chunks.push(content); pendingBytes += content.length; } @@ -531,9 +526,8 @@ export class InkRenderer { }; /** - * A resize may reflow the previous frame. Reset its origin in the same write - * as Ink's next paint, since its resize listener can defer the actual output. - * Clearing from a resize listener would publish an empty intermediate frame. + * Erase reflowed rows inside the next paint. iTerm2 archives erase-display + * operations into scrollback; cursor-addressed line erases avoid that behavior. */ private resizeFramePrefix = (paint: string): string => { if (!this.fullScreenTranscript) return ''; @@ -541,7 +535,22 @@ export class InkRenderer { if (this.paintedViewport?.columns === viewport.columns && this.paintedViewport.rows === viewport.rows) return ''; if (!stripAnsiCodes(paint).trim()) return ''; this.paintedViewport = viewport; - return '\x1b[2J\x1b[H'; + return this.eraseFullScreenRows(); + }; + + private eraseFullScreenRows(): string { + return Array.from({ length: Math.max(1, process.stdout.rows ?? 24) }, (_, row) => `\x1b[${row + 1};1H\x1b[2K`).join('') + '\x1b[H'; + } + + private prepareFramePaint = (paint: string): string => { + if (!this.fullScreenTranscript || !this.instance) return paint; + const prefix = this.resizeFramePrefix(paint); + const inkOverflowClear = '\x1b[2J\x1b[3J\x1b[H'; + // A frame can arrive before SIGWINCH updates React. Cursor motion clamps at + // the new bottom row; line feeds would scroll the stale frame into history. + const safePaint = paint.replaceAll(inkOverflowClear, '\x1b[H').replaceAll(/\r?\n/gu, '\x1b[1E'); + if (!paint.includes(inkOverflowClear)) return prefix + safePaint; + return (prefix || this.eraseFullScreenRows()) + safePaint; }; /** @@ -561,7 +570,7 @@ export class InkRenderer { try { // Enable synchronized output wrapping to eliminate flicker from partial // frame updates. Must happen before Ink starts writing to stdout. - this.unpatchedStdout = patchStdoutForSyncOutput(this.resizeFramePrefix, () => this.instance !== null); + this.unpatchedStdout = patchStdoutForSyncOutput(this.prepareFramePaint, () => this.instance !== null); // Shift+Enter, Alt+Enter and Esc are only distinguishable when the // terminal encodes modified keys. Ink parses the kitty CSI u form; iTerm2, diff --git a/src/utils/tmux.ts b/src/utils/tmux.ts index 42445fa4..23e23fd6 100644 --- a/src/utils/tmux.ts +++ b/src/utils/tmux.ts @@ -39,8 +39,9 @@ function shellQuote(arg: string): string { return `'${arg.replace(/'/g, `'\"'\"'`)}'`; } -export function buildTmuxLaunchCommand(argv: string[]): string { - const cleaned = stripTmuxArgs(argv); +export function buildTmuxLaunchCommand(argv: string[], executable = process.execPath): string { + const launch = /(?:\$bunfs|~BUN)/.test(argv[1] ?? '') ? [executable, ...argv.slice(2)] : argv; + const cleaned = stripTmuxArgs(launch); const quoted = cleaned.map(shellQuote).join(' '); return `AUTOHAND_TMUX_LAUNCHED=1 ${quoted}`; } diff --git a/tests/config.test.ts b/tests/config.test.ts index 9226af37..ad3844a7 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -9,12 +9,45 @@ import path from 'node:path'; import os from 'node:os'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as atomicFiles from '../src/utils/atomicFile.js'; -import { applyTrustedWorkspaceEntries, getProviderConfig, loadConfig, resolveRequestedWorkspaceRoot, saveConfig } from '../src/config'; +import { applyTrustedWorkspaceEntries, getProviderConfig, loadConfig, resolveRequestedWorkspaceRoot, saveConfig, saveValidatedAuth } from '../src/config'; import { trustWorkspace } from '../src/permissions/workspaceTrust'; import type { AutohandConfig } from '../src/types'; const originalApiUrl = process.env.AUTOHAND_API_URL; +describe('configuration write contention', () => { + it.each(['configuration', 'validated authentication'] as const)('waits for a brief competing %s write', async (operation) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'autohand-config-contention-')); + const configPath = path.join(directory, 'config.json'); + await fs.writeJson(configPath, { auth: { token: 'fixture-token' }, mcp: { servers: [] } }); + const config = await loadConfig(configPath); + const lease = await atomicFiles.acquireFileLock(`${configPath}.lock`); + expect(lease).not.toBeNull(); + vi.useFakeTimers(); + const timerBaseline = vi.getTimerCount(); + const retryTimer = vi.spyOn(globalThis, 'setTimeout'); + try { + const result = (operation === 'configuration' + ? saveConfig({ ...config, mcp: { servers: [{ name: 'fixture', transport: 'http', url: 'https://example.com/mcp' }] } }) + : saveValidatedAuth({ ...config, auth: { token: 'fixture-token', user: { id: 'updated', email: 'fixture@example.com' } } }, 'fixture-token')) + .then(() => undefined, (error: unknown) => error); + await vi.waitFor(() => expect(retryTimer.mock.calls.some(([, delay]) => delay === 25)).toBe(true)); + await lease?.release(); + await vi.runAllTimersAsync(); + await expect(result).resolves.toBeUndefined(); + const saved = await fs.readJson(configPath); + if (operation === 'configuration') expect(saved.mcp.servers[0].name).toBe('fixture'); + else expect(saved.auth.user.id).toBe('updated'); + expect(vi.getTimerCount()).toBe(timerBaseline); + } finally { + retryTimer.mockRestore(); + vi.useRealTimers(); + await lease?.release(); + await fs.remove(directory); + } + }); +}); + afterEach(() => { if (originalApiUrl === undefined) delete process.env.AUTOHAND_API_URL; else process.env.AUTOHAND_API_URL = originalApiUrl; diff --git a/tests/startup/cliOptions.test.ts b/tests/startup/cliOptions.test.ts index 67a76553..697b7013 100644 --- a/tests/startup/cliOptions.test.ts +++ b/tests/startup/cliOptions.test.ts @@ -10,6 +10,7 @@ import { normalizePromptAndProtocolOptions, normalizeSearchEngineOption, normalizeTmuxWorktreeOption, + validateZitOption, type RootCliOptions, } from '../../src/startup/cliOptions.js'; @@ -145,15 +146,18 @@ describe('CLI option normalization', () => { expect(Reflect.has(options, 'chrome')).toBe(false); }); - it('defaults tmux sessions to worktree isolation and rejects an explicit opt-out', () => { - const defaults: RootCliOptions = { tmux: true }; - const conflict: RootCliOptions = { tmux: true, worktree: false }; + it.each([undefined, false, true, 'chosen-worktree'])('keeps tmux independent of workspace isolation %s', worktree => { + const options: RootCliOptions = { tmux: true, worktree }; + expect(normalizeTmuxWorktreeOption(options)).toBeNull(); + expect(options.worktree).toBe(worktree); + }); - expect(normalizeTmuxWorktreeOption(defaults)).toBeNull(); - expect(defaults.worktree).toBe(true); - expect(normalizeTmuxWorktreeOption(conflict)).toBe( - '--tmux cannot be used with --no-worktree', - ); + it('supports a Zit workspace inside tmux without implicitly adding a git worktree', () => { + const options: RootCliOptions = { tmux: true, zit: true }; + expect(validateZitOption(options)).toBeNull(); + expect(normalizeTmuxWorktreeOption(options)).toBeNull(); + expect(options.worktree).toBeUndefined(); + expect(validateZitOption(options)).toBeNull(); }); it('maps context compaction and canonicalizes search providers', () => { diff --git a/tests/testing/nativeTerminal.test.ts b/tests/testing/nativeTerminal.test.ts new file mode 100644 index 00000000..1047b1ba --- /dev/null +++ b/tests/testing/nativeTerminal.test.ts @@ -0,0 +1,18 @@ +/** @license Apache-2.0 */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { prepareNativeTerminalTest } from '../../src/testing/drivers/native-terminal-driver.js'; + +afterEach(() => vi.unstubAllEnvs()); + +describe('native terminal automation boundary', () => { + it('refuses to launch or terminate applications on a developer machine', async () => { + vi.stubEnv('GITHUB_ACTIONS', 'false'); + await expect(prepareNativeTerminalTest()).rejects.toThrow('isolated macOS GitHub Actions runner'); + }); + it.skipIf(process.platform !== 'darwin')('rejects an unknown terminal before generating or launching a test', async () => { + vi.stubEnv('GITHUB_ACTIONS', 'true'); + vi.stubEnv('CI', 'true'); + vi.stubEnv('AUTOHAND_NATIVE_TERMINAL', 'unknown'); + await expect(prepareNativeTerminalTest()).rejects.toThrow('Choose iterm2, ghostty, cmux, or vscode'); + }); +}); diff --git a/tests/tuistory/full-screen.tuistory.test.ts b/tests/tuistory/full-screen.tuistory.test.ts index 9f8c936b..1db44cba 100644 --- a/tests/tuistory/full-screen.tuistory.test.ts +++ b/tests/tuistory/full-screen.tuistory.test.ts @@ -2,7 +2,7 @@ import { renderAutohandLogo } from '../../src/utils/asciiArt.js'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Session } from 'tuistory'; import { isTerminalMouseTrackingEnabled } from '../../src/testing/scenarios/mouseScrollbackScenario.js'; import { composerCaretLine } from '../../src/testing/assertions/composerCaret.js'; @@ -159,8 +159,9 @@ describe('full-screen terminal', () => { expect(screens.paged).not.toContain('live-row-1\n'); expect(screens.resized).toContain('preserved draft'); expect(session.getRawOutput().indexOf('\x1b[?7l')).toBeGreaterThan(session.getRawOutput().indexOf('\x1b[?1049h')); - const clearedAt = screens.resizeOutput.indexOf('\x1b[2J\x1b[H'); + const clearedAt = screens.resizeOutput.indexOf('\x1b[1;1H\x1b[2K'); expect(clearedAt).toBeGreaterThanOrEqual(0); + expect(screens.resizeOutput).not.toMatch(/\x1b\[(?:[023])?J/u); expect(screens.resizeOutput.indexOf('Autohand Code', clearedAt)).toBeGreaterThan(clearedAt); const resizedRows = screens.resized.split('\n'); expect(resizedRows[0]).toContain('Autohand Code'); @@ -193,7 +194,9 @@ describe('full-screen terminal', () => { await session.waitIdle(); const narrow = outputViewport(session); const narrowRows = narrow.split('\n'); - expect(session.getRawOutput().slice(resizeOffset)).toContain('\x1b[2J\x1b[H'); + const resizePaint = session.getRawOutput().slice(resizeOffset); + expect(resizePaint).toContain('\x1b[1;1H\x1b[2K'); + expect(resizePaint).not.toMatch(/\x1b\[(?:[023])?J/u); expect(narrowRows[0]).toContain('Autohand Code'); expect(menuRows(narrow).map(row => row.split(/ {2}/u)[0])).toEqual(menuRows(wide).map(row => row.split(/ {2}/u)[0])); expect(narrowRows.filter(row => row.includes('Tab to accept'))).toHaveLength(1); @@ -265,15 +268,17 @@ describe('full-screen terminal', () => { await session.type('?'); await session.waitForText('Keyboard shortcuts'); await session.waitIdle(); - expect(composerRow()).toBe(baseline); - const screen = outputViewport(session).split('\n'); - expect(screen.findIndex(line => line.includes('Keyboard shortcuts'))).toBeLessThan(baseline); - if (columns >= 110) { - expect(screen.find(line => line.includes('Compose'))).toContain('Session'); - expect(screen.find(line => line.includes('Compose'))).toContain('Transcript'); - expect(screen.find(line => line.includes('/whatityped'))).toMatch(/\/whatityped\s+Typed history/); - } - expect(composerCaretLine(session)).toMatch(/^❯ \|/); + await vi.waitFor(() => { + expect(composerRow()).toBe(baseline); + const screen = outputViewport(session!).split('\n'); + expect(screen.findIndex(line => line.includes('Keyboard shortcuts'))).toBeLessThan(baseline); + if (columns >= 110) { + expect(screen.find(line => line.includes('Compose'))).toContain('Session'); + expect(screen.find(line => line.includes('Compose'))).toContain('Transcript'); + expect(screen.find(line => line.includes('/whatityped'))).toMatch(/\/whatityped\s+Typed history/); + } + expect(composerCaretLine(session!)).toMatch(/^❯ \|/); + }, { timeout: 5_000, interval: 20 }); await session.type('?'); await session.type('still editable'); await session.waitForText('❯ still editable'); diff --git a/tests/tuistory/native-terminal.tuistory.test.ts b/tests/tuistory/native-terminal.tuistory.test.ts new file mode 100644 index 00000000..f7b53819 --- /dev/null +++ b/tests/tuistory/native-terminal.tuistory.test.ts @@ -0,0 +1,21 @@ +/** @license Apache-2.0 */ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { prepareNativeTerminalTest } from '../../src/testing/drivers/native-terminal-driver.js'; +import { launchTuistorySession, waitForExit } from '../../src/testing/drivers/tuistory-driver.js'; + +describe.skipIf(!process.env.AUTOHAND_NATIVE_TERMINAL)('native macOS terminal compatibility', () => { + it('retains one transcript and composer through idle and streaming window resizes', async () => { + const test = await prepareNativeTerminalTest(); + const session = await launchTuistorySession({ command: '/usr/bin/xcodebuild', args: test.args, + cwd: process.cwd(), cols: 180, rows: 50, waitForDataTimeout: 120_000, env: { ...process.env, CI: 'true' } }); + try { + await waitForExit(session, 300_000); + expect(session.exitInfo?.exitCode, session.getRawOutput().slice(-10_000)).toBe(0); + expect(session.getRawOutput()).toContain('NATIVE_TERMINAL_IDLE_PASS'); + expect(session.getRawOutput()).toContain('NATIVE_TERMINAL_STREAMING_PASS'); + expect(existsSync(path.join(test.resultBundle, 'Info.plist'))).toBe(true); + } finally { session.close(); } + }, 360_000); +}); diff --git a/tests/tuistory/resize-flicker.tuistory.test.ts b/tests/tuistory/resize-flicker.tuistory.test.ts index 261b78ba..c35fa85b 100644 --- a/tests/tuistory/resize-flicker.tuistory.test.ts +++ b/tests/tuistory/resize-flicker.tuistory.test.ts @@ -1,18 +1,44 @@ /** @license Apache-2.0 */ import path from 'node:path'; import { stripVTControlCharacters } from 'node:util'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Session } from 'tuistory'; import { composerCaretLine } from '../../src/testing/assertions/composerCaret.js'; import { readTerminalWriteTrace, TerminalFrameRecorder } from '../../src/testing/assertions/terminalFrames.js'; -import { launchTuistorySession, waitForExit, waitForTerminalScreen } from '../../src/testing/drivers/tuistory-driver.js'; +import { exitInteractive, launchTuistorySession, waitForTerminalScreen } from '../../src/testing/drivers/tuistory-driver.js'; import { createTempAutohandHome, type TuistoryTempState } from './helpers/autohandTuistory.js'; +import { outputViewport } from '../../src/testing/scenarios/compactOutputScenario.js'; let session: Session | undefined; let state: TuistoryTempState | undefined; afterEach(async () => { session?.close(); await state?.cleanup(); session = undefined; state = undefined; }); describe('terminal resize frames', () => { + it.each(['node', 'bun'])('keeps one copy of each full-screen row during ordinary %s redraws', async runtime => { + state = await createTempAutohandHome(); + session = await launchTuistorySession({ + command: runtime === 'node' ? process.execPath : 'bun', + args: [...(runtime === 'node' ? ['--import', 'tsx'] : []), + path.resolve('src/testing/scenarios/resizeFlickerScenario.tsx'), '--full-screen', '--streaming'], + cwd: process.cwd(), cols: 100, rows: 30, waitForDataTimeout: 15_000, + env: { ...process.env, AUTOHAND_HOME: state.autohandHome, CI: 'false', TERM: 'xterm-256color', TERM_PROGRAM: 'iTerm.app', FORCE_COLOR: '0' }, + }); + await waitForTerminalScreen(session, { timeout: 5_000, + waitFor: screen => /RESIZE_STREAM [1-9]\d+ retained streamed text/u.test(screen) }); + await session.type('/exit'); + await vi.waitFor(() => { + const screen = outputViewport(session!); + expect(screen.match(/Autohand Code/g)).toHaveLength(1); + expect(screen.match(/RESIZE_HISTORY retained text/g)).toHaveLength(1); + expect(screen.match(/RESIZE_STREAM \d+ retained streamed text/g)).toHaveLength(1); + expect(screen.match(/❯ \/exit/g)).toHaveLength(1); + }, { timeout: 5_000, interval: 20 }); + await session.press('escape'); + await session.waitForText('RESIZE_STREAM_COMPLETE'); + await exitInteractive(session); + expect(session.exitInfo?.exitCode).toBe(0); + }); + it.each(['xterm-256color', 'xterm-ghostty', 'screen-256color', 'tmux-256color'].flatMap(terminal => [ { terminal, synchronized: true, streaming: true }, { terminal, synchronized: false, streaming: true }, @@ -34,7 +60,10 @@ describe('terminal resize frames', () => { const sizes = [[100, 24], [90, 24], [80, 26], [70, 24], [60, 22], [70, 24], [80, 26], [90, 28], [100, 30]]; for (const [columns, rows] of sizes) { session.resize({ cols: columns, rows }); - await new Promise(resolve => setTimeout(resolve, 50)); + await vi.waitFor(async () => { + const paints = (await readTerminalWriteTrace(tracePath)).slice(traceOffset); + expect(paints.some(paint => paint.columns === columns && paint.rows === rows)).toBe(true); + }, { timeout: 5_000, interval: 20 }); } await session.type('X'); await session.waitForText('preserved draftX'); @@ -55,6 +84,10 @@ describe('terminal resize frames', () => { || !frame.text.includes('RESIZE_HISTORY retained text') || !frame.text.includes('preserved draft') || !frame.text.includes('▔') || !frame.text.includes('▁')); expect(incomplete[0], `${incomplete.length} terminal writes published an incomplete transcript or composer`).toBeUndefined(); + for (const frame of frames) { + expect(frame.text.match(/Autohand Code/g), frame.text).toHaveLength(1); + expect(frame.text.match(/RESIZE_HISTORY retained text/g), frame.text).toHaveLength(1); + } if (!synchronized) expect(session.getRawOutput()).not.toMatch(/\x1b\[\?2026[hl]/); } finally { recorder.close(); @@ -63,10 +96,7 @@ describe('terminal resize frames', () => { await session.press('escape'); await session.waitForText('RESIZE_STREAM_COMPLETE'); } - await session.press(['ctrl', 'c']); - await session.press(['ctrl', 'c']); - await session.press(['ctrl', 'c']); - await waitForExit(session); + await exitInteractive(session); expect(session.exitInfo?.exitCode).toBe(0); expect(session.getRawOutput()).toContain('RESIZE_PTY_EXIT'); }); @@ -112,10 +142,7 @@ describe('terminal resize frames', () => { await session.press('escape'); await session.waitForText('RESIZE_STREAM_COMPLETE'); } - await session.press(['ctrl', 'c']); - await session.press(['ctrl', 'c']); - await session.press(['ctrl', 'c']); - await waitForExit(session); + await exitInteractive(session); expect(session.exitInfo?.exitCode).toBe(0); expect(session.getRawOutput()).toContain('RESIZE_PTY_EXIT'); }); @@ -186,10 +213,7 @@ describe('terminal resize frames', () => { } await session.waitIdle(); expect(composerCaretLine(session)).toContain(multiline ? 'taXY|il' : streaming ? 'preserved draXY|ft' : 'preserved draX|ft'); - await session.press(['ctrl', 'c']); - await session.press(['ctrl', 'c']); - await session.press(['ctrl', 'c']); - await waitForExit(session); + await exitInteractive(session); expect(session.exitInfo?.exitCode).toBe(0); expect(session.getRawOutput()).toContain('RESIZE_PTY_EXIT'); }); diff --git a/tests/tuistory/tmux-startup.tuistory.test.ts b/tests/tuistory/tmux-startup.tuistory.test.ts new file mode 100644 index 00000000..ceeaa03b --- /dev/null +++ b/tests/tuistory/tmux-startup.tuistory.test.ts @@ -0,0 +1,77 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { Session } from 'tuistory'; +import { TmuxTestServer } from '../../src/testing/drivers/tmux-driver.js'; +import { resolveZitBin } from '../../src/utils/sessionZit.js'; +import { launchTuistorySession, exitInteractive } from '../../src/testing/drivers/tuistory-driver.js'; +import { outputViewport } from '../../src/testing/scenarios/compactOutputScenario.js'; +import { createTempAutohandHome, type TuistoryTempState } from './helpers/autohandTuistory.js'; + +let compiledDirectory: string | undefined; +let executable: string; +let session: Session | undefined; +let state: TuistoryTempState | undefined; +let server: TmuxTestServer | undefined; + +describe.skipIf(process.platform === 'win32')('standalone tmux startup', () => { + beforeAll(async () => { + if (process.env.AUTOHAND_TUISTORY_EXECUTABLE) { + executable = path.resolve(process.env.AUTOHAND_TUISTORY_EXECUTABLE); + return; + } + compiledDirectory = await mkdtemp(path.join(os.tmpdir(), 'autohand-tmux-compiled-')); + executable = path.join(compiledDirectory, 'autohand'); + execFileSync('bun', ['build', path.resolve('src/index.ts'), '--compile', '--external', 'node-llama-cpp', '--outfile', executable], + { cwd: process.cwd(), timeout: 60_000, stdio: 'pipe' }); + }); + afterAll(async () => { if (compiledDirectory) await rm(compiledDirectory, { recursive: true, force: true }); }); + afterEach(async () => { session?.close(); server?.stop(); await state?.cleanup(); session = undefined; state = undefined; server = undefined; }); + + it.each(['current-directory', 'worktree', 'zit'])('starts %s isolation, preserves resize input, and exits cleanly', async isolation => { + state = await createTempAutohandHome({ initializeGit: isolation !== 'current-directory', config: { + features: { fullScreenMode: true }, agent: { autoMemory: false }, ui: { promptSuggestions: false }, + } }); + if (isolation !== 'current-directory') { + await writeFile(path.join(state.workspaceRoot, 'README.md'), '# Terminal fixture\n'); + execFileSync('git', ['add', '.'], { cwd: state.workspaceRoot }); + execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-qm', 'Initialize terminal fixture'], { cwd: state.workspaceRoot }); + } + server = new TmuxTestServer(); + const searchPath = await server.prepare(path.join(state.autohandHome, 'bin')); + const flags = isolation === 'worktree' ? ['--worktree', 'tmux-fixture'] : isolation === 'zit' ? ['--zit'] : []; + session = await launchTuistorySession({ command: '/usr/bin/env', + args: ['TERM=xterm-256color', executable, '--tmux', ...flags, '--config', state.configPath, '--offline', '--yes'], + cwd: state.workspaceRoot, cols: 120, rows: 36, waitForDataTimeout: 20_000, + env: { ...process.env, PATH: searchPath, TMUX: undefined, TMUX_PANE: undefined, + CI: 'false', TERM: 'xterm-256color', AUTOHAND_HOME: state.autohandHome, + AUTOHAND_OFFLINE: '1', AUTOHAND_DISABLE_COMPUTER_USE: '1', AUTOHAND_SKIP_UPDATE_CHECK: '1', + AUTOHAND_NO_BROWSER: '1', AUTOHAND_SKIP_PING: '1', AUTOHAND_DISABLE_AUTO_REPORT: '1', + ZIT_HOME: path.join(state.autohandHome, 'zit-home'), + ZIT_BIN: process.env.AUTOHAND_TUISTORY_ZIT_BIN ?? resolveZitBin(), + AUTOHAND_TMUX_LAUNCHED: undefined }, + }); + await session.waitForText('Plan, search, build anything', { timeout: 20_000 }); + await session.type('preserved tmux draft'); + const historySize = server.snapshot().historySize; + for (const [cols, rows] of [[100, 30], [70, 24], [120, 36]]) { + session.resize({ cols, rows }); + await session.waitForText('preserved tmux draft'); + await vi.waitFor(() => { + const pane = server!.snapshot(); + expect(pane.historySize).toBe(historySize); + expect(pane.text.match(/^>_ Autohand Code/gm), pane.text).toHaveLength(1); + expect(pane.text.match(/❯ preserved tmux draft/g), pane.text).toHaveLength(1); + const screen = outputViewport(session!); + expect(screen.match(/^>_ Autohand Code/gm), screen).toHaveLength(1); + expect(screen.match(/❯ preserved tmux draft/g), screen).toHaveLength(1); + }, { timeout: 5_000, interval: 50 }); + } + await exitInteractive(session); + expect(session.exitInfo?.exitCode).toBe(0); + expect(session.readAll()).not.toContain('Module not found'); + expect(session.readAll()).not.toContain('--zit cannot be used with --tmux'); + }); +}); diff --git a/tests/ui/ink/InkRenderer.pause-resume.test.ts b/tests/ui/ink/InkRenderer.pause-resume.test.ts index 5c289d12..af7cb873 100644 --- a/tests/ui/ink/InkRenderer.pause-resume.test.ts +++ b/tests/ui/ink/InkRenderer.pause-resume.test.ts @@ -527,10 +527,11 @@ describe('full-screen renderer lifecycle', () => { process.stdout.write('\x1b[?2026l'); await Promise.resolve(); const frame = writes.join(''); - expect(frame).toContain('\x1b[2J\x1b[H'); + expect(frame).not.toMatch(/\x1b\[(?:[023])?J/u); + expect(frame).toContain('\x1b[1;1H\x1b[2K'); expect(writes).toHaveLength(1); - expect(writes[0]).toContain('\x1b[2J\x1b[Hresized frame'); - expect(frame.indexOf('\x1b[?2026h')).toBeLessThan(frame.indexOf('\x1b[2J\x1b[H')); + expect(writes[0]).toContain('\x1b[Hresized frame'); + expect(frame.indexOf('\x1b[?2026h')).toBeLessThan(frame.indexOf('\x1b[1;1H\x1b[2K')); expect(frame.indexOf('resized frame')).toBeLessThan(frame.lastIndexOf('\x1b[?2026l')); writes.length = 0; process.stdout.write('\x1b[?2026h'); diff --git a/tests/ui/ink/InkRenderer.resize.test.ts b/tests/ui/ink/InkRenderer.resize.test.ts index ef275fcc..eaf4783a 100644 --- a/tests/ui/ink/InkRenderer.resize.test.ts +++ b/tests/ui/ink/InkRenderer.resize.test.ts @@ -30,6 +30,7 @@ vi.mock('ink', () => { Transform: (() => null) as any, measureElement: vi.fn(), }; + }); // Mock safeSetRawMode to actually call setRawMode so our spy tracks it @@ -236,10 +237,10 @@ describe('terminal resize painting', () => { process.stdout.write('\x1b[?2026l'); await Promise.resolve(); const frame = writes.join(''); - expect(frame).toContain('\x1b[2J\x1b[H'); + expect(frame).toContain('\x1b[H'); expect(writes).toHaveLength(1); - expect(writes[0]).toContain('\x1b[2J\x1b[Hresized frame'); - expect(frame.indexOf('\x1b[?2026h')).toBeLessThan(frame.indexOf('\x1b[2J\x1b[H')); + expect(writes[0]).toContain('\x1b[Hresized frame'); + expect(frame.indexOf('\x1b[?2026h')).toBeLessThan(frame.indexOf('\x1b[H')); expect(frame.indexOf('resized frame')).toBeLessThan(frame.lastIndexOf('\x1b[?2026l')); writes.length = 0; process.stdout.write('\x1b[?2026h'); @@ -271,4 +272,60 @@ describe('terminal resize painting', () => { }); }); + it('does not archive the old full-screen frame through iTerm2 erase-display handling during resize', async () => { + await withInteractiveTerminal(async writes => { + const renderer = new InkRenderer({ fullScreenMode: true, onInstruction() {}, onEscape() {}, onCtrlC() {} }); + try { + renderer.start(); + await Promise.resolve(); + writes.length = 0; + resizeTo(80, 24); + process.stdout.write('\x1b[?2026h'); + process.stdout.write('replacement screen'); + process.stdout.write('\x1b[?2026l'); + await Promise.resolve(); + expect(writes.join('')).not.toMatch(/\x1b\[(?:[023])?J/u); + expect(writes.join('')).toContain('\x1b[Hreplacement screen'); + for (let row = 1; row <= 24; row++) { + expect(writes.join('')).toContain(`\x1b[${row};1H\x1b[2K`); + } + } finally { await renderer.stop(); } + }); + }); + + it.each([true, false])('handles Ink overflow clears without losing scrollback with fullScreen=%s', async fullScreenMode => { + await withInteractiveTerminal(async writes => { + const renderer = new InkRenderer({ fullScreenMode, onInstruction() {}, onEscape() {}, onCtrlC() {} }); + try { + renderer.start(); + await Promise.resolve(); + writes.length = 0; + process.stdout.write('\x1b[?2026h'); + process.stdout.write('\x1b[2J\x1b[3J\x1b[Hoverflow frame'); + process.stdout.write('\x1b[?2026l'); + await Promise.resolve(); + if (fullScreenMode) { + expect(writes.join('')).not.toMatch(/\x1b\[(?:[023])?J/u); + expect(writes.join('')).toContain('\x1b[1;1H\x1b[2K'); + } else expect(writes.join('')).toContain('\x1b[2J\x1b[3J\x1b[H'); + expect(writes.join('')).toContain('\x1b[Hoverflow frame'); + } finally { await renderer.stop(); } + }); + }); + it.each([true, false])('keeps line advancement inside the viewport with fullScreen=%s', async fullScreenMode => { + await withInteractiveTerminal(async writes => { + const renderer = new InkRenderer({ fullScreenMode, onInstruction() {}, onEscape() {}, onCtrlC() {} }); + try { + renderer.start(); + await Promise.resolve(); + writes.length = 0; + process.stdout.write('\x1b[?2026h'); + process.stdout.write('first row\nlast row\n'); + process.stdout.write('\x1b[?2026l'); + await Promise.resolve(); + expect(writes.join('')).toContain(fullScreenMode ? 'first row\x1b[1Elast row\x1b[1E' : 'first row\nlast row\n'); + } finally { await renderer.stop(); } + }); + }); + }); diff --git a/tests/utils/sessionZit.spec.ts b/tests/utils/sessionZit.spec.ts index 7af9cb84..d43be45a 100644 --- a/tests/utils/sessionZit.spec.ts +++ b/tests/utils/sessionZit.spec.ts @@ -291,10 +291,10 @@ describe('isSessionZitEnabled', () => { }); describe('validateZitOption', () => { - it('rejects --zit with --worktree or --tmux', () => { + it('rejects --zit with --worktree and permits tmux as a terminal transport', () => { expect(validateZitOption({ zit: true, worktree: true })).toContain('--zit cannot be used with --worktree'); expect(validateZitOption({ zit: 'x', worktree: 'feature' })).toContain('--zit cannot be used with --worktree'); - expect(validateZitOption({ zit: true, tmux: true })).toContain('--zit cannot be used with --tmux'); + expect(validateZitOption({ zit: true, tmux: true })).toBeNull(); }); it('rejects modes that do not run a CLI session', () => { diff --git a/tests/utils/tmux.spec.ts b/tests/utils/tmux.spec.ts index 3a9ab05c..8d34d132 100644 --- a/tests/utils/tmux.spec.ts +++ b/tests/utils/tmux.spec.ts @@ -49,6 +49,11 @@ describe('tmux utils', () => { expect(cmd).toContain("'fix user'\"'\"'s test'"); }); + it.each(['/$bunfs/root/autohand', 'B:/~BUN/root/autohand'])('relaunches a compiled executable instead of its virtual entrypoint %s', entrypoint => { + const command = buildTmuxLaunchCommand(['bun', entrypoint, '--tmux', '--zit'], '/opt/Autohand Code/autohand'); + expect(command).toBe("AUTOHAND_TMUX_LAUNCHED=1 '/opt/Autohand Code/autohand' --zit"); + }); + it('creates a stable tmux session name format', () => { const name = createTmuxSessionName(); expect(name).toMatch(/^autohand-[a-z0-9]+-[a-f0-9]{4}$/);