diff --git a/src/components/PromptInput/PromptInput.tsx b/src/components/PromptInput/PromptInput.tsx index 66f934d6..b65440e4 100644 --- a/src/components/PromptInput/PromptInput.tsx +++ b/src/components/PromptInput/PromptInput.tsx @@ -111,7 +111,7 @@ import { BackgroundTasksDialog } from '../tasks/BackgroundTasksDialog.js'; import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js'; import { TeamsDialog } from '../teams/TeamsDialog.js'; import VimTextInput from '../VimTextInput.js'; -import { getModeFromInput, getValueFromInput } from './inputModes.js'; +import { detectModeEntry, getModeFromInput, getValueFromInput } from './inputModes.js'; import { FOOTER_TEMPORARY_STATUS_TIMEOUT, Notifications } from './Notifications.js'; import PromptInputFooter from './PromptInputFooter.js'; import type { SuggestionItem } from './PromptInputFooterSuggestions.js'; @@ -878,24 +878,22 @@ function PromptInput({ abortPromptSuggestion(); abortSpeculation(setAppState); - // Check if this is a single character insertion at the start - const isSingleCharInsertion = value.length === input.length + 1; - const insertedAtStart = cursorOffset === 0; - const mode = getModeFromInput(value); - if (insertedAtStart && mode !== 'prompt') { - if (isSingleCharInsertion) { - onModeChange(mode); - return; - } - // Multi-char insertion into empty input (e.g. tab-accepting "! gcloud auth login") - if (input.length === 0) { - onModeChange(mode); - const valueWithoutMode = getValueFromInput(value).replaceAll('\t', ' '); - pushToBuffer(input, cursorOffset, pastedContents); - trackAndSetInput(valueWithoutMode); - setCursorOffset(valueWithoutMode.length); - return; - } + // Strip the mode character from the buffer when entering bash mode — the + // mode itself is shown via the prompt prefix in the UI. Without this, + // typing `!` into empty input would enter bash mode but leave the literal + // `!` in the buffer (issue #662). + const modeEntry = detectModeEntry({ + value, + prevInputLength: input.length, + cursorOffset, + }); + if (modeEntry) { + onModeChange(modeEntry.mode); + const cleaned = modeEntry.strippedValue.replaceAll('\t', ' '); + pushToBuffer(input, cursorOffset, pastedContents); + trackAndSetInput(cleaned); + setCursorOffset(cleaned.length); + return; } const processedValue = value.replaceAll('\t', ' '); diff --git a/src/components/PromptInput/inputModes.test.ts b/src/components/PromptInput/inputModes.test.ts new file mode 100644 index 00000000..69c97998 --- /dev/null +++ b/src/components/PromptInput/inputModes.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'bun:test' +import { + detectModeEntry, + getModeFromInput, + getValueFromInput, + isInputModeCharacter, + prependModeCharacterToInput, +} from './inputModes.js' + +describe('inputModes', () => { + describe('getModeFromInput', () => { + it('returns bash mode for input starting with !', () => { + expect(getModeFromInput('!')).toBe('bash') + expect(getModeFromInput('!ls')).toBe('bash') + }) + + it('returns prompt mode for non-bash input', () => { + expect(getModeFromInput('')).toBe('prompt') + expect(getModeFromInput('hello')).toBe('prompt') + expect(getModeFromInput(' !')).toBe('prompt') + }) + }) + + describe('getValueFromInput', () => { + it('strips the leading ! when entering bash mode', () => { + expect(getValueFromInput('!')).toBe('') + expect(getValueFromInput('!ls -la')).toBe('ls -la') + }) + + it('returns input unchanged in prompt mode', () => { + expect(getValueFromInput('')).toBe('') + expect(getValueFromInput('hello')).toBe('hello') + }) + }) + + describe('isInputModeCharacter', () => { + it('returns true only for the bare ! character', () => { + expect(isInputModeCharacter('!')).toBe(true) + expect(isInputModeCharacter('!ls')).toBe(false) + expect(isInputModeCharacter('')).toBe(false) + }) + }) + + describe('prependModeCharacterToInput', () => { + it('prepends ! when mode is bash', () => { + expect(prependModeCharacterToInput('ls', 'bash')).toBe('!ls') + expect(prependModeCharacterToInput('', 'bash')).toBe('!') + }) + + it('returns input unchanged in prompt mode', () => { + expect(prependModeCharacterToInput('hello', 'prompt')).toBe('hello') + }) + }) + + describe('detectModeEntry', () => { + // Regression for #662 — typing `!` into empty input must switch to bash + // mode AND yield an empty stripped buffer. Before the fix the single-char + // path returned without stripping, leaving `!` visible in the buffer. + it('strips the mode character when typing ! into empty input', () => { + expect( + detectModeEntry({ value: '!', prevInputLength: 0, cursorOffset: 0 }), + ).toEqual({ mode: 'bash', strippedValue: '' }) + }) + + it('strips the mode character when pasting !cmd into empty input', () => { + expect( + detectModeEntry({ value: '!ls -la', prevInputLength: 0, cursorOffset: 0 }), + ).toEqual({ mode: 'bash', strippedValue: 'ls -la' }) + }) + + it('returns null when the cursor is not at the start', () => { + expect( + detectModeEntry({ value: '!', prevInputLength: 0, cursorOffset: 1 }), + ).toBeNull() + }) + + it('returns null when the value does not start with !', () => { + expect( + detectModeEntry({ value: 'hello', prevInputLength: 0, cursorOffset: 0 }), + ).toBeNull() + }) + + it('returns null when typing ! after existing text', () => { + // value="ab!" with prevInputLength=2 is a single-char insertion but does + // not start with ! — getModeFromInput returns 'prompt'. + expect( + detectModeEntry({ value: 'ab!', prevInputLength: 2, cursorOffset: 0 }), + ).toBeNull() + }) + + it('returns null when prepending ! to non-empty existing text', () => { + // Single-char insertion at start that produces "!ab" from "ab" — value + // length is 3, prevInputLength is 2, so isSingleCharInsertion is true + // and isMultiCharIntoEmpty is false. We accept the mode change here so + // that typing ! at the start of existing text still toggles mode. + const result = detectModeEntry({ + value: '!ab', + prevInputLength: 2, + cursorOffset: 0, + }) + expect(result).toEqual({ mode: 'bash', strippedValue: 'ab' }) + }) + }) +}) diff --git a/src/components/PromptInput/inputModes.ts b/src/components/PromptInput/inputModes.ts index f464a206..2db312da 100644 --- a/src/components/PromptInput/inputModes.ts +++ b/src/components/PromptInput/inputModes.ts @@ -31,3 +31,30 @@ export function getValueFromInput(input: string): string { export function isInputModeCharacter(input: string): boolean { return input === '!' } + +export type ModeEntryDecision = { + mode: HistoryMode + strippedValue: string +} + +/** + * Decide whether an onChange `value` should switch the input mode (e.g. + * `prompt` → `bash`) and what the stripped buffer value should be. + * + * Returns null when no mode change applies. Returns a decision otherwise so + * callers run a single update path — no separate single-char vs multi-char + * branches that can drift apart. + */ +export function detectModeEntry(args: { + value: string + prevInputLength: number + cursorOffset: number +}): ModeEntryDecision | null { + if (args.cursorOffset !== 0) return null + const mode = getModeFromInput(args.value) + if (mode === 'prompt') return null + const isSingleCharInsertion = args.value.length === args.prevInputLength + 1 + const isMultiCharIntoEmpty = args.prevInputLength === 0 + if (!isSingleCharInsertion && !isMultiCharIntoEmpty) return null + return { mode, strippedValue: getValueFromInput(args.value) } +}