diff --git a/docs/config-reference.md b/docs/config-reference.md index 99f3abf0..edf24e07 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -730,6 +730,7 @@ See [Workspace Safety](./workspace-safety.md) for full details. | `showCompletionNotification` | boolean | `true` | Show system notification when task completes | | `showThinking` | boolean | `true` | Display LLM's reasoning/thought process | | `mouseComposerCursor` | boolean | on, except iTerm2 | Enable click-to-position editing in the Ink composer | +| `keybindingProfile` | string | `"autohand"` | Shortcut profile for the composer: `autohand`, `claude-code`, `codex`, `cursor`, `antigravity`, `devin` or `factory`. See [Keyboard Shortcut Profiles](#keyboard-shortcut-profiles) | | `terminalBell` | boolean | `true` | Ring terminal bell when task completes (shows badge on terminal tab/dock) | | `checkForUpdates` | boolean | `true` | Check for CLI updates on startup | | `updateCheckInterval` | number | `24` | Hours between update checks (uses cached result within interval) | @@ -920,6 +921,62 @@ autohand config set ui.mouseComposerCursor false Terminal mouse reporting can change native selection and scroll-wheel behavior. During active work, click a live command to expand or compact its output; clicks in the composer continue to position its cursor. Mouse reporting is always restored when Autohand exits. Terminal-specific modifier keys, commonly Shift, may bypass mouse reporting for native selection. +### Keyboard Shortcut Profiles + +If you already use another coding agent, the composer can follow its shortcuts. +Pick a profile during setup (offered when Autohand detects the agent on your +machine), in `/settings` → UI → Keyboard shortcuts, or directly: + +```sh +autohand config set ui.keybindingProfile codex +``` + +Every profile keeps Autohand's fixed keys: Enter submits, Esc interrupts, +Shift+Tab cycles interaction modes, Ctrl+C clears the input and exits on a +second press, `?` on an empty composer shows the shortcuts panel, and Ctrl+O, +Ctrl+T and Ctrl+G expand command output and toggle the team and goals panels. +Profiles add what the other agent binds on top: + +| Profile | Newline | Exit | History | +| ------------- | ------------------------------------ | ------------------- | ----------------------- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C twice | `/whatityped` | +| `claude-code` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `codex` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C twice | `/whatityped` | + +Ctrl+D exits only when the composer is empty. Ctrl+R opens the same history +view as `/whatityped`. The `?` panel always lists the chords of the active +profile, so it is the quickest way to check what is bound. + +Two agents let you remap their own shortcuts in a file, and Autohand honours +those remaps for the actions it shares. With `claude-code`, bindings in +`~/.claude/keybindings.json` for `chat:newline`, `chat:cycleMode`, `app:exit`, +`app:toggleTranscript`, `app:toggleTodos` and `history:search` are applied, +including `null` to unbind. With `codex`, `insert_newline` and +`history_search` under `[tui.keymap.composer]` and `exit` under +`[tui.keymap.global]` in `~/.codex/config.toml` replace the profile's chords. +Chords with more than one keystroke are ignored, and a malformed file leaves +the profile defaults in place. Extension keybindings can never take a chord +that the active profile uses. + +Which chords actually arrive depends on the terminal, not on the profile: + +- **Shift+Enter** needs a terminal that encodes modified keys through the kitty + keyboard protocol, which Autohand requests at start: Ghostty, kitty, WezTerm + and iTerm2 3.5 or later do; Terminal.app does not. +- **Alt+Enter** needs Option configured as Meta in macOS terminals. +- **Ctrl+J** is a plain control byte and works on any tty, including tmux, + which is why every non-default profile includes it. + +**Import during setup.** When setup detects Claude Code, Codex, Cursor, Gemini, +Cline, Continue, Augment, OpenCode, Kimi or Grok, it also offers to bring your +memories, sessions and skills across. Accepting runs the same import as +`autohand import --all --categories memory,sessions,skills`; you can decline +and run `/import` later, and a failed import never blocks setup. + ### Update Check When `checkForUpdates` is enabled (default), Autohand checks for new releases on startup: diff --git a/docs/config-reference_cs.md b/docs/config-reference_cs.md index 8390ef50..63f77934 100644 --- a/docs/config-reference_cs.md +++ b/docs/config-reference_cs.md @@ -704,6 +704,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 ``` --- +### Profily klávesových zkratek + +Pokud už používáte jiného kódovacího agenta, skladatel může sledovat jeho zkratky. Profil vyberete při nastavení (nabídne se, když Autohand agenta najde), v `/settings` → UI → Klávesové zkratky, nebo příkazem: + +```sh +autohand config set ui.keybindingProfile codex +``` + +| Profil | Nový řádek | Ukončení | Historie | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D ukončí program jen s prázdným skladatelem. Panel `?` vždy vypisuje zkratky aktivního profilu. U `claude-code` se použijí i vlastní přemapování ze souboru `~/.claude/keybindings.json`, u `codex` z `[tui.keymap.*]` v `~/.codex/config.toml`. + +Terminál rozhoduje, které zkratky dorazí: Shift+Enter vyžaduje protokol klávesnice kitty (Ghostty, kitty, WezTerm, iTerm2 3.5+), Alt+Enter vyžaduje Option jako Meta na macOS, Ctrl+J funguje v každém terminálu včetně tmux. + +**Import při nastavení.** Když nastavení najde jiného agenta, nabídne také import pamětí, relací a dovedností stejným způsobem jako `/import`; neúspěšný import nastavení nezablokuje. + +--- + ## Nastavení agenta Řízení chování agenta a limity iterací. diff --git a/docs/config-reference_de.md b/docs/config-reference_de.md index e42a8d31..143ee60b 100644 --- a/docs/config-reference_de.md +++ b/docs/config-reference_de.md @@ -744,6 +744,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 --- +### Profile für Tastenkürzel + +Wer bereits einen anderen Coding-Agenten nutzt, kann den Composer dessen Kürzel folgen lassen. Das Profil wird beim Setup angeboten (wenn Autohand den Agenten findet), unter `/settings` → UI → Tastenkürzel gewählt oder direkt gesetzt: + +```sh +autohand config set ui.keybindingProfile codex +``` + +| Profil | Zeilenumbruch | Beenden | Verlauf | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D beendet nur bei leerem Composer. Das `?`-Panel zeigt immer die Kürzel des aktiven Profils. Mit `claude-code` werden eigene Belegungen aus `~/.claude/keybindings.json` übernommen, mit `codex` die aus `[tui.keymap.*]` in `~/.codex/config.toml`. + +Welche Kürzel ankommen, entscheidet das Terminal: Shift+Enter braucht das kitty-Tastaturprotokoll (Ghostty, kitty, WezTerm, iTerm2 3.5+), Alt+Enter braucht Option als Meta unter macOS, Ctrl+J funktioniert in jedem Terminal, auch in tmux. + +**Import beim Setup.** Findet das Setup einen anderen Agenten, bietet es zusätzlich an, Erinnerungen, Sitzungen und Skills so zu importieren wie `/import`; ein fehlgeschlagener Import blockiert das Setup nicht. + +--- + ## Agenten-Einstellungen Steuern Sie das Agentenverhalten und die Iterationslimits. diff --git a/docs/config-reference_es.md b/docs/config-reference_es.md index 90dae466..887d135e 100644 --- a/docs/config-reference_es.md +++ b/docs/config-reference_es.md @@ -395,6 +395,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 --- +### Perfiles de atajos de teclado + +Si ya usas otro agente de programación, el compositor puede seguir sus atajos. Elige el perfil durante la configuración (se ofrece cuando Autohand detecta el agente), en `/settings` → UI → Atajos de teclado, o directamente: + +```sh +autohand config set ui.keybindingProfile codex +``` + +| Perfil | Nueva línea | Salir | Historial | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D solo sale con el compositor vacío. El panel `?` siempre muestra los atajos del perfil activo. Con `claude-code` se aplican tus reasignaciones de `~/.claude/keybindings.json`; con `codex`, las de `[tui.keymap.*]` en `~/.codex/config.toml`. + +El terminal decide qué atajos llegan: Shift+Enter necesita el protocolo de teclado kitty (Ghostty, kitty, WezTerm, iTerm2 3.5+), Alt+Enter necesita Option como Meta en macOS y Ctrl+J funciona en cualquier terminal, incluido tmux. + +**Importación durante la configuración.** Cuando la configuración detecta otro agente, también ofrece importar memorias, sesiones y habilidades igual que `/import`; una importación fallida no bloquea la configuración. + +--- + ## Configuración del Agente Controla el comportamiento del agente y límites de iteración. diff --git a/docs/config-reference_fr.md b/docs/config-reference_fr.md index 14629a7e..cca5abed 100644 --- a/docs/config-reference_fr.md +++ b/docs/config-reference_fr.md @@ -686,6 +686,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 ``` --- +### Profils de raccourcis clavier + +Si vous utilisez déjà un autre agent de codage, le composeur peut suivre ses raccourcis. Choisissez le profil pendant la configuration (proposé quand Autohand détecte l'agent), dans `/settings` → UI → Raccourcis clavier, ou directement : + +```sh +autohand config set ui.keybindingProfile codex +``` + +| Profil | Nouvelle ligne | Quitter | Historique | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D ne quitte que si le composeur est vide. Le panneau `?` liste toujours les raccourcis du profil actif. Avec `claude-code`, vos réaffectations de `~/.claude/keybindings.json` sont appliquées ; avec `codex`, celles de `[tui.keymap.*]` dans `~/.codex/config.toml`. + +Le terminal décide des raccourcis reçus : Shift+Enter exige le protocole clavier kitty (Ghostty, kitty, WezTerm, iTerm2 3.5+), Alt+Enter exige Option en Meta sous macOS, Ctrl+J fonctionne dans tout terminal, tmux compris. + +**Import pendant la configuration.** Quand la configuration détecte un autre agent, elle propose aussi d'importer mémoires, sessions et compétences comme `/import` ; un import échoué ne bloque pas la configuration. + +--- + ## Paramètres des agents Contrôlez le comportement de l’agent et les limites d’itération. diff --git a/docs/config-reference_hi.md b/docs/config-reference_hi.md index 0b91cf54..bb9094cf 100644 --- a/docs/config-reference_hi.md +++ b/docs/config-reference_hi.md @@ -396,6 +396,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 --- +### कीबोर्ड शॉर्टकट प्रोफ़ाइल + +यदि आप पहले से कोई अन्य कोडिंग एजेंट उपयोग करते हैं, तो कंपोज़र उसके शॉर्टकट अपना सकता है। प्रोफ़ाइल सेटअप के दौरान चुनें (जब Autohand एजेंट का पता लगाता है), `/settings` → UI → कीबोर्ड शॉर्टकट में, या सीधे: + +```sh +autohand config set ui.keybindingProfile codex +``` + +| प्रोफ़ाइल | नई पंक्ति | बाहर निकलें | इतिहास | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D केवल खाली कंपोज़र पर बाहर निकलता है। `?` पैनल हमेशा सक्रिय प्रोफ़ाइल के शॉर्टकट दिखाता है। `claude-code` के साथ `~/.claude/keybindings.json` की आपकी रीमैपिंग लागू होती है; `codex` के साथ `~/.codex/config.toml` के `[tui.keymap.*]` की। + +कौन से शॉर्टकट पहुँचते हैं यह टर्मिनल तय करता है: Shift+Enter के लिए kitty कीबोर्ड प्रोटोकॉल चाहिए (Ghostty, kitty, WezTerm, iTerm2 3.5+), Alt+Enter के लिए macOS पर Option को Meta बनाना चाहिए, Ctrl+J हर टर्मिनल में काम करता है, tmux में भी। + +**सेटअप के दौरान आयात।** जब सेटअप किसी अन्य एजेंट का पता लगाता है, तो वह `/import` की तरह मेमोरी, सत्र और स्किल आयात करने की पेशकश भी करता है; असफल आयात सेटअप को नहीं रोकता। + +--- + ## एजेंट सेटिंग्स एजेंट व्यवहार और इटरेशन लिमिट्स को नियंत्रित करें। diff --git a/docs/config-reference_hu.md b/docs/config-reference_hu.md index a41db51f..35f679b3 100644 --- a/docs/config-reference_hu.md +++ b/docs/config-reference_hu.md @@ -686,6 +686,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 ``` --- +### Billentyűparancs-profilok + +Ha már használsz másik kódoló ügynököt, a szerkesztő követheti annak billentyűparancsait. A profilt a beállítás során választhatod (ha az Autohand megtalálja az ügynököt), a `/settings` → UI → Billentyűparancsok alatt, vagy közvetlenül: + +```sh +autohand config set ui.keybindingProfile codex +``` + +| Profil | Új sor | Kilépés | Előzmények | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +A Ctrl+D csak üres szerkesztőnél lép ki. A `?` panel mindig az aktív profil parancsait mutatja. A `claude-code` profil átveszi a `~/.claude/keybindings.json` saját hozzárendeléseit, a `codex` a `~/.codex/config.toml` `[tui.keymap.*]` szakaszait. + +Hogy melyik parancs érkezik meg, a terminálon múlik: a Shift+Enter a kitty billentyűzet-protokollt igényli (Ghostty, kitty, WezTerm, iTerm2 3.5+), az Alt+Enter macOS-en az Option Meta-ként való beállítását, a Ctrl+J minden terminálban működik, tmux alatt is. + +**Importálás a beállítás során.** Ha a beállítás másik ügynököt talál, felajánlja az emlékek, munkamenetek és készségek importálását is, ugyanúgy, mint az `/import`; a sikertelen importálás nem akadályozza a beállítást. + +--- + ## Ügynök beállításai Az ügynök viselkedésének és iterációs korlátainak szabályozása. diff --git a/docs/config-reference_id.md b/docs/config-reference_id.md index 85a8b0d9..c2a03078 100644 --- a/docs/config-reference_id.md +++ b/docs/config-reference_id.md @@ -368,6 +368,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 --- +### Profil pintasan keyboard + +Jika Anda sudah memakai agen pengkodean lain, composer dapat mengikuti pintasannya. Pilih profil saat penyiapan (ditawarkan ketika Autohand mendeteksi agen tersebut), di `/settings` → UI → Pintasan keyboard, atau langsung: + +```sh +autohand config set ui.keybindingProfile codex +``` + +| Profil | Baris baru | Keluar | Riwayat | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D hanya keluar saat composer kosong. Panel `?` selalu menampilkan pintasan profil aktif. Dengan `claude-code`, pemetaan ulang Anda di `~/.claude/keybindings.json` diterapkan; dengan `codex`, yang ada di `[tui.keymap.*]` pada `~/.codex/config.toml`. + +Terminal menentukan pintasan mana yang sampai: Shift+Enter butuh protokol keyboard kitty (Ghostty, kitty, WezTerm, iTerm2 3.5+), Alt+Enter butuh Option sebagai Meta di macOS, Ctrl+J bekerja di terminal apa pun termasuk tmux. + +**Impor saat penyiapan.** Ketika penyiapan mendeteksi agen lain, ia juga menawarkan impor memori, sesi, dan skill seperti `/import`; impor yang gagal tidak menghentikan penyiapan. + +--- + ## Pengaturan Agent Kontrol perilaku agent dan batas iterasi. diff --git a/docs/config-reference_it.md b/docs/config-reference_it.md index f35bb1cd..7b876567 100644 --- a/docs/config-reference_it.md +++ b/docs/config-reference_it.md @@ -686,6 +686,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 ``` --- +### Profili di scorciatoie da tastiera + +Se usi già un altro agente di programmazione, il compositore può seguirne le scorciatoie. Scegli il profilo durante la configurazione (proposto quando Autohand rileva l'agente), in `/settings` → UI → Scorciatoie da tastiera, oppure direttamente: + +```sh +autohand config set ui.keybindingProfile codex +``` + +| Profilo | Nuova riga | Uscita | Cronologia | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D esce solo con il compositore vuoto. Il pannello `?` elenca sempre le scorciatoie del profilo attivo. Con `claude-code` vengono applicate le tue rimappature in `~/.claude/keybindings.json`; con `codex`, quelle in `[tui.keymap.*]` di `~/.codex/config.toml`. + +È il terminale a decidere quali scorciatoie arrivano: Shift+Enter richiede il protocollo tastiera kitty (Ghostty, kitty, WezTerm, iTerm2 3.5+), Alt+Enter richiede Option come Meta su macOS, Ctrl+J funziona in qualsiasi terminale, tmux incluso. + +**Importazione durante la configurazione.** Quando la configurazione rileva un altro agente, propone anche di importare memorie, sessioni e skill come fa `/import`; un'importazione fallita non blocca la configurazione. + +--- + ## Impostazioni dell'agente Comportamento dell'agente di controllo e limiti di iterazione. diff --git a/docs/config-reference_ja.md b/docs/config-reference_ja.md index fe363d76..934737b7 100644 --- a/docs/config-reference_ja.md +++ b/docs/config-reference_ja.md @@ -409,6 +409,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 --- +### キーボードショートカットのプロファイル + +すでに別のコーディングエージェントを使っている場合、コンポーザーはそのショートカットに合わせられます。プロファイルはセットアップ中(Autohand がエージェントを検出したとき)、`/settings` → UI → キーボードショートカット、または次のコマンドで選べます。 + +```sh +autohand config set ui.keybindingProfile codex +``` + +| プロファイル | 改行 | 終了 | 履歴 | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D はコンポーザーが空のときだけ終了します。`?` パネルは常に有効なプロファイルのショートカットを表示します。`claude-code` では `~/.claude/keybindings.json` の独自設定が、`codex` では `~/.codex/config.toml` の `[tui.keymap.*]` が反映されます。 + +どのショートカットが届くかはターミナル次第です。Shift+Enter には kitty キーボードプロトコル(Ghostty、kitty、WezTerm、iTerm2 3.5 以降)が必要で、Alt+Enter には macOS で Option を Meta にする設定が必要です。Ctrl+J は tmux を含むどのターミナルでも動作します。 + +**セットアップ中のインポート。** セットアップが他のエージェントを検出すると、`/import` と同じ方法でメモリ・セッション・スキルのインポートも提案します。インポートに失敗してもセットアップは止まりません。 + +--- + ## エージェント設定 エージェントの動作と反復制限を制御します。 diff --git a/docs/config-reference_ko.md b/docs/config-reference_ko.md index 706db522..1e2fbeb1 100644 --- a/docs/config-reference_ko.md +++ b/docs/config-reference_ko.md @@ -396,6 +396,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 --- +### 키보드 단축키 프로필 + +이미 다른 코딩 에이전트를 사용 중이라면 컴포저가 그 단축키를 따를 수 있습니다. 프로필은 설정 중(Autohand가 에이전트를 감지했을 때), `/settings` → UI → 키보드 단축키, 또는 다음 명령으로 선택합니다. + +```sh +autohand config set ui.keybindingProfile codex +``` + +| 프로필 | 줄 바꿈 | 종료 | 기록 | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D는 컴포저가 비어 있을 때만 종료합니다. `?` 패널은 항상 활성 프로필의 단축키를 표시합니다. `claude-code`에서는 `~/.claude/keybindings.json`의 사용자 재매핑이, `codex`에서는 `~/.codex/config.toml`의 `[tui.keymap.*]`가 적용됩니다. + +어떤 단축키가 전달되는지는 터미널이 결정합니다. Shift+Enter는 kitty 키보드 프로토콜(Ghostty, kitty, WezTerm, iTerm2 3.5 이상)이 필요하고, Alt+Enter는 macOS에서 Option을 Meta로 설정해야 하며, Ctrl+J는 tmux를 포함한 모든 터미널에서 동작합니다. + +**설정 중 가져오기.** 설정이 다른 에이전트를 감지하면 `/import`와 같은 방식으로 메모리, 세션, 스킬 가져오기도 제안합니다. 가져오기에 실패해도 설정은 계속됩니다. + +--- + ## 에이전트 설정 에이전트 동작 및 반복 제한을 제어합니다. diff --git a/docs/config-reference_pl.md b/docs/config-reference_pl.md index 2064599a..8c668848 100644 --- a/docs/config-reference_pl.md +++ b/docs/config-reference_pl.md @@ -686,6 +686,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 ``` --- +### Profile skrótów klawiszowych + +Jeśli używasz już innego agenta programistycznego, kompozytor może przejąć jego skróty. Profil wybierzesz podczas konfiguracji (gdy Autohand wykryje agenta), w `/settings` → UI → Skróty klawiszowe albo bezpośrednio: + +```sh +autohand config set ui.keybindingProfile codex +``` + +| Profil | Nowa linia | Wyjście | Historia | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D wychodzi tylko przy pustym kompozytorze. Panel `?` zawsze pokazuje skróty aktywnego profilu. Dla `claude-code` stosowane są własne mapowania z `~/.claude/keybindings.json`, dla `codex` te z `[tui.keymap.*]` w `~/.codex/config.toml`. + +O tym, które skróty docierają, decyduje terminal: Shift+Enter wymaga protokołu klawiatury kitty (Ghostty, kitty, WezTerm, iTerm2 3.5+), Alt+Enter wymaga Option jako Meta w macOS, Ctrl+J działa w każdym terminalu, także w tmux. + +**Import podczas konfiguracji.** Gdy konfiguracja wykryje innego agenta, proponuje też import pamięci, sesji i umiejętności tak samo jak `/import`; nieudany import nie blokuje konfiguracji. + +--- + ## Ustawienia agenta Kontroluj zachowanie agenta i limity iteracji. diff --git a/docs/config-reference_ptBR.md b/docs/config-reference_ptBR.md index ef241e47..a16a4b12 100644 --- a/docs/config-reference_ptBR.md +++ b/docs/config-reference_ptBR.md @@ -410,6 +410,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 --- +### Perfis de atalhos de teclado + +Se você já usa outro agente de codificação, o compositor pode seguir os atalhos dele. Escolha o perfil durante a configuração (oferecido quando o Autohand detecta o agente), em `/settings` → UI → Atalhos de teclado, ou diretamente: + +```sh +autohand config set ui.keybindingProfile codex +``` + +| Perfil | Nova linha | Sair | Histórico | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D só sai com o compositor vazio. O painel `?` sempre lista os atalhos do perfil ativo. Com `claude-code`, seus remapeamentos em `~/.claude/keybindings.json` são aplicados; com `codex`, os de `[tui.keymap.*]` em `~/.codex/config.toml`. + +O terminal decide quais atalhos chegam: Shift+Enter exige o protocolo de teclado kitty (Ghostty, kitty, WezTerm, iTerm2 3.5+), Alt+Enter exige Option como Meta no macOS e Ctrl+J funciona em qualquer terminal, inclusive no tmux. + +**Importação durante a configuração.** Quando a configuração detecta outro agente, ela também oferece importar memórias, sessões e skills do mesmo jeito que `/import`; uma importação com falha não bloqueia a configuração. + +--- + ## Configurações do Agente Controle o comportamento do agente e limites de iteração. diff --git a/docs/config-reference_ru.md b/docs/config-reference_ru.md index 4b3fab52..ff0cc1f8 100644 --- a/docs/config-reference_ru.md +++ b/docs/config-reference_ru.md @@ -686,6 +686,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 ``` --- +### Профили сочетаний клавиш + +Если вы уже пользуетесь другим агентом для кода, композитор может следовать его сочетаниям. Профиль выбирается при настройке (предлагается, когда Autohand находит агента), в `/settings` → UI → Сочетания клавиш или напрямую: + +```sh +autohand config set ui.keybindingProfile codex +``` + +| Профиль | Новая строка | Выход | История | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D завершает работу только при пустом композиторе. Панель `?` всегда показывает сочетания активного профиля. Для `claude-code` применяются ваши переназначения из `~/.claude/keybindings.json`, для `codex` — из `[tui.keymap.*]` в `~/.codex/config.toml`. + +Какие сочетания доходят, решает терминал: Shift+Enter требует клавиатурный протокол kitty (Ghostty, kitty, WezTerm, iTerm2 3.5+), Alt+Enter требует Option как Meta в macOS, Ctrl+J работает в любом терминале, включая tmux. + +**Импорт при настройке.** Когда настройка находит другого агента, она также предлагает импортировать память, сессии и навыки так же, как `/import`; неудачный импорт не останавливает настройку. + +--- + ## Настройки агента Управляйте поведением агента и ограничениями итераций. diff --git a/docs/config-reference_tr.md b/docs/config-reference_tr.md index 7440d609..3559aba1 100644 --- a/docs/config-reference_tr.md +++ b/docs/config-reference_tr.md @@ -686,6 +686,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 ``` --- +### Klavye kısayolu profilleri + +Zaten başka bir kodlama aracısı kullanıyorsanız, düzenleyici onun kısayollarını izleyebilir. Profili kurulum sırasında (Autohand aracıyı algıladığında), `/settings` → UI → Klavye kısayolları altında veya doğrudan seçin: + +```sh +autohand config set ui.keybindingProfile codex +``` + +| Profil | Yeni satır | Çıkış | Geçmiş | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D yalnızca düzenleyici boşken çıkar. `?` paneli her zaman etkin profilin kısayollarını listeler. `claude-code` ile `~/.claude/keybindings.json` içindeki yeniden atamalarınız, `codex` ile `~/.codex/config.toml` içindeki `[tui.keymap.*]` uygulanır. + +Hangi kısayolların ulaşacağına terminal karar verir: Shift+Enter kitty klavye protokolünü gerektirir (Ghostty, kitty, WezTerm, iTerm2 3.5+), Alt+Enter macOS'ta Option'ın Meta olmasını gerektirir, Ctrl+J tmux dahil her terminalde çalışır. + +**Kurulum sırasında içe aktarma.** Kurulum başka bir aracı algıladığında, bellekleri, oturumları ve becerileri `/import` ile aynı şekilde içe aktarmayı da önerir; başarısız bir içe aktarma kurulumu engellemez. + +--- + ## Temsilci Ayarları Kontrol aracısı davranışı ve yineleme sınırları. diff --git a/docs/config-reference_zh-tw.md b/docs/config-reference_zh-tw.md index b3b83fcf..3876ea12 100644 --- a/docs/config-reference_zh-tw.md +++ b/docs/config-reference_zh-tw.md @@ -686,6 +686,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 ``` --- +### 鍵盤快捷鍵設定檔 + +如果你已經在使用其他程式碼代理,撰寫區可以沿用它的快捷鍵。可在設定精靈中選擇設定檔(當 Autohand 偵測到該代理時提供)、在 `/settings` → UI → 鍵盤快捷鍵中選擇,或直接執行: + +```sh +autohand config set ui.keybindingProfile codex +``` + +| 設定檔 | 換行 | 離開 | 歷史 | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D 只在撰寫區為空時離開。`?` 面板永遠列出目前設定檔的快捷鍵。使用 `claude-code` 時會套用 `~/.claude/keybindings.json` 中的自訂對應;使用 `codex` 時則套用 `~/.codex/config.toml` 的 `[tui.keymap.*]`。 + +哪些快捷鍵能送達由終端機決定:Shift+Enter 需要 kitty 鍵盤協定(Ghostty、kitty、WezTerm、iTerm2 3.5 以上),Alt+Enter 需要在 macOS 將 Option 設為 Meta,Ctrl+J 在任何終端機(包含 tmux)都可用。 + +**設定時匯入。** 當設定精靈偵測到其他代理時,也會提議以與 `/import` 相同的方式匯入記憶、工作階段與技能;匯入失敗不會中斷設定。 + +--- + ## 代理設定 控制代理行為和迭代限制。 diff --git a/docs/config-reference_zh.md b/docs/config-reference_zh.md index 26ad703f..f6b7a373 100644 --- a/docs/config-reference_zh.md +++ b/docs/config-reference_zh.md @@ -396,6 +396,29 @@ export AUTOHAND_SKIP_UPDATE_CHECK=1 --- +### 键盘快捷键配置文件 + +如果你已经在使用其他编码代理,撰写区可以沿用它的快捷键。可在设置向导中选择配置文件(当 Autohand 检测到该代理时提供)、在 `/settings` → UI → 键盘快捷键中选择,或直接运行: + +```sh +autohand config set ui.keybindingProfile codex +``` + +| 配置文件 | 换行 | 退出 | 历史 | +| --- | --- | --- | --- | +| `autohand` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | +| `claude-code`, `codex`, `devin` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | Ctrl+R | +| `cursor`, `antigravity` | Shift+Enter, Alt+Enter, Ctrl+J | Ctrl+D | `/whatityped` | +| `factory` | Shift+Enter, Alt+Enter | Ctrl+C ×2 | `/whatityped` | + +Ctrl+D 仅在撰写区为空时退出。`?` 面板始终列出当前配置文件的快捷键。使用 `claude-code` 时会应用 `~/.claude/keybindings.json` 中的自定义映射;使用 `codex` 时则应用 `~/.codex/config.toml` 的 `[tui.keymap.*]`。 + +哪些快捷键能送达由终端决定:Shift+Enter 需要 kitty 键盘协议(Ghostty、kitty、WezTerm、iTerm2 3.5 及以上),Alt+Enter 需要在 macOS 上将 Option 设为 Meta,Ctrl+J 在任何终端(包括 tmux)都可用。 + +**设置时导入。** 当设置向导检测到其他代理时,也会提议以与 `/import` 相同的方式导入记忆、会话和技能;导入失败不会中断设置。 + +--- + ## 代理设置 控制代理行为和迭代限制。 diff --git a/src/commands/settings.ts b/src/commands/settings.ts index db75432a..44ee6c0b 100644 --- a/src/commands/settings.ts +++ b/src/commands/settings.ts @@ -5,6 +5,7 @@ */ import chalk from 'chalk'; import { resolveMouseComposerCursor } from '../ui/mouseReporting.js'; +import { KEYBINDING_PROFILE_IDS } from '../keybindings/profiles.js'; import { t } from '../i18n/index.js'; import { showModal, showInput, showConfirm, showPassword, type ModalOption } from '../ui/ink/components/Modal.js'; import { saveConfig } from '../config.js'; @@ -122,6 +123,7 @@ export const SETTINGS_REGISTRY: SettingDef[] = [ { key: 'ui.completionReportEnabled', labelKey: 'commands.settings.ui.completionReportEnabled', descriptionKey: 'commands.settings.ui.completionReportEnabledDesc', category: 'ui', type: 'boolean', defaultValue: true }, { key: 'ui.promptSuggestions', labelKey: 'commands.settings.ui.promptSuggestions', descriptionKey: 'commands.settings.ui.promptSuggestionsDesc', category: 'ui', type: 'boolean', defaultValue: true }, { key: 'ui.mouseComposerCursor', labelKey: 'commands.settings.ui.mouseComposerCursor', descriptionKey: 'commands.settings.ui.mouseComposerCursorDesc', category: 'ui', type: 'boolean', defaultValue: resolveMouseComposerCursor(undefined) }, + { key: 'ui.keybindingProfile', labelKey: 'commands.settings.ui.keybindingProfile', descriptionKey: 'commands.settings.ui.keybindingProfileDesc', category: 'ui', type: 'enum', enumValues: [...KEYBINDING_PROFILE_IDS], defaultValue: 'autohand' }, { key: 'ui.activityVerbsEnabled', labelKey: 'commands.settings.ui.activityVerbsEnabled', descriptionKey: 'commands.settings.ui.activityVerbsEnabledDesc', category: 'ui', type: 'boolean', defaultValue: true }, { key: 'ui.activitySymbol', labelKey: 'commands.settings.ui.activitySymbol', descriptionKey: 'commands.settings.ui.activitySymbolDesc', category: 'ui', type: 'string', defaultValue: '\u2733' }, { key: 'ui.statusLine', labelKey: 'commands.settings.ui.statusLine', descriptionKey: 'commands.settings.ui.statusLineDesc', category: 'ui', type: 'string', redirect: '/statusline' }, diff --git a/src/config.ts b/src/config.ts index 27e31c4d..42a8fd9e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,7 @@ import type { AutohandAISettings, } from "./types.js"; import { AUTOHAND_FILES, AUTOHAND_HOME } from "./constants.js"; +import { KEYBINDING_PROFILE_IDS, isKeybindingProfileId } from "./keybindings/profiles.js"; import { isAutohandInferenceEnabled } from "./featureFlags.js"; import { autoInitTheme, configureThemeSources, getDefaultThemeName, themeExists } from "./ui/theme/index.js"; import { loadLocalProjectSettings, type LocalProjectSettings } from "./permissions/localProjectPermissions.js"; @@ -938,6 +939,14 @@ function validateConfig(config: AutohandConfig, configPath: string): void { ) { throw new Error(`ui.taskListPosition must be up or above-composer in ${configPath}`); } + if ( + config.ui.keybindingProfile !== undefined && + !isKeybindingProfileId(config.ui.keybindingProfile) + ) { + throw new Error( + `ui.keybindingProfile must be one of ${KEYBINDING_PROFILE_IDS.join(", ")} in ${configPath}`, + ); + } if ( config.ui.completionReportEnabled !== undefined && typeof config.ui.completionReportEnabled !== "boolean" diff --git a/src/core/agent/AgentUIRuntime.ts b/src/core/agent/AgentUIRuntime.ts index 553643d6..deb53b2b 100644 --- a/src/core/agent/AgentUIRuntime.ts +++ b/src/core/agent/AgentUIRuntime.ts @@ -18,6 +18,8 @@ import { writeAutohandDebugLine } from '../../utils/debugLog.js'; import { buildStatusLineExtension, getConfigStatusLineSettings } from './StatusLineSettings.js'; import { resolveStatusLineGitLabel } from './AgentContextRuntime.js'; import { extensionRuntimeHost } from '../../extensions/ExtensionRuntimeHost.js'; +import { resolveKeybindings } from '../../keybindings/profiles.js'; +import { loadExternalKeybindingOverrides } from '../../keybindings/externalKeybindings.js'; import { t } from '../../i18n/index.js'; import type { AnnouncementLineState } from '../../ui/ink/AgentUI.js'; import type { AgentUILineExtensions } from '../../ui/ink/AgentUI.js'; @@ -282,6 +284,10 @@ export function initializeAgentUIManager(host: AgentUIRuntimeHost): void { getInteractionMode: () => host.getInteractionMode(), onCycleInteractionMode: () => host.cycleInteractionMode(), mouseComposerCursor: resolveMouseComposerCursor(host.runtime?.config?.ui?.mouseComposerCursor), + keybindings: resolveKeybindings( + host.runtime?.config?.ui?.keybindingProfile, + loadExternalKeybindingOverrides(host.runtime?.config?.ui?.keybindingProfile ?? 'autohand'), + ), taskListPositionProvider: () => host.runtime?.config?.ui?.taskListPosition ?? 'above-composer', onEditGoalObjective: async (request) => { diff --git a/src/core/agent/AutohandAIModelTierPolicy.ts b/src/core/agent/AutohandAIModelTierPolicy.ts index 0eb323f3..d8abab9c 100644 --- a/src/core/agent/AutohandAIModelTierPolicy.ts +++ b/src/core/agent/AutohandAIModelTierPolicy.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ import type { LoadedConfig } from '../../types.js'; -import { getAutohandAICloudModelContextWindow } from '../../providers/AutohandAIProvider.js'; +import { + getAutohandAICloudModelContextWindow, + resolveAutohandAICloudModel, +} from '../../providers/AutohandAIProvider.js'; /** * Moa is a paid-tier model. A free-plan account that ends up with `moa` selected @@ -40,18 +43,27 @@ export interface AutohandAIModelTierInput { tier: string | undefined; } +const AUTOHAND_AI_MODEL_PREFIX = 'autohandai/'; + /** - * Resolve the model a given account tier may run. Returns `'fantail'` only for - * the exact combination that must be corrected — free tier + autohandai cloud + - * Moa — and passes every other selection through unchanged. + * Resolve the model a given account may run on the Autohand cloud plan. + * + * Two corrections apply, both only for provider `autohandai` + plan `cloud`: + * a model the gateway does not serve (for example one left over from another + * provider, see issue #584) becomes Fantail on every tier, and Moa becomes + * Fantail on the free tier. Local plans and other providers pass through. */ export function resolveAutohandAIModelForTier(input: AutohandAIModelTierInput): string { - const applies = - input.provider === 'autohandai' && - input.plan === 'cloud' && - isMoaModel(input.model) && - isFreeTier(input.tier); - return applies ? AUTOHAND_AI_FREE_TIER_MODEL : (input.model ?? AUTOHAND_AI_FREE_TIER_MODEL); + const cloudPlan = input.provider === 'autohandai' && input.plan === 'cloud'; + if (!cloudPlan) { + return input.model ?? AUTOHAND_AI_FREE_TIER_MODEL; + } + + const bare = input.model?.toLowerCase().startsWith(AUTOHAND_AI_MODEL_PREFIX) + ? input.model.slice(AUTOHAND_AI_MODEL_PREFIX.length) + : input.model; + const served = resolveAutohandAICloudModel(bare); + return isMoaModel(served) && isFreeTier(input.tier) ? AUTOHAND_AI_FREE_TIER_MODEL : served; } export interface AutohandAIModelTierPolicyResult { @@ -109,3 +121,17 @@ export function applyAutohandAIModelTierPolicy( return { config: next, switched: true, previousModel, resolvedModel: resolved }; } + +/** + * Startup pass, before any entitlement is known: only the served-model rule + * applies, so a model the gateway cannot run never reaches the banner, the + * status line or a request. Mutates `config` in place (the CLI hands the same + * object to the agent) and returns the corrected model, or undefined when the + * selection was already fine. + */ +export function normalizeAutohandAIStartupModel(config: LoadedConfig): string | undefined { + const result = applyAutohandAIModelTierPolicy(config, undefined); + if (!result.switched) return undefined; + config.autohandai = result.config.autohandai; + return result.resolvedModel; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 28dae52c..ad34d40d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -219,6 +219,8 @@ "promptSuggestionsDesc": "Show LLM-generated next-step suggestions", "mouseComposerCursor": "Mouse composer cursor", "mouseComposerCursorDesc": "Click in composer text to position the cursor", + "keybindingProfile": "Keyboard shortcuts", + "keybindingProfileDesc": "Follow another coding agent's shortcuts (Ctrl+J newline, Ctrl+D exit, Ctrl+R history)", "activityVerbsEnabled": "Activity verbs", "activityVerbsEnabledDesc": "Show rotating activity verbs while the agent is working", "activitySymbol": "Activity symbol", @@ -668,6 +670,23 @@ "description": "Configure notifications, network, search, MCP, agent behavior, and community skills.", "prompt": "Would you like to configure advanced settings?" }, + "keybindings": { + "title": "Keyboard Shortcuts", + "description": "{{agents}} found on this machine. Autohand can follow the shortcuts you already know; change this any time in /settings.", + "prompt": "Which keyboard shortcuts should the composer use?", + "autohandOption": "Autohand defaults (recommended)", + "autohandDescription": "Shift+Tab cycles modes, Shift+Enter or Alt+Enter inserts a newline, Ctrl+C twice exits", + "agentOption": "Same as {{agent}}", + "agentDescription": "Adds that agent's chords such as Ctrl+J for a newline, Ctrl+D to exit and Ctrl+R for history" + }, + "importAgents": { + "title": "Import From Other Agents", + "description": "Memories, sessions and skills can be brought across with the same /import command you can run later.", + "prompt": "Import memories, sessions and skills from {{agents}}?", + "running": "Importing from {{agents}}...", + "skipped": "Skipped. Run /import whenever you want to bring them across.", + "failed": "Import did not finish: {{error}}. Setup continues; run /import later to retry." + }, "registration": { "title": "Autohand Account", "description": "Create a free Autohand account to unlock cloud sync, team features, and usage analytics.", diff --git a/src/index.ts b/src/index.ts index ed28b623..b62ef062 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,6 +106,19 @@ if (process.argv.includes('--answer-only') || process.argv.includes('--setup-onl process.env.AUTOHAND_DISABLE_AUTO_REPORT = '1'; } +/** + * A model the Autohand cloud gateway cannot serve (issue #584: one left over + * from another provider) is corrected as soon as the config is loaded, so the + * banner, the status line and the first request all agree on the served model. + */ +async function applyServedAutohandModel(config: LoadedConfig, opts: { model?: string }): Promise { + const { normalizeAutohandAIStartupModel } = await import('./core/agent/AutohandAIModelTierPolicy.js'); + const served = normalizeAutohandAIStartupModel(config); + if (served && opts.model) { + opts.model = served; + } +} + function applyCliModelOverride(config: LoadedConfig, model: string): void { const providerName = config.provider ?? 'openrouter'; if (isCustomProviderName(providerName)) { @@ -1332,6 +1345,7 @@ async function runCLI(options: InternalCLIOptions): Promise { if (commandLifecycleController.signal.aborted) { return; } + await applyServedAutohandModel(config, options); const originalWorkspaceRoot = resolveWorkspaceRoot(config, options.path); let workspaceRoot = originalWorkspaceRoot; let sessionWorktree: ReturnType | null = null; @@ -2365,6 +2379,7 @@ async function runPatchMode(opts: CLIOptions): Promise { if (opts.model) { applyCliModelOverride(config, opts.model); } + await applyServedAutohandModel(config, opts); const { ProviderFactory } = await import('./providers/ProviderFactory.js'); const { FileActionManager } = await import('./actions/filesystem.js'); @@ -2495,6 +2510,7 @@ async function runAutoMode(opts: CLIOptions): Promise { if (opts.model) { applyCliModelOverride(config, opts.model); } + await applyServedAutohandModel(config, opts); // Override debug mode from CLI if provided if (opts.debug) { diff --git a/src/keybindings/externalKeybindings.ts b/src/keybindings/externalKeybindings.ts new file mode 100644 index 00000000..07f019d4 --- /dev/null +++ b/src/keybindings/externalKeybindings.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { parse as parseToml } from 'smol-toml'; +import { writeAutohandDebugLine } from '../utils/debugLog.js'; +import { + DEFAULT_BINDING_SPECS, + getKeybindingProfile, + normalizeChord, + parseChord, + type KeybindingAction, + type KeybindingOverrides, + type KeybindingProfileId, +} from './profiles.js'; + +/** + * Overlays a user's own remaps from another agent onto a profile. Only Claude + * Code and Codex document their keymap files; other profiles get defaults. + * Reads are synchronous because they run once, at UI start, on tiny files. + */ +export function loadExternalKeybindingOverrides( + profile: KeybindingProfileId, + homeDir: string = os.homedir(), +): KeybindingOverrides { + try { + if (profile === 'claude-code') { + return readClaudeCodeOverrides(path.join(homeDir, '.claude', 'keybindings.json')); + } + if (profile === 'codex') { + return readCodexOverrides(path.join(homeDir, '.codex', 'config.toml')); + } + } catch (error) { + writeAutohandDebugLine( + `[DEBUG] Ignoring ${profile} keybinding overrides: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return {}; +} + +/** Claude Code `context:action` ids that have an Autohand equivalent. */ +export const CLAUDE_CODE_ACTION_MAP: Readonly> = { + 'chat:cycleMode': 'cycleMode', + 'chat:newline': 'newline', + 'app:exit': 'exit', + 'app:toggleTranscript': 'toggleLiveOutput', + 'app:toggleTodos': 'toggleGoals', + 'history:search': 'openHistory', +}; + +/** Codex `[tui.keymap.]` keys that have an Autohand equivalent. */ +export const CODEX_ACTION_MAP: Readonly> = { + 'composer.insert_newline': 'newline', + 'composer.history_search': 'openHistory', + 'global.exit': 'exit', +}; + +const CLAUDE_CODE_CONTEXTS = new Set(['Chat', 'Global']); + +function readOptionalFile(filePath: string): string | null { + try { + return readFileSync(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Claude Code bindings are edits on top of its defaults, so they are applied + * as edits on top of the profile: a string action adds the chord, `null` + * removes it. Only actions that actually changed are returned. + */ +function readClaudeCodeOverrides(filePath: string): KeybindingOverrides { + const content = readOptionalFile(filePath); + if (content === null) return {}; + const parsed: unknown = JSON.parse(content); + if (!isRecord(parsed) || !Array.isArray(parsed.bindings)) return {}; + + const profileBindings = getKeybindingProfile('claude-code').bindings; + const chords = new Map(); + const current = (action: KeybindingAction): string[] => { + const existing = chords.get(action); + if (existing) return existing; + const seeded = [...(profileBindings[action] ?? defaultChordsFor(action))]; + chords.set(action, seeded); + return seeded; + }; + + for (const block of parsed.bindings) { + if (!isRecord(block) || !CLAUDE_CODE_CONTEXTS.has(String(block.context)) || !isRecord(block.bindings)) continue; + for (const [keySpec, action] of Object.entries(block.bindings)) { + if (!parseChord(keySpec)) continue; + const normalized = normalizeSpec(keySpec); + if (action === null) { + for (const target of Object.values(CLAUDE_CODE_ACTION_MAP)) { + const list = current(target); + const index = list.findIndex((spec) => normalizeSpec(spec) === normalized); + if (index !== -1) list.splice(index, 1); + } + continue; + } + const target = typeof action === 'string' ? CLAUDE_CODE_ACTION_MAP[action] : undefined; + if (!target) continue; + const list = current(target); + if (!list.some((spec) => normalizeSpec(spec) === normalized)) list.push(keySpec); + } + } + + const overrides: KeybindingOverrides = {}; + for (const [action, list] of chords) { + const original = profileBindings[action] ?? defaultChordsFor(action); + if (list.length !== original.length || list.some((spec, index) => normalizeSpec(spec) !== normalizeSpec(original[index] ?? ''))) { + overrides[action] = list; + } + } + return overrides; +} + +/** Codex keymaps state an action's complete binding list, so they replace. */ +function readCodexOverrides(filePath: string): KeybindingOverrides { + const content = readOptionalFile(filePath); + if (content === null) return {}; + const parsed: unknown = parseToml(content); + if (!isRecord(parsed) || !isRecord(parsed.tui) || !isRecord(parsed.tui.keymap)) return {}; + + const overrides: KeybindingOverrides = {}; + for (const [id, action] of Object.entries(CODEX_ACTION_MAP)) { + const [context, name] = id.split('.'); + const section = parsed.tui.keymap[context!]; + if (!isRecord(section) || !(name! in section)) continue; + const value = section[name!]; + const specs = typeof value === 'string' ? [value] : Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : null; + if (specs) overrides[action] = specs; + } + return overrides; +} + +function defaultChordsFor(action: KeybindingAction): readonly string[] { + return DEFAULT_BINDING_SPECS[action]; +} + +function normalizeSpec(spec: string): string { + const chord = parseChord(spec); + return chord ? normalizeChord(chord) : spec.trim().toLowerCase(); +} diff --git a/src/keybindings/profiles.ts b/src/keybindings/profiles.ts new file mode 100644 index 00000000..ed86c3d7 --- /dev/null +++ b/src/keybindings/profiles.ts @@ -0,0 +1,291 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Keybinding profiles for the interactive composer. + * + * A profile maps the handful of composer actions that other coding agents + * bind differently onto chords. Everything the ecosystem agrees on (Esc, + * Ctrl+C, Enter, Tab, editing keys) stays fixed and is not represented here. + */ + +export const KEYBINDING_ACTIONS = [ + 'cycleMode', + 'newline', + 'exit', + 'toggleLiveOutput', + 'toggleTeamPanel', + 'toggleGoals', + 'openHistory', + 'toggleShortcutsHelp', +] as const; + +export type KeybindingAction = (typeof KEYBINDING_ACTIONS)[number]; + +export const KEYBINDING_PROFILE_IDS = [ + 'autohand', + 'claude-code', + 'codex', + 'cursor', + 'antigravity', + 'devin', + 'factory', +] as const; + +export type KeybindingProfileId = (typeof KEYBINDING_PROFILE_IDS)[number]; + +export interface Chord { + key: string; + ctrl: boolean; + shift: boolean; + meta: boolean; +} + +/** A keypress normalised from an Ink key or a readline key. */ +export interface KeyEvent { + input: string; + name?: string; + ctrl: boolean; + shift: boolean; + meta: boolean; +} + +export type KeybindingOverrides = Partial>; + +export interface KeybindingProfile { + id: KeybindingProfileId; + /** Shown in settings and onboarding, e.g. "Same as Claude Code". */ + label: string; + /** The agent's own name, used when listing detected agents. */ + agentLabel?: string; + /** Home-relative directories whose presence means the agent is installed. */ + homeDirectories: string[]; + /** Chords that replace the Autohand defaults for an action. */ + bindings: KeybindingOverrides; +} + +export interface KeybindingHelpRow { + left: string; + right: string; +} + +export interface ResolvedKeybindings { + profile: KeybindingProfileId; + bindings: Record; + matches(action: KeybindingAction, event: KeyEvent): boolean; + /** Normalised chords ("ctrl+d") that extensions must not shadow. */ + reservedChords(): Set; + helpRows(): KeybindingHelpRow[]; +} + +const MODIFIER_ALIASES: Record> = { + ctrl: 'ctrl', + control: 'ctrl', + shift: 'shift', + alt: 'meta', + opt: 'meta', + option: 'meta', + meta: 'meta', +}; + +const KEY_ALIASES: Record = { + esc: 'escape', + return: 'enter', +}; + +const NAMED_KEYS = new Set(['enter', 'tab', 'escape', 'up', 'down', 'left', 'right', 'space', 'backspace', 'delete']); + +export const DEFAULT_BINDING_SPECS: Readonly> = { + cycleMode: ['shift+tab'], + newline: ['shift+enter', 'alt+enter'], + exit: [], + toggleLiveOutput: ['ctrl+o'], + toggleTeamPanel: ['ctrl+t', 'meta+t'], + toggleGoals: ['ctrl+g', 'meta+g'], + openHistory: [], + toggleShortcutsHelp: ['?'], +}; + +const NEWLINE_WITH_CTRL_J = [...DEFAULT_BINDING_SPECS.newline, 'ctrl+j']; + +const PROFILES: Record = { + autohand: { + id: 'autohand', + label: 'Autohand defaults', + homeDirectories: ['.autohand'], + bindings: {}, + }, + 'claude-code': { + id: 'claude-code', + label: 'Same as Claude Code', + agentLabel: 'Claude Code', + homeDirectories: ['.claude'], + bindings: { newline: NEWLINE_WITH_CTRL_J, exit: ['ctrl+d'], openHistory: ['ctrl+r'] }, + }, + codex: { + id: 'codex', + label: 'Same as Codex', + agentLabel: 'Codex', + homeDirectories: ['.codex'], + bindings: { newline: NEWLINE_WITH_CTRL_J, exit: ['ctrl+d'], openHistory: ['ctrl+r'] }, + }, + cursor: { + id: 'cursor', + label: 'Same as Cursor', + agentLabel: 'Cursor', + homeDirectories: ['.cursor'], + bindings: { newline: NEWLINE_WITH_CTRL_J, exit: ['ctrl+d'] }, + }, + antigravity: { + id: 'antigravity', + label: 'Same as Antigravity', + agentLabel: 'Antigravity', + homeDirectories: ['.gemini/antigravity-cli'], + bindings: { newline: NEWLINE_WITH_CTRL_J, exit: ['ctrl+d'] }, + }, + devin: { + id: 'devin', + label: 'Same as Devin', + agentLabel: 'Devin', + homeDirectories: ['.config/devin'], + bindings: { newline: NEWLINE_WITH_CTRL_J, exit: ['ctrl+d'], openHistory: ['ctrl+r'] }, + }, + factory: { + id: 'factory', + label: 'Same as Factory Droid', + agentLabel: 'Factory Droid', + homeDirectories: ['.factory'], + // Droid exits with Ctrl+C twice and only inserts newlines with Shift+Enter. + bindings: {}, + }, +}; + +const ACTION_HELP: Record = { + cycleMode: 'cycles interaction modes', + newline: 'inserts newline', + exit: 'exits', + toggleLiveOutput: 'expands command output', + toggleTeamPanel: 'toggles the team panel', + toggleGoals: 'toggles the goals panel', + openHistory: 'opens history', + toggleShortcutsHelp: 'toggles this shortcuts panel', +}; + +export function isKeybindingProfileId(value: unknown): value is KeybindingProfileId { + return typeof value === 'string' && (KEYBINDING_PROFILE_IDS as readonly string[]).includes(value); +} + +export function getKeybindingProfile(id: KeybindingProfileId): KeybindingProfile { + return PROFILES[id]; +} + +export function parseChord(spec: string): Chord | null { + const trimmed = spec.trim().toLowerCase(); + if (!trimmed || /\s/.test(trimmed)) return null; + const parts = trimmed === '?' ? ['?'] : trimmed.split(/[+-]/); + const key = parts.at(-1); + if (!key) return null; + const chord: Chord = { key: KEY_ALIASES[key] ?? key, ctrl: false, shift: false, meta: false }; + for (const modifier of parts.slice(0, -1)) { + const field = MODIFIER_ALIASES[modifier]; + if (!field) return null; + chord[field] = true; + } + return chord; +} + +export function formatChord(chord: Chord): string { + const label = chord.key === 'meta' ? 'alt' : chord.key; + const parts = [chord.ctrl ? 'ctrl' : null, chord.meta ? 'alt' : null, chord.shift ? 'shift' : null, label]; + return parts.filter(Boolean).join(' + '); +} + +export function normalizeChord(chord: Chord): string { + return [chord.ctrl ? 'ctrl' : null, chord.meta ? 'meta' : null, chord.shift ? 'shift' : null, chord.key] + .filter(Boolean) + .join('+'); +} + +export function matchesChord(chord: Chord, event: KeyEvent): boolean { + if (chord.key === '?') { + return event.input === '?' && !event.ctrl && !event.meta; + } + // Ctrl+J has no modifier bits on a legacy terminal: it arrives as a bare line feed. + if (chord.key === 'j' && chord.ctrl && !chord.shift && !chord.meta && event.input === '\n') { + return true; + } + if (event.ctrl !== chord.ctrl || event.shift !== chord.shift || event.meta !== chord.meta) { + return false; + } + if (NAMED_KEYS.has(chord.key)) { + const name = event.name === 'return' ? 'enter' : event.name; + if (name === chord.key) return true; + return chord.key === 'enter' && event.input === '\r'; + } + return event.input.toLowerCase() === chord.key; +} + +function parseChords(specs: readonly string[]): Chord[] { + return specs.map(parseChord).filter((chord): chord is Chord => chord !== null); +} + +export function resolveKeybindings( + profile: KeybindingProfileId = 'autohand', + overrides: KeybindingOverrides = {}, +): ResolvedKeybindings { + const profileBindings = PROFILES[profile].bindings; + const bindings = Object.fromEntries( + KEYBINDING_ACTIONS.map((action) => [ + action, + parseChords(overrides[action] ?? profileBindings[action] ?? DEFAULT_BINDING_SPECS[action]), + ]), + ) as Record; + + return { + profile, + bindings, + matches: (action, event) => bindings[action].some((chord) => matchesChord(chord, event)), + reservedChords: () => new Set(Object.values(bindings).flat().map(normalizeChord)), + helpRows: () => buildHelpRows(bindings), + }; +} + +function describe(action: KeybindingAction, chords: Chord[]): string[] { + return chords.map((chord) => `${formatChord(chord)} ${ACTION_HELP[action]}`); +} + +/** + * Two-column rows for the `?` panel. Fixed entries (slash, mention, shell, + * Enter, Ctrl+C, Esc) sit next to the profile's active chords. + */ +function buildHelpRows(bindings: Record): KeybindingHelpRow[] { + const cells = [ + '/ for commands', + '! for shell commands', + '@ for file paths', + 'tab accepts suggestion', + '$ for skills', + ...describe('cycleMode', bindings.cycleMode), + ...describe('newline', bindings.newline), + 'enter submits prompt', + 'ctrl + c clears input / exits', + ...describe('exit', bindings.exit), + '↑ / ↓ recalls typed messages', + '/whatityped opens history', + ...describe('openHistory', bindings.openHistory), + ...describe('toggleLiveOutput', bindings.toggleLiveOutput.slice(0, 1)), + ...describe('toggleShortcutsHelp', bindings.toggleShortcutsHelp.slice(0, 1)), + 'esc interrupts active turn', + 'type /, @, $, or ! to switch mode', + ]; + const rows: KeybindingHelpRow[] = []; + for (let index = 0; index < cells.length; index += 2) { + rows.push({ left: cells[index] ?? '', right: cells[index + 1] ?? '' }); + } + return rows; +} + +export const DEFAULT_KEYBINDINGS: ResolvedKeybindings = resolveKeybindings('autohand'); diff --git a/src/onboarding/externalAgents.ts b/src/onboarding/externalAgents.ts new file mode 100644 index 00000000..04d3fefe --- /dev/null +++ b/src/onboarding/externalAgents.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fse from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import type { ImportSource } from '../import/types.js'; +import { + KEYBINDING_PROFILE_IDS, + getKeybindingProfile, + type KeybindingProfileId, +} from '../keybindings/profiles.js'; + +/** Another coding agent found on this machine, and what Autohand can take from it. */ +export interface DetectedExternalAgent { + id: string; + label: string; + /** Present when the agent's shortcuts can be followed. */ + profile?: KeybindingProfileId; + /** Present when `/import` knows how to read the agent's data. */ + importSource?: ImportSource; +} + +/** Profiles whose agent is also an import source, keyed by profile id. */ +const PROFILE_IMPORT_SOURCES: Partial> = { + 'claude-code': 'claude', + codex: 'codex', + cursor: 'cursor', +}; + +/** + * Probes the home directory for agents that have a keybinding profile or an + * importer. Only directory existence is checked, so the call is cheap and the + * result never depends on the agent's data. + */ +export async function detectExternalAgents(homeDir: string = os.homedir()): Promise { + const { ImporterRegistry } = await import('../import/registry.js'); + const importers = new ImporterRegistry({}).getAll(); + const importerHomes = await Promise.all( + importers.map(async (importer) => ({ + importer, + installed: await fse.pathExists(importer.homePath.replace(/^~(?=\/|$)/, homeDir)), + })), + ); + const installedImporters = new Map( + importerHomes.filter(({ installed }) => installed).map(({ importer }) => [importer.name, importer]), + ); + + const detected: DetectedExternalAgent[] = []; + const claimedSources = new Set(); + for (const profileId of KEYBINDING_PROFILE_IDS) { + const profile = getKeybindingProfile(profileId); + if (!profile.agentLabel) continue; + const installed = await anyExists(profile.homeDirectories.map((directory) => path.join(homeDir, directory))); + if (!installed) continue; + const importSource = PROFILE_IMPORT_SOURCES[profileId]; + const importer = importSource ? installedImporters.get(importSource) : undefined; + if (importer) claimedSources.add(importer.name); + detected.push({ + id: profileId, + label: profile.agentLabel, + profile: profileId, + ...(importer ? { importSource: importer.name } : {}), + }); + } + + for (const importer of installedImporters.values()) { + if (claimedSources.has(importer.name)) continue; + detected.push({ id: importer.name, label: importer.displayName, importSource: importer.name }); + } + return detected; +} + +/** "Claude Code, Codex and Devin" for prompts and summaries. */ +export function describeDetectedAgents(agents: readonly DetectedExternalAgent[]): string { + const labels = agents.map((agent) => agent.label); + if (labels.length <= 1) return labels.join(''); + return `${labels.slice(0, -1).join(', ')} and ${labels.at(-1)}`; +} + +async function anyExists(paths: string[]): Promise { + const results = await Promise.all(paths.map((candidate) => fse.pathExists(candidate))); + return results.some(Boolean); +} diff --git a/src/onboarding/setupWizard.ts b/src/onboarding/setupWizard.ts index 04f0e046..18525734 100644 --- a/src/onboarding/setupWizard.ts +++ b/src/onboarding/setupWizard.ts @@ -50,6 +50,9 @@ import { installLlamaCpp, probeLlamaCppEnvironment } from '../providers/llamaCpp import { ProjectAnalyzer } from './projectAnalyzer.js'; import { AgentsGenerator } from './agentsGenerator.js'; import { checkWorkspaceSafety, printDangerousWorkspaceWarning } from '../startup/workspaceSafety.js'; +import { describeDetectedAgents, detectExternalAgents, type DetectedExternalAgent } from './externalAgents.js'; +import { getKeybindingProfile, type KeybindingProfileId } from '../keybindings/profiles.js'; +import type { ImportOptions } from '../import/types.js'; import { getAuthClient } from '../auth/index.js'; import { AUTH_CONFIG } from '../constants.js'; import { @@ -74,6 +77,8 @@ export type OnboardingStep = | 'telemetry' | 'autoReport' | 'preferences' + | 'keybindings' + | 'importAgents' | 'advanced' | 'notifications' | 'network' @@ -127,6 +132,8 @@ interface OnboardingState { debug?: boolean; }; communitySkillsEnabled?: boolean; + keybindingProfile?: KeybindingProfileId; + importedAgents?: DetectedExternalAgent[]; agentsFileCreated?: boolean; reasoningEffort?: ReasoningEffort; openAIAuthMode?: OpenAIAuthMode; @@ -151,6 +158,10 @@ export interface OnboardingOptions { force?: boolean; skipWelcome?: boolean; quickSetup?: boolean; + /** Probe for other coding agents; tests inject a fixed list. */ + detectExternalAgents?: () => Promise; + /** Import runner used by the "import from other agents" step; tests inject a fake. */ + runImport?: (options: ImportOptions) => Promise; } /** @@ -298,6 +309,13 @@ export class SetupWizard { this.state.skipped.push('preferences'); } + // Step 11b: Other coding agents on this machine (skip in quickSetup) + if (!options?.quickSetup) { + await this.promptExternalAgents(options); + } else { + this.state.skipped.push('keybindings', 'importAgents'); + } + // Step 12: Advanced settings gate (skip in quickSetup) if (!options?.quickSetup) { const wantsAdvanced = await showConfirm({ @@ -1031,6 +1049,105 @@ export class SetupWizard { this.state.preferences = { theme, autoConfirm, checkForUpdates }; } + /** + * Offer the shortcuts of, and an import from, coding agents already + * installed on this machine. Both steps disappear when nothing is detected. + */ + private async promptExternalAgents(options?: OnboardingOptions): Promise { + const agents = await (options?.detectExternalAgents ?? detectExternalAgents)(); + const withProfiles = agents.filter((agent): agent is DetectedExternalAgent & { profile: KeybindingProfileId } => + agent.profile !== undefined); + const withImporters = agents.filter((agent) => agent.importSource !== undefined); + + if (withProfiles.length > 0) { + await this.promptKeybindings(withProfiles); + } else { + this.state.skipped.push('keybindings'); + } + + if (withImporters.length > 0) { + await this.promptImportAgents(withImporters, options); + } else { + this.state.skipped.push('importAgents'); + } + } + + private async promptKeybindings(agents: Array): Promise { + this.state.currentStep = 'keybindings'; + + console.log(); + console.log(chalk.gray(' ────────────────────────────────────────────────────────')); + console.log(chalk.white.bold(' ' + t('setup.keybindings.title'))); + console.log(chalk.gray(' ────────────────────────────────────────────────────────')); + console.log(); + console.log(chalk.gray(' ' + t('setup.keybindings.description', { agents: describeDetectedAgents(agents) }))); + console.log(); + + const options: ModalOption[] = [ + { + label: t('setup.keybindings.autohandOption'), + value: 'autohand', + description: t('setup.keybindings.autohandDescription'), + }, + ...agents.map((agent) => ({ + label: t('setup.keybindings.agentOption', { agent: agent.label }), + value: agent.profile, + description: t('setup.keybindings.agentDescription'), + })), + ]; + + const result = await showModal({ + title: t('setup.keybindings.prompt'), + options, + initialIndex: 0, + }); + + this.state.keybindingProfile = (result?.value as KeybindingProfileId | undefined) ?? 'autohand'; + } + + private async promptImportAgents(agents: DetectedExternalAgent[], options?: OnboardingOptions): Promise { + this.state.currentStep = 'importAgents'; + const agentList = describeDetectedAgents(agents); + + console.log(); + console.log(chalk.gray(' ────────────────────────────────────────────────────────')); + console.log(chalk.white.bold(' ' + t('setup.importAgents.title'))); + console.log(chalk.gray(' ────────────────────────────────────────────────────────')); + console.log(); + console.log(chalk.gray(' ' + t('setup.importAgents.description'))); + console.log(); + + const accepted = await showConfirm({ + title: t('setup.importAgents.prompt', { agents: agentList }), + defaultValue: true + }); + + if (!accepted) { + this.state.skipped.push('importAgents'); + console.log(chalk.gray(' ' + t('setup.importAgents.skipped'))); + return; + } + + console.log(chalk.gray(' ' + t('setup.importAgents.running', { agents: agentList }))); + const runImport = options?.runImport ?? (async (importOptions: ImportOptions) => { + const { runImport: run } = await import('../import/index.js'); + await run(importOptions); + }); + try { + await runImport({ + all: true, + categories: ['memory', 'sessions', 'skills'], + configPath: this.existingConfig?.configPath, + workspaceRoot: this.workspaceRoot, + }); + this.state.importedAgents = agents; + } catch (error) { + console.log(chalk.yellow(' ' + t('setup.importAgents.failed', { + error: error instanceof Error ? error.message : String(error), + }))); + } + } + /** * Prompt for AGENTS.md creation */ @@ -1358,6 +1475,9 @@ export class SetupWizard { if (this.state.notifications) { uiConfig.notifications = this.state.notifications; } + if (this.state.keybindingProfile) { + uiConfig.keybindingProfile = this.state.keybindingProfile; + } if (Object.keys(uiConfig).length > 0) { config.ui = uiConfig as AutohandConfig['ui']; } @@ -2369,6 +2489,12 @@ export class SetupWizard { if (this.state.mcpEnabled !== undefined) { console.log(chalk.white(` MCP: ${this.state.mcpEnabled ? 'enabled' : 'disabled'}`)); } + if (this.state.keybindingProfile) { + console.log(chalk.white(` Keyboard shortcuts: ${getKeybindingProfile(this.state.keybindingProfile).label}`)); + } + if (this.state.importedAgents?.length) { + console.log(chalk.white(` Imported from: ${describeDetectedAgents(this.state.importedAgents)}`)); + } if (this.state.authUser) { console.log(chalk.white(` Account: ${this.state.authUser.email}`)); } diff --git a/src/providers/AutohandAIProvider.ts b/src/providers/AutohandAIProvider.ts index 379c6401..9415535b 100644 --- a/src/providers/AutohandAIProvider.ts +++ b/src/providers/AutohandAIProvider.ts @@ -70,7 +70,8 @@ export const AUTOHAND_AI_LOCAL_MODELS = [ ...AUTOHAND_AI_LOCAL_CODING_MODEL_FALLBACKS.map((model) => model.id), ]; -function resolveAutohandAICloudModel(model: string | undefined): string { +/** The cloud model the gateway will actually serve for a selection; unknown ids fall back to Fantail. */ +export function resolveAutohandAICloudModel(model: string | undefined): string { return model && AUTOHAND_AI_CLOUD_MODELS.includes(model) ? model : "fantail"; } diff --git a/src/types.ts b/src/types.ts index 4fa25535..2ab63a55 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ */ import type { Ora } from 'ora'; import type { ThemeDefinition } from './ui/theme/types.js'; +import type { KeybindingProfileId } from './keybindings/profiles.js'; import type { TeamActivitySnapshot } from './core/teams/types.js'; // InkRenderer type defined inline to avoid tsx dev mode issues with .tsx imports @@ -330,6 +331,8 @@ export interface UISettings { promptSuggestions?: boolean; /** Enable mouse click-to-position editing in the Ink composer (default: true). */ mouseComposerCursor?: boolean; + /** Shortcut profile for the Ink composer: Autohand defaults or another agent's conventions (default: autohand). */ + keybindingProfile?: KeybindingProfileId; /** Fixed composer status-line display preferences. */ statusLine?: StatusLineSettings; } diff --git a/src/ui/InkUIManager.ts b/src/ui/InkUIManager.ts index e15cf6cb..926b2688 100644 --- a/src/ui/InkUIManager.ts +++ b/src/ui/InkUIManager.ts @@ -12,6 +12,7 @@ import { InkRenderer, type InkRendererOptions } from './ink/InkRenderer.js'; import type { SlashCommand } from '../core/slashCommandTypes.js'; import type { SkillMentionInfo } from './mentionFilter.js'; import type { ExtensionKeybinding } from '../extensions/ExtensionRuntimeHost.js'; +import type { ResolvedKeybindings } from '../keybindings/profiles.js'; import type { AgentUILineExtensions } from './ink/AgentUI.js'; import type { GoalEditRequest } from './ink/GoalPanel.js'; import type { InteractionMode } from '../core/agent/InteractionModeController.js'; @@ -35,6 +36,7 @@ export interface InkUIManagerOptions { getInteractionMode?: () => InteractionMode; onCycleInteractionMode?: () => InteractionMode; mouseComposerCursor?: boolean; + keybindings?: ResolvedKeybindings; taskListPositionProvider?: () => TaskListPosition; onEditGoalObjective?: (request: GoalEditRequest) => void | Promise; onCancelAgentRun?: (id: string) => void | Promise; diff --git a/src/ui/ink/AgentUI.tsx b/src/ui/ink/AgentUI.tsx index 180be500..13d7cbe8 100644 --- a/src/ui/ink/AgentUI.tsx +++ b/src/ui/ink/AgentUI.tsx @@ -38,6 +38,14 @@ import type { InputBorderStyle } from '../box.js'; import { PLAN_BORDER_COLOR, hexToAnsiRgb } from '../box.js'; import { TextBuffer } from '../textBuffer.js'; import { handleTextBufferKey, type KeyHandlerResult } from '../textBufferKeyHandler.js'; +import { + DEFAULT_KEYBINDINGS, + matchesChord, + normalizeChord, + parseChord, + type KeyEvent, + type ResolvedKeybindings, +} from '../../keybindings/profiles.js'; import { getInlineGhostCompletionSuffix, getPrimaryHotTipSuggestion, @@ -250,6 +258,8 @@ export interface AgentUIProps { onCycleInteractionMode?: () => InteractionMode; /** Enable click-to-position composer input. */ mouseComposerCursor?: boolean; + /** Resolved shortcut profile; defaults to Autohand's own chords. */ + keybindings?: ResolvedKeybindings; /** Place the task list above status or directly above the composer. */ taskListPosition?: TaskListPosition; } @@ -262,51 +272,58 @@ interface TextBufferKeyInfo { sequence?: string; } -const RESERVED_EXTENSION_KEYBINDINGS = new Set([ - 'ctrl+c', - 'ctrl+d', - 'ctrl+g', - 'ctrl+x', - 'ctrl+t', - 'meta+g', - 'meta+t', - 'shift+tab', - 'escape', - 'enter', - 'return', -]); +/** Chords no extension may take, whichever profile is active. */ +const FIXED_RESERVED_KEYBINDINGS = ['ctrl+c', 'ctrl+d', 'ctrl+x', 'escape', 'enter', 'return']; + +/** Normalises an Ink keypress into the shape the keybinding matcher reads. */ +export function toInkKeyEvent(input: string, key: InkKey): KeyEvent { + const name = key.return + ? 'return' + : key.tab + ? 'tab' + : key.escape + ? 'escape' + : key.upArrow + ? 'up' + : key.downArrow + ? 'down' + : key.leftArrow + ? 'left' + : key.rightArrow + ? 'right' + : key.backspace + ? 'backspace' + : key.delete + ? 'delete' + : input === ' ' + ? 'space' + : undefined; + return { input, name, ctrl: key.ctrl, shift: key.shift, meta: key.meta }; +} export function isTeamViewShortcut(input: string, key: InkKey): boolean { - return input.toLowerCase() === 't' && (key.meta || key.ctrl); + return DEFAULT_KEYBINDINGS.matches('toggleTeamPanel', toInkKeyEvent(input, key)); } export function isGoalViewShortcut(input: string, key: InkKey): boolean { - return input.toLowerCase() === 'g' && (key.meta || key.ctrl); + return DEFAULT_KEYBINDINGS.matches('toggleGoals', toInkKeyEvent(input, key)); } export function matchesExtensionKeybinding( input: string, key: InkKey, binding: Pick, + reserved: ReadonlySet = DEFAULT_KEYBINDINGS.reservedChords(), ): boolean { - const normalized = binding.key.toLowerCase(); - if (RESERVED_EXTENSION_KEYBINDINGS.has(normalized)) { + const chord = parseChord(binding.key); + if (!chord) { return false; } - const parts = normalized.split('+'); - const primary = parts.at(-1); - const modifiers = new Set(parts.slice(0, -1)); - const expectsMeta = modifiers.has('meta') || modifiers.has('alt'); - if (key.ctrl !== modifiers.has('ctrl') || key.shift !== modifiers.has('shift') || key.meta !== expectsMeta) { + const normalized = normalizeChord(chord); + if (reserved.has(normalized) || FIXED_RESERVED_KEYBINDINGS.includes(normalized)) { return false; } - if (primary === 'tab') return key.tab; - if (primary === 'up') return key.upArrow; - if (primary === 'down') return key.downArrow; - if (primary === 'left') return key.leftArrow; - if (primary === 'right') return key.rightArrow; - if (primary === 'space') return input === ' '; - return input.toLowerCase() === primary; + return matchesChord(chord, toInkKeyEvent(input, key)); } const INK_TEXTBUFFER_VIEWPORT_HEIGHT = 10; @@ -655,9 +672,10 @@ export function clearInkComposerInputForSubmit( export function handleInkTextBufferInput( buffer: TextBuffer, input: string, - key: InkKey + key: InkKey, + keybindings: ResolvedKeybindings = DEFAULT_KEYBINDINGS, ): KeyHandlerResult { - if (isShiftEnterResidualSequence(input)) { + if (isShiftEnterResidualSequence(input) || keybindings.matches('newline', toInkKeyEvent(input, key))) { buffer.insert('\n'); return 'handled'; } @@ -778,6 +796,7 @@ export function AgentUI({ getInteractionMode, onCycleInteractionMode, mouseComposerCursor = false, + keybindings = DEFAULT_KEYBINDINGS, taskListPosition = 'above-composer', }: AgentUIProps) { const { stdout } = useStdout(); @@ -895,6 +914,8 @@ export function AgentUI({ onEscapeRef.current = onEscape; const onCtrlCRef = useRef(onCtrlC); onCtrlCRef.current = onCtrlC; + const keybindingsRef = useRef(keybindings); + keybindingsRef.current = keybindings; const onDismissAnnouncementRef = useRef(onDismissAnnouncement); onDismissAnnouncementRef.current = onDismissAnnouncement; const announcementRef = useRef(state.announcement); @@ -1429,6 +1450,8 @@ export function AgentUI({ // a major source of flicker during rapid keystrokes. const handleInput = useCallback((char: string, key: InkKey) => { syncBufferViewport(); + const keyEvent = toInkKeyEvent(char, key); + const activeKeybindings = keybindingsRef.current; if (mouseComposerCursor) { const mouseInput = parseSgrMouseInput(char); @@ -1537,26 +1560,27 @@ export function AgentUI({ return; } - if (isTeamViewShortcut(char, key)) { + if (activeKeybindings.matches('toggleTeamPanel', keyEvent)) { onToggleTeamPanelRef.current?.(); return; } - if (isGoalViewShortcut(char, key)) { + if (activeKeybindings.matches('toggleGoals', keyEvent)) { onToggleGoalPanelRef.current?.(); return; } + const reservedChords = activeKeybindings.reservedChords(); const extensionKeybinding = extensionKeybindingsRef.current.find((binding) => - matchesExtensionKeybinding(char, key, binding) + matchesExtensionKeybinding(char, key, binding, reservedChords) && (binding.when === 'always' || textBufferRef.current.getText().trim().length === 0)); if (extensionKeybinding) { onInstructionRef.current(extensionKeybinding.command); return; } - // Handle Shift+Tab for interaction mode cycling - if (key.tab && key.shift) { + // Cycle the interaction mode (Shift+Tab in every profile) + if (activeKeybindings.matches('cycleMode', keyEvent)) { const cycleInteractionMode = onCycleInteractionModeRef.current; if (cycleInteractionMode) { setInteractionMode(cycleInteractionMode()); @@ -1654,11 +1678,25 @@ export function AgentUI({ return; } - if (key.ctrl && char === 'o' && liveCommandsRef.current.length > 0) { + if (activeKeybindings.matches('toggleLiveOutput', keyEvent) && liveCommandsRef.current.length > 0) { onToggleLiveCommandExpandedRef.current?.(); return; } + // Profile-only chords: an exit key on an empty composer takes the same path + // as the second Ctrl+C, and a history key opens the typed-message history. + if (activeKeybindings.matches('exit', keyEvent)) { + if (textBufferRef.current.getText().length === 0) { + setImmediate(() => onCtrlCRef.current()); + } + return; + } + + if (activeKeybindings.matches('openHistory', keyEvent)) { + onInstructionRef.current('/whatityped'); + return; + } + // Block input only when working AND queue-input is disabled. // When idle (isWorking=false), always allow input so the user can // compose their next prompt. @@ -1892,8 +1930,8 @@ export function AgentUI({ } } - // ── Toggle shortcut help on '?' when input is empty ── - if (char === '?' && !key.ctrl && !key.meta && !key.shift) { + // ── Toggle shortcut help on the profile's help chord when input is empty ── + if (activeKeybindings.matches('toggleShortcutsHelp', keyEvent)) { const currentText = textBufferRef.current.getText(); if (currentText.trim() === '' || currentText.trim() === '?') { if (currentText.trim() === '?') { @@ -1917,7 +1955,7 @@ export function AgentUI({ const buffer = textBufferRef.current; const textBeforeKey = buffer.getText(); - const result = handleInkTextBufferInput(buffer, char, key); + const result = handleInkTextBufferInput(buffer, char, key, activeKeybindings); if (buffer.getText() !== textBeforeKey) historyNavigationRef.current = null; if (result === 'submit') { @@ -2401,6 +2439,7 @@ export function AgentUI({ nextPromptSuggestion={composerNextPromptSuggestion} inlineGhostSuffix={composerInlineGhostSuffix} mouseComposerCursor={mouseComposerCursor} + keybindings={keybindings} isReadingHistory={isReadingHistory} enableMouseTargetControls={mouseComposerCursor && ( liveCommandItems.length > 0 @@ -3140,6 +3179,7 @@ interface FixedBottomProps { onComposerLayoutChange?: (layout: ComposerOutputLayout | null) => void; /** Whether the shortcuts help panel is visible */ showShortcuts: boolean; + keybindings: ResolvedKeybindings; /** Current mutually-exclusive editing interaction mode, rendered as a colored glyph. */ interactionMode?: InteractionMode; /** Whether to show the mode word (PLAN/YOLO/AUTO) next to the glyph. */ @@ -3247,6 +3287,7 @@ const FixedBottom = memo(function FixedBottom({ enableMouseTargetControls, onComposerLayoutChange, showShortcuts, + keybindings, interactionMode, showModeLabel, modeIndicator, @@ -3312,7 +3353,7 @@ const FixedBottom = memo(function FixedBottom({ - + InteractionMode; onCycleInteractionMode?: () => InteractionMode; mouseComposerCursor?: boolean; + keybindings?: ResolvedKeybindings; taskListPositionProvider?: () => TaskListPosition; onEditGoalObjective?: (request: GoalEditRequest) => void | Promise; onCancelAgentRun?: (id: string) => void | Promise; @@ -217,6 +220,7 @@ interface AgentUIWrapperProps { getInteractionMode?: () => InteractionMode; onCycleInteractionMode?: () => InteractionMode; mouseComposerCursor?: boolean; + keybindings?: ResolvedKeybindings; taskListPositionProvider?: () => TaskListPosition; } @@ -255,6 +259,7 @@ const AgentUIWrapper = forwardRef( getInteractionMode, onCycleInteractionMode, mouseComposerCursor, + keybindings, taskListPositionProvider, } = props; @@ -309,6 +314,7 @@ const AgentUIWrapper = forwardRef( getInteractionMode={getInteractionMode} onCycleInteractionMode={onCycleInteractionMode} mouseComposerCursor={mouseComposerCursor} + keybindings={keybindings} taskListPosition={taskListPositionProvider?.() ?? 'above-composer'} /> ); @@ -454,6 +460,13 @@ export class InkRenderer { // frame updates. Must happen before Ink starts writing to stdout. this.unpatchedStdout = patchStdoutForSyncOutput(); + // Shift+Enter, Alt+Enter and Esc are only distinguishable when the + // terminal encodes modified keys. Ink parses the kitty CSI u form; iTerm2, + // Ghostty, kitty and WezTerm honour the request, others ignore it. + if (process.stdout.isTTY) { + enableKittyProtocol(process.stdout, KITTY_DISAMBIGUATE_FLAG); + } + // Install our resize guard BEFORE Ink registers its own handler. // Node.js event listeners fire in registration order. this.resizeHandler = this.onResize; @@ -494,6 +507,7 @@ export class InkRenderer { getInteractionMode={this.options.getInteractionMode} onCycleInteractionMode={this.options.onCycleInteractionMode} mouseComposerCursor={this.options.mouseComposerCursor} + keybindings={this.options.keybindings} taskListPositionProvider={this.options.taskListPositionProvider} /> @@ -513,6 +527,9 @@ export class InkRenderer { * Stop the Ink renderer and cleanup */ stop(): void { + if (this.instance && process.stdout.isTTY) { + disableKittyProtocol(process.stdout); + } if (this.instance) { const instance = this.instance; try { @@ -1374,6 +1391,7 @@ export class InkRenderer { getInteractionMode={this.options.getInteractionMode} onCycleInteractionMode={this.options.onCycleInteractionMode} mouseComposerCursor={this.options.mouseComposerCursor} + keybindings={this.options.keybindings} taskListPositionProvider={this.options.taskListPositionProvider} /> diff --git a/src/ui/ink/ShortcutsHelpPanel.tsx b/src/ui/ink/ShortcutsHelpPanel.tsx index 80639a8c..1840fd50 100644 --- a/src/ui/ink/ShortcutsHelpPanel.tsx +++ b/src/ui/ink/ShortcutsHelpPanel.tsx @@ -7,23 +7,17 @@ import React, { memo } from 'react'; import { Box, Text } from 'ink'; import { useTheme } from '../theme/ThemeContext.js'; +import { DEFAULT_KEYBINDINGS, type ResolvedKeybindings } from '../../keybindings/profiles.js'; export interface ShortcutsHelpPanelProps { visible: boolean; + /** Active shortcut profile; the rows follow whatever it binds. */ + keybindings?: ResolvedKeybindings; } -const SHORTCUT_ROWS: Array<{ left: string; right: string }> = [ - { left: '/ for commands', right: '! for shell commands' }, - { left: '@ for file paths', right: 'tab accepts suggestion' }, - { left: '$ for skills', right: 'shift + tab cycles interaction modes' }, - { left: 'shift + enter inserts newline', right: 'alt + enter inserts newline' }, - { left: 'enter submits prompt', right: 'ctrl + c clears input / exits' }, - { left: '↑ / ↓ recalls typed messages', right: '/whatityped opens history' }, - { left: 'esc interrupts active turn', right: 'type /, @, $, or ! to switch mode' }, -]; - export const ShortcutsHelpPanel = memo(function ShortcutsHelpPanel({ visible, + keybindings = DEFAULT_KEYBINDINGS, }: ShortcutsHelpPanelProps) { const { colors } = useTheme(); @@ -34,7 +28,7 @@ export const ShortcutsHelpPanel = memo(function ShortcutsHelpPanel({ return ( {' ? shortcuts'} - {SHORTCUT_ROWS.map((row, i) => ( + {keybindings.helpRows().map((row, i) => ( {` ${row.left}`} {row.right} diff --git a/src/ui/inputPrompt.ts b/src/ui/inputPrompt.ts index 302319d9..0c1bb569 100644 --- a/src/ui/inputPrompt.ts +++ b/src/ui/inputPrompt.ts @@ -39,6 +39,7 @@ import { import { buildFileMentionSuggestions, buildSkillMentionSuggestions, type SkillMentionInfo } from './mentionFilter.js'; import { themedFg } from './theme/index.js'; import { stripAnsiCodes, enableBracketedPaste, disableBracketedPaste } from './displayUtils.js'; +import { DEFAULT_KEYBINDINGS } from '../keybindings/profiles.js'; import { TextBuffer } from './textBuffer.js'; import { handleTextBufferKey } from './textBufferKeyHandler.js'; import { calculateLayout, logicalToVisual, visualToLogical } from './textBufferLayout.js'; @@ -293,16 +294,6 @@ export function resetCachedSkillMentions(): void { cachedSkillMentions = undefined; } -const CONTEXTUAL_HELP_ROWS: Array<{ left: string; right: string }> = [ - { left: '/ for commands', right: '! for shell commands' }, - { left: '@ for file paths', right: 'tab accepts suggestion' }, - { left: '$ for skills', right: 'tab accepts suggestion' }, - { left: '? toggles this shortcuts panel', right: 'shift + tab cycles interaction modes' }, - { left: 'shift + enter inserts newline', right: 'alt + enter inserts newline' }, - { left: 'enter submits prompt', right: 'ctrl + c clears input / exits' }, - { left: 'esc interrupts active turn', right: 'type /, @, $, or ! to switch mode' }, -]; - function truncatePlainText(value: string, width: number): string { if (width <= 0) { return ''; @@ -579,7 +570,7 @@ export function buildContextualHelpPanelLines( return truncatePlainText(plain, cellWidth).padEnd(cellWidth, ' '); }; - const rowLines = CONTEXTUAL_HELP_ROWS.map((row) => { + const rowLines = DEFAULT_KEYBINDINGS.helpRows().map((row) => { const left = formatCell(row.left, leftWidth); const right = formatCell(row.right, rightWidth); return `${left}${' '.repeat(gap)}${right}`; diff --git a/src/ui/kittyProtocol.ts b/src/ui/kittyProtocol.ts index 50b1eedd..6bb3adda 100644 --- a/src/ui/kittyProtocol.ts +++ b/src/ui/kittyProtocol.ts @@ -62,6 +62,13 @@ export function queryKittyProtocol(stdout: NodeJS.WriteStream): void { stdout.write('\x1b[?u'); } +/** + * Flag 1 only: modified keys that have no legacy encoding (Shift+Enter, + * Alt+key, Esc) arrive as CSI u while plain keys and Ctrl+letters keep their + * legacy bytes. Terminals without the protocol ignore the request. + */ +export const KITTY_DISAMBIGUATE_FLAG = 1; + /** * Enable Kitty keyboard protocol with specified flags. * diff --git a/tests/commands/settings.test.ts b/tests/commands/settings.test.ts index 6fcab663..d5132ed5 100644 --- a/tests/commands/settings.test.ts +++ b/tests/commands/settings.test.ts @@ -100,6 +100,14 @@ describe('SETTINGS_REGISTRY', () => { } }); + it('offers the keybinding profile as an enum over every known profile', () => { + const setting = SETTINGS_REGISTRY.find(s => s.key === 'ui.keybindingProfile'); + expect(setting).toMatchObject({ category: 'ui', type: 'enum', defaultValue: 'autohand' }); + expect(setting?.enumValues).toEqual([ + 'autohand', 'claude-code', 'codex', 'cursor', 'antigravity', 'devin', 'factory', + ]); + }); + it('redirect settings have redirect field', () => { const redirects = SETTINGS_REGISTRY.filter(s => s.redirect); expect(redirects.length).toBeGreaterThan(0); diff --git a/tests/config.test.ts b/tests/config.test.ts index f242491e..184dabbe 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -120,6 +120,23 @@ describe('getProviderConfig', () => { } }); + it('accepts a known keybinding profile and rejects unknown ones', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'autohand-config-')); + const configPath = path.join(tempDir, 'config.json'); + + try { + await fs.writeJson(configPath, { provider: 'openrouter', ui: { keybindingProfile: 'codex' } }); + expect((await loadConfig(configPath)).ui?.keybindingProfile).toBe('codex'); + + await fs.writeJson(configPath, { provider: 'openrouter', ui: { keybindingProfile: 'emacs' } }); + await expect(loadConfig(configPath)).rejects.toThrow( + 'ui.keybindingProfile must be one of autohand, claude-code, codex, cursor, antigravity, devin, factory', + ); + } finally { + await fs.remove(tempDir); + } + }); + it('repairs a saved website deployment URL used as the control-plane API', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'autohand-config-')); const configPath = path.join(tempDir, 'config.json'); diff --git a/tests/core/agent/AutohandAIModelTierPolicy.test.ts b/tests/core/agent/AutohandAIModelTierPolicy.test.ts index 4040a6d2..27fee688 100644 --- a/tests/core/agent/AutohandAIModelTierPolicy.test.ts +++ b/tests/core/agent/AutohandAIModelTierPolicy.test.ts @@ -190,4 +190,66 @@ describe('resolveAutohandAIModelForTier', () => { expect(resolveAutohandAIModelForTier({ provider: 'openrouter', plan: 'cloud', model: 'moa', tier: 'free' })).toBe('moa'); expect(resolveAutohandAIModelForTier({ provider: 'autohandai', plan: 'cloud', model: 'moa', tier: undefined })).toBe('moa'); }); + + it('maps a model the Autohand cloud gateway does not serve to fantail on every tier', async () => { + const { resolveAutohandAIModelForTier } = await import('../../../src/core/agent/AutohandAIModelTierPolicy.js'); + + // Issue #584: a model left over from another provider was sent to the gateway + // and rejected with "Use model `auto`, `fantail`, or `moa`". + for (const tier of ['free', 'pro', 'max', undefined]) { + expect(resolveAutohandAIModelForTier({ provider: 'autohandai', plan: 'cloud', model: 'anthropic/claude-5-sonnet', tier })).toBe('fantail'); + } + expect(resolveAutohandAIModelForTier({ provider: 'autohandai', plan: 'cloud', model: 'autohandai/moa', tier: 'pro' })).toBe('moa'); + expect(resolveAutohandAIModelForTier({ provider: 'autohandai', plan: 'cloud', model: 'auto', tier: 'free' })).toBe('auto'); + expect(resolveAutohandAIModelForTier({ provider: 'autohandai', plan: 'cloud', model: undefined, tier: 'pro' })).toBe('fantail'); + // Local plans and other providers keep whatever the user chose. + expect(resolveAutohandAIModelForTier({ provider: 'autohandai', plan: 'local', model: 'anthropic/claude-5-sonnet', tier: 'pro' })).toBe('anthropic/claude-5-sonnet'); + expect(resolveAutohandAIModelForTier({ provider: 'openrouter', plan: undefined, model: 'anthropic/claude-5-sonnet', tier: 'pro' })).toBe('anthropic/claude-5-sonnet'); + }); +}); + +describe('applyAutohandAIModelTierPolicy with a foreign model', () => { + it('switches a model the gateway does not serve to fantail regardless of tier', async () => { + const { applyAutohandAIModelTierPolicy } = await import('../../../src/core/agent/AutohandAIModelTierPolicy.js'); + + const result = applyAutohandAIModelTierPolicy( + cloudMoaConfig({ autohandai: { plan: 'cloud', authMode: 'account', accountToken: 'ahc_token', model: 'anthropic/claude-5-sonnet' } }), + 'pro', + ); + + expect(result.switched).toBe(true); + expect(result.previousModel).toBe('anthropic/claude-5-sonnet'); + expect(result.resolvedModel).toBe('fantail'); + expect(result.config.autohandai?.model).toBe('fantail'); + }); +}); + +describe('normalizeAutohandAIStartupModel', () => { + it('rewrites a foreign cloud model in place before any entitlement is known', async () => { + const { normalizeAutohandAIStartupModel } = await import('../../../src/core/agent/AutohandAIModelTierPolicy.js'); + const config = cloudMoaConfig({ + autohandai: { plan: 'cloud', authMode: 'account', accountToken: 'ahc_token', model: 'anthropic/claude-5-sonnet' }, + }); + + expect(normalizeAutohandAIStartupModel(config)).toBe('fantail'); + expect(config.autohandai?.model).toBe('fantail'); + }); + + it('leaves moa alone at startup because the tier is not known yet', async () => { + const { normalizeAutohandAIStartupModel } = await import('../../../src/core/agent/AutohandAIModelTierPolicy.js'); + const config = cloudMoaConfig(); + + expect(normalizeAutohandAIStartupModel(config)).toBeUndefined(); + expect(config.autohandai?.model).toBe('moa'); + }); + + it('ignores other providers and local plans', async () => { + const { normalizeAutohandAIStartupModel } = await import('../../../src/core/agent/AutohandAIModelTierPolicy.js'); + const openrouter = baseConfig({ provider: 'openrouter', openrouter: { apiKey: 'k', model: 'anthropic/claude-5-sonnet' } }); + const local = cloudMoaConfig({ autohandai: { plan: 'local', model: 'anthropic/claude-5-sonnet' } }); + + expect(normalizeAutohandAIStartupModel(openrouter)).toBeUndefined(); + expect(normalizeAutohandAIStartupModel(local)).toBeUndefined(); + expect(local.autohandai?.model).toBe('anthropic/claude-5-sonnet'); + }); }); diff --git a/tests/keybindings/externalKeybindings.test.ts b/tests/keybindings/externalKeybindings.test.ts new file mode 100644 index 00000000..43582146 --- /dev/null +++ b/tests/keybindings/externalKeybindings.test.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { loadExternalKeybindingOverrides } from '../../src/keybindings/externalKeybindings.js'; +import { formatChord, resolveKeybindings } from '../../src/keybindings/profiles.js'; + +const homes: string[] = []; + +async function createHome(): Promise { + const home = await mkdtemp(path.join(os.tmpdir(), 'autohand-keybindings-home-')); + homes.push(home); + return home; +} + +async function writeHomeFile(home: string, relativePath: string, content: string): Promise { + const target = path.join(home, relativePath); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content, 'utf8'); +} + +afterEach(async () => { + await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true }))); +}); + +describe('loadExternalKeybindingOverrides', () => { + it('returns no overrides when the agent has no keybinding file', async () => { + const home = await createHome(); + expect(loadExternalKeybindingOverrides('claude-code', home)).toEqual({}); + expect(loadExternalKeybindingOverrides('codex', home)).toEqual({}); + }); + + it('returns no overrides for profiles whose agent has no readable keymap', async () => { + const home = await createHome(); + await writeHomeFile(home, '.cursor/keybindings.json', '{"bindings":[]}'); + expect(loadExternalKeybindingOverrides('cursor', home)).toEqual({}); + expect(loadExternalKeybindingOverrides('autohand', home)).toEqual({}); + }); + + it('adds Claude Code remaps to the profile chords and honours unbinding', async () => { + const home = await createHome(); + await writeHomeFile(home, '.claude/keybindings.json', JSON.stringify({ + bindings: [ + { context: 'Chat', bindings: { 'ctrl+n': 'chat:newline', 'ctrl+j': null, 'ctrl+x ctrl+e': 'chat:externalEditor' } }, + { context: 'Global', bindings: { 'ctrl+q': 'app:exit', 'ctrl+d': null, 'ctrl+y': 'app:unknownAction' } }, + { context: 'Transcript', bindings: { 'ctrl+n': 'transcript:toggleShowAll' } }, + ], + })); + + const overrides = loadExternalKeybindingOverrides('claude-code', home); + const resolved = resolveKeybindings('claude-code', overrides); + + expect(resolved.bindings.newline.map(formatChord)).toEqual(['shift + enter', 'alt + enter', 'ctrl + n']); + expect(resolved.bindings.exit.map(formatChord)).toEqual(['ctrl + q']); + expect(resolved.bindings.openHistory.map(formatChord)).toEqual(['ctrl + r']); + expect(overrides).not.toHaveProperty('toggleLiveOutput'); + }); + + it('maps every documented Claude Code action Autohand supports', async () => { + const home = await createHome(); + await writeHomeFile(home, '.claude/keybindings.json', JSON.stringify({ + bindings: [{ + context: 'Chat', + bindings: { + 'ctrl+shift+m': 'chat:cycleMode', + 'ctrl+shift+o': 'app:toggleTranscript', + 'ctrl+shift+t': 'app:toggleTodos', + 'ctrl+shift+h': 'history:search', + }, + }], + })); + + const resolved = resolveKeybindings('claude-code', loadExternalKeybindingOverrides('claude-code', home)); + + expect(resolved.bindings.cycleMode.map(formatChord)).toEqual(['shift + tab', 'ctrl + shift + m']); + expect(resolved.bindings.toggleLiveOutput.map(formatChord)).toEqual(['ctrl + o', 'ctrl + shift + o']); + expect(resolved.bindings.toggleGoals.map(formatChord)).toEqual(['ctrl + g', 'alt + g', 'ctrl + shift + t']); + expect(resolved.bindings.openHistory.map(formatChord)).toEqual(['ctrl + r', 'ctrl + shift + h']); + }); + + it('reads Codex keymaps from config.toml with dash-separated keys', async () => { + const home = await createHome(); + await writeHomeFile(home, '.codex/config.toml', [ + 'model = "gpt-5"', + '', + '[tui.keymap.composer]', + 'insert_newline = ["ctrl-j", "alt-enter"]', + 'history_search = "ctrl-h"', + '', + '[tui.keymap.global]', + 'exit = []', + ].join('\n')); + + const resolved = resolveKeybindings('codex', loadExternalKeybindingOverrides('codex', home)); + + expect(resolved.bindings.newline.map(formatChord)).toEqual(['ctrl + j', 'alt + enter']); + expect(resolved.bindings.openHistory.map(formatChord)).toEqual(['ctrl + h']); + expect(resolved.bindings.exit).toEqual([]); + }); + + it('ignores malformed files instead of throwing', async () => { + const home = await createHome(); + await writeHomeFile(home, '.claude/keybindings.json', '{"bindings": ['); + await writeHomeFile(home, '.codex/config.toml', '[tui.keymap.composer\ninsert_newline = "ctrl-j"'); + + expect(loadExternalKeybindingOverrides('claude-code', home)).toEqual({}); + expect(loadExternalKeybindingOverrides('codex', home)).toEqual({}); + }); + + it('ignores Claude Code files whose bindings are not the documented shape', async () => { + const home = await createHome(); + await writeHomeFile(home, '.claude/keybindings.json', JSON.stringify({ bindings: { 'ctrl+n': 'chat:newline' } })); + expect(loadExternalKeybindingOverrides('claude-code', home)).toEqual({}); + }); +}); diff --git a/tests/keybindings/profiles.test.ts b/tests/keybindings/profiles.test.ts new file mode 100644 index 00000000..5bdc836b --- /dev/null +++ b/tests/keybindings/profiles.test.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_KEYBINDINGS, + KEYBINDING_ACTIONS, + KEYBINDING_PROFILE_IDS, + formatChord, + getKeybindingProfile, + matchesChord, + parseChord, + resolveKeybindings, + type KeyEvent, +} from '../../src/keybindings/profiles.js'; + +const inkEvent = (input: string, key: Partial = {}): KeyEvent => ({ + input, + ctrl: false, + shift: false, + meta: false, + ...key, +}); + +describe('parseChord', () => { + it('accepts plus and dash separators and modifier aliases', () => { + expect(parseChord('ctrl+j')).toEqual({ key: 'j', ctrl: true, shift: false, meta: false }); + expect(parseChord('ctrl-j')).toEqual({ key: 'j', ctrl: true, shift: false, meta: false }); + expect(parseChord('Alt+Enter')).toEqual({ key: 'enter', ctrl: false, shift: false, meta: true }); + expect(parseChord('opt+return')).toEqual({ key: 'enter', ctrl: false, shift: false, meta: true }); + expect(parseChord('meta+t')).toEqual({ key: 't', ctrl: false, shift: false, meta: true }); + expect(parseChord('shift+tab')).toEqual({ key: 'tab', ctrl: false, shift: true, meta: false }); + expect(parseChord('esc')).toEqual({ key: 'escape', ctrl: false, shift: false, meta: false }); + expect(parseChord('?')).toEqual({ key: '?', ctrl: false, shift: false, meta: false }); + }); + + it('rejects multi-keystroke chords, unknown modifiers, and empty specs', () => { + expect(parseChord('ctrl+x ctrl+e')).toBeNull(); + expect(parseChord('hyper+j')).toBeNull(); + expect(parseChord('')).toBeNull(); + expect(parseChord('ctrl+')).toBeNull(); + }); + + it('formats chords the way the help panel shows them', () => { + expect(formatChord(parseChord('ctrl+j')!)).toBe('ctrl + j'); + expect(formatChord(parseChord('shift+enter')!)).toBe('shift + enter'); + expect(formatChord(parseChord('alt+enter')!)).toBe('alt + enter'); + expect(formatChord(parseChord('?')!)).toBe('?'); + }); +}); + +describe('matchesChord', () => { + it('matches Ink-shaped events by modifiers and lower-cased input', () => { + const chord = parseChord('ctrl+j')!; + expect(matchesChord(chord, inkEvent('j', { ctrl: true }))).toBe(true); + expect(matchesChord(chord, inkEvent('J', { ctrl: true }))).toBe(true); + expect(matchesChord(chord, inkEvent('j'))).toBe(false); + expect(matchesChord(chord, inkEvent('j', { ctrl: true, shift: true }))).toBe(false); + }); + + it('matches the raw line feed that a terminal sends for Ctrl+J', () => { + expect(matchesChord(parseChord('ctrl+j')!, inkEvent('\n'))).toBe(true); + expect(matchesChord(parseChord('ctrl+j')!, inkEvent('\r', { name: 'return' }))).toBe(false); + }); + + it('matches named keys through the event name', () => { + expect(matchesChord(parseChord('shift+tab')!, inkEvent('', { name: 'tab', shift: true }))).toBe(true); + expect(matchesChord(parseChord('shift+tab')!, inkEvent('', { name: 'tab' }))).toBe(false); + expect(matchesChord(parseChord('shift+enter')!, inkEvent('\r', { name: 'return', shift: true }))).toBe(true); + expect(matchesChord(parseChord('alt+enter')!, inkEvent('\r', { name: 'return', meta: true }))).toBe(true); + expect(matchesChord(parseChord('escape')!, inkEvent('', { name: 'escape' }))).toBe(true); + expect(matchesChord(parseChord('ctrl+d')!, inkEvent('d', { ctrl: true }))).toBe(true); + }); + + it('treats ? as a plain key that needs no modifiers', () => { + expect(matchesChord(parseChord('?')!, inkEvent('?'))).toBe(true); + expect(matchesChord(parseChord('?')!, inkEvent('?', { shift: true }))).toBe(true); + expect(matchesChord(parseChord('?')!, inkEvent('?', { ctrl: true }))).toBe(false); + }); +}); + +describe('keybinding profiles', () => { + it('resolves every profile to a full binding table', () => { + for (const id of KEYBINDING_PROFILE_IDS) { + const resolved = resolveKeybindings(id); + expect(resolved.profile).toBe(id); + for (const action of KEYBINDING_ACTIONS) { + expect(Array.isArray(resolved.bindings[action])).toBe(true); + } + expect(resolved.bindings.cycleMode.map(formatChord)).toContain('shift + tab'); + expect(getKeybindingProfile(id).homeDirectories.length).toBeGreaterThan(0); + } + }); + + it('keeps Autohand defaults without exit or history chords', () => { + expect(DEFAULT_KEYBINDINGS.profile).toBe('autohand'); + expect(DEFAULT_KEYBINDINGS.bindings.exit).toEqual([]); + expect(DEFAULT_KEYBINDINGS.bindings.openHistory).toEqual([]); + expect(DEFAULT_KEYBINDINGS.bindings.newline.map(formatChord)).toEqual(['shift + enter', 'alt + enter']); + expect(DEFAULT_KEYBINDINGS.matches('exit', inkEvent('d', { ctrl: true }))).toBe(false); + }); + + it('gives the Codex profile Ctrl+J, Ctrl+D and Ctrl+R on top of the defaults', () => { + const codex = resolveKeybindings('codex'); + expect(codex.bindings.newline.map(formatChord)).toEqual(['shift + enter', 'alt + enter', 'ctrl + j']); + expect(codex.bindings.exit.map(formatChord)).toEqual(['ctrl + d']); + expect(codex.bindings.openHistory.map(formatChord)).toEqual(['ctrl + r']); + expect(codex.matches('newline', inkEvent('\n'))).toBe(true); + expect(codex.matches('exit', inkEvent('d', { ctrl: true }))).toBe(true); + expect(codex.matches('openHistory', inkEvent('r', { ctrl: true }))).toBe(true); + }); + + it('keeps Factory on Ctrl+C twice because Droid has no Ctrl+D exit', () => { + expect(resolveKeybindings('factory').bindings.exit).toEqual([]); + }); + + it('applies overrides by replacing a profile action and unbinding with an empty list', () => { + const resolved = resolveKeybindings('claude-code', { newline: ['ctrl+n'], exit: [] }); + expect(resolved.bindings.newline.map(formatChord)).toEqual(['ctrl + n']); + expect(resolved.bindings.exit).toEqual([]); + expect(resolved.bindings.openHistory.map(formatChord)).toEqual(['ctrl + r']); + }); + + it('drops override chords that cannot be parsed instead of failing', () => { + const resolved = resolveKeybindings('codex', { newline: ['ctrl+x ctrl+e', 'ctrl+n'] }); + expect(resolved.bindings.newline.map(formatChord)).toEqual(['ctrl + n']); + }); + + it('reserves the active chords so extensions cannot shadow them', () => { + expect(resolveKeybindings('codex').reservedChords().has('ctrl+d')).toBe(true); + expect(DEFAULT_KEYBINDINGS.reservedChords().has('ctrl+d')).toBe(false); + expect(DEFAULT_KEYBINDINGS.reservedChords().has('shift+tab')).toBe(true); + expect(DEFAULT_KEYBINDINGS.reservedChords().has('ctrl+t')).toBe(true); + expect(DEFAULT_KEYBINDINGS.reservedChords().has('meta+g')).toBe(true); + }); + + it('describes the active chords for the shortcuts panel', () => { + const rows = resolveKeybindings('codex').helpRows(); + const cells = rows.flatMap((row) => [row.left, row.right]); + expect(cells).toContain('shift + tab cycles interaction modes'); + expect(cells).toContain('ctrl + j inserts newline'); + expect(cells).toContain('ctrl + d exits'); + expect(cells).toContain('ctrl + r opens history'); + const defaults = DEFAULT_KEYBINDINGS.helpRows().flatMap((row) => [row.left, row.right]); + expect(defaults).toContain('shift + enter inserts newline'); + expect(defaults).not.toContain('ctrl + d exits'); + expect(defaults).toContain('esc interrupts active turn'); + }); +}); diff --git a/tests/onboarding/externalAgents.test.ts b/tests/onboarding/externalAgents.test.ts new file mode 100644 index 00000000..5ffbe282 --- /dev/null +++ b/tests/onboarding/externalAgents.test.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { detectExternalAgents, describeDetectedAgents } from '../../src/onboarding/externalAgents.js'; + +const homes: string[] = []; + +async function createHome(...directories: string[]): Promise { + const home = await mkdtemp(path.join(os.tmpdir(), 'autohand-external-agents-')); + homes.push(home); + for (const directory of directories) { + await mkdir(path.join(home, directory), { recursive: true }); + } + return home; +} + +afterEach(async () => { + await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true }))); +}); + +describe('detectExternalAgents', () => { + it('returns nothing for a home directory without other agents', async () => { + const home = await createHome(); + expect(await detectExternalAgents(home)).toEqual([]); + }); + + it('reports installed agents with their keybinding profile and import source', async () => { + const home = await createHome('.codex', '.config/devin', '.gemini', '.autohand'); + + const detected = await detectExternalAgents(home); + + expect(detected).toEqual([ + { id: 'codex', label: 'Codex', profile: 'codex', importSource: 'codex' }, + { id: 'devin', label: 'Devin', profile: 'devin' }, + { id: 'gemini', label: 'Google Gemini', importSource: 'gemini' }, + ]); + }); + + it('merges Claude Code under one entry for its profile and importer', async () => { + const home = await createHome('.claude'); + + expect(await detectExternalAgents(home)).toEqual([ + { id: 'claude-code', label: 'Claude Code', profile: 'claude-code', importSource: 'claude' }, + ]); + }); + + it('describes detected agents as a readable list', async () => { + const home = await createHome('.claude', '.codex', '.factory'); + expect(describeDetectedAgents(await detectExternalAgents(home))).toBe('Claude Code, Codex and Factory Droid'); + expect(describeDetectedAgents([])).toBe(''); + }); +}); diff --git a/tests/onboarding/setupWizard.test.ts b/tests/onboarding/setupWizard.test.ts index eeba23a0..cccc54bd 100644 --- a/tests/onboarding/setupWizard.test.ts +++ b/tests/onboarding/setupWizard.test.ts @@ -1685,4 +1685,137 @@ describe("SetupWizard", () => { expect(result.config.ui?.locale).toBe("en"); }); }); + + describe("other agents on this machine", () => { + const detectedAgents = [ + { id: "claude-code", label: "Claude Code", profile: "claude-code" as const, importSource: "claude" as const }, + { id: "devin", label: "Devin", profile: "devin" as const }, + ]; + + /** + * Chain for the cloud flow once agents are detected. The keybindings modal + * comes after the permissions modal; the import confirm sits between the + * preferences and advanced confirms. + */ + function setupDetectedAgentMocks(options: { profile: string; importAgents: boolean }) { + mockShowModal + .mockResolvedValueOnce({ value: "en" }) // language + .mockResolvedValueOnce({ value: "openrouter" }) // provider + .mockResolvedValueOnce({ value: "interactive" }) // permissions + .mockResolvedValueOnce({ value: options.profile }); // keyboard shortcuts + mockShowPassword.mockResolvedValueOnce("sk-test-key-long-enough"); + mockShowInput.mockResolvedValueOnce("your-modelcard-id-here"); + mockShowConfirm + .mockResolvedValueOnce(true) // remember session + .mockResolvedValueOnce(true) // telemetry + .mockResolvedValueOnce(true) // autoReport + .mockResolvedValueOnce(false) // preferences (skip) + .mockResolvedValueOnce(options.importAgents) // import from other agents + .mockResolvedValueOnce(false) // advanced (skip) + .mockResolvedValueOnce(false) // agents (skip) + .mockResolvedValueOnce(false) // registration (skip) + .mockResolvedValueOnce(true); // review confirm + } + + it("keeps every existing prompt in place when no other agent is installed", async () => { + const wizard = new SetupWizard(testWorkspace); + setupCloudProviderMocks("openrouter", "sk-test-key-long-enough", "your-modelcard-id-here"); + + const result = await wizard.run({ + skipWelcome: true, + detectExternalAgents: async () => [], + }); + + expect(result.success).toBe(true); + expect(mockShowModal).toHaveBeenCalledTimes(3); + expect(result.skippedSteps).toEqual(expect.arrayContaining(["keybindings", "importAgents"])); + expect(result.config.ui?.keybindingProfile).toBeUndefined(); + }); + + it("offers the detected agents' shortcuts and stores the chosen profile", async () => { + const wizard = new SetupWizard(testWorkspace); + const runImport = vi.fn().mockResolvedValue(undefined); + setupDetectedAgentMocks({ profile: "claude-code", importAgents: false }); + + const result = await wizard.run({ + skipWelcome: true, + detectExternalAgents: async () => detectedAgents, + runImport, + }); + + expect(result.success).toBe(true); + const keybindingsCall = mockShowModal.mock.calls[3]?.[0] as { options: Array<{ value: string }> }; + expect(keybindingsCall.options.map((option) => option.value)).toEqual(["autohand", "claude-code", "devin"]); + expect(result.config.ui?.keybindingProfile).toBe("claude-code"); + expect(runImport).not.toHaveBeenCalled(); + expect(result.skippedSteps).toContain("importAgents"); + }); + + it("imports memories, sessions and skills through the shared import path when accepted", async () => { + const wizard = new SetupWizard(testWorkspace, { + configPath: testConfigPath, + provider: "openrouter", + } as LoadedConfig); + const runImport = vi.fn().mockResolvedValue(undefined); + setupDetectedAgentMocks({ profile: "autohand", importAgents: true }); + + const result = await wizard.run({ + skipWelcome: true, + detectExternalAgents: async () => detectedAgents, + runImport, + }); + + expect(result.success).toBe(true); + expect(result.config.ui?.keybindingProfile).toBe("autohand"); + expect(runImport).toHaveBeenCalledWith({ + all: true, + categories: ["memory", "sessions", "skills"], + configPath: testConfigPath, + workspaceRoot: testWorkspace, + }); + expect(result.skippedSteps).not.toContain("importAgents"); + }); + + it("finishes setup even when the import fails", async () => { + const wizard = new SetupWizard(testWorkspace); + const runImport = vi.fn().mockRejectedValue(new Error("network down")); + setupDetectedAgentMocks({ profile: "devin", importAgents: true }); + + const result = await wizard.run({ + skipWelcome: true, + detectExternalAgents: async () => detectedAgents, + runImport, + }); + + expect(result.success).toBe(true); + expect(result.config.ui?.keybindingProfile).toBe("devin"); + expect(runImport).toHaveBeenCalledTimes(1); + }); + + it("skips both steps in quick setup", async () => { + const wizard = new SetupWizard(testWorkspace); + const detectExternalAgents = vi.fn().mockResolvedValue(detectedAgents); + mockShowModal + .mockResolvedValueOnce({ value: "en" }) // language + .mockResolvedValueOnce({ value: "openrouter" }) // provider + .mockResolvedValueOnce({ value: "interactive" }); // permissions + mockShowPassword.mockResolvedValueOnce("sk-test-key-long-enough"); + mockShowInput.mockResolvedValueOnce("your-modelcard-id-here"); + mockShowConfirm + .mockResolvedValueOnce(true) // remember session + .mockResolvedValueOnce(true) // telemetry + .mockResolvedValueOnce(true) // autoReport + .mockResolvedValueOnce(false); // agents (skip) + + const result = await wizard.run({ + skipWelcome: true, + quickSetup: true, + detectExternalAgents, + }); + + expect(result.success).toBe(true); + expect(detectExternalAgents).not.toHaveBeenCalled(); + expect(result.skippedSteps).toEqual(expect.arrayContaining(["keybindings", "importAgents"])); + }); + }); }); diff --git a/tests/tuistory/built-cli.tuistory.test.ts b/tests/tuistory/built-cli.tuistory.test.ts index 920814d2..09343acd 100644 --- a/tests/tuistory/built-cli.tuistory.test.ts +++ b/tests/tuistory/built-cli.tuistory.test.ts @@ -965,6 +965,38 @@ describe('interactive built CLI Tuistory tests', () => { expect(Date.now() - exitRequestedAt).toBeLessThan(6_000); }); + it('follows the Codex shortcut profile: Ctrl+J inserts a newline, ? lists it, Ctrl+D exits', async () => { + const session = await launchInteractive({ + config: { ui: { keybindingProfile: 'codex', promptSuggestions: false } }, + }); + await waitForComposer(session); + + await session.type('first'); + session.writeRaw('\n'); + await session.type('second'); + const multilineScreen = await session.text({ + timeout: 20_000, + waitFor: (text) => text.includes('first') && text.includes('second'), + trimEnd: true, + }); + expect(multilineScreen).toMatch(/❯ first\n\s*second/); + + await clearComposerInput(session); + await session.type('?'); + const helpScreen = await session.text({ + timeout: 10_000, + waitFor: (text) => text.includes('? shortcuts'), + trimEnd: true, + }); + expect(helpScreen).toContain('ctrl + j inserts newline'); + expect(helpScreen).toContain('ctrl + d exits'); + await session.press('escape'); + + await session.press(['ctrl', 'd']); + await waitForExit(session, 15_000); + expectCleanExit(session); + }); + it('keeps working-turn status refreshes from refocusing a drafted composer', async () => { const openRouterServer = await createMockOpenRouterServer( 'Delayed response completed.', @@ -1818,6 +1850,9 @@ describe('interactive built CLI Tuistory tests', () => { }); await waitForComposer(session); + // Shift+Enter is only distinguishable from Enter once the terminal encodes + // modified keys; the composer asks for the kitty disambiguate flag on start. + expect(session.getRawOutput()).toContain('\u001b[>1u'); await session.type('first line'); await session.press(['shift', 'enter']); await session.type('second line'); @@ -1861,6 +1896,8 @@ describe('interactive built CLI Tuistory tests', () => { expect(imagePasteScreen).toMatch(/\[Image #\d+\]/); await exitInteractive(session); + const rawOutput = session.getRawOutput(); + expect(rawOutput.lastIndexOf('\u001b[1u')); }); it('auto-initializes git for an empty workspace before rendering the composer', async () => { @@ -2993,6 +3030,41 @@ describe('interactive built CLI Tuistory tests', () => { await exitInteractive(session); }); + it('corrects a model the Autohand cloud gateway does not serve back to fantail', async () => { + // Issue #584: a model left over from another provider stayed selected under + // the autohandai provider and was shown (and sent) as the session model. + const authServer = await createMockAuthServer(); + mockAuthServers.push(authServer); + const session = await launchInteractive({ + config: { + provider: 'autohandai', + autohandai: { + plan: 'cloud', + authMode: 'account', + accountToken: 'tuistory-account-token', + model: 'anthropic/claude-5-sonnet', + }, + auth: { + token: 'tuistory-account-token', + user: { + id: 'tuistory-test-user', + email: 'tuistory@example.com', + name: 'Tuistory Test', + }, + }, + }, + env: { + AUTOHAND_AUTH_API_URL: `${authServer.baseUrl}/api/auth`, + }, + }); + + await waitForComposer(session); + await session.waitForText('(Autohand AI, fantail)', { timeout: 10_000 }); + expect(session.readAll()).not.toContain('claude-5-sonnet'); + + await exitInteractive(session); + }); + it('shows the signed-in Autohand plan and live provider quota in the /status Usage tab', async () => { const authServer = await createMockAuthServer(); mockAuthServers.push(authServer); diff --git a/tests/ui/ink/AgentUI.test.ts b/tests/ui/ink/AgentUI.test.ts index 9e329d34..a18f4d45 100644 --- a/tests/ui/ink/AgentUI.test.ts +++ b/tests/ui/ink/AgentUI.test.ts @@ -29,6 +29,7 @@ import { I18nProvider } from '../../../src/ui/i18n/index.js'; import { ThemeProvider } from '../../../src/ui/theme/ThemeContext.js'; import { getPromptBlockWidth } from '../../../src/ui/inputPrompt.js'; import { GoalPanel } from '../../../src/ui/ink/GoalPanel.js'; +import { DEFAULT_KEYBINDINGS, resolveKeybindings } from '../../../src/keybindings/profiles.js'; function stripAnsi(value: string): string { return value.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, ''); @@ -2226,3 +2227,122 @@ describe('AgentUI idle composer input handling', () => { expect(src).not.toContain('!isWorkingRef.current || !enableQueueInputRef.current'); }); }); + +describe('AgentUI keybinding profiles', () => { + function renderComposer(options: { + profile?: 'autohand' | 'codex'; + currentInput?: string; + onCtrlC?: () => void; + onInstruction?: (text: string) => void; + } = {}) { + return render( + React.createElement( + I18nProvider, + null, + React.createElement( + ThemeProvider, + null, + React.createElement(AgentUI, { + state: { ...createInitialUIState(), currentInput: options.currentInput ?? '' }, + onInstruction: options.onInstruction ?? (() => {}), + onEscape: () => {}, + onCtrlC: options.onCtrlC ?? (() => {}), + keybindings: resolveKeybindings(options.profile ?? 'autohand'), + }), + ), + ), + ); + } + const tick = () => new Promise((resolve) => setImmediate(resolve)); + + afterEach(() => { + cleanup(); + }); + + it('inserts a newline on Ctrl+J under the Codex profile', async () => { + const instance = renderComposer({ profile: 'codex', currentInput: 'one' }); + await tick(); + instance.stdin.write('\n'); + await tick(); + instance.stdin.write('two'); + await tick(); + const frame = stripAnsi(instance.lastFrame() ?? ''); + expect(frame).toContain('❯ one'); + expect(frame).toMatch(/\n\s*two/); + }); + + it('keeps Ctrl+J as a plain submit-neutral key under the Autohand defaults', async () => { + const onInstruction = vi.fn(); + const instance = renderComposer({ profile: 'autohand', currentInput: 'one', onInstruction }); + await tick(); + instance.stdin.write('\n'); + await tick(); + expect(stripAnsi(instance.lastFrame() ?? '')).not.toMatch(/❯ one\n\s*\n/); + expect(onInstruction).not.toHaveBeenCalled(); + }); + + it('requests exit on Ctrl+D with an empty composer under the Codex profile only', async () => { + const codexExit = vi.fn(); + const codex = renderComposer({ profile: 'codex', onCtrlC: codexExit }); + await tick(); + codex.stdin.write('\x04'); + await tick(); + await tick(); + expect(codexExit).toHaveBeenCalledTimes(1); + codex.unmount(); + + const defaultExit = vi.fn(); + const defaults = renderComposer({ profile: 'autohand', onCtrlC: defaultExit }); + await tick(); + defaults.stdin.write('\x04'); + await tick(); + await tick(); + expect(defaultExit).not.toHaveBeenCalled(); + }); + + it('does not exit on Ctrl+D while the composer has text', async () => { + const onCtrlC = vi.fn(); + const instance = renderComposer({ profile: 'codex', currentInput: 'draft', onCtrlC }); + await tick(); + instance.stdin.write('\x04'); + await tick(); + await tick(); + expect(onCtrlC).not.toHaveBeenCalled(); + }); + + it('opens the typed history on Ctrl+R under the Codex profile', async () => { + const onInstruction = vi.fn(); + const instance = renderComposer({ profile: 'codex', onInstruction }); + await tick(); + instance.stdin.write('\x12'); + await tick(); + expect(onInstruction).toHaveBeenCalledWith('/whatityped'); + }); + + it('lists the profile chords in the ? shortcuts panel', async () => { + const instance = renderComposer({ profile: 'codex' }); + await tick(); + instance.stdin.write('?'); + await tick(); + const frame = stripAnsi(instance.lastFrame() ?? ''); + expect(frame).toContain('ctrl + j inserts newline'); + expect(frame).toContain('ctrl + d exits'); + }); + + it('handles Ctrl+J in the text buffer bridge only when the profile binds it', () => { + const codexBuffer = new TextBuffer(80, 10, 'test'); + expect(handleInkTextBufferInput(codexBuffer, '\n', createInkKey(), resolveKeybindings('codex'))).toBe('handled'); + expect(codexBuffer.getText()).toBe('test\n'); + + const defaultBuffer = new TextBuffer(80, 10, 'test'); + handleInkTextBufferInput(defaultBuffer, '\n', createInkKey(), DEFAULT_KEYBINDINGS); + expect(defaultBuffer.getText()).toBe('test'); + }); + + it('reserves the active profile chords from extension keybindings', () => { + const binding = { key: 'ctrl+d', command: 'ext.deploy' }; + expect(matchesExtensionKeybinding('d', createInkKey({ ctrl: true }), binding, DEFAULT_KEYBINDINGS.reservedChords())).toBe(false); + expect(matchesExtensionKeybinding('d', createInkKey({ ctrl: true }), binding, resolveKeybindings('codex').reservedChords())).toBe(false); + expect(matchesExtensionKeybinding('k', createInkKey({ ctrl: true }), { key: 'ctrl+k', command: 'ext.k' })).toBe(true); + }); +}); diff --git a/tests/ui/ink/InkRenderer.pause-resume.test.ts b/tests/ui/ink/InkRenderer.pause-resume.test.ts index 7e633f4c..25e61f9a 100644 --- a/tests/ui/ink/InkRenderer.pause-resume.test.ts +++ b/tests/ui/ink/InkRenderer.pause-resume.test.ts @@ -61,6 +61,59 @@ function lastRenderedTaskListPositionProvider(): (() => unknown) | undefined { return root?.props.children.props.children.props.taskListPositionProvider; } +describe('InkRenderer keyboard protocol lifecycle', () => { + it('pushes the kitty disambiguate flag when the UI starts and pops it when it stops', () => { + const originalIsTTY = process.stdout.isTTY; + (process.stdout as any).isTTY = true; + const writes: string[] = []; + const write = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + const renderer = new InkRenderer({ + onInstruction: () => {}, + onEscape: () => {}, + onCtrlC: () => {}, + }); + try { + renderer.start(); + expect(writes).toContain('\x1b[>1u'); + expect(writes).not.toContain('\x1b[1u')); + } finally { + renderer.stop(); + write.mockRestore(); + (process.stdout as any).isTTY = originalIsTTY; + } + }); + + it('leaves a non-terminal stdout alone', () => { + const originalIsTTY = process.stdout.isTTY; + (process.stdout as any).isTTY = false; + const writes: string[] = []; + const write = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + const renderer = new InkRenderer({ + onInstruction: () => {}, + onEscape: () => {}, + onCtrlC: () => {}, + }); + try { + renderer.start(); + renderer.stop(); + expect(writes.join('')).not.toContain('\x1b[>1u'); + expect(writes.join('')).not.toContain('\x1b[ { let renderer: InkRenderer; let originalIsTTY: boolean | undefined;