diff --git a/docs/config-reference.md b/docs/config-reference.md index 99f3abf0..401ab924 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) | @@ -854,7 +855,7 @@ Customize the verbs in the config file when you want a fixed status label or a s } ``` -`activityVerbs` accepts either a single string or a non-empty string array. When `activityVerbsEnabled` is `false`, Autohand falls back to `Working...` instead of rotating through custom or built-in verbs. +`activityVerbs` accepts either a single string or a non-empty string array. When `activityVerbsEnabled` is `false`, Autohand falls back to `Working...` instead of rotating through custom or built-in verbs. While the agent works, a rotating tip line also appears under the status line with shortcuts, slash commands, sub-agent hints and your installed skills; it disappears when the turn ends. You can toggle completion reports, including the structured `SITREP` prompt, without editing the file: @@ -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: @@ -2595,6 +2652,7 @@ The picker loads twenty sessions per page and provides **More sessions** and **P | `/share` | Share current session | | `/status` | Show session status and the signed-in Autohand plan | | `/usage` | Show Autohand plan limits and project token activity | +| `/upgrade` | Open the console to upgrade your Autohand plan | `/undo` never resets or cleans the Git worktree. It preserves unrelated tracked and untracked work, and refuses to overwrite a file that changed after the recorded agent mutation. diff --git a/docs/config-reference_cs.md b/docs/config-reference_cs.md index 8390ef50..dad71248 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í. @@ -2036,6 +2059,7 @@ Autohand poskytuje bohatou sadu příkazů lomítka pro interaktivní použití. | `/status` | Zobrazit stav relace | | `/usage` | Zobrazit model, poskytovatele, kontext a limity využití | +| `/upgrade`| Otevřít konzoli a upgradovat plán Autohand | ### Model a poskytovatel | Příkaz | Popis | diff --git a/docs/config-reference_de.md b/docs/config-reference_de.md index e42a8d31..0de8cdf9 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. @@ -2160,6 +2183,7 @@ Autohand bietet eine umfangreiche Reihe von Slash-Befehlen für die interaktive | `/status` | Sitzungsstatus anzeigen | | `/usage` | Modell, Anbieter, Kontext und Nutzungslimits anzeigen | +| `/upgrade` | Konsole öffnen, um den Autohand-Plan zu upgraden | ### Modell & Anbieter | Befehl | Beschreibung | 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..64005a33 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. @@ -2018,6 +2041,7 @@ Autohand fournit un riche ensemble de commandes slash pour une utilisation inter | `/status` | Afficher l'état de la session | | `/usage` | Afficher les limites du modèle, du fournisseur, du contexte et de l'utilisation | +| `/upgrade`| Ouvrir la console pour passer à un plan Autohand supérieur | ### Modèle et fournisseur | Commande | Descriptif | 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..b6d4ec03 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. @@ -2018,6 +2041,7 @@ Az Autohand perjel parancsok gazdag készletét kínálja interaktív használat | `/status` | Munkamenet állapotának megjelenítése | | `/usage` | Modell, szolgáltató, kontextus és használati korlátok megjelenítése | +| `/upgrade`| Konzol megnyitása az Autohand-csomag bővítéséhez | ### Modell és szolgáltató | Parancs | Leírás | 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..5395da15 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. @@ -2018,6 +2041,7 @@ Autohand fornisce un ricco set di comandi slash per l'uso interattivo. Digita `/ | `/status` | Mostra lo stato della sessione | | `/usage` | Mostra modello, fornitore, contesto e limiti di utilizzo | +| `/upgrade`| Apri la console per passare a un piano Autohand superiore| ### Modello e fornitore | Comando | Descrizione | 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..d0ded0fb 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`; неудачный импорт не останавливает настройку. + +--- + ## Настройки агента Управляйте поведением агента и ограничениями итераций. @@ -2018,6 +2041,7 @@ Autohand предоставляет богатый набор косых ком | `/status` | Показать статус сеанса | | `/usage` | Показать модель, поставщика, контекст и ограничения на использование | +| `/upgrade`| Открыть консоль для повышения плана Autohand | ### Модель и поставщик | Команда | Описание | diff --git a/docs/config-reference_tr.md b/docs/config-reference_tr.md index 7440d609..c76ea542 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ı. @@ -2018,6 +2041,7 @@ Autohand etkileşimli kullanım için zengin bir eğik çizgi komutları seti sa | `/status` | Oturum durumunu göster | | `/usage` | Modeli, sağlayıcıyı, içeriği ve kullanım sınırlarını göster | +| `/upgrade`| Autohand planını yükseltmek için konsolu aç | ### Model ve Sağlayıcı | Komut | Açıklama | diff --git a/docs/config-reference_zh-tw.md b/docs/config-reference_zh-tw.md index b3b83fcf..ba1a642f 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` 相同的方式匯入記憶、工作階段與技能;匯入失敗不會中斷設定。 + +--- + ## 代理設定 控制代理行為和迭代限制。 @@ -2018,6 +2041,7 @@ Autohand 提供了一組豐富的斜線命令供互動式使用。在 REPL 中 | `/status` |顯示會話狀態 | | `/usage` |顯示模型、提供者、上下文和使用限制 | +| `/upgrade`| 開啟主控台以升級 Autohand 方案| ### 型號和提供者 |命令 |描述 | 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/billing/planSummary.ts b/src/billing/planSummary.ts index 60597d0d..13c7e63e 100644 --- a/src/billing/planSummary.ts +++ b/src/billing/planSummary.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { t } from '../i18n/index.js'; + export type PlanInterval = 'month' | 'year' | null; export interface PlanSummary { @@ -63,3 +65,32 @@ export function formatComposerPlanLabel(plan: PlanSummary | null | undefined): s if (!plan || plan.tier === 'enterprise') return undefined; return plan.tier === 'team' ? plan.accountName || plan.label : plan.label; } + +export type UpgradeTarget = 'pro' | 'max' | 'team'; + +/** Self-serve tiers in purchase order. Team and enterprise are managed in billing. */ +const PLAN_LADDER: ReadonlyArray<'free' | UpgradeTarget> = ['free', 'pro', 'max', 'team']; + +/** Next paid tier for a self-serve plan; null for team, enterprise and unknown tiers. */ +export function nextPlanTier(tier: string | undefined): UpgradeTarget | null { + const index = PLAN_LADDER.indexOf(tier as 'free' | UpgradeTarget); + if (index < 0) return null; + return (PLAN_LADDER[index + 1] as UpgradeTarget | undefined) ?? null; +} + +export const CONSOLE_ORIGIN = 'https://console.autohand.ai'; + +/** Console deep link that starts checkout for `target`, or the billing page when there is nothing to sell. */ +export function buildUpgradeUrl(target: UpgradeTarget | null, source = 'cli'): string { + const url = new URL(target ? '/' : '/billing', CONSOLE_ORIGIN); + if (target) url.searchParams.set('upgrade', target); + url.searchParams.set('source', source); + return url.toString(); +} + +/** One-line hint shown after an Autohand quota ends a turn. */ +export function formatUpgradeHint(plan: PlanSummary | null | undefined): string { + const target = nextPlanTier(plan?.tier); + if (!plan || !target) return t('ui.upgradeHintManaged'); + return t('ui.upgradeHint', { plan: plan.label, next: labelForTier(target) }); +} diff --git a/src/commands/login.ts b/src/commands/login.ts index a47960b7..618ecad7 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -141,7 +141,12 @@ type LoginContext = Pick & { * Open URL in the default browser * Uses platform-specific commands with existence checks for Linux. */ -async function openBrowser(url: string): Promise { +export async function openBrowser(url: string): Promise { + // CI, SSH sessions and terminal tests have no browser to hand the URL to. + if (process.env.AUTOHAND_NO_BROWSER === '1') { + console.log(`\nPlease open this URL manually:\n${url}\n`); + return false; + } try { const { exec, execFile } = await import('node:child_process'); const { promisify } = await import('node:util'); 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/commands/upgrade.ts b/src/commands/upgrade.ts new file mode 100644 index 00000000..031d08e5 --- /dev/null +++ b/src/commands/upgrade.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import type { SlashCommandContext } from '../core/slashCommandTypes.js'; +import { buildUpgradeUrl, nextPlanTier } from '../billing/planSummary.js'; +import { t } from '../i18n/index.js'; +import { openBrowser as openBrowserDefault } from './login.js'; +import { resolveAccountEntitlement } from './usage.js'; + +export interface UpgradeCommandDeps { + openBrowser?: (url: string) => Promise; +} + +/** + * Opens the console upgrade flow for the next plan up from the signed-in + * account. Unlike the shell command `autohand upgrade`, which updates the + * CLI binary, this is about the Autohand plan. + */ +export async function upgrade( + ctx: SlashCommandContext, + deps: UpgradeCommandDeps = {}, +): Promise { + const entitlement = await resolveAccountEntitlement(ctx); + if (!entitlement) { + return t('commands.upgrade.signIn'); + } + + const target = nextPlanTier(entitlement.tier); + const url = buildUpgradeUrl(target); + await ctx.trackFeatureActivation?.('upgrade_link', { tier: entitlement.tier, target }); + + if (ctx.isNonInteractive) { + return t('commands.upgrade.link', { url }); + } + + await (deps.openBrowser ?? openBrowserDefault)(url); + return t('commands.upgrade.opening', { url }); +} + +export const metadata = { + command: '/upgrade', + description: 'open the console to upgrade your Autohand plan', + implemented: true, +}; 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.ts b/src/core/agent.ts index f07129b0..e2312b08 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -13,6 +13,7 @@ import { resolveAutohandAIModelForTier } from './agent/AutohandAIModelTierPolicy import { getAuthClient } from '../auth/index.js'; import { formatComposerPlanLabel, + formatUpgradeHint, planSummaryFromEntitlement, type PlanSummary, } from '../billing/planSummary.js'; @@ -1889,6 +1890,9 @@ export class AutohandAgent { model, provider: this.activeProvider, }); + if (this.activeProvider === 'autohandai') { + this.ui?.setTip?.({ kind: 'upgrade', text: formatUpgradeHint(this.accountPlan) }); + } } } diff --git a/src/core/agent/AgentDependencyComposer.ts b/src/core/agent/AgentDependencyComposer.ts index c84f1092..0801325a 100644 --- a/src/core/agent/AgentDependencyComposer.ts +++ b/src/core/agent/AgentDependencyComposer.ts @@ -11,7 +11,7 @@ import { saveConfig, getProviderConfig } from '../../config.js'; import type { LLMProvider } from '../../providers/LLMProvider.js'; import { ProviderFactory } from '../../providers/ProviderFactory.js'; import { getOpenRouterModelContextWindow } from '../../providers/modelCapabilities.js'; -import { promptInterrupt, promptNotify } from '../../ui/inputPrompt.js'; +import { getHelpOrderedSlashCommands, promptInterrupt, promptNotify } from '../../ui/inputPrompt.js'; import { isShellCommand, parseShellCommand } from '../../ui/shellCommand.js'; import { shouldUseInkRenderer } from '../../ui/inkMode.js'; import { getContextWindow } from '../context/tokenizer.js'; @@ -473,6 +473,13 @@ export function initializeAgentDependencies( activityVerbs: runtime.config.ui?.activityVerbs, activityVerbsEnabled: runtime.config.ui?.activityVerbsEnabled, activitySymbol: runtime.config.ui?.activitySymbol, + tipContext: { + listSkills: () => (host.skillsRegistry?.listSkills() ?? []).map((skill: SkillDefinition) => ({ + name: skill.name, + description: skill.description, + })), + listCommands: () => getHelpOrderedSlashCommands(SLASH_COMMANDS), + }, }); // Create permission manager with persistence callback and local project support diff --git a/src/core/agent/AgentUIRuntime.ts b/src/core/agent/AgentUIRuntime.ts index 553643d6..627dfc44 100644 --- a/src/core/agent/AgentUIRuntime.ts +++ b/src/core/agent/AgentUIRuntime.ts @@ -18,8 +18,10 @@ 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 { AnnouncementLineState, TipLineState } from '../../ui/ink/AgentUI.js'; import type { AgentUILineExtensions } from '../../ui/ink/AgentUI.js'; import { mergeLineExtensions, @@ -34,6 +36,9 @@ export interface AgentUIRuntimeHost { } const USER_NOTIFICATION_DEDUPE_WINDOW_MS = 10 * 60 * 1000; +/** How long each working tip stays on screen before the next one rotates in. */ +export const TIP_ROTATION_MS = 10_000; +const STATUS_TICK_MS = 1_000; const MAX_PENDING_INK_SUBMIT_ECHOES = 20; export function buildPeerLineExtension(peerCount: number): LineExtension | undefined { @@ -282,6 +287,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) => { @@ -788,6 +797,11 @@ export function setAgentSpinnerStatus(host: AgentUIRuntimeHost, status: string): host.runtime.spinner.text = host.buildSpinnerStatusText(status, footerText); } +function currentWorkingTip(host: AgentUIRuntimeHost): TipLineState | undefined { + const text = host.activityIndicator?.getTip?.(); + return text ? { kind: 'tip', text } : undefined; +} + export function startAgentStatusUpdates(host: AgentUIRuntimeHost): void { if (host.statusInterval) { clearInterval(host.statusInterval); @@ -798,15 +812,24 @@ export function startAgentStatusUpdates(host: AgentUIRuntimeHost): void { // Pick a fresh verb and tip for host working session host.activityIndicator?.next?.(); + host.inkRenderer?.setTip?.(currentWorkingTip(host)); // Immediate initial render host.forceRenderSpinner(); // Update every second for elapsed time, but forceRenderSpinner - // handles deduplication so frequent calls are fine + // handles deduplication so frequent calls are fine. The tip rotates on + // its own slower cadence from the same ticker. + const ticksPerTip = TIP_ROTATION_MS / STATUS_TICK_MS; + let ticks = 0; host.statusInterval = setInterval(() => { host.forceRenderSpinner(); - }, 1000); // Once per second is enough for time updates + ticks += 1; + if (ticks % ticksPerTip === 0 && host.inkRenderer?.setTip) { + const text = host.activityIndicator?.nextTip?.(); + if (text) host.inkRenderer.setTip({ kind: 'tip', text }); + } + }, STATUS_TICK_MS); if (process.stdout.isTTY && !host.resizeHandler) { host.resizeHandler = () => { diff --git a/src/core/slashCommandHandler.ts b/src/core/slashCommandHandler.ts index 9d2b494e..9f540a13 100644 --- a/src/core/slashCommandHandler.ts +++ b/src/core/slashCommandHandler.ts @@ -455,6 +455,10 @@ export class SlashCommandHandler { const { usage } = await import('../commands/usage.js'); return usage(this.ctx, args); } + case '/upgrade': { + const { upgrade } = await import('../commands/upgrade.js'); + return upgrade(this.ctx); + } case '/login': { const { login } = await import('../commands/login.js'); await this.ctx.onBeforeModal?.(); diff --git a/src/core/slashCommands.ts b/src/core/slashCommands.ts index ad42b700..d1c10db5 100644 --- a/src/core/slashCommands.ts +++ b/src/core/slashCommands.ts @@ -27,6 +27,7 @@ import * as completion from '../commands/completion.js'; import * as exportCmd from '../commands/export.js'; import * as status from '../commands/status.js'; import * as usage from '../commands/usage.js'; +import * as upgrade from '../commands/upgrade.js'; import * as login from '../commands/login.js'; import * as logout from '../commands/logout.js'; import * as permissions from '../commands/permissions.js'; @@ -105,6 +106,7 @@ export const SLASH_COMMANDS: SlashCommand[] = ([ exportCmd.metadata, status.metadata, usage.metadata, + upgrade.metadata, login.metadata, logout.metadata, permissions.metadata, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 28dae52c..e0d93c25 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -158,6 +158,11 @@ "alreadyExists": "AGENTS.md already exists in {{path}}", "overwritePrompt": "Overwrite existing AGENTS.md?" }, + "upgrade": { + "signIn": "Sign in with /login to see upgrade options.", + "opening": "Opening {{url}}", + "link": "Upgrade your Autohand plan: {{url}}" + }, "undo": { "description": "revert the last file mutation", "success": "Reverted changes to {{file}}", @@ -219,6 +224,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 +675,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.", @@ -1085,6 +1109,8 @@ "ui": { "cancel": "Cancel", "escToCancel": "esc to cancel", + "upgradeHint": "You've reached your {{plan}} plan limit. Run /upgrade to move to {{next}}.", + "upgradeHintManaged": "You've reached your plan limit. Run /upgrade to review your plan.", "commandHint": "? shortcuts · / commands · @ mention files · $ skills · ! terminal", "ctrlCToExit": "Press Ctrl+C again to exit", "noMatchingCommands": "No matching commands.", 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/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..461ac3c1 100644 --- a/src/ui/InkUIManager.ts +++ b/src/ui/InkUIManager.ts @@ -12,7 +12,8 @@ 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 { AgentUILineExtensions } from './ink/AgentUI.js'; +import type { ResolvedKeybindings } from '../keybindings/profiles.js'; +import type { AgentUILineExtensions, TipLineState } from './ink/AgentUI.js'; import type { GoalEditRequest } from './ink/GoalPanel.js'; import type { InteractionMode } from '../core/agent/InteractionModeController.js'; import type { TaskListPosition } from '../types.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; @@ -116,6 +118,10 @@ export class InkUIManager extends BaseUIManager implements UIManager { this.inkRenderer?.setPlanLabel(planLabel); } + setTip(tip: TipLineState | undefined): void { + this.inkRenderer?.setTip(tip); + } + setFinalResponse(response: string): void { this.inkRenderer?.setFinalResponse(response); this.finalResponse = response; diff --git a/src/ui/UIManager.ts b/src/ui/UIManager.ts index 656d5336..188d4626 100644 --- a/src/ui/UIManager.ts +++ b/src/ui/UIManager.ts @@ -10,6 +10,7 @@ */ import type { InkRenderer } from './ink/InkRenderer.js'; +import type { TipLineState } from './ink/AgentUI.js'; export interface UIManager { start(): Promise; @@ -22,6 +23,7 @@ export interface UIManager { setWorking(working: boolean, message?: string): void; setProviderModel?(provider: string, model: string): void; setPlanLabel?(planLabel: string | undefined): void; + setTip?(tip: TipLineState | undefined): void; setFinalResponse(response: string): void; addUserMessage(text: string): void; addToolOutput(tool: string, success: boolean, output: string): void; diff --git a/src/ui/activityIndicator.ts b/src/ui/activityIndicator.ts index 4493b29a..270fc99c 100644 --- a/src/ui/activityIndicator.ts +++ b/src/ui/activityIndicator.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import chalk from 'chalk'; -import { TipsBag } from './tips.js'; +import { TipsBag, type TipContext } from './tips.js'; import { shuffleInPlace } from './displayUtils.js'; const DEFAULT_VERBS: string[] = [ @@ -39,6 +39,8 @@ export interface ActivityConfig { activityVerbs?: string | string[]; activityVerbsEnabled?: boolean; activitySymbol?: string; + /** Lazy providers used to expand skill and command tip templates. */ + tipContext?: TipContext; } /** @@ -64,7 +66,7 @@ export class ActivityIndicator { this.verbs = DEFAULT_VERBS; } this.symbol = config?.activitySymbol ?? DEFAULT_SYMBOL; - this.tips = new TipsBag(); + this.tips = new TipsBag(undefined, config?.tipContext); } /** @@ -96,6 +98,14 @@ export class ActivityIndicator { return this.currentTip; } + /** + * Rotate only the tip, leaving the current verb alone. + */ + nextTip(): string { + this.currentTip = this.tips.next(); + return this.currentTip; + } + private pickVerb(): string { if (!this.verbsEnabled) { return DISABLED_VERB; diff --git a/src/ui/ink/AgentUI.tsx b/src/ui/ink/AgentUI.tsx index 180be500..13445f57 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, @@ -59,6 +67,7 @@ import { type InteractionMode, } from '../../core/agent/InteractionModeController.js'; import { AnnouncementLine } from './AnnouncementLine.js'; +import { TipLine } from './TipLine.js'; import { REQUEST_CURSOR_POSITION, parseCursorPositionReport, @@ -118,6 +127,12 @@ export interface AnnouncementLineState { visible: boolean; } +/** One-line hint under the status line: a rotating tip while working, or a pinned upgrade hint. */ +export interface TipLineState { + text: string; + kind: 'tip' | 'upgrade'; +} + /** A slash-command result held in the fixed composer area until the next turn. */ export interface CommandResultState { command: string; @@ -193,6 +208,8 @@ export interface AgentUIState { goalPanelVisible: boolean; /** Highest-priority active CLI announcement rendered above status. */ announcement?: AnnouncementLineState; + /** Rotating tip while working, or a pinned upgrade hint, rendered under the status line. */ + tip?: TipLineState; /** Compact command result placed below the status line instead of transcript history. */ commandResult?: CommandResultState; } @@ -250,6 +267,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 +281,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 +681,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 +805,7 @@ export function AgentUI({ getInteractionMode, onCycleInteractionMode, mouseComposerCursor = false, + keybindings = DEFAULT_KEYBINDINGS, taskListPosition = 'above-composer', }: AgentUIProps) { const { stdout } = useStdout(); @@ -895,6 +923,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 +1459,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 +1569,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 +1687,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 +1939,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 +1964,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') { @@ -2346,6 +2393,7 @@ export function AgentUI({ /> ) : 0 @@ -2670,6 +2719,8 @@ interface StatusSectionProps { modeDescription?: string; taskListPosition: TaskListPosition; lineExtension?: LineExtension; + tip?: TipLineState; + columns: number; } interface QueuedInstructionsPanelProps { @@ -2766,6 +2817,8 @@ const StatusSection = memo(function StatusSection({ modeDescription, taskListPosition, lineExtension, + tip, + columns, }: StatusSectionProps) { const { colors } = useTheme(); @@ -2794,6 +2847,7 @@ const StatusSection = memo(function StatusSection({ teamActivity={teamActivity} lineExtension={lineExtension} /> + {/* Keep interactive panels adjacent to the status line, before the composer. */} {commandResult && } @@ -2865,6 +2919,8 @@ const StatusSection = memo(function StatusSection({ prev.model === next.model && prev.modeIndicator === next.modeIndicator && prev.modeDescription === next.modeDescription && + prev.tip === next.tip && + prev.columns === next.columns && prev.taskListPosition === next.taskListPosition && prev.lineExtension === next.lineExtension; }); @@ -3111,6 +3167,7 @@ interface FixedBottomProps { selectedGoalIndex: number | null; onGoalRowLayoutChange?: (target: GoalEditRequest, layout: OutputLayout | null) => void; commandResult?: CommandResultState; + tip?: TipLineState; enableQueueInput: boolean; input: string; cursorOffset: number; @@ -3140,6 +3197,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. */ @@ -3222,6 +3280,7 @@ const FixedBottom = memo(function FixedBottom({ selectedGoalIndex, onGoalRowLayoutChange, commandResult, + tip, enableQueueInput, input, cursorOffset, @@ -3247,6 +3306,7 @@ const FixedBottom = memo(function FixedBottom({ enableMouseTargetControls, onComposerLayoutChange, showShortcuts, + keybindings, interactionMode, showModeLabel, modeIndicator, @@ -3282,6 +3342,8 @@ const FixedBottom = memo(function FixedBottom({ selectedGoalIndex={selectedGoalIndex} onGoalRowLayoutChange={onGoalRowLayoutChange} commandResult={commandResult} + tip={tip} + columns={terminalColumns} contextPercent={contextPercent} contextTokens={contextTokens} provider={provider} @@ -3312,7 +3374,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 +221,7 @@ interface AgentUIWrapperProps { getInteractionMode?: () => InteractionMode; onCycleInteractionMode?: () => InteractionMode; mouseComposerCursor?: boolean; + keybindings?: ResolvedKeybindings; taskListPositionProvider?: () => TaskListPosition; } @@ -255,6 +260,7 @@ const AgentUIWrapper = forwardRef( getInteractionMode, onCycleInteractionMode, mouseComposerCursor, + keybindings, taskListPositionProvider, } = props; @@ -309,6 +315,7 @@ const AgentUIWrapper = forwardRef( getInteractionMode={getInteractionMode} onCycleInteractionMode={onCycleInteractionMode} mouseComposerCursor={mouseComposerCursor} + keybindings={keybindings} taskListPosition={taskListPositionProvider?.() ?? 'above-composer'} /> ); @@ -454,6 +461,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 +508,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 +528,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 { @@ -640,6 +658,12 @@ export class InkRenderer { updates.commandResult = undefined; } + // A rotating tip only lives while working; an upgrade hint stays until the next turn starts. + const tip = this.state.tip; + if (tip && (isWorking ? tip.kind === 'upgrade' : tip.kind === 'tip')) { + updates.tip = undefined; + } + this.updateState(updates); } @@ -1102,6 +1126,10 @@ export class InkRenderer { this.updateState({ announcement }); } + setTip(tip: TipLineState | undefined): void { + this.updateState({ tip }); + } + /** * Replace todo-kind activity items while preserving active sub-agent rows. */ @@ -1374,6 +1402,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/ink/TipLine.tsx b/src/ui/ink/TipLine.tsx new file mode 100644 index 00000000..19575d81 --- /dev/null +++ b/src/ui/ink/TipLine.tsx @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import React, { memo } from 'react'; +import { Box, Text } from 'ink'; +import { useTheme } from '../theme/ThemeContext.js'; +import { truncateAnnouncementLine } from './AnnouncementLine.js'; +import type { TipLineState } from './AgentUI.js'; + +export interface TipLineProps { + tip: TipLineState | undefined; + isWorking: boolean; + columns: number; +} + +const PREFIXES: Record = { + tip: '⎿ Tip: ', + upgrade: '⎿ Plan: ', +}; + +/** + * One muted row under the status line. A rotating tip only exists while the + * agent works; an upgrade hint stays until the next turn starts. + */ +function TipLineComponent({ tip, isWorking, columns }: TipLineProps): React.ReactNode { + const { theme } = useTheme(); + if (!tip || (tip.kind === 'tip' && !isWorking)) { + return null; + } + const content = truncateAnnouncementLine(`${PREFIXES[tip.kind]}${tip.text}`, Math.max(1, columns)); + return ( + + {theme.fg(tip.kind === 'upgrade' ? 'warning' : 'muted', content)} + + ); +} + +export const TipLine = memo(TipLineComponent); 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/src/ui/tips.ts b/src/ui/tips.ts index d1abf0ac..fc75c95e 100644 --- a/src/ui/tips.ts +++ b/src/ui/tips.ts @@ -4,51 +4,92 @@ * SPDX-License-Identifier: Apache-2.0 */ import { shuffleInPlace } from './displayUtils.js'; +import toolTips from './tool_tips.json' with { type: 'json' }; -const DEFAULT_TIPS: string[] = [ - 'Use @filename to give the agent context about specific files', - 'Press Shift+Tab to cycle edit, plan, auto, and YOLO modes', - 'Type /undo to revert the last change the agent made', - 'Use /memory to save and recall project-specific notes', - 'Press Shift+Enter to add newlines in your prompt', - 'Type /sessions to list and /resume to continue a past session', - 'Prefix with ! to run shell commands without leaving autohand', - 'Drag and drop images into the prompt for visual context', - 'Use /new to reset conversation context when switching topics', - 'Be specific in your instructions for better results', - 'Mention multiple @files in one prompt to give broader context', - 'Use /model to switch LLM models mid-session', - 'Press ESC to cancel an in-flight LLM request', - 'Use /help to see all available slash commands', - 'Type /init to scaffold an AGENTS.md template in your workspace', - 'Review diffs carefully before approving destructive operations', - 'Use /feedback to report issues or suggest improvements', - 'Ctrl+C once clears input, twice exits autohand', - 'Use /agents to manage sub-agents for parallel tasks', - 'Pin important context with @file so the model always sees it', -]; +/** + * Tips shown under the status line while the agent works. + * + * Edit `src/ui/tool_tips.json` to add or change them. Entries without a + * `kind` are shown verbatim. `skill` and `command` entries are templates + * expanded against the user's installed skills and the slash-command + * registry, using the `{{skill}}`, `{{command}}` and `{{description}}` + * placeholders. + */ +export type ToolTipKind = 'static' | 'skill' | 'command'; + +export interface ToolTip { + kind?: ToolTipKind; + text: string; +} + +export interface TipContext { + listSkills?: () => ReadonlyArray<{ name: string; description?: string }>; + listCommands?: () => ReadonlyArray<{ command: string; description: string }>; +} + +const FALLBACK_TIP = 'Type /help to see all available slash commands'; + +export const DEFAULT_TOOL_TIPS: ReadonlyArray = toolTips.tips as ToolTip[]; + +function lowerFirst(value: string): string { + const trimmed = value.trim(); + return trimmed.charAt(0).toLowerCase() + trimmed.slice(1); +} + +function fill(template: string, values: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => values[key] ?? ''); +} + +function expandOne(tip: ToolTip, context: TipContext): string[] { + if (typeof tip.text !== 'string' || tip.text.trim().length === 0) return []; + switch (tip.kind) { + case 'skill': + return (context.listSkills?.() ?? []) + .filter((skill) => skill.description?.trim()) + .map((skill) => fill(tip.text, { skill: skill.name, description: lowerFirst(skill.description ?? '') })); + case 'command': + return (context.listCommands?.() ?? []) + .map((cmd) => fill(tip.text, { command: cmd.command, description: lowerFirst(cmd.description) })); + default: + return [tip.text]; + } +} + +/** Turn the JSON tip list into concrete strings for the current session. */ +export function expandToolTips(tips: ReadonlyArray, context: TipContext): string[] { + return tips.flatMap((tip) => expandOne(tip, context)); +} /** * Shuffle-bag random tip selector. - * Returns each tip once before reshuffling, preventing repeats. + * Returns each tip once before reshuffling, preventing repeats. Templates are + * re-expanded on every refill so skills installed mid-session show up. */ export class TipsBag { - private pool: string[]; + private readonly tips: ReadonlyArray; + private readonly context: TipContext; private remaining: string[] = []; + private poolSize = 0; - constructor(tips?: string[]) { - this.pool = tips ?? DEFAULT_TIPS; + constructor(tips: ReadonlyArray = DEFAULT_TOOL_TIPS, context: TipContext = {}) { + this.tips = tips; + this.context = context; + this.refill(); } get size(): number { - return this.pool.length; + return this.poolSize; } next(): string { - if (this.remaining.length === 0) { - this.remaining = [...this.pool]; - shuffleInPlace(this.remaining); - } - return this.remaining.pop()!; + if (this.remaining.length === 0) this.refill(); + return this.remaining.pop() ?? FALLBACK_TIP; + } + + private refill(): void { + const expanded = expandToolTips(this.tips, this.context); + this.poolSize = expanded.length; + shuffleInPlace(expanded); + this.remaining = expanded.length > 0 ? expanded : [FALLBACK_TIP]; } } diff --git a/src/ui/tool_tips.json b/src/ui/tool_tips.json new file mode 100644 index 00000000..f15270e8 --- /dev/null +++ b/src/ui/tool_tips.json @@ -0,0 +1,35 @@ +{ + "tips": [ + { "text": "Use @filename to give the agent context about specific files" }, + { "text": "Press Shift+Tab to cycle edit, plan, auto, and YOLO modes" }, + { "text": "Type /undo to revert the last change the agent made" }, + { "text": "Use /memory to save and recall project-specific notes" }, + { "text": "Press Shift+Enter, Alt+Enter or Ctrl+J (Codex profile) to add a newline" }, + { "text": "Type /sessions to list and /resume to continue a past session" }, + { "text": "Prefix with ! to run shell commands without leaving autohand" }, + { "text": "Drag and drop images into the prompt for visual context" }, + { "text": "Use /new to reset conversation context when switching topics" }, + { "text": "Be specific in your instructions for better results" }, + { "text": "Mention multiple @files in one prompt to give broader context" }, + { "text": "Use /model to switch LLM models mid-session" }, + { "text": "Press ESC to cancel an in-flight LLM request" }, + { "text": "Use /help to see all available slash commands" }, + { "text": "Type /init to scaffold an AGENTS.md template in your workspace" }, + { "text": "Review diffs carefully before approving destructive operations" }, + { "text": "Use /feedback to report issues or suggest improvements" }, + { "text": "Ctrl+C once clears input, twice exits autohand" }, + { "text": "Use /agents to manage sub-agents for parallel tasks" }, + { "text": "Pin important context with @file so the model always sees it" }, + { "text": "Don't like what you see? Change it with $extension-builder" }, + { "text": "Type $ to mention an installed skill and put it to work" }, + { "text": "Set a persistent objective with /goal and let autohand keep at it" }, + { "text": "Type ? on an empty composer to list keyboard shortcuts" }, + { "text": "Run /upgrade to see what the next Autohand plan unlocks" }, + { "text": "Want autohand to fan out? Ask it to delegate the work to sub-agents in parallel" }, + { "text": "Run /agents definitions to see the specialists autohand can delegate to, then name one in your prompt" }, + { "text": "Ask for a team of specialists to review, test and document in parallel, then watch them with /agents view" }, + { "text": "Create your own sub-agent with /agents-new and mention it by name to hand it a task" }, + { "kind": "skill", "text": "Try ${{skill}}: {{description}}" }, + { "kind": "command", "text": "Type {{command}} to {{description}}" } + ] +} diff --git a/tests/billing/planSummary.test.ts b/tests/billing/planSummary.test.ts index 80f95521..cf02926b 100644 --- a/tests/billing/planSummary.test.ts +++ b/tests/billing/planSummary.test.ts @@ -75,4 +75,35 @@ describe('plan summary', () => { expect(formatPlan({ tier: 'free', label: 'Free', interval: null })).toBe('Free'); expect(formatPlan(null)).toBeNull(); }); + + it('walks the self-serve ladder and stops at team', async () => { + const { nextPlanTier } = await import('../../src/billing/planSummary.js'); + + expect(nextPlanTier('free')).toBe('pro'); + expect(nextPlanTier('pro')).toBe('max'); + expect(nextPlanTier('max')).toBe('team'); + expect(nextPlanTier('team')).toBeNull(); + expect(nextPlanTier('enterprise')).toBeNull(); + expect(nextPlanTier(undefined)).toBeNull(); + expect(nextPlanTier('mystery')).toBeNull(); + }); + + it('builds console upgrade links with the source attached', async () => { + const { buildUpgradeUrl } = await import('../../src/billing/planSummary.js'); + + expect(buildUpgradeUrl('pro')).toBe('https://console.autohand.ai/?upgrade=pro&source=cli'); + expect(buildUpgradeUrl('team', 'vscode')).toBe('https://console.autohand.ai/?upgrade=team&source=vscode'); + expect(buildUpgradeUrl(null)).toBe('https://console.autohand.ai/billing?source=cli'); + }); + + it('phrases the quota hint for the next plan or for managed plans', async () => { + const { formatUpgradeHint } = await import('../../src/billing/planSummary.js'); + + expect(formatUpgradeHint({ tier: 'free', label: 'Free', interval: null })) + .toBe("You've reached your Free plan limit. Run /upgrade to move to Pro."); + expect(formatUpgradeHint({ tier: 'team', label: 'Team', interval: 'month' })) + .toBe("You've reached your plan limit. Run /upgrade to review your plan."); + expect(formatUpgradeHint(null)) + .toBe("You've reached your plan limit. Run /upgrade to review your plan."); + }); }); diff --git a/tests/commands/openBrowser.test.ts b/tests/commands/openBrowser.test.ts new file mode 100644 index 00000000..448c59c8 --- /dev/null +++ b/tests/commands/openBrowser.test.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('openBrowser', () => { + afterEach(() => { + delete process.env.AUTOHAND_NO_BROWSER; + vi.restoreAllMocks(); + }); + + it('prints the URL instead of launching anything when AUTOHAND_NO_BROWSER is set', async () => { + process.env.AUTOHAND_NO_BROWSER = '1'; + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { openBrowser } = await import('../../src/commands/login.js'); + + await expect(openBrowser('https://console.autohand.ai/?upgrade=pro&source=cli')).resolves.toBe(false); + expect(log).toHaveBeenCalledWith(expect.stringContaining('https://console.autohand.ai/?upgrade=pro&source=cli')); + }); +}); 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/commands/upgrade.test.ts b/tests/commands/upgrade.test.ts new file mode 100644 index 00000000..b76f151e --- /dev/null +++ b/tests/commands/upgrade.test.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it, vi } from 'vitest'; +import type { SlashCommandContext } from '../../src/core/slashCommandTypes.js'; + +function makeContext(overrides: Partial = {}): SlashCommandContext { + return { workspaceRoot: '/tmp/project', ...overrides } as SlashCommandContext; +} + +describe('/upgrade', () => { + it('asks the user to sign in when there is no entitlement', async () => { + const { upgrade } = await import('../../src/commands/upgrade.js'); + const openBrowser = vi.fn(); + const result = await upgrade(makeContext({ getAccountEntitlement: async () => null }), { openBrowser }); + expect(result).toBe('Sign in with /login to see upgrade options.'); + expect(openBrowser).not.toHaveBeenCalled(); + }); + + it('opens checkout for the next plan and records the activation', async () => { + const { upgrade } = await import('../../src/commands/upgrade.js'); + const openBrowser = vi.fn().mockResolvedValue(true); + const trackFeatureActivation = vi.fn(); + const result = await upgrade( + makeContext({ + getAccountEntitlement: async () => ({ tier: 'free', freeRemaining: 0 }), + trackFeatureActivation, + }), + { openBrowser }, + ); + expect(openBrowser).toHaveBeenCalledWith('https://console.autohand.ai/?upgrade=pro&source=cli'); + expect(result).toBe('Opening https://console.autohand.ai/?upgrade=pro&source=cli'); + expect(trackFeatureActivation).toHaveBeenCalledWith('upgrade_link', { tier: 'free', target: 'pro' }); + }); + + it('sends managed plans to the billing page', async () => { + const { upgrade } = await import('../../src/commands/upgrade.js'); + const openBrowser = vi.fn().mockResolvedValue(true); + await upgrade( + makeContext({ getAccountEntitlement: async () => ({ tier: 'team', freeRemaining: null }) }), + { openBrowser }, + ); + expect(openBrowser).toHaveBeenCalledWith('https://console.autohand.ai/billing?source=cli'); + }); + + it('returns the link without opening a browser in non-interactive mode', async () => { + const { upgrade } = await import('../../src/commands/upgrade.js'); + const openBrowser = vi.fn(); + const result = await upgrade( + makeContext({ + isNonInteractive: true, + getAccountEntitlement: async () => ({ tier: 'pro', freeRemaining: null }), + }), + { openBrowser }, + ); + expect(result).toBe('Upgrade your Autohand plan: https://console.autohand.ai/?upgrade=max&source=cli'); + expect(openBrowser).not.toHaveBeenCalled(); + }); +}); 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/AgentRateLimitUpgradeHint.test.ts b/tests/core/agent/AgentRateLimitUpgradeHint.test.ts new file mode 100644 index 00000000..4c59339a --- /dev/null +++ b/tests/core/agent/AgentRateLimitUpgradeHint.test.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it, vi } from 'vitest'; +import { AutohandAgent } from '../../../src/core/agent.js'; +import { ApiError } from '../../../src/providers/errors.js'; + +interface FailureAgent { + notifySessionFailure(error: Error): Promise; +} + +function makeAgent(activeProvider: string) { + const ui = { setTip: vi.fn() }; + const agent = Object.assign(Object.create(AutohandAgent.prototype), { + activeProvider, + runtime: { config: {}, options: {} }, + ui, + sessionManager: { getCurrentSession: () => undefined }, + hookManager: { executeHooks: vi.fn().mockResolvedValue(undefined) }, + accountPlan: { tier: 'free', label: 'Free', interval: null }, + }) as unknown as FailureAgent; + return { agent, ui }; +} + +describe('AutohandAgent quota upgrade hint', () => { + it('pins the upgrade hint when Autohand rate limits end the turn', async () => { + const { agent, ui } = makeAgent('autohandai'); + await agent.notifySessionFailure(new ApiError('quota', 'rate_limited', 429, false)); + expect(ui.setTip).toHaveBeenCalledWith({ + kind: 'upgrade', + text: "You've reached your Free plan limit. Run /upgrade to move to Pro.", + }); + }); + + it('leaves the tip alone for other providers and other errors', async () => { + const other = makeAgent('openrouter'); + await other.agent.notifySessionFailure(new ApiError('quota', 'rate_limited', 429, false)); + expect(other.ui.setTip).not.toHaveBeenCalled(); + + const autohand = makeAgent('autohandai'); + await autohand.agent.notifySessionFailure(new ApiError('boom', 'server_error', 500, false)); + expect(autohand.ui.setTip).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/core/agent/AgentUIRuntime.tips.test.ts b/tests/core/agent/AgentUIRuntime.tips.test.ts new file mode 100644 index 00000000..1e1befb0 --- /dev/null +++ b/tests/core/agent/AgentUIRuntime.tips.test.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + startAgentStatusUpdates, + stopAgentStatusUpdates, + TIP_ROTATION_MS, +} from '../../../src/core/agent/AgentUIRuntime.js'; + +function makeHost(withRenderer = true) { + const tips = ['first', 'second', 'third']; + let index = 0; + const inkRenderer = { setTip: vi.fn() }; + const host = { + statusInterval: undefined as ReturnType | undefined, + lastRenderedStatus: '', + activityIndicator: { + next: vi.fn(), + getTip: () => tips[index], + nextTip: () => tips[++index % tips.length], + }, + forceRenderSpinner: vi.fn(), + isUsingTerminalRegionsForActiveTurn: () => false, + inkRenderer: withRenderer ? inkRenderer : undefined, + resizeHandler: undefined, + }; + return { host, inkRenderer }; +} + +describe('working tip rotation', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('shows the first tip immediately and rotates every TIP_ROTATION_MS', () => { + const { host, inkRenderer } = makeHost(); + startAgentStatusUpdates(host as never); + expect(inkRenderer.setTip).toHaveBeenCalledWith({ kind: 'tip', text: 'first' }); + + vi.advanceTimersByTime(TIP_ROTATION_MS - 1000); + expect(inkRenderer.setTip).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1000); + expect(inkRenderer.setTip).toHaveBeenLastCalledWith({ kind: 'tip', text: 'second' }); + + vi.advanceTimersByTime(TIP_ROTATION_MS); + expect(inkRenderer.setTip).toHaveBeenLastCalledWith({ kind: 'tip', text: 'third' }); + stopAgentStatusUpdates(host as never); + }); + + it('keeps refreshing the spinner every second without an Ink renderer', () => { + const { host } = makeHost(false); + startAgentStatusUpdates(host as never); + vi.advanceTimersByTime(TIP_ROTATION_MS); + expect(host.forceRenderSpinner).toHaveBeenCalledTimes(TIP_ROTATION_MS / 1000 + 1); + stopAgentStatusUpdates(host as never); + }); +}); 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/slashCommands.spec.ts b/tests/slashCommands.spec.ts index 6f1ead79..d8055c09 100644 --- a/tests/slashCommands.spec.ts +++ b/tests/slashCommands.spec.ts @@ -13,7 +13,7 @@ describe('slash commands registry', () => { '/quit', '/exit', '/model', '/session', '/sessions', '/resume', '/init', '/agents', '/agents new', '/feedback', '/help', '/?', '/undo', '/new', '/memory', '/browser', '/review', '/pr-review', - '/usage', '/go', '/handoff session', '/handoff web', '/statusline', '/goal', '/goals', '/whatityped' + '/usage', '/upgrade', '/go', '/handoff session', '/handoff web', '/statusline', '/goal', '/goals', '/whatityped' ]; expected.forEach((cmd) => expect(commands).toContain(cmd)); expect(commands).not.toContain('/chrome'); diff --git a/tests/tuistory/built-cli.tuistory.test.ts b/tests/tuistory/built-cli.tuistory.test.ts index 920814d2..99f0ae92 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.', @@ -1011,6 +1043,66 @@ describe('interactive built CLI Tuistory tests', () => { await exitInteractive(session); }); + it('rotates a tip under the working status line and drops it when the turn ends', async () => { + const openRouterServer = await createMockOpenRouterServer('Tip check completed.', 4_000); + mockServers.push(openRouterServer); + const session = await launchInteractive({ + config: { + openrouter: { baseUrl: openRouterServer.baseUrl }, + agent: { sessionRetryLimit: 0 }, + }, + }); + await waitForComposer(session); + + await session.type('Run a delayed response so I can read the tip.'); + await session.press('enter'); + const working = stripAnsi(await session.text({ + timeout: 10_000, + waitFor: (text) => text.includes('esc to cancel') && text.includes('⎿ Tip:'), + })); + const lines = working.split('\n'); + const statusIndex = lines.findIndex((line) => line.includes('esc to cancel')); + expect(statusIndex).toBeGreaterThanOrEqual(0); + expect(lines[statusIndex + 1]).toContain('⎿ Tip:'); + + const finished = stripAnsi(await session.text({ + timeout: 15_000, + waitFor: (text) => text.includes('Tip check completed.') && !text.includes('esc to cancel'), + })); + expect(finished).not.toContain('⎿ Tip:'); + + await exitInteractive(session); + }); + + it('opens the console upgrade link for the next plan from /upgrade', async () => { + const authServer = await createMockAuthServer(); + mockAuthServers.push(authServer); + const session = await launchInteractive({ + config: { + provider: 'openai', + openai: { apiKey: 'tuistory-test-api-key', model: 'gpt-5.5' }, + 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.type('/upgrade'); + await session.press('enter'); + // The mock account is on Pro, so the next plan is Max. AUTOHAND_NO_BROWSER + // makes the opener print the link instead of launching a browser. + const output = stripAnsi(await session.text({ + timeout: 10_000, + waitFor: (text) => text.includes('upgrade=max'), + })); + expect(output).toContain('https://console.autohand.ai/?upgrade=max&source=cli'); + + await exitInteractive(session); + }); + const cachedAnnouncements = [ { id: 'tuistory-announcement-one', @@ -1818,6 +1910,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 +1956,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 () => { diff --git a/tests/tuistory/helpers/autohandTuistory.ts b/tests/tuistory/helpers/autohandTuistory.ts index a460d897..dcfbdf1a 100644 --- a/tests/tuistory/helpers/autohandTuistory.ts +++ b/tests/tuistory/helpers/autohandTuistory.ts @@ -1162,6 +1162,7 @@ export async function launchBuiltAutohand( AUTOHAND_SKIP_PING: '1', AUTOHAND_SKIP_UPDATE_CHECK: '1', AUTOHAND_OFFLINE: '1', + AUTOHAND_NO_BROWSER: '1', // Hermetic version resolution: an ambient AUTOHAND_VERSION_SOURCE (e.g. // when the suite runs inside an Autohand session) would otherwise make // the built CLI report a git-derived version instead of the manifest one. diff --git a/tests/ui/activityIndicator.spec.ts b/tests/ui/activityIndicator.spec.ts index a9667fcc..8d216bea 100644 --- a/tests/ui/activityIndicator.spec.ts +++ b/tests/ui/activityIndicator.spec.ts @@ -68,4 +68,22 @@ describe('ActivityIndicator', () => { expect(tip).toBeTruthy(); expect(typeof tip).toBe('string'); }); + + it('rotates the tip without changing the verb', () => { + const custom = new ActivityIndicator({ activityVerbs: ['Hacking'] }); + custom.next(); + const seen = new Set([custom.getTip()]); + for (let i = 0; i < 5; i++) seen.add(custom.nextTip()); + expect(seen.size).toBeGreaterThan(1); + expect(custom.getVerb()).toBe('Hacking'); + }); + + it('forwards the tip context so installed skills can be suggested', () => { + const custom = new ActivityIndicator({ + tipContext: { listSkills: () => [{ name: 'deploy', description: 'Ship it' }] }, + }); + const tips = new Set(); + for (let i = 0; i < 60; i++) tips.add(custom.nextTip()); + expect([...tips].some((tip) => tip.includes('$deploy'))).toBe(true); + }); }); 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/AgentUI.tipLine.test.tsx b/tests/ui/ink/AgentUI.tipLine.test.tsx new file mode 100644 index 00000000..d9afba13 --- /dev/null +++ b/tests/ui/ink/AgentUI.tipLine.test.tsx @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { cleanup, render } from 'ink-testing-library'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AgentUI, createInitialUIState, type AgentUIState } from '../../../src/ui/ink/AgentUI.js'; +import { I18nProvider } from '../../../src/ui/i18n/index.js'; +import { ThemeProvider } from '../../../src/ui/theme/ThemeContext.js'; + +afterEach(() => cleanup()); + +function renderState(state: AgentUIState) { + return render( + + + + + , + ); +} + +describe('AgentUI tip line', () => { + it('renders the tip on the line after the working status', () => { + const { lastFrame } = renderState({ + ...createInitialUIState(), + isWorking: true, + status: 'Grokking...', + tip: { kind: 'tip', text: 'Use @filename to give the agent context' }, + }); + const lines = (lastFrame() ?? '').split('\n'); + const statusIndex = lines.findIndex((line) => line.includes('Grokking...')); + expect(statusIndex).toBeGreaterThanOrEqual(0); + expect(lines[statusIndex + 1]).toContain('⎿ Tip: Use @filename'); + }); + + it('hides a rotating tip once work stops but keeps an upgrade hint', () => { + const idle = { ...createInitialUIState(), isWorking: false }; + expect(renderState({ ...idle, tip: { kind: 'tip', text: 'gone' } }).lastFrame()).not.toContain('gone'); + expect(renderState({ ...idle, tip: { kind: 'upgrade', text: 'Run /upgrade' } }).lastFrame()).toContain('⎿ Plan: Run /upgrade'); + }); +}); 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; diff --git a/tests/ui/ink/InkRenderer.tip.test.ts b/tests/ui/ink/InkRenderer.tip.test.ts new file mode 100644 index 00000000..b6cb6314 --- /dev/null +++ b/tests/ui/ink/InkRenderer.tip.test.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { InkRenderer } from '../../../src/ui/ink/InkRenderer.js'; + +function makeRenderer(): InkRenderer { + return new InkRenderer({ onInstruction: () => {}, onEscape: () => {}, onCtrlC: () => {} }); +} + +describe('InkRenderer tip state', () => { + it('clears a rotating tip when work stops', () => { + const renderer = makeRenderer(); + renderer.setWorking(true, 'Grokking...'); + renderer.setTip({ kind: 'tip', text: 'rotating' }); + expect(renderer.getState().tip).toEqual({ kind: 'tip', text: 'rotating' }); + renderer.setWorking(false); + expect(renderer.getState().tip).toBeUndefined(); + }); + + it('keeps an upgrade hint after work stops and clears it when work starts', () => { + const renderer = makeRenderer(); + renderer.setWorking(true, 'Grokking...'); + renderer.setTip({ kind: 'upgrade', text: 'Run /upgrade' }); + renderer.setWorking(false); + expect(renderer.getState().tip).toEqual({ kind: 'upgrade', text: 'Run /upgrade' }); + renderer.setWorking(true, 'Grokking...'); + expect(renderer.getState().tip).toBeUndefined(); + }); +}); diff --git a/tests/ui/ink/TipLine.test.tsx b/tests/ui/ink/TipLine.test.tsx new file mode 100644 index 00000000..8b632c00 --- /dev/null +++ b/tests/ui/ink/TipLine.test.tsx @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { describe, expect, it } from 'vitest'; +import { TipLine } from '../../../src/ui/ink/TipLine.js'; +import { ThemeProvider } from '../../../src/ui/theme/ThemeContext.js'; + +function renderTip(props: React.ComponentProps) { + return render(); +} + +describe('TipLine', () => { + it('renders a rotating tip with the Tip prefix while working', () => { + const { lastFrame } = renderTip({ tip: { kind: 'tip', text: 'Use /undo' }, isWorking: true, columns: 80 }); + expect(lastFrame()).toContain('⎿ Tip: Use /undo'); + }); + + it('hides a rotating tip when work has stopped', () => { + const { lastFrame } = renderTip({ tip: { kind: 'tip', text: 'Use /undo' }, isWorking: false, columns: 80 }); + expect(lastFrame()).toBe(''); + }); + + it('keeps an upgrade hint visible after work stops with the Plan prefix', () => { + const { lastFrame } = renderTip({ tip: { kind: 'upgrade', text: 'Run /upgrade' }, isWorking: false, columns: 80 }); + expect(lastFrame()).toContain('⎿ Plan: Run /upgrade'); + }); + + it('truncates to the terminal width', () => { + const { lastFrame } = renderTip({ tip: { kind: 'tip', text: 'x'.repeat(100) }, isWorking: true, columns: 30 }); + const frame = (lastFrame() ?? '').replace(/\[[0-9;]*m/g, ''); + expect(frame.length).toBeLessThanOrEqual(30); + expect(frame.endsWith('…')).toBe(true); + }); + + it('renders nothing without a tip', () => { + const { lastFrame } = renderTip({ tip: undefined, isWorking: true, columns: 80 }); + expect(lastFrame()).toBe(''); + }); +}); diff --git a/tests/ui/tips.spec.ts b/tests/ui/tips.spec.ts index a56fd7e8..aa913ce0 100644 --- a/tests/ui/tips.spec.ts +++ b/tests/ui/tips.spec.ts @@ -1,41 +1,92 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { TipsBag } from '../../src/ui/tips.js'; +import { describe, it, expect } from 'vitest'; +import { TipsBag, expandToolTips, type ToolTip } from '../../src/ui/tips.js'; +import toolTips from '../../src/ui/tool_tips.json' with { type: 'json' }; -describe('TipsBag', () => { - let bag: TipsBag; +describe('tool_tips.json', () => { + it('keeps the full built-in pool and mentions the extension builder', () => { + const texts = toolTips.tips.map((tip) => tip.text); + expect(texts.length).toBeGreaterThanOrEqual(20); + expect(texts.some((text) => text.includes('$extension-builder'))).toBe(true); + expect(toolTips.tips.some((tip) => tip.kind === 'skill')).toBe(true); + expect(toolTips.tips.some((tip) => tip.kind === 'command')).toBe(true); + }); +}); + +describe('expandToolTips', () => { + const tips: ToolTip[] = [ + { text: 'Plain tip' }, + { kind: 'skill', text: 'Try ${{skill}}: {{description}}' }, + { kind: 'command', text: 'Type {{command}} to {{description}}' }, + ]; + + it('expands skill and command templates once per item', () => { + const expanded = expandToolTips(tips, { + listSkills: () => [{ name: 'deploy', description: 'Ship to production' }], + listCommands: () => [ + { command: '/goal', description: 'Track a persistent goal' }, + { command: '/undo', description: 'revert the last change' }, + ], + }); + expect(expanded).toEqual([ + 'Plain tip', + 'Try $deploy: ship to production', + 'Type /goal to track a persistent goal', + 'Type /undo to revert the last change', + ]); + }); + + it('drops template tips when there is nothing to fill them with', () => { + expect(expandToolTips(tips, {})).toEqual(['Plain tip']); + expect(expandToolTips(tips, { listSkills: () => [] })).toEqual(['Plain tip']); + }); - beforeEach(() => { - bag = new TipsBag(); + it('skips skills without a description and malformed entries', () => { + const expanded = expandToolTips( + [...tips, { text: '' } as ToolTip, { kind: 'static' } as ToolTip], + { listSkills: () => [{ name: 'bare' }] }, + ); + expect(expanded).toEqual(['Plain tip']); }); +}); - it('returns a non-empty string', () => { - const tip = bag.next(); - expect(tip).toBeTruthy(); +describe('TipsBag', () => { + it('returns a non-empty string from the built-in pool', () => { + const tip = new TipsBag().next(); expect(typeof tip).toBe('string'); + expect(tip.length).toBeGreaterThan(0); }); - it('does not repeat until pool is exhausted', () => { + it('does not repeat until the pool is exhausted', () => { + const bag = new TipsBag(); const seen = new Set(); - const poolSize = bag.size; - for (let i = 0; i < poolSize; i++) { + for (let i = 0; i < bag.size; i++) { const tip = bag.next(); expect(seen.has(tip)).toBe(false); seen.add(tip); } - const tip = bag.next(); - expect(tip).toBeTruthy(); - }); - - it('accepts a custom tip pool', () => { - const custom = new TipsBag(['Tip A', 'Tip B']); - const tips = [custom.next(), custom.next()]; - expect(tips).toContain('Tip A'); - expect(tips).toContain('Tip B'); + expect(bag.next()).toBeTruthy(); }); - it('handles single-element pool', () => { - const single = new TipsBag(['Only tip']); + it('accepts a custom pool and handles a single entry', () => { + const custom = new TipsBag([{ text: 'Tip A' }, { text: 'Tip B' }]); + expect([custom.next(), custom.next()].sort()).toEqual(['Tip A', 'Tip B']); + const single = new TipsBag([{ text: 'Only tip' }]); expect(single.next()).toBe('Only tip'); expect(single.next()).toBe('Only tip'); }); + + it('re-expands templates each time the pool refills so new skills appear', () => { + const skills: { name: string; description: string }[] = []; + const bag = new TipsBag( + [{ text: 'Plain' }, { kind: 'skill', text: '{{skill}}' }], + { listSkills: () => skills }, + ); + expect(bag.next()).toBe('Plain'); + skills.push({ name: 'later', description: 'added later' }); + expect(new Set([bag.next(), bag.next()])).toEqual(new Set(['Plain', 'later'])); + }); + + it('falls back to a generic tip when nothing expands', () => { + expect(new TipsBag([]).next()).toBe('Type /help to see all available slash commands'); + }); });