From a647b3896cc90871263b64f3a63b24bc63bced80 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 5 Nov 2026 22:20:09 +1300 Subject: [PATCH 1/5] Preserve terminal frames during resize and repair standalone tmux startup Erase resized full-screen rows atomically without archival erase-display sequences. Relaunch the real compiled Bun executable inside tmux and allow current-directory, worktree, or Zit sessions independently of terminal transport. Add real tmux pane and scrollback checks, native macOS terminal compatibility scenarios, and updated CLI documentation. Co-authored-by: Autohand Evolve --- .github/workflows/ci.yml | 6 +- .github/workflows/full-proof.yml | 2 +- .github/workflows/release.yml | 4 +- .github/workflows/terminal-compatibility.yml | 101 ++++++++++++++++++ docs/config-reference.md | 15 ++- docs/config-reference_cs.md | 2 +- docs/config-reference_de.md | 2 +- docs/config-reference_es.md | 2 +- docs/config-reference_fr.md | 2 +- docs/config-reference_hi.md | 2 +- docs/config-reference_hu.md | 2 +- docs/config-reference_id.md | 2 +- docs/config-reference_it.md | 2 +- docs/config-reference_ja.md | 2 +- docs/config-reference_ko.md | 2 +- docs/config-reference_pl.md | 2 +- docs/config-reference_ptBR.md | 2 +- docs/config-reference_ru.md | 2 +- docs/config-reference_tr.md | 2 +- docs/config-reference_zh-tw.md | 2 +- docs/config-reference_zh.md | 2 +- docs/terminal-compatibility.md | 39 +++++++ src/index.ts | 4 +- src/startup/cliOptions.ts | 9 -- src/testing/drivers/native-terminal-driver.ts | 48 +++++++++ .../drivers/native-terminal-lab/Host.swift | 8 ++ .../TerminalCompatibilityUITests.swift | 91 ++++++++++++++++ .../drivers/native-terminal-lab/project.yml | 32 ++++++ src/testing/drivers/tmux-driver.ts | 39 +++++++ src/ui/ink/InkRenderer.tsx | 7 +- src/utils/tmux.ts | 5 +- tests/startup/cliOptions.test.ts | 20 ++-- tests/testing/nativeTerminal.test.ts | 18 ++++ .../tuistory/native-terminal.tuistory.test.ts | 21 ++++ .../tuistory/resize-flicker.tuistory.test.ts | 54 +++++++--- tests/tuistory/tmux-startup.tuistory.test.ts | 76 +++++++++++++ tests/ui/ink/InkRenderer.pause-resume.test.ts | 7 +- tests/ui/ink/InkRenderer.resize.test.ts | 27 ++++- tests/utils/sessionZit.spec.ts | 4 +- tests/utils/tmux.spec.ts | 5 + 40 files changed, 601 insertions(+), 73 deletions(-) create mode 100644 .github/workflows/terminal-compatibility.yml create mode 100644 docs/terminal-compatibility.md create mode 100644 src/testing/drivers/native-terminal-driver.ts create mode 100644 src/testing/drivers/native-terminal-lab/Host.swift create mode 100644 src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift create mode 100644 src/testing/drivers/native-terminal-lab/project.yml create mode 100644 src/testing/drivers/tmux-driver.ts create mode 100644 tests/testing/nativeTerminal.test.ts create mode 100644 tests/tuistory/native-terminal.tuistory.test.ts create mode 100644 tests/tuistory/tmux-startup.tuistory.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca19a317a..d044dbceb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,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 +131,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 @@ -164,7 +164,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 4746168c8..4cb1c42c8 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d886a1b6..12b6798ea 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 diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml new file mode 100644 index 000000000..b5669ddcb --- /dev/null +++ b/.github/workflows/terminal-compatibility.yml @@ -0,0 +1,101 @@ +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: 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 ;; + ghostty) brew install --cask ghostty ;; + cmux) brew install --cask cmux ;; + vscode) brew install --cask visual-studio-code ;; + esac + 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: 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 + if-no-files-found: error + retention-days: 7 diff --git a/docs/config-reference.md b/docs/config-reference.md index 4f0a679a5..9936c4ddc 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 618dd1eec..2a94fca08 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 9ab7e1d10..0299ac4ad 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 0b088f5e0..2ec753e74 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 bc938ade1..a57d3f20c 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 997ed5348..2e310d7ec 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 79d36139c..da619f30d 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 541177ca5..c71d8fcc9 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 9690ad488..af1e206fc 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 3a54d2fc5..81d1b3d71 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 40140e001..da122334b 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 4a574db1e..d2f341752 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 3a0bca20c..3ee868a00 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 0111fc546..f9caeaebf 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 d8f459e57..79ef66ba3 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 52cca8ed4..4b4c82631 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 cce42f9fe..29bf586ca 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 000000000..eccc6b031 --- /dev/null +++ b/docs/terminal-compatibility.md @@ -0,0 +1,39 @@ +# 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 neither emits an intermediate empty frame nor uses erase-display for this resize reset. 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. + +## Automated coverage + +Install tmux locally, build the CLI, and run: + +```bash +bun run build +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. + +The regular CI, full proof, and release terminal-test jobs install tmux explicitly. The native matrix runs for terminal-related pull requests and can also be invoked manually. It is separate from package publication. diff --git a/src/index.ts b/src/index.ts index 764141cda..394f6146d 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 468ff7155..95cc35fb5 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 000000000..8425e77f5 --- /dev/null +++ b/src/testing/drivers/native-terminal-driver.ts @@ -0,0 +1,48 @@ +/** @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; + if (!terminal || !['iterm2', 'ghostty', 'cmux', 'vscode'].includes(terminal)) { + 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', + '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, 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 000000000..23d70ec98 --- /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 000000000..9efcfea18 --- /dev/null +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -0,0 +1,91 @@ +import AppKit +import XCTest + +final class TerminalCompatibilityUITests: XCTestCase { + private var environment: [String: String] { ProcessInfo.processInfo.environment } + + func testIdleResize() throws { try resizeScenario(streaming: false) } + func testStreamingResize() throws { try resizeScenario(streaming: true) } + + private func resizeScenario(streaming: Bool) throws { + guard environment["GITHUB_ACTIONS"] == "true", + let terminal = environment["AUTOHAND_NATIVE_TERMINAL"], + 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 bundles = ["iterm2": "com.googlecode.iterm2", "ghostty": "com.mitchellh.ghostty", + "cmux": "com.cmuxterm.app", "vscode": "com.microsoft.VSCode"] + let bundle = try XCTUnwrap(bundles[terminal]) + let app = XCUIApplication(bundleIdentifier: bundle) + defer { app.terminate() } + let command = "'" + fixture.replacingOccurrences(of: "'", with: "'\\''") + "'" + (streaming ? " --streaming" : "") + if terminal == "ghostty" { + app.launchArguments = ["--config-default-files=false", "--confirm-close-surface=false", "-e", "/bin/sh", "-c", command] + } 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"] + } + app.launch() + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 30), app.debugDescription) + if terminal != "ghostty" { + if terminal == "vscode" { app.typeKey("`", modifierFlags: .control) } + let terminalView = app.textViews.firstMatch + XCTAssertTrue(terminalView.waitForExistence(timeout: 20), app.debugDescription) + app.typeText(command + "\n") + } + let ready = NSPredicate { _, _ in self.terminalValue(app).contains("RESIZE_HISTORY retained text") } + expectation(for: ready, evaluatedWith: app) + waitForExpectations(timeout: 30) + 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.typeText("X") + 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 000000000..e3bbebf43 --- /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/tmux-driver.ts b/src/testing/drivers/tmux-driver.ts new file mode 100644 index 000000000..9ee98556b --- /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/ui/ink/InkRenderer.tsx b/src/ui/ink/InkRenderer.tsx index 7ec91dc42..27db81aba 100644 --- a/src/ui/ink/InkRenderer.tsx +++ b/src/ui/ink/InkRenderer.tsx @@ -531,9 +531,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 +540,7 @@ 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 Array.from({ length: Math.max(1, viewport.rows ?? 24) }, (_, row) => `\x1b[${row + 1};1H\x1b[2K`).join('') + '\x1b[H'; }; /** diff --git a/src/utils/tmux.ts b/src/utils/tmux.ts index 42445fa4a..23e23fd65 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/startup/cliOptions.test.ts b/tests/startup/cliOptions.test.ts index 67a765530..697b7013e 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 000000000..1047b1ba8 --- /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/native-terminal.tuistory.test.ts b/tests/tuistory/native-terminal.tuistory.test.ts new file mode 100644 index 000000000..f7b53819b --- /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 261b78bab..c35fa85b4 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 000000000..46280c0f1 --- /dev/null +++ b/tests/tuistory/tmux-startup.tuistory.test.ts @@ -0,0 +1,76 @@ +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 { 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 ?? path.resolve('vendor', 'zit', `${process.platform}-${process.arch}`, 'zit'), + 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 5c289d127..af7cb8736 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 ef275fcc6..8e84d54f4 100644 --- a/tests/ui/ink/InkRenderer.resize.test.ts +++ b/tests/ui/ink/InkRenderer.resize.test.ts @@ -236,10 +236,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 +271,25 @@ 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(); } + }); + }); + }); diff --git a/tests/utils/sessionZit.spec.ts b/tests/utils/sessionZit.spec.ts index 7af9cb84a..d43be45a7 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 3a9ab05c9..8d34d1327 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}$/); From 50f3d94d334b93703d48f9aae88797d61a7db413 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 5 Nov 2026 22:34:44 +1300 Subject: [PATCH 2/5] Prepare installed terminal applications and Zit for compatibility tests Register native application bundles and launch their actual URLs on fresh macOS runners. Prepare the checksum-verified Zit binary explicitly because development postinstall intentionally skips it, and preserve configured Zit resolution in tmux scenarios. Avoid duplicate push and pull-request CI for repair branches. Co-authored-by: Autohand Evolve --- .github/workflows/ci.yml | 4 ++++ .github/workflows/full-proof.yml | 6 +++++- .github/workflows/release.yml | 4 +++- .github/workflows/terminal-compatibility.yml | 12 ++++++++---- docs/terminal-compatibility.md | 5 ++++- src/testing/drivers/native-terminal-driver.ts | 7 +++++-- .../TerminalCompatibilityUITests.swift | 6 ++---- tests/tuistory/tmux-startup.tuistory.test.ts | 3 ++- 8 files changed, 33 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d044dbceb..0313e2e35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ on: - beta - 'cto/**' - 'nikita/**' + - 'repair/**' workflow_dispatch: concurrency: @@ -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 diff --git a/.github/workflows/full-proof.yml b/.github/workflows/full-proof.yml index 4cb1c42c8..1ef281e09 100644 --- a/.github/workflows/full-proof.yml +++ b/.github/workflows/full-proof.yml @@ -43,4 +43,8 @@ jobs: run: bun run build - name: Run complete proof - run: bun run proof + # Git development checkouts intentionally skip Zit in postinstall. + # The terminal suite explicitly exercises the real binary. + run: | + node --input-type=module -e "import { installZit } from './scripts/install-zit.mjs'; await installZit({ force:true });" + bun run proof diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12b6798ea..8ab1fab47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -249,7 +249,9 @@ jobs: run: bun run build - name: Run built terminal tests - run: bun run test:tuistory -- --shard=${{ matrix.shard }}/4 + run: | + node --input-type=module -e "import { installZit } from './scripts/install-zit.mjs'; await installZit({ force:true });" + bun run test:tuistory -- --shard=${{ matrix.shard }}/4 build: needs: [prepare, test, tuistory] diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml index b5669ddcb..58ae5d6eb 100644 --- a/.github/workflows/terminal-compatibility.yml +++ b/.github/workflows/terminal-compatibility.yml @@ -41,6 +41,8 @@ jobs: - 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 @@ -72,11 +74,13 @@ jobs: set -euo pipefail brew install xcodegen ripgrep tmux case "$AUTOHAND_NATIVE_TERMINAL" in - iterm2) brew install --cask iterm2 ;; - ghostty) brew install --cask ghostty ;; - cmux) brew install --cask cmux ;; - vscode) brew install --cask visual-studio-code ;; + 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 + /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$terminal_app" + defaults read "$terminal_app/Contents/Info" CFBundleIdentifier 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. diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index eccc6b031..558cd2a91 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -25,6 +25,7 @@ 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 ``` @@ -36,4 +37,6 @@ The tmux suite launches a compiled CLI through a real tmux server on a unique te 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. -The regular CI, full proof, and release terminal-test jobs install tmux explicitly. The native matrix runs for terminal-related pull requests and can also be invoked manually. It is separate from package publication. +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/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts index 8425e77f5..0e21d78b1 100644 --- a/src/testing/drivers/native-terminal-driver.ts +++ b/src/testing/drivers/native-terminal-driver.ts @@ -8,7 +8,10 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res throw new Error('Native terminal UI automation runs only on an isolated macOS GitHub Actions runner.'); } const terminal = process.env.AUTOHAND_NATIVE_TERMINAL; - if (!terminal || !['iterm2', 'ghostty', 'cmux', 'vscode'].includes(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(); @@ -41,7 +44,7 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res 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, GITHUB_ACTIONS: 'true' })], { timeout: 10_000 }); + 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/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 9efcfea18..fd2786741 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -10,16 +10,14 @@ final class TerminalCompatibilityUITests: XCTestCase { 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 bundles = ["iterm2": "com.googlecode.iterm2", "ghostty": "com.mitchellh.ghostty", - "cmux": "com.cmuxterm.app", "vscode": "com.microsoft.VSCode"] - let bundle = try XCTUnwrap(bundles[terminal]) - let app = XCUIApplication(bundleIdentifier: bundle) + let app = XCUIApplication(url: URL(fileURLWithPath: application)) defer { app.terminate() } let command = "'" + fixture.replacingOccurrences(of: "'", with: "'\\''") + "'" + (streaming ? " --streaming" : "") if terminal == "ghostty" { diff --git a/tests/tuistory/tmux-startup.tuistory.test.ts b/tests/tuistory/tmux-startup.tuistory.test.ts index 46280c0f1..ceeaa03b8 100644 --- a/tests/tuistory/tmux-startup.tuistory.test.ts +++ b/tests/tuistory/tmux-startup.tuistory.test.ts @@ -5,6 +5,7 @@ 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'; @@ -49,7 +50,7 @@ describe.skipIf(process.platform === 'win32')('standalone tmux startup', () => { 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 ?? path.resolve('vendor', 'zit', `${process.platform}-${process.arch}`, 'zit'), + ZIT_BIN: process.env.AUTOHAND_TUISTORY_ZIT_BIN ?? resolveZitBin(), AUTOHAND_TMUX_LAUNCHED: undefined }, }); await session.waitForText('Plan, search, build anything', { timeout: 20_000 }); From 1edc613a43ae66748103d9d6de6fa4c4895276b2 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Thu, 5 Nov 2026 22:46:56 +1300 Subject: [PATCH 3/5] Launch native terminal fixtures through the macOS workspace Start the actual application with NSWorkspace and attach XCTest after launch, preserving command arguments and fresh application instances. Export desktop captures and native display diagnostics so launch failures are inspectable on hosted runners. Co-authored-by: Autohand Evolve --- .github/workflows/terminal-compatibility.yml | 11 ++++++++++ .../TerminalCompatibilityUITests.swift | 20 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml index 58ae5d6eb..22f9e84aa 100644 --- a/.github/workflows/terminal-compatibility.yml +++ b/.github/workflows/terminal-compatibility.yml @@ -81,6 +81,8 @@ jobs: esac /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. @@ -93,6 +95,14 @@ jobs: - 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 @@ -101,5 +111,6 @@ jobs: 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/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index fd2786741..64bc1a757 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -18,7 +18,7 @@ final class TerminalCompatibilityUITests: XCTestCase { } continueAfterFailure = false let app = XCUIApplication(url: URL(fileURLWithPath: application)) - defer { app.terminate() } + 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", "-e", "/bin/sh", "-c", command] @@ -26,7 +26,23 @@ final class TerminalCompatibilityUITests: XCTestCase { app.launchArguments = ["--user-data-dir", profile, "--extensions-dir", profile + "/extensions", "--disable-extensions", "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes", "--new-window"] } - app.launch() + 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 != "ghostty" { From dd929315edb998810c5b4e3a9e4302bdbabb91bd Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Fri, 6 Nov 2026 01:09:00 +1300 Subject: [PATCH 4/5] Keep resized terminal frames and startup configuration saves consistent Keep stale full-screen paints inside the viewport and preserve scrollback during Ink overflow resets. Wait for brief contention between startup configuration writes, and sample completed frames in terminal scenarios. Verify native terminal signatures and Gatekeeper assessment before preparing the installed app for unattended CI launch. Focused renderer, configuration, workflow, PTY, and native iTerm2 checks passed. Aggregate validation still records worker-start failures and one question-navigation timeout; focused recovery passed. Co-authored-by: Autohand Evolve --- .github/workflows/full-proof.yml | 9 +++-- .github/workflows/release.yml | 7 ++-- .github/workflows/terminal-compatibility.yml | 4 +++ docs/terminal-compatibility.md | 4 ++- src/config.ts | 4 +-- .../TerminalCompatibilityUITests.swift | 5 +-- src/testing/drivers/pty-driver.ts | 2 +- src/testing/scenarios/fullScreenScenario.ts | 4 +++ src/ui/ink/InkRenderer.tsx | 32 +++++++++++------ tests/config.test.ts | 35 +++++++++++++++++- tests/tuistory/full-screen.tuistory.test.ts | 29 ++++++++------- tests/ui/ink/InkRenderer.resize.test.ts | 36 +++++++++++++++++++ 12 files changed, 133 insertions(+), 38 deletions(-) diff --git a/.github/workflows/full-proof.yml b/.github/workflows/full-proof.yml index 1ef281e09..891a0b9fa 100644 --- a/.github/workflows/full-proof.yml +++ b/.github/workflows/full-proof.yml @@ -42,9 +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 - # Git development checkouts intentionally skip Zit in postinstall. - # The terminal suite explicitly exercises the real binary. - run: | - node --input-type=module -e "import { installZit } from './scripts/install-zit.mjs'; await installZit({ force:true });" - bun run proof + run: bun run proof diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ab1fab47..74ed4c4b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -248,10 +248,11 @@ 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: | - node --input-type=module -e "import { installZit } from './scripts/install-zit.mjs'; await installZit({ force:true });" - bun run test:tuistory -- --shard=${{ matrix.shard }}/4 + run: bun run test:tuistory -- --shard=${{ matrix.shard }}/4 build: needs: [prepare, test, tuistory] diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml index 22f9e84aa..14a0fdc53 100644 --- a/.github/workflows/terminal-compatibility.yml +++ b/.github/workflows/terminal-compatibility.yml @@ -79,6 +79,10 @@ jobs: 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 diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index 558cd2a91..9328ccfc4 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -13,12 +13,14 @@ Plain `--tmux` runs in the current directory and does not require Git. `--worktr ## 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 neither emits an intermediate empty frame nor uses erase-display for this resize reset. See [iTerm2's erase-display implementation](https://github.com/gnachman/iTerm2/blob/master/sources/VT100Screen/VT100ScreenMutableState.m). +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: diff --git a/src/config.ts b/src/config.ts index b55627e16..b6388b977 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/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 64bc1a757..f03df05c1 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -4,9 +4,10 @@ import XCTest final class TerminalCompatibilityUITests: XCTestCase { private var environment: [String: String] { ProcessInfo.processInfo.environment } - func testIdleResize() throws { try resizeScenario(streaming: false) } - func testStreamingResize() throws { try resizeScenario(streaming: true) } + @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"], diff --git a/src/testing/drivers/pty-driver.ts b/src/testing/drivers/pty-driver.ts index a6ebbd629..839a0f7fb 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/scenarios/fullScreenScenario.ts b/src/testing/scenarios/fullScreenScenario.ts index 569415d9e..bb2001561 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 27db81aba..2e4c5f413 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; } @@ -540,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 Array.from({ length: Math.max(1, viewport.rows ?? 24) }, (_, row) => `\x1b[${row + 1};1H\x1b[2K`).join('') + '\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; }; /** @@ -560,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/tests/config.test.ts b/tests/config.test.ts index 9226af37b..ad3844a70 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/tuistory/full-screen.tuistory.test.ts b/tests/tuistory/full-screen.tuistory.test.ts index 9f8c936b5..1db44cbab 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/ui/ink/InkRenderer.resize.test.ts b/tests/ui/ink/InkRenderer.resize.test.ts index 8e84d54f4..eaf4783a7 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 @@ -292,4 +293,39 @@ describe('terminal resize painting', () => { }); }); + 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(); } + }); + }); + }); From 196f2403ce30c724619ec3e78bb7ba7ad0c933bc Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Fri, 6 Nov 2026 01:45:10 +1300 Subject: [PATCH 5/5] Drive native terminal scenarios with paste and physical key events Run the fixture inside an interactive shell on every terminal and paste its command before pressing Return. This avoids XCTest text-input sessions against read-only terminal views and retains completion output after the fixture exits. Maximize the VS Code terminal panel and attach bounded terminal text and a native screenshot when startup fails. The revised XCTest project compiled successfully. Native automation boundary and workflow checks passed, along with lint and type checking. Native CI verification remains pending. Co-authored-by: Autohand Evolve --- src/testing/drivers/native-terminal-driver.ts | 1 + .../TerminalCompatibilityUITests.swift | 35 +++++++++++++------ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts index 0e21d78b1..7af853e43 100644 --- a/src/testing/drivers/native-terminal-driver.ts +++ b/src/testing/drivers/native-terminal-driver.ts @@ -25,6 +25,7 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res 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, })); diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index f03df05c1..cb0110db2 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -22,7 +22,7 @@ final class TerminalCompatibilityUITests: XCTestCase { 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", "-e", "/bin/sh", "-c", command] + 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"] @@ -46,15 +46,30 @@ final class TerminalCompatibilityUITests: XCTestCase { app.activate() let window = app.windows.firstMatch XCTAssertTrue(window.waitForExistence(timeout: 30), app.debugDescription) - if terminal != "ghostty" { - if terminal == "vscode" { app.typeKey("`", modifierFlags: .control) } - let terminalView = app.textViews.firstMatch - XCTAssertTrue(terminalView.waitForExistence(timeout: 20), app.debugDescription) - app.typeText(command + "\n") + 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 } - let ready = NSPredicate { _, _ in self.terminalValue(app).contains("RESIZE_HISTORY retained text") } - expectation(for: ready, evaluatedWith: app) - waitForExpectations(timeout: 30) 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 @@ -79,7 +94,7 @@ final class TerminalCompatibilityUITests: XCTestCase { } app.typeKey(.leftArrow, modifierFlags: []) app.typeKey(.leftArrow, modifierFlags: []) - app.typeText("X") + app.typeKey("x", modifierFlags: .shift) let edited = NSPredicate { _, _ in self.terminalValue(app).contains("taXil") } expectation(for: edited, evaluatedWith: app) waitForExpectations(timeout: 10)