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