Compare commits

..

1 Commits

Author SHA1 Message Date
gnanam1990
632807d0ba fix(input): strip leading ! when entering bash mode
The PromptInput onChange handler had two branches for entering bash
mode: a single-char path that just toggled the mode and a multi-char
paste path that also stripped the leading `!` from the buffer. The
single-char path returned without stripping, so typing a bare `!` into
empty input switched modes but left the literal `!` visible.

Consolidated both paths through a new pure helper `detectModeEntry`
that returns the new mode plus the stripped buffer value, so there is
no longer a branch where the mode character can leak into the buffer.

Fixes #662
2026-04-29 06:59:17 +05:30
9 changed files with 157 additions and 170 deletions

View File

@@ -421,16 +421,3 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# WEB_CUSTOM_ALLOW_HTTP=false — set "true" to allow http:// URLs # WEB_CUSTOM_ALLOW_HTTP=false — set "true" to allow http:// URLs
# WEB_CUSTOM_ALLOW_PRIVATE=false — set "true" to target localhost/private IPs # WEB_CUSTOM_ALLOW_PRIVATE=false — set "true" to target localhost/private IPs
# (needed for self-hosted SearXNG) # (needed for self-hosted SearXNG)
# ── Config directory override ───────────────────────────────────────
#
# By default openclaude stores per-user state under ~/.openclaude
# (and falls back to ~/.claude for installs that pre-date the rename).
# Set this to point openclaude at a different directory — useful for
# isolating profiles or sharing config across machines.
#
# OPENCLAUDE_CONFIG_DIR=/path/to/dir — preferred name
# CLAUDE_CONFIG_DIR=/path/to/dir — legacy alias (still works)
#
# When both are set with different values, OPENCLAUDE_CONFIG_DIR wins
# and a warning is logged once per process.

View File

@@ -111,7 +111,7 @@ import { BackgroundTasksDialog } from '../tasks/BackgroundTasksDialog.js';
import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js'; import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js';
import { TeamsDialog } from '../teams/TeamsDialog.js'; import { TeamsDialog } from '../teams/TeamsDialog.js';
import VimTextInput from '../VimTextInput.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 { FOOTER_TEMPORARY_STATUS_TIMEOUT, Notifications } from './Notifications.js';
import PromptInputFooter from './PromptInputFooter.js'; import PromptInputFooter from './PromptInputFooter.js';
import type { SuggestionItem } from './PromptInputFooterSuggestions.js'; import type { SuggestionItem } from './PromptInputFooterSuggestions.js';
@@ -878,25 +878,23 @@ function PromptInput({
abortPromptSuggestion(); abortPromptSuggestion();
abortSpeculation(setAppState); abortSpeculation(setAppState);
// Check if this is a single character insertion at the start // Strip the mode character from the buffer when entering bash mode — the
const isSingleCharInsertion = value.length === input.length + 1; // mode itself is shown via the prompt prefix in the UI. Without this,
const insertedAtStart = cursorOffset === 0; // typing `!` into empty input would enter bash mode but leave the literal
const mode = getModeFromInput(value); // `!` in the buffer (issue #662).
if (insertedAtStart && mode !== 'prompt') { const modeEntry = detectModeEntry({
if (isSingleCharInsertion) { value,
onModeChange(mode); prevInputLength: input.length,
return; cursorOffset,
} });
// Multi-char insertion into empty input (e.g. tab-accepting "! gcloud auth login") if (modeEntry) {
if (input.length === 0) { onModeChange(modeEntry.mode);
onModeChange(mode); const cleaned = modeEntry.strippedValue.replaceAll('\t', ' ');
const valueWithoutMode = getValueFromInput(value).replaceAll('\t', ' ');
pushToBuffer(input, cursorOffset, pastedContents); pushToBuffer(input, cursorOffset, pastedContents);
trackAndSetInput(valueWithoutMode); trackAndSetInput(cleaned);
setCursorOffset(valueWithoutMode.length); setCursorOffset(cleaned.length);
return; return;
} }
}
const processedValue = value.replaceAll('\t', ' '); const processedValue = value.replaceAll('\t', ' ');
// Push current state to buffer before making changes // Push current state to buffer before making changes

View File

@@ -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' })
})
})
})

View File

@@ -31,3 +31,30 @@ export function getValueFromInput(input: string): string {
export function isInputModeCharacter(input: string): boolean { export function isInputModeCharacter(input: string): boolean {
return input === '!' 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) }
}

View File

@@ -3,11 +3,7 @@ import { homedir } from 'os'
import { join } from 'path' import { join } from 'path'
import { fileSuffixForOauthConfig } from '../constants/oauth.js' import { fileSuffixForOauthConfig } from '../constants/oauth.js'
import { isRunningWithBun } from './bundledMode.js' import { isRunningWithBun } from './bundledMode.js'
import { import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
getClaudeConfigHomeDir,
isEnvTruthy,
resolveConfigDirEnv,
} from './envUtils.js'
import { findExecutable } from './findExecutable.js' import { findExecutable } from './findExecutable.js'
import { getFsImplementation } from './fsOperations.js' import { getFsImplementation } from './fsOperations.js'
import { which } from './which.js' import { which } from './which.js'
@@ -26,11 +22,7 @@ export const getGlobalClaudeFile = memoize((): string => {
} }
const oauthSuffix = fileSuffixForOauthConfig() const oauthSuffix = fileSuffixForOauthConfig()
const configDir = const configDir = process.env.CLAUDE_CONFIG_DIR || homedir()
resolveConfigDirEnv({
openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR,
legacyConfigDir: process.env.CLAUDE_CONFIG_DIR,
}) ?? homedir()
// Default to .openclaude.json. Fall back to .claude.json only if the new // Default to .openclaude.json. Fall back to .claude.json only if the new
// file doesn't exist yet and the legacy one does (same migration pattern // file doesn't exist yet and the legacy one does (same migration pattern

View File

@@ -3,39 +3,6 @@ import { existsSync } from 'fs'
import { homedir } from 'os' import { homedir } from 'os'
import { join } from 'path' import { join } from 'path'
/**
* Resolves the override env value for the config home directory.
* `OPENCLAUDE_CONFIG_DIR` is preferred — `CLAUDE_CONFIG_DIR` is the legacy
* Anthropic name kept working for backward compatibility. When both are set
* and disagree, `OPENCLAUDE_CONFIG_DIR` wins and we warn once so the user
* can clean up. Exported for tests.
*/
let warnedAboutConflictingConfigDirEnvs = false
export function resolveConfigDirEnv(options?: {
openClaudeConfigDir?: string
legacyConfigDir?: string
warn?: (message: string) => void
}): string | undefined {
const open = options?.openClaudeConfigDir
const legacy = options?.legacyConfigDir
if (open && legacy && open !== legacy && !warnedAboutConflictingConfigDirEnvs) {
warnedAboutConflictingConfigDirEnvs = true
options?.warn?.(
`Both OPENCLAUDE_CONFIG_DIR and CLAUDE_CONFIG_DIR are set to different values. Using OPENCLAUDE_CONFIG_DIR=${open}; ignoring CLAUDE_CONFIG_DIR=${legacy}.`,
)
}
return open || legacy || undefined
}
/**
* Test-only escape hatch — resets the once-per-process conflict warning so
* unit tests can re-trigger it.
*/
export function __resetConfigDirEnvWarningForTesting(): void {
warnedAboutConflictingConfigDirEnvs = false
}
export function resolveClaudeConfigHomeDir(options?: { export function resolveClaudeConfigHomeDir(options?: {
configDirEnv?: string configDirEnv?: string
homeDir?: string homeDir?: string
@@ -63,21 +30,13 @@ export function resolveClaudeConfigHomeDir(options?: {
return openClaudeDir.normalize('NFC') return openClaudeDir.normalize('NFC')
} }
// Memoized: 150+ callers, many on hot paths. Keyed off both override env // Memoized: 150+ callers, many on hot paths. Keyed off CLAUDE_CONFIG_DIR so
// vars so tests that change either get a fresh value without explicit // tests that change the env var get a fresh value without explicit cache.clear.
// cache.clear.
export const getClaudeConfigHomeDir = memoize( export const getClaudeConfigHomeDir = memoize(
(): string => resolveClaudeConfigHomeDir({ (): string => resolveClaudeConfigHomeDir({
configDirEnv: resolveConfigDirEnv({ configDirEnv: process.env.CLAUDE_CONFIG_DIR,
openClaudeConfigDir: process.env.OPENCLAUDE_CONFIG_DIR,
legacyConfigDir: process.env.CLAUDE_CONFIG_DIR,
warn: message => {
// eslint-disable-next-line no-console
console.warn(`[openclaude] ${message}`)
},
}), }),
}), () => process.env.CLAUDE_CONFIG_DIR,
() => `${process.env.OPENCLAUDE_CONFIG_DIR ?? ''}|${process.env.CLAUDE_CONFIG_DIR ?? ''}`,
) )
export function getTeamsDir(): string { export function getTeamsDir(): string {

View File

@@ -51,8 +51,7 @@ describe('OpenClaude paths', () => {
).toBe(join(homedir(), '.claude')) ).toBe(join(homedir(), '.claude'))
}) })
test('uses CLAUDE_CONFIG_DIR override when provided (legacy)', async () => { test('uses CLAUDE_CONFIG_DIR override when provided', async () => {
delete process.env.OPENCLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = '/tmp/custom-openclaude' process.env.CLAUDE_CONFIG_DIR = '/tmp/custom-openclaude'
const { getClaudeConfigHomeDir, resolveClaudeConfigHomeDir } = const { getClaudeConfigHomeDir, resolveClaudeConfigHomeDir } =
await importFreshEnvUtils() await importFreshEnvUtils()
@@ -65,83 +64,6 @@ describe('OpenClaude paths', () => {
).toBe('/tmp/custom-openclaude') ).toBe('/tmp/custom-openclaude')
}) })
test('OPENCLAUDE_CONFIG_DIR overrides the default (issue #454)', async () => {
delete process.env.CLAUDE_CONFIG_DIR
process.env.OPENCLAUDE_CONFIG_DIR = '/tmp/oc-config-only'
const { getClaudeConfigHomeDir } = await importFreshEnvUtils()
expect(getClaudeConfigHomeDir()).toBe('/tmp/oc-config-only')
})
test('OPENCLAUDE_CONFIG_DIR wins when both env vars are set with different values', async () => {
process.env.OPENCLAUDE_CONFIG_DIR = '/tmp/oc-wins'
process.env.CLAUDE_CONFIG_DIR = '/tmp/legacy-loses'
const { getClaudeConfigHomeDir } = await importFreshEnvUtils()
expect(getClaudeConfigHomeDir()).toBe('/tmp/oc-wins')
})
test('CLAUDE_CONFIG_DIR is still honored when OPENCLAUDE_CONFIG_DIR is unset', async () => {
delete process.env.OPENCLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = '/tmp/legacy-only'
const { getClaudeConfigHomeDir } = await importFreshEnvUtils()
expect(getClaudeConfigHomeDir()).toBe('/tmp/legacy-only')
})
test('empty OPENCLAUDE_CONFIG_DIR falls through to CLAUDE_CONFIG_DIR', async () => {
process.env.OPENCLAUDE_CONFIG_DIR = ''
process.env.CLAUDE_CONFIG_DIR = '/tmp/legacy-fallback'
const { getClaudeConfigHomeDir } = await importFreshEnvUtils()
expect(getClaudeConfigHomeDir()).toBe('/tmp/legacy-fallback')
})
test('resolveConfigDirEnv prefers OPENCLAUDE over CLAUDE and warns on conflict', async () => {
const { resolveConfigDirEnv, __resetConfigDirEnvWarningForTesting } =
await importFreshEnvUtils()
__resetConfigDirEnvWarningForTesting()
const warnings: string[] = []
const result = resolveConfigDirEnv({
openClaudeConfigDir: '/a',
legacyConfigDir: '/b',
warn: m => warnings.push(m),
})
expect(result).toBe('/a')
expect(warnings.length).toBe(1)
expect(warnings[0]).toContain('OPENCLAUDE_CONFIG_DIR=/a')
expect(warnings[0]).toContain('CLAUDE_CONFIG_DIR=/b')
})
test('resolveConfigDirEnv does not warn when both env vars agree', async () => {
const { resolveConfigDirEnv, __resetConfigDirEnvWarningForTesting } =
await importFreshEnvUtils()
__resetConfigDirEnvWarningForTesting()
const warnings: string[] = []
const result = resolveConfigDirEnv({
openClaudeConfigDir: '/same',
legacyConfigDir: '/same',
warn: m => warnings.push(m),
})
expect(result).toBe('/same')
expect(warnings).toEqual([])
})
test('resolveConfigDirEnv returns undefined when neither env var is set', async () => {
const { resolveConfigDirEnv } = await importFreshEnvUtils()
expect(
resolveConfigDirEnv({
openClaudeConfigDir: undefined,
legacyConfigDir: undefined,
}),
).toBeUndefined()
})
test('project and local settings paths use .openclaude', async () => { test('project and local settings paths use .openclaude', async () => {
const { getRelativeSettingsFilePathForSource } = await importFreshSettings() const { getRelativeSettingsFilePathForSource } = await importFreshSettings()

View File

@@ -34,8 +34,7 @@ export function getSecureStorageServiceName(
serviceSuffix: string = '', serviceSuffix: string = '',
): string { ): string {
const configDir = getClaudeConfigHomeDir() const configDir = getClaudeConfigHomeDir()
const isDefaultDir = const isDefaultDir = !process.env.CLAUDE_CONFIG_DIR
!process.env.OPENCLAUDE_CONFIG_DIR && !process.env.CLAUDE_CONFIG_DIR
// Use a hash of the config dir path to create a unique but stable suffix // Use a hash of the config dir path to create a unique but stable suffix
// Only add suffix for non-default directories to maintain backwards compatibility // Only add suffix for non-default directories to maintain backwards compatibility

View File

@@ -117,8 +117,7 @@ const TEAMMATE_ENV_VARS = [
'MISTRAL_BASE_URL', 'MISTRAL_BASE_URL',
// Custom API endpoint // Custom API endpoint
'ANTHROPIC_BASE_URL', 'ANTHROPIC_BASE_URL',
// Config directory override (preferred name + legacy alias) // Config directory override
'OPENCLAUDE_CONFIG_DIR',
'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIR',
// CCR marker — teammates need this for CCR-aware code paths. Auth finds // CCR marker — teammates need this for CCR-aware code paths. Auth finds
// its own way via /home/claude/.claude/remote/.oauth_token regardless; // its own way via /home/claude/.claude/remote/.oauth_token regardless;