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