mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
feat: add Vietnamese i18n for slash command descriptions (#1431)
* feat: add Vietnamese i18n support for slash command descriptions
Add a simple i18n helper that reads the `language` setting from config
to display localized skill descriptions. Currently supports English
(default) and Vietnamese.
To switch to Vietnamese, set in ~/.claude/settings.json:
{ "language": "vietnamese" }
Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
* feat(i18n): add Vietnamese translations for all 85 command descriptions
- Fix detectLocale() to read ~/.claude/settings.json directly via
readFileSync instead of broken require('../../utils/config.js')
- Add commandDescVi translation map with 85 Vietnamese descriptions
- Export translateCommandDescription() for use in command rendering
- Modify formatDescriptionWithSource() to translate descriptions
when language is set to "vietnamese"
- Bump version to 0.15.1
* fix: add prepare script for git-based installs
When installing via `npm install -g git+https://...`, npm runs the
`prepare` script automatically. This ensures the CLI is built from
source during installation.
Requires Bun to be installed globally.
Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
* fix(i18n): read locale from merged settings
* feat(i18n): translate all prompt-type commands + add env validation + node version files
## Changes
### 1. Fix prompt-type command translations (src/commands.ts)
- `formatDescriptionWithSource()` now calls `translateCommandDescription()` for ALL command types
- Previously only translated `builtin`/`mcp` source commands
- Now translates: workflow, plugin, bundled, and default cases
- Fixes: /review, /insights, and other prompt-type commands now display Vietnamese
### 2. Add missing Vietnamese translations (src/skills/bundled/i18n.ts)
Added 17 new command translations:
- /btw: "Đặt câu hỏi nhanh bên lề mà không làm gián đoạn cuộc hội thoại chính"
- /compact: "Xóa lịch sử hội thoại nhưng giữ tóm tắt trong ngữ cảnh"
- /auto-fix: "Cấu hình tự động sửa: chạy lint/test sau khi AI chỉnh sửa"
- /bridge-kick: "Chèn trạng thái lỗi bridge để kiểm thử khôi phục thủ công"
- /review: "Hoàn thành đánh giá bảo mật cho các thay đổi đang chờ trên nhánh hiện tại"
- +12 more commands
### 3. Add Zod env validation at startup (src/utils/envValidation.ts)
- New file: validates critical env vars using Zod at startup
- Crashes immediately if invalid (instead of wasting time)
- Validated vars: ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CONFIG_DIR, HTTP_PROXY, HTTPS_PROXY, NODE_EXTRA_CA_CERTS
- Integrated into src/entrypoints/init.ts
### 4. Add node version files
- .nvmrc: Node 22
- .node-version: Node 22
- Matches Dockerfile (node:22-slim) and package.json engines (>=22.0.0)
## Test Results
- 3007 pass, 11 fail (all in changeDetector.test.ts - pre-existing, unrelated to i18n)
Co-Authored-By: OpenClaude <noreply@openclaude.ai>
* fix: restore validateBoundedIntEnvVar in envValidation.ts
* Localize bundled skills descriptions at read time
* fix(i18n): localize slash command suggestions
Search rendered localized command descriptions and rebuild the Fuse index when language-sensitive text changes.
Preserve Unicode letters and numbers for Vietnamese slash queries, localize the remaining requested command descriptions, and keep exact slash command submission from following a stale highlighted suggestion.
Tests: bun test src/commands.test.ts; bun test src/utils/suggestions/commandSuggestions.test.ts; bun test src/utils/envValidation.test.ts
Thanks to @jatmn for the patient review and guidance.
* fix(i18n): tighten slash command localization scope
* fix(i18n): centralize localization and preserve external metadata
* fix(commands): scope localized descriptions to OpenClaude-owned commands
* fix(i18n): read session language before initial settings
* fix(i18n): prefer whenToUse localization keys
---------
Co-authored-by: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
Co-authored-by: OpenClaude <noreply@openclaude.ai>
Co-authored-by: lht3003-rgb <lht3003-rgb@users.noreply.github.com>
This commit is contained in:
co-authored by
OpenClaude
OpenClaude
lht3003-rgb
parent
a3f144bbf2
commit
89d05317b6
@@ -0,0 +1 @@
|
|||||||
|
22
|
||||||
+248
-1
@@ -1,9 +1,35 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { afterEach, describe, expect, test } from 'bun:test'
|
||||||
import {
|
import {
|
||||||
builtInCommandNames,
|
builtInCommandNames,
|
||||||
formatDescriptionWithSource,
|
formatDescriptionWithSource,
|
||||||
} from './commands.js'
|
} from './commands.js'
|
||||||
|
import { registerBatchSkill } from './skills/bundled/batch.js'
|
||||||
|
import { registerDebugSkill } from './skills/bundled/debug.js'
|
||||||
|
import { registerLoopSkill } from './skills/bundled/loop.js'
|
||||||
|
import { registerSimplifySkill } from './skills/bundled/simplify.js'
|
||||||
|
import { registerUpdateConfigSkill } from './skills/bundled/updateConfig.js'
|
||||||
|
import {
|
||||||
|
clearBundledSkills,
|
||||||
|
getBundledSkills,
|
||||||
|
registerBundledSkill,
|
||||||
|
} from './skills/bundledSkills.js'
|
||||||
import { isCommand } from './types/command.js'
|
import { isCommand } from './types/command.js'
|
||||||
|
import {
|
||||||
|
resetSettingsCache,
|
||||||
|
setSessionSettingsCache,
|
||||||
|
} from './utils/settings/settingsCache.js'
|
||||||
|
|
||||||
|
function useLanguage(language?: string): void {
|
||||||
|
setSessionSettingsCache({
|
||||||
|
settings: language ? { language } : {},
|
||||||
|
errors: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetSettingsCache()
|
||||||
|
clearBundledSkills()
|
||||||
|
})
|
||||||
|
|
||||||
describe('builtInCommandNames', () => {
|
describe('builtInCommandNames', () => {
|
||||||
test('includes the LSP command', () => {
|
test('includes the LSP command', () => {
|
||||||
@@ -71,4 +97,225 @@ describe('formatDescriptionWithSource', () => {
|
|||||||
|
|
||||||
expect(formatDescriptionWithSource(command)).toBe('(MyPlugin) ')
|
expect(formatDescriptionWithSource(command)).toBe('(MyPlugin) ')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('translates prompt built-in descriptions using the current language', () => {
|
||||||
|
const command = {
|
||||||
|
name: 'review',
|
||||||
|
type: 'prompt',
|
||||||
|
source: 'builtin',
|
||||||
|
description: 'Review a pull request',
|
||||||
|
localizationKey: 'commands.review.description',
|
||||||
|
} as any
|
||||||
|
|
||||||
|
useLanguage('english')
|
||||||
|
expect(formatDescriptionWithSource(command)).toBe('Review a pull request')
|
||||||
|
|
||||||
|
useLanguage('vietnamese')
|
||||||
|
expect(formatDescriptionWithSource(command)).toBe('Đánh giá pull request')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to English when an OpenClaude localization key is missing', () => {
|
||||||
|
const command = {
|
||||||
|
name: 'example',
|
||||||
|
type: 'prompt',
|
||||||
|
source: 'builtin',
|
||||||
|
description: 'English fallback description',
|
||||||
|
localizationKey: 'commands.example.missing.description',
|
||||||
|
} as any
|
||||||
|
|
||||||
|
useLanguage('vietnamese')
|
||||||
|
expect(formatDescriptionWithSource(command)).toBe(
|
||||||
|
'English fallback description',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not translate project, policy, workflow, or user-authored descriptions', () => {
|
||||||
|
const description = 'Review a pull request'
|
||||||
|
const promptCommand = (source: string) =>
|
||||||
|
({
|
||||||
|
name: 'external-review',
|
||||||
|
type: 'prompt',
|
||||||
|
source,
|
||||||
|
description,
|
||||||
|
}) as any
|
||||||
|
|
||||||
|
useLanguage('vietnamese')
|
||||||
|
|
||||||
|
expect(formatDescriptionWithSource(promptCommand('projectSettings'))).toBe(
|
||||||
|
'Review a pull request (project)',
|
||||||
|
)
|
||||||
|
expect(formatDescriptionWithSource(promptCommand('userSettings'))).toBe(
|
||||||
|
'Review a pull request (user)',
|
||||||
|
)
|
||||||
|
expect(formatDescriptionWithSource(promptCommand('policySettings'))).toBe(
|
||||||
|
'Review a pull request (managed)',
|
||||||
|
)
|
||||||
|
expect(formatDescriptionWithSource(promptCommand('localSettings'))).toBe(
|
||||||
|
'Review a pull request (project, gitignored)',
|
||||||
|
)
|
||||||
|
expect(formatDescriptionWithSource(promptCommand('flagSettings'))).toBe(
|
||||||
|
'Review a pull request (cli flag)',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
formatDescriptionWithSource({
|
||||||
|
...promptCommand('projectSettings'),
|
||||||
|
kind: 'workflow',
|
||||||
|
}),
|
||||||
|
).toBe('Review a pull request (workflow)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not translate plugin descriptions that match built-in English text', () => {
|
||||||
|
const command = {
|
||||||
|
name: 'external-review',
|
||||||
|
type: 'prompt',
|
||||||
|
source: 'plugin',
|
||||||
|
description: 'Review a pull request',
|
||||||
|
pluginInfo: {
|
||||||
|
pluginManifest: {
|
||||||
|
name: 'MyPlugin',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
|
||||||
|
useLanguage('vietnamese')
|
||||||
|
|
||||||
|
expect(formatDescriptionWithSource(command)).toBe(
|
||||||
|
'(MyPlugin) Review a pull request',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not translate non-prompt local descriptions without a localization key', () => {
|
||||||
|
const command = {
|
||||||
|
name: 'external-review',
|
||||||
|
type: 'local',
|
||||||
|
description: 'Review a pull request',
|
||||||
|
} as any
|
||||||
|
|
||||||
|
useLanguage('vietnamese')
|
||||||
|
|
||||||
|
expect(formatDescriptionWithSource(command)).toBe('Review a pull request')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('translates non-prompt local descriptions only with an explicit localization key', () => {
|
||||||
|
const command = {
|
||||||
|
name: 'copy',
|
||||||
|
type: 'local',
|
||||||
|
description:
|
||||||
|
"Copy Claude's last response to clipboard (or /copy N for the Nth-latest)",
|
||||||
|
localizationKey: 'commands.copy.description',
|
||||||
|
} as any
|
||||||
|
|
||||||
|
useLanguage('vietnamese')
|
||||||
|
expect(formatDescriptionWithSource(command)).toBe(
|
||||||
|
'Sao chép phản hồi gần nhất của Claude vào clipboard (hoặc /copy N cho phản hồi thứ N gần nhất)',
|
||||||
|
)
|
||||||
|
|
||||||
|
useLanguage('english')
|
||||||
|
expect(formatDescriptionWithSource(command)).toBe(
|
||||||
|
"Copy Claude's last response to clipboard (or /copy N for the Nth-latest)",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('bundled skill localization', () => {
|
||||||
|
test('resolves descriptions from the current language at read time', () => {
|
||||||
|
resetSettingsCache()
|
||||||
|
clearBundledSkills()
|
||||||
|
registerBatchSkill()
|
||||||
|
registerDebugSkill()
|
||||||
|
registerLoopSkill()
|
||||||
|
registerSimplifySkill()
|
||||||
|
registerUpdateConfigSkill()
|
||||||
|
const batch = getBundledSkills().find(command => command.name === 'batch')
|
||||||
|
const debug = getBundledSkills().find(command => command.name === 'debug')
|
||||||
|
const loop = getBundledSkills().find(command => command.name === 'loop')
|
||||||
|
const simplify = getBundledSkills().find(
|
||||||
|
command => command.name === 'simplify',
|
||||||
|
)
|
||||||
|
const updateConfig = getBundledSkills().find(
|
||||||
|
command => command.name === 'update-config',
|
||||||
|
)
|
||||||
|
const expectedDebugEnglish =
|
||||||
|
process.env.USER_TYPE === 'ant'
|
||||||
|
? 'Debug your current Claude Code session by reading the session debug log. Includes all event logging'
|
||||||
|
: 'Enable debug logging for this session and help diagnose issues'
|
||||||
|
const expectedDebugVietnamese =
|
||||||
|
process.env.USER_TYPE === 'ant'
|
||||||
|
? 'Debug phiên Claude Code hiện tại bằng cách đọc debug log của phiên. Bao gồm toàn bộ event logging'
|
||||||
|
: 'Bật debug logging cho phiên này và hỗ trợ chẩn đoán sự cố'
|
||||||
|
|
||||||
|
expect(batch).toBeDefined()
|
||||||
|
expect(debug).toBeDefined()
|
||||||
|
expect(loop).toBeDefined()
|
||||||
|
expect(simplify).toBeDefined()
|
||||||
|
expect(updateConfig).toBeDefined()
|
||||||
|
expect(batch!.localizationKey).toBe('skills.batch.description')
|
||||||
|
expect(loop!.localizationKey).toBe('skills.loop.description')
|
||||||
|
expect(loop!.whenToUseLocalizationKey).toBe('skills.loop.whenToUse')
|
||||||
|
|
||||||
|
useLanguage('english')
|
||||||
|
expect(batch!.description).toBe(
|
||||||
|
'Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.',
|
||||||
|
)
|
||||||
|
expect(debug!.description).toBe(expectedDebugEnglish)
|
||||||
|
expect(loop!.description).toBe(
|
||||||
|
'Run a prompt on a fixed interval or dynamically reschedule it, including bare maintenance-mode loops.',
|
||||||
|
)
|
||||||
|
expect(loop!.whenToUse).toBe(
|
||||||
|
'When the user wants to poll for status, babysit a workflow, run recurring maintenance, or keep re-running a prompt within the current session.',
|
||||||
|
)
|
||||||
|
expect(simplify!.description).toBe(
|
||||||
|
'Review changed code for reuse, quality, and efficiency, then fix any issues found.',
|
||||||
|
)
|
||||||
|
expect(updateConfig!.description).toStartWith(
|
||||||
|
'Use this skill to configure the Claude Code harness via settings.json.',
|
||||||
|
)
|
||||||
|
|
||||||
|
useLanguage('vietnamese')
|
||||||
|
expect(batch!.description).toBe(
|
||||||
|
'Nghiên cứu và lập kế hoạch cho một thay đổi quy mô lớn, rồi thực thi song song trên 5–30 agent worktree cô lập, mỗi agent mở một PR.',
|
||||||
|
)
|
||||||
|
expect(debug!.description).toBe(expectedDebugVietnamese)
|
||||||
|
expect(loop!.description).toBe(
|
||||||
|
'Chạy một prompt theo khoảng thời gian cố định hoặc lên lịch lại động, bao gồm cả chế độ bảo trì lặp lại.',
|
||||||
|
)
|
||||||
|
expect(loop!.whenToUse).toBe(
|
||||||
|
'Khi người dùng muốn kiểm tra trạng thái, giám sát quy trình, chạy bảo trì định kỳ, hoặc chạy lại một prompt trong phiên hiện tại.',
|
||||||
|
)
|
||||||
|
expect(simplify!.description).toBe(
|
||||||
|
'Đánh giá code đã thay đổi về mặt tái sử dụng, chất lượng và hiệu suất, sau đó sửa các vấn đề tìm được.',
|
||||||
|
)
|
||||||
|
expect(updateConfig!.description).toStartWith(
|
||||||
|
'Sử dụng skill này để cấu hình Claude Code qua settings.json.',
|
||||||
|
)
|
||||||
|
|
||||||
|
useLanguage('english')
|
||||||
|
expect(loop!.description).toBe(
|
||||||
|
'Run a prompt on a fixed interval or dynamically reschedule it, including bare maintenance-mode loops.',
|
||||||
|
)
|
||||||
|
expect(updateConfig!.description).toStartWith(
|
||||||
|
'Use this skill to configure the Claude Code harness via settings.json.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to bundled skill English text when a localization key is missing', () => {
|
||||||
|
registerBundledSkill({
|
||||||
|
name: 'fallback-skill',
|
||||||
|
description: 'English-only bundled skill description',
|
||||||
|
descriptionKey: 'skills.fallback-skill.missing.description',
|
||||||
|
getPromptForCommand: async () => [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const skill = getBundledSkills().find(
|
||||||
|
command => command.name === 'fallback-skill',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(skill).toBeDefined()
|
||||||
|
|
||||||
|
useLanguage('vietnamese')
|
||||||
|
expect(skill!.description).toBe('English-only bundled skill description')
|
||||||
|
expect(formatDescriptionWithSource(skill!)).toBe(
|
||||||
|
'English-only bundled skill description (bundled)',
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+23
-4
@@ -168,6 +168,10 @@ import {
|
|||||||
getDynamicSkills,
|
getDynamicSkills,
|
||||||
} from './skills/loadSkillsDir.js'
|
} from './skills/loadSkillsDir.js'
|
||||||
import { getBundledSkills } from './skills/bundledSkills.js'
|
import { getBundledSkills } from './skills/bundledSkills.js'
|
||||||
|
import {
|
||||||
|
getOpenClaudeCommandDescriptionKey,
|
||||||
|
localize,
|
||||||
|
} from './i18n/index.js'
|
||||||
import { getBuiltinPluginSkillCommands } from './plugins/builtinPlugins.js'
|
import { getBuiltinPluginSkillCommands } from './plugins/builtinPlugins.js'
|
||||||
import {
|
import {
|
||||||
getPluginCommands,
|
getPluginCommands,
|
||||||
@@ -365,7 +369,12 @@ const COMMANDS = memoize((): Command[] => [
|
|||||||
...(process.env.USER_TYPE === 'ant' && !process.env.IS_DEMO
|
...(process.env.USER_TYPE === 'ant' && !process.env.IS_DEMO
|
||||||
? INTERNAL_ONLY_COMMANDS
|
? INTERNAL_ONLY_COMMANDS
|
||||||
: []),
|
: []),
|
||||||
].filter(isCommand))
|
].filter(isCommand).map(withOpenClaudeCommandLocalizationKey))
|
||||||
|
|
||||||
|
function withOpenClaudeCommandLocalizationKey(cmd: Command): Command {
|
||||||
|
cmd.localizationKey ??= getOpenClaudeCommandDescriptionKey(cmd.name)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
export const builtInCommandNames = memoize(
|
export const builtInCommandNames = memoize(
|
||||||
(): Set<string> =>
|
(): Set<string> =>
|
||||||
@@ -752,7 +761,7 @@ export function getCommand(commandName: string, commands: Command[]): Command {
|
|||||||
*/
|
*/
|
||||||
export function formatDescriptionWithSource(cmd: Command): string {
|
export function formatDescriptionWithSource(cmd: Command): string {
|
||||||
if (cmd.type !== 'prompt') {
|
if (cmd.type !== 'prompt') {
|
||||||
return cmd.description ?? ''
|
return formatOpenClaudeOwnedDescription(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
const desc = cmd.description ?? ''
|
const desc = cmd.description ?? ''
|
||||||
@@ -770,12 +779,22 @@ export function formatDescriptionWithSource(cmd: Command): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cmd.source === 'builtin' || cmd.source === 'mcp') {
|
if (cmd.source === 'builtin' || cmd.source === 'mcp') {
|
||||||
return desc
|
return cmd.source === 'builtin'
|
||||||
|
? formatOpenClaudeOwnedDescription(cmd)
|
||||||
|
: desc
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmd.source === 'bundled') {
|
if (cmd.source === 'bundled') {
|
||||||
return `${desc} (bundled)`
|
return `${formatOpenClaudeOwnedDescription(cmd)} (bundled)`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${desc} (${getSettingSourceName(cmd.source)})`
|
return `${desc} (${getSettingSourceName(cmd.source)})`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatOpenClaudeOwnedDescription(cmd: Command): string {
|
||||||
|
const desc = cmd.description ?? ''
|
||||||
|
if (cmd.localizationKey) {
|
||||||
|
return localize(cmd.localizationKey, desc)
|
||||||
|
}
|
||||||
|
return desc
|
||||||
|
}
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ type Props = {
|
|||||||
setAppState: (f: (prev: AppState) => AppState) => void;
|
setAppState: (f: (prev: AppState) => AppState) => void;
|
||||||
}, options?: {
|
}, options?: {
|
||||||
fromKeybinding?: boolean;
|
fromKeybinding?: boolean;
|
||||||
|
slashCommandOverride?: Command;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
onAgentSubmit?: (input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers) => Promise<void>;
|
onAgentSubmit?: (input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers) => Promise<void>;
|
||||||
isSearchingHistory: boolean;
|
isSearchingHistory: boolean;
|
||||||
@@ -990,7 +991,7 @@ function PromptInput({
|
|||||||
const setSuggestionsState = useCallback((updater: typeof suggestionsState | ((prev: typeof suggestionsState) => typeof suggestionsState)) => {
|
const setSuggestionsState = useCallback((updater: typeof suggestionsState | ((prev: typeof suggestionsState) => typeof suggestionsState)) => {
|
||||||
setSuggestionsStateRaw(prev => typeof updater === 'function' ? updater(prev) : updater);
|
setSuggestionsStateRaw(prev => typeof updater === 'function' ? updater(prev) : updater);
|
||||||
}, []);
|
}, []);
|
||||||
const onSubmit = useCallback(async (inputParam: string, isSubmittingSlashCommand = false) => {
|
const onSubmit = useCallback(async (inputParam: string, isSubmittingSlashCommand = false, slashCommandOverride?: Command) => {
|
||||||
inputParam = inputParam.trimEnd();
|
inputParam = inputParam.trimEnd();
|
||||||
|
|
||||||
// Don't submit if a footer indicator is being opened. Read fresh from
|
// Don't submit if a footer indicator is being opened. Read fresh from
|
||||||
@@ -1110,7 +1111,9 @@ function PromptInput({
|
|||||||
setCursorOffset,
|
setCursorOffset,
|
||||||
clearBuffer,
|
clearBuffer,
|
||||||
resetHistory
|
resetHistory
|
||||||
});
|
}, undefined, slashCommandOverride ? {
|
||||||
|
slashCommandOverride
|
||||||
|
} : undefined);
|
||||||
}, [promptSuggestionState, speculation, speculationSessionTimeSavedMs, teamContext, store, footerItems, suggestionsState.suggestions, onSubmitProp, onAgentSubmit, clearBuffer, resetHistory, logOutcomeAtSubmission, setAppState, markAccepted, pastedContents, removeNotification]);
|
}, [promptSuggestionState, speculation, speculationSessionTimeSavedMs, teamContext, store, footerItems, suggestionsState.suggestions, onSubmitProp, onAgentSubmit, clearBuffer, resetHistory, logOutcomeAtSubmission, setAppState, markAccepted, pastedContents, removeNotification]);
|
||||||
const {
|
const {
|
||||||
suggestions,
|
suggestions,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { profileCheckpoint } from '../utils/startupProfiler.js'
|
import { profileCheckpoint } from '../utils/startupProfiler.js'
|
||||||
|
import { validateEnvVars } from '../utils/envValidation.js'
|
||||||
import '../bootstrap/state.js'
|
import '../bootstrap/state.js'
|
||||||
import '../utils/config.js'
|
import '../utils/config.js'
|
||||||
import memoize from 'lodash-es/memoize.js'
|
import memoize from 'lodash-es/memoize.js'
|
||||||
@@ -46,8 +47,9 @@ export const init = memoize(async (): Promise<void> => {
|
|||||||
const initStartTime = Date.now()
|
const initStartTime = Date.now()
|
||||||
logForDiagnosticsNoPII('info', 'init_started')
|
logForDiagnosticsNoPII('info', 'init_started')
|
||||||
profileCheckpoint('init_function_start')
|
profileCheckpoint('init_function_start')
|
||||||
|
// Validate critical environment variables early
|
||||||
// Validate configs are valid and enable configuration system
|
// Crashes immediately if invalid to avoid wasting time
|
||||||
|
validateEnvVars()
|
||||||
try {
|
try {
|
||||||
const configsStart = Date.now()
|
const configsStart = Date.now()
|
||||||
enableConfigs()
|
enableConfigs()
|
||||||
@@ -66,6 +68,9 @@ export const init = memoize(async (): Promise<void> => {
|
|||||||
// via BoringSSL, so this must happen before the first TLS handshake.
|
// via BoringSSL, so this must happen before the first TLS handshake.
|
||||||
applyExtraCACertsFromConfig()
|
applyExtraCACertsFromConfig()
|
||||||
|
|
||||||
|
// Re-validate values hydrated from trusted config before network setup.
|
||||||
|
validateEnvVars()
|
||||||
|
|
||||||
logForDiagnosticsNoPII('info', 'init_safe_env_vars_applied', {
|
logForDiagnosticsNoPII('info', 'init_safe_env_vars_applied', {
|
||||||
duration_ms: Date.now() - envVarsStart,
|
duration_ms: Date.now() - envVarsStart,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { generateProgressiveArgumentHint, parseArguments } from '../utils/argume
|
|||||||
import { getShellCompletions, type ShellCompletionType } from '../utils/bash/shellCompletion.js';
|
import { getShellCompletions, type ShellCompletionType } from '../utils/bash/shellCompletion.js';
|
||||||
import { formatLogMetadata } from '../utils/format.js';
|
import { formatLogMetadata } from '../utils/format.js';
|
||||||
import { getSessionIdFromLog, searchSessionsByCustomTitle } from '../utils/sessionStorage.js';
|
import { getSessionIdFromLog, searchSessionsByCustomTitle } from '../utils/sessionStorage.js';
|
||||||
import { applyCommandSuggestion, findMidInputSlashCommand, generateCommandSuggestions, getBestCommandMatch, isCommandInput } from '../utils/suggestions/commandSuggestions.js';
|
import { applyCommandSuggestion, findMidInputSlashCommand, generateCommandSuggestions, getBestCommandMatch, getCommandSuggestionForEnter, isCommandInput } from '../utils/suggestions/commandSuggestions.js';
|
||||||
import { getDirectoryCompletions, getPathCompletions, isPathLikeToken } from '../utils/suggestions/directoryCompletion.js';
|
import { getDirectoryCompletions, getPathCompletions, isPathLikeToken } from '../utils/suggestions/directoryCompletion.js';
|
||||||
import { getShellHistoryCompletion } from '../utils/suggestions/shellHistoryCompletion.js';
|
import { getShellHistoryCompletion } from '../utils/suggestions/shellHistoryCompletion.js';
|
||||||
import { getSlackChannelSuggestions, hasSlackMcpServer } from '../utils/suggestions/slackChannelSuggestions.js';
|
import { getSlackChannelSuggestions, hasSlackMcpServer } from '../utils/suggestions/slackChannelSuggestions.js';
|
||||||
@@ -80,7 +80,7 @@ function buildResumeInputFromSuggestion(suggestion: SuggestionItem): string {
|
|||||||
}
|
}
|
||||||
type Props = {
|
type Props = {
|
||||||
onInputChange: (value: string) => void;
|
onInputChange: (value: string) => void;
|
||||||
onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void;
|
onSubmit: (value: string, isSubmittingSlashCommand?: boolean, slashCommandOverride?: Command) => void;
|
||||||
setCursorOffset: (offset: number) => void;
|
setCursorOffset: (offset: number) => void;
|
||||||
input: string;
|
input: string;
|
||||||
cursorOffset: number;
|
cursorOffset: number;
|
||||||
@@ -1138,8 +1138,10 @@ export function useTypeahead({
|
|||||||
if (selectedSuggestion < 0 || suggestions.length === 0) return;
|
if (selectedSuggestion < 0 || suggestions.length === 0) return;
|
||||||
const suggestion = suggestions[selectedSuggestion];
|
const suggestion = suggestions[selectedSuggestion];
|
||||||
if (suggestionType === 'command' && selectedSuggestion < suggestions.length) {
|
if (suggestionType === 'command' && selectedSuggestion < suggestions.length) {
|
||||||
if (suggestion) {
|
const commandSuggestion = getCommandSuggestionForEnter(input, suggestion, commands);
|
||||||
applyCommandSuggestion(suggestion, true,
|
|
||||||
|
if (commandSuggestion) {
|
||||||
|
applyCommandSuggestion(commandSuggestion, true,
|
||||||
// execute on return
|
// execute on return
|
||||||
commands, onInputChange, setCursorOffset, onSubmit);
|
commands, onInputChange, setCursorOffset, onSubmit);
|
||||||
debouncedFetchFileSuggestions.cancel();
|
debouncedFetchFileSuggestions.cancel();
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { LocalizationKey } from './types.js'
|
||||||
|
|
||||||
|
const openClaudeCommandDescriptionKeys: Record<string, LocalizationKey> = {
|
||||||
|
'add-dir': 'commands.add-dir.description',
|
||||||
|
agents: 'commands.agents.description',
|
||||||
|
'auto-fix': 'commands.auto-fix.description',
|
||||||
|
branch: 'commands.branch.description',
|
||||||
|
btw: 'commands.btw.description',
|
||||||
|
'cache-stats': 'commands.cache-stats.description',
|
||||||
|
clear: 'commands.clear.description',
|
||||||
|
color: 'commands.color.description',
|
||||||
|
compact: 'commands.compact.description',
|
||||||
|
'commit-message': 'commands.commit-message.description',
|
||||||
|
config: 'commands.config.description',
|
||||||
|
copy: 'commands.copy.description',
|
||||||
|
context: 'commands.context.description',
|
||||||
|
cost: 'commands.cost.description',
|
||||||
|
diff: 'commands.diff.description',
|
||||||
|
dream: 'commands.dream.description',
|
||||||
|
doctor: 'commands.doctor.description',
|
||||||
|
effort: 'commands.effort.description',
|
||||||
|
exit: 'commands.exit.description',
|
||||||
|
export: 'commands.export.description',
|
||||||
|
heapdump: 'commands.heapdump.description',
|
||||||
|
help: 'commands.help.description',
|
||||||
|
hooks: 'commands.hooks.description',
|
||||||
|
ide: 'commands.ide.description',
|
||||||
|
init: 'commands.init.description',
|
||||||
|
insights: 'commands.insights.description',
|
||||||
|
'install-github-app': 'commands.install-github-app.description',
|
||||||
|
knowledge: 'commands.knowledge.description',
|
||||||
|
login: 'commands.login.description',
|
||||||
|
logout: 'commands.logout.description',
|
||||||
|
lsp: 'commands.lsp.description',
|
||||||
|
mcp: 'commands.mcp.description',
|
||||||
|
memory: 'commands.memory.description',
|
||||||
|
'onboard-github': 'commands.onboard-github.description',
|
||||||
|
'output-style': 'commands.output-style.description',
|
||||||
|
permissions: 'commands.permissions.description',
|
||||||
|
plan: 'commands.plan.description',
|
||||||
|
plugin: 'commands.plugin.description',
|
||||||
|
provider: 'commands.provider.description',
|
||||||
|
'pr-comments': 'commands.pr-comments.description',
|
||||||
|
'release-notes': 'commands.release-notes.description',
|
||||||
|
'reload-plugins': 'commands.reload-plugins.description',
|
||||||
|
rename: 'commands.rename.description',
|
||||||
|
'request-size': 'commands.request-size.description',
|
||||||
|
resume: 'commands.resume.description',
|
||||||
|
review: 'commands.review.description',
|
||||||
|
rewind: 'commands.rewind.description',
|
||||||
|
'security-review': 'commands.security-review.description',
|
||||||
|
skills: 'commands.skills.description',
|
||||||
|
stats: 'commands.stats.description',
|
||||||
|
status: 'commands.status.description',
|
||||||
|
statusline: 'commands.statusline.description',
|
||||||
|
stickers: 'commands.stickers.description',
|
||||||
|
tasks: 'commands.tasks.description',
|
||||||
|
'terminal-setup': 'commands.terminal-setup.description',
|
||||||
|
theme: 'commands.theme.description',
|
||||||
|
usage: 'commands.usage.description',
|
||||||
|
vim: 'commands.vim.description',
|
||||||
|
wiki: 'commands.wiki.description',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpenClaudeCommandDescriptionKey(
|
||||||
|
commandName: string,
|
||||||
|
): LocalizationKey | undefined {
|
||||||
|
return openClaudeCommandDescriptionKeys[commandName]
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { detectLocale } from './locale.js'
|
||||||
|
import { en } from './languages/en.js'
|
||||||
|
import { vi } from './languages/vi.js'
|
||||||
|
import type {
|
||||||
|
I18nDictionary,
|
||||||
|
InterpolationValues,
|
||||||
|
LocalizationKey,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
const dictionaries: Record<string, I18nDictionary> = {
|
||||||
|
en,
|
||||||
|
vi,
|
||||||
|
}
|
||||||
|
|
||||||
|
export { detectLocale }
|
||||||
|
export { getOpenClaudeCommandDescriptionKey } from './commandDescriptions.js'
|
||||||
|
export type { InterpolationValues, Locale, LocalizationKey } from './types.js'
|
||||||
|
|
||||||
|
export function localize(
|
||||||
|
key: LocalizationKey | undefined,
|
||||||
|
fallback: string,
|
||||||
|
values?: InterpolationValues,
|
||||||
|
): string {
|
||||||
|
if (!key) return fallback
|
||||||
|
|
||||||
|
const locale = detectLocale()
|
||||||
|
const template = dictionaries[locale]?.[key] ?? en[key] ?? fallback
|
||||||
|
return interpolate(template, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(
|
||||||
|
template: string,
|
||||||
|
values: InterpolationValues | undefined,
|
||||||
|
): string {
|
||||||
|
if (!values) return template
|
||||||
|
return template.replace(/\{(\w+)\}/g, (match, key: string) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(values, key)
|
||||||
|
? String(values[key])
|
||||||
|
: match,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
export const en = {
|
||||||
|
'commands.add-dir.description': 'Add a new working directory',
|
||||||
|
'commands.agents.description': 'Manage agent configurations',
|
||||||
|
'commands.auto-fix.description':
|
||||||
|
'Configure auto-fix: run lint/test after AI edits',
|
||||||
|
'commands.branch.description':
|
||||||
|
'Create a branch of the current conversation at this point',
|
||||||
|
'commands.btw.description':
|
||||||
|
'Ask a quick side question without interrupting the main conversation',
|
||||||
|
'commands.cache-stats.description':
|
||||||
|
'Show per-turn and session cache hit/miss stats (works across all providers)',
|
||||||
|
'commands.clear.description':
|
||||||
|
'Clear conversation history and free up context',
|
||||||
|
'commands.color.description': 'Set the prompt bar color for this session',
|
||||||
|
'commands.compact.description':
|
||||||
|
'Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]',
|
||||||
|
'commands.commit-message.description':
|
||||||
|
'Configure commit attribution text',
|
||||||
|
'commands.config.description': 'Open config panel',
|
||||||
|
'commands.copy.description':
|
||||||
|
"Copy Claude's last response to clipboard (or /copy N for the Nth-latest)",
|
||||||
|
'commands.context.description': 'Show current context usage',
|
||||||
|
'commands.cost.description':
|
||||||
|
'Show the total cost and duration of the current session',
|
||||||
|
'commands.diff.description': 'View uncommitted changes and per-turn diffs',
|
||||||
|
'commands.doctor.description':
|
||||||
|
'Diagnose and verify your OpenClaude installation and settings',
|
||||||
|
'commands.dream.description':
|
||||||
|
'Run memory consolidation — synthesize recent sessions into durable memories',
|
||||||
|
'commands.effort.description': 'Set effort level for model usage',
|
||||||
|
'commands.exit.description': 'Exit the REPL',
|
||||||
|
'commands.export.description':
|
||||||
|
'Export the current conversation to a file or clipboard',
|
||||||
|
'commands.heapdump.description': 'Dump the JS heap to ~/Desktop',
|
||||||
|
'commands.help.description': 'Show help and available commands',
|
||||||
|
'commands.hooks.description': 'View hook configurations for tool events',
|
||||||
|
'commands.ide.description': 'Manage IDE integrations and show status',
|
||||||
|
'commands.init.description':
|
||||||
|
'Initialize a new project instruction file with codebase documentation',
|
||||||
|
'commands.insights.description':
|
||||||
|
'Generate a report analyzing your OpenClaude sessions',
|
||||||
|
'commands.install-github-app.description':
|
||||||
|
'Set up Claude GitHub Actions for a repository',
|
||||||
|
'commands.knowledge.description': 'Manage native Knowledge Graph',
|
||||||
|
'commands.login.description': 'Sign in with your Anthropic account',
|
||||||
|
'commands.logout.description': 'Sign out from your Anthropic account',
|
||||||
|
'commands.lsp.description':
|
||||||
|
'Inspect and set up Language Server Protocol code intelligence',
|
||||||
|
'commands.mcp.description': 'Manage MCP servers',
|
||||||
|
'commands.memory.description': 'Edit Claude memory files',
|
||||||
|
'commands.onboard-github.description':
|
||||||
|
'Interactive setup for GitHub Copilot: OAuth device login stored in secure storage',
|
||||||
|
'commands.output-style.description':
|
||||||
|
'Deprecated: use /config to change output style',
|
||||||
|
'commands.permissions.description':
|
||||||
|
'Manage allow & deny tool permission rules',
|
||||||
|
'commands.plan.description':
|
||||||
|
'Enable plan mode or view the current session plan',
|
||||||
|
'commands.plugin.description': 'Manage OpenClaude plugins',
|
||||||
|
'commands.provider.description': 'Manage API provider profiles',
|
||||||
|
'commands.pr-comments.description':
|
||||||
|
'Get comments from a GitHub pull request',
|
||||||
|
'commands.release-notes.description': 'View release notes',
|
||||||
|
'commands.reload-plugins.description':
|
||||||
|
'Activate pending plugin changes in the current session',
|
||||||
|
'commands.rename.description': 'Rename the current conversation',
|
||||||
|
'commands.request-size.description':
|
||||||
|
'Show estimated request context load and top contributors',
|
||||||
|
'commands.resume.description': 'Resume a previous conversation',
|
||||||
|
'commands.review.description': 'Review a pull request',
|
||||||
|
'commands.rewind.description':
|
||||||
|
'Restore the code and/or conversation to a previous point',
|
||||||
|
'commands.security-review.description':
|
||||||
|
'Complete a security review of the pending changes on the current branch',
|
||||||
|
'commands.skills.description': 'List available skills',
|
||||||
|
'commands.stats.description':
|
||||||
|
'Show your OpenClaude usage statistics and activity',
|
||||||
|
'commands.status.description':
|
||||||
|
'Show OpenClaude status including version, model, account, API connectivity, and tool statuses',
|
||||||
|
'commands.statusline.description': "Set up OpenClaude's status line UI",
|
||||||
|
'commands.stickers.description': 'Order OpenClaude stickers',
|
||||||
|
'commands.tasks.description': 'List and manage background tasks',
|
||||||
|
'commands.terminal-setup.description':
|
||||||
|
'Install Shift+Enter key binding for newlines',
|
||||||
|
'commands.theme.description': 'Change the theme',
|
||||||
|
'commands.usage.description': 'Show plan usage limits',
|
||||||
|
'commands.vim.description': 'Toggle between Vim and Normal editing modes',
|
||||||
|
'commands.wiki.description':
|
||||||
|
'Initialize and inspect the OpenClaude project wiki',
|
||||||
|
'skills.batch.description':
|
||||||
|
'Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.',
|
||||||
|
'skills.batch.whenToUse':
|
||||||
|
'Use when the user wants to make a sweeping, mechanical change across many files (migrations, refactors, bulk renames) that can be decomposed into independent parallel units.',
|
||||||
|
'skills.debug.ant.description':
|
||||||
|
'Debug your current Claude Code session by reading the session debug log. Includes all event logging',
|
||||||
|
'skills.debug.default.description':
|
||||||
|
'Enable debug logging for this session and help diagnose issues',
|
||||||
|
'skills.loop.description':
|
||||||
|
'Run a prompt on a fixed interval or dynamically reschedule it, including bare maintenance-mode loops.',
|
||||||
|
'skills.loop.whenToUse':
|
||||||
|
'When the user wants to poll for status, babysit a workflow, run recurring maintenance, or keep re-running a prompt within the current session.',
|
||||||
|
'skills.simplify.description':
|
||||||
|
'Review changed code for reuse, quality, and efficiency, then fix any issues found.',
|
||||||
|
'skills.update-config.description':
|
||||||
|
'Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, use Config tool.',
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
export const vi = {
|
||||||
|
'commands.add-dir.description': 'Thêm thư mục làm việc mới',
|
||||||
|
'commands.agents.description': 'Quản lý cấu hình agent',
|
||||||
|
'commands.auto-fix.description':
|
||||||
|
'Cấu hình tự động sửa: chạy lint/test sau khi AI chỉnh sửa',
|
||||||
|
'commands.branch.description':
|
||||||
|
'Tạo nhánh của cuộc hội thoại tại điểm này',
|
||||||
|
'commands.btw.description':
|
||||||
|
'Đặt câu hỏi nhanh bên lề mà không làm gián đoạn cuộc hội thoại chính',
|
||||||
|
'commands.cache-stats.description':
|
||||||
|
'Hiển thị thống kê cache hit/miss theo lượt và phiên (hoạt động trên tất cả nhà cung cấp)',
|
||||||
|
'commands.clear.description':
|
||||||
|
'Xóa lịch sử hội thoại và giải phóng ngữ cảnh',
|
||||||
|
'commands.color.description': 'Đặt màu thanh prompt cho phiên này',
|
||||||
|
'commands.compact.description':
|
||||||
|
'Xóa lịch sử hội thoại nhưng giữ tóm tắt trong ngữ cảnh. Tùy chọn: /compact [hướng dẫn tóm tắt]',
|
||||||
|
'commands.commit-message.description':
|
||||||
|
'Cấu hình văn bản ghi công commit',
|
||||||
|
'commands.config.description': 'Mở bảng cấu hình',
|
||||||
|
'commands.copy.description':
|
||||||
|
'Sao chép phản hồi gần nhất của Claude vào clipboard (hoặc /copy N cho phản hồi thứ N gần nhất)',
|
||||||
|
'commands.context.description': 'Hiện mức sử dụng ngữ cảnh',
|
||||||
|
'commands.cost.description':
|
||||||
|
'Hiện tổng chi phí và thời lượng phiên hiện tại',
|
||||||
|
'commands.diff.description': 'Xem thay đổi chưa commit và diff từng lượt',
|
||||||
|
'commands.doctor.description':
|
||||||
|
'Chẩn đoán và xác minh cài đặt OpenClaude',
|
||||||
|
'commands.dream.description':
|
||||||
|
'Chạy hợp nhất bộ nhớ — tổng hợp các phiên gần đây thành bộ nhớ lâu dài',
|
||||||
|
'commands.effort.description': 'Đặt mức độ nỗ lực cho mô hình',
|
||||||
|
'commands.exit.description': 'Thoát REPL',
|
||||||
|
'commands.export.description':
|
||||||
|
'Xuất cuộc hội thoại ra file hoặc clipboard',
|
||||||
|
'commands.heapdump.description': 'Xuất JS heap ra ~/Desktop',
|
||||||
|
'commands.help.description': 'Hiện trợ giúp và các lệnh có sẵn',
|
||||||
|
'commands.hooks.description': 'Xem cấu hình hook cho sự kiện tool',
|
||||||
|
'commands.ide.description': 'Quản lý tích hợp IDE và hiện trạng thái',
|
||||||
|
'commands.init.description':
|
||||||
|
'Khởi tạo file hướng dẫn dự án mới với tài liệu codebase',
|
||||||
|
'commands.insights.description':
|
||||||
|
'Tạo báo cáo phân tích các phiên OpenClaude',
|
||||||
|
'commands.install-github-app.description':
|
||||||
|
'Thiết lập Claude GitHub Actions cho kho lưu trữ',
|
||||||
|
'commands.knowledge.description': 'Quản lý Knowledge Graph',
|
||||||
|
'commands.login.description': 'Đăng nhập bằng tài khoản Anthropic',
|
||||||
|
'commands.logout.description': 'Đăng xuất khỏi tài khoản Anthropic',
|
||||||
|
'commands.lsp.description':
|
||||||
|
'Kiểm tra và thiết lập LSP code intelligence',
|
||||||
|
'commands.mcp.description': 'Quản lý máy chủ MCP',
|
||||||
|
'commands.memory.description': 'Chỉnh sửa file bộ nhớ Claude',
|
||||||
|
'commands.onboard-github.description':
|
||||||
|
'Thiết lập tương tác cho GitHub Copilot: đăng nhập OAuth lưu trong secure storage',
|
||||||
|
'commands.output-style.description':
|
||||||
|
'Đã ngừng sử dụng: dùng /config để đổi kiểu output',
|
||||||
|
'commands.permissions.description':
|
||||||
|
'Quản lý quy tắc cho phép & từ chối tool',
|
||||||
|
'commands.plan.description':
|
||||||
|
'Bật chế độ kế hoạch hoặc xem kế hoạch phiên hiện tại',
|
||||||
|
'commands.plugin.description': 'Quản lý plugin OpenClaude',
|
||||||
|
'commands.provider.description': 'Quản lý hồ sơ nhà cung cấp API',
|
||||||
|
'commands.pr-comments.description':
|
||||||
|
'Lấy bình luận từ pull request GitHub',
|
||||||
|
'commands.release-notes.description': 'Xem ghi chú phát hành',
|
||||||
|
'commands.reload-plugins.description':
|
||||||
|
'Kích hoạt thay đổi plugin đang chờ trong phiên hiện tại',
|
||||||
|
'commands.rename.description': 'Đổi tên cuộc hội thoại hiện tại',
|
||||||
|
'commands.request-size.description':
|
||||||
|
'Hiện tải ngữ cảnh ước tính và các thành phần chính',
|
||||||
|
'commands.resume.description': 'Tiếp tục cuộc hội thoại trước',
|
||||||
|
'commands.review.description': 'Đánh giá pull request',
|
||||||
|
'commands.rewind.description':
|
||||||
|
'Khôi phục mã và/hoặc cuộc hội thoại về điểm trước',
|
||||||
|
'commands.security-review.description':
|
||||||
|
'Hoàn thành đánh giá bảo mật cho các thay đổi đang chờ trên nhánh hiện tại',
|
||||||
|
'commands.skills.description': 'Liệt kê các kỹ năng có sẵn',
|
||||||
|
'commands.stats.description':
|
||||||
|
'Hiện thống kê sử dụng và hoạt động OpenClaude',
|
||||||
|
'commands.status.description':
|
||||||
|
'Hiển thị trạng thái OpenClaude bao gồm phiên bản, mô hình, tài khoản, kết nối API và trạng thái công cụ',
|
||||||
|
'commands.statusline.description':
|
||||||
|
'Thiết lập giao diện dòng trạng thái của OpenClaude',
|
||||||
|
'commands.stickers.description': 'Đặt mua sticker OpenClaude',
|
||||||
|
'commands.tasks.description': 'Liệt kê và quản lý tác vụ nền',
|
||||||
|
'commands.terminal-setup.description':
|
||||||
|
'Cài đặt phím tắt Shift+Enter để xuống dòng',
|
||||||
|
'commands.theme.description': 'Đổi giao diện',
|
||||||
|
'commands.usage.description': 'Hiện giới hạn sử dụng gói',
|
||||||
|
'commands.vim.description': 'Chuyển đổi giữa chế độ Vim và Normal',
|
||||||
|
'commands.wiki.description':
|
||||||
|
'Khởi tạo và kiểm tra wiki dự án OpenClaude',
|
||||||
|
'skills.batch.description':
|
||||||
|
'Nghiên cứu và lập kế hoạch cho một thay đổi quy mô lớn, rồi thực thi song song trên 5–30 agent worktree cô lập, mỗi agent mở một PR.',
|
||||||
|
'skills.batch.whenToUse':
|
||||||
|
'Dùng khi người dùng muốn thực hiện một thay đổi bao quát, cơ học trên nhiều file (migration, refactor, đổi tên hàng loạt) có thể chia thành các đơn vị song song độc lập.',
|
||||||
|
'skills.debug.ant.description':
|
||||||
|
'Debug phiên Claude Code hiện tại bằng cách đọc debug log của phiên. Bao gồm toàn bộ event logging',
|
||||||
|
'skills.debug.default.description':
|
||||||
|
'Bật debug logging cho phiên này và hỗ trợ chẩn đoán sự cố',
|
||||||
|
'skills.loop.description':
|
||||||
|
'Chạy một prompt theo khoảng thời gian cố định hoặc lên lịch lại động, bao gồm cả chế độ bảo trì lặp lại.',
|
||||||
|
'skills.loop.whenToUse':
|
||||||
|
'Khi người dùng muốn kiểm tra trạng thái, giám sát quy trình, chạy bảo trì định kỳ, hoặc chạy lại một prompt trong phiên hiện tại.',
|
||||||
|
'skills.simplify.description':
|
||||||
|
'Đánh giá code đã thay đổi về mặt tái sử dụng, chất lượng và hiệu suất, sau đó sửa các vấn đề tìm được.',
|
||||||
|
'skills.update-config.description':
|
||||||
|
'Sử dụng skill này để cấu hình Claude Code qua settings.json. Các hành vi tự động ("từ giờ khi X", "mỗi lần X", "bất cứ khi nào X", "trước/sau X") yêu cầu hooks được cấu hình trong settings.json - hệ thống thực thi hooks, không phải Claude, nên memory/preferences không thể thực hiện được. Cũng dùng cho: phân quyền ("cho phép X", "thêm quyền", "chuyển quyền"), biến môi trường ("set X=Y"), khắc phục sự cố hooks, hoặc bất kỳ thay đổi nào với settings.json/settings.local.json. Ví dụ: "cho phép lệnh npm", "thêm quyền bq vào settings toàn cục", "chuyển quyền sang user settings", "set DEBUG=true", "khi claude dừng hiển thị X". Với cài đặt đơn giản như theme/model, dùng Config tool.',
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { getInitialSettings } from '../utils/settings/settings.js'
|
||||||
|
import { getSessionSettingsCache } from '../utils/settings/settingsCache.js'
|
||||||
|
import type { Locale } from './types.js'
|
||||||
|
|
||||||
|
const LANGUAGE_MAP: Record<string, Locale> = {
|
||||||
|
english: 'en',
|
||||||
|
en: 'en',
|
||||||
|
vietnamese: 'vi',
|
||||||
|
vi: 'vi',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectLocale(): Locale {
|
||||||
|
const settings = getSessionSettingsCache()?.settings ?? getInitialSettings()
|
||||||
|
const lang = settings.language
|
||||||
|
if (typeof lang !== 'string') {
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
return LANGUAGE_MAP[lang.toLowerCase()] ?? 'en'
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type Locale = 'en' | 'vi'
|
||||||
|
|
||||||
|
export type LocalizationKey = string
|
||||||
|
|
||||||
|
export type I18nDictionary = Record<LocalizationKey, string>
|
||||||
|
|
||||||
|
export type InterpolationValues = Record<string, string | number>
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Command } from '../commands.js'
|
import type { Command } from '../commands.js'
|
||||||
|
import { localize } from '../i18n/index.js'
|
||||||
import type { BundledSkillDefinition } from '../skills/bundledSkills.js'
|
import type { BundledSkillDefinition } from '../skills/bundledSkills.js'
|
||||||
import type { BuiltinPluginDefinition, LoadedPlugin } from '../types/plugin.js'
|
import type { BuiltinPluginDefinition, LoadedPlugin } from '../types/plugin.js'
|
||||||
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
|
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
|
||||||
@@ -79,7 +80,7 @@ export function getBuiltinPlugins(): {
|
|||||||
name,
|
name,
|
||||||
manifest: {
|
manifest: {
|
||||||
name,
|
name,
|
||||||
description: definition.description,
|
description: localize(definition.descriptionKey, definition.description),
|
||||||
version: definition.version,
|
version: definition.version,
|
||||||
},
|
},
|
||||||
path: BUILTIN_MARKETPLACE_NAME, // sentinel — no filesystem path
|
path: BUILTIN_MARKETPLACE_NAME, // sentinel — no filesystem path
|
||||||
@@ -133,11 +134,21 @@ function skillDefinitionToCommand(definition: BundledSkillDefinition): Command {
|
|||||||
return {
|
return {
|
||||||
type: 'prompt',
|
type: 'prompt',
|
||||||
name: definition.name,
|
name: definition.name,
|
||||||
description: definition.description,
|
get description() {
|
||||||
|
return localize(definition.descriptionKey, definition.description)
|
||||||
|
},
|
||||||
|
localizationKey: definition.descriptionKey,
|
||||||
hasUserSpecifiedDescription: true,
|
hasUserSpecifiedDescription: true,
|
||||||
allowedTools: definition.allowedTools ?? [],
|
allowedTools: definition.allowedTools ?? [],
|
||||||
argumentHint: definition.argumentHint,
|
argumentHint: definition.argumentHint,
|
||||||
whenToUse: definition.whenToUse,
|
get whenToUse() {
|
||||||
|
if (definition.whenToUseKey) {
|
||||||
|
return localize(definition.whenToUseKey, definition.whenToUse ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return definition.whenToUse
|
||||||
|
},
|
||||||
|
whenToUseLocalizationKey: definition.whenToUseKey,
|
||||||
model: definition.model,
|
model: definition.model,
|
||||||
disableModelInvocation: definition.disableModelInvocation ?? false,
|
disableModelInvocation: definition.disableModelInvocation ?? false,
|
||||||
userInvocable: definition.userInvocable ?? true,
|
userInvocable: definition.userInvocable ?? true,
|
||||||
|
|||||||
@@ -3198,6 +3198,7 @@ export function REPL({
|
|||||||
setAppState: SetAppState;
|
setAppState: SetAppState;
|
||||||
}, options?: {
|
}, options?: {
|
||||||
fromKeybinding?: boolean;
|
fromKeybinding?: boolean;
|
||||||
|
slashCommandOverride?: Command;
|
||||||
}) => {
|
}) => {
|
||||||
// Re-pin scroll to bottom on submit so the user always sees the new
|
// Re-pin scroll to bottom on submit so the user always sees the new
|
||||||
// exchange (matches OpenCode's auto-scroll behavior).
|
// exchange (matches OpenCode's auto-scroll behavior).
|
||||||
@@ -3565,6 +3566,7 @@ export function REPL({
|
|||||||
canUseTool,
|
canUseTool,
|
||||||
addNotification,
|
addNotification,
|
||||||
setMessages,
|
setMessages,
|
||||||
|
slashCommandOverride: options?.slashCommandOverride,
|
||||||
// Read via ref so streamMode can be dropped from onSubmit deps —
|
// Read via ref so streamMode can be dropped from onSubmit deps —
|
||||||
// handlePromptSubmit only uses it for debug log + telemetry event.
|
// handlePromptSubmit only uses it for debug log + telemetry event.
|
||||||
streamMode: streamModeRef.current,
|
streamMode: streamModeRef.current,
|
||||||
|
|||||||
@@ -102,8 +102,10 @@ export function registerBatchSkill(): void {
|
|||||||
name: 'batch',
|
name: 'batch',
|
||||||
description:
|
description:
|
||||||
'Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.',
|
'Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.',
|
||||||
|
descriptionKey: 'skills.batch.description',
|
||||||
whenToUse:
|
whenToUse:
|
||||||
'Use when the user wants to make a sweeping, mechanical change across many files (migrations, refactors, bulk renames) that can be decomposed into independent parallel units.',
|
'Use when the user wants to make a sweeping, mechanical change across many files (migrations, refactors, bulk renames) that can be decomposed into independent parallel units.',
|
||||||
|
whenToUseKey: 'skills.batch.whenToUse',
|
||||||
argumentHint: '<instruction>',
|
argumentHint: '<instruction>',
|
||||||
userInvocable: true,
|
userInvocable: true,
|
||||||
disableModelInvocation: true,
|
disableModelInvocation: true,
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export function registerDebugSkill(): void {
|
|||||||
process.env.USER_TYPE === 'ant'
|
process.env.USER_TYPE === 'ant'
|
||||||
? 'Debug your current Claude Code session by reading the session debug log. Includes all event logging'
|
? 'Debug your current Claude Code session by reading the session debug log. Includes all event logging'
|
||||||
: 'Enable debug logging for this session and help diagnose issues',
|
: 'Enable debug logging for this session and help diagnose issues',
|
||||||
|
descriptionKey:
|
||||||
|
process.env.USER_TYPE === 'ant'
|
||||||
|
? 'skills.debug.ant.description'
|
||||||
|
: 'skills.debug.default.description',
|
||||||
allowedTools: ['Read', 'Grep', 'Glob'],
|
allowedTools: ['Read', 'Grep', 'Glob'],
|
||||||
argumentHint: '[issue description]',
|
argumentHint: '[issue description]',
|
||||||
// disableModelInvocation so that the user has to explicitly request it in
|
// disableModelInvocation so that the user has to explicitly request it in
|
||||||
|
|||||||
@@ -206,8 +206,10 @@ export function registerLoopSkill(): void {
|
|||||||
name: 'loop',
|
name: 'loop',
|
||||||
description:
|
description:
|
||||||
'Run a prompt on a fixed interval or dynamically reschedule it, including bare maintenance-mode loops.',
|
'Run a prompt on a fixed interval or dynamically reschedule it, including bare maintenance-mode loops.',
|
||||||
|
descriptionKey: 'skills.loop.description',
|
||||||
whenToUse:
|
whenToUse:
|
||||||
'When the user wants to poll for status, babysit a workflow, run recurring maintenance, or keep re-running a prompt within the current session.',
|
'When the user wants to poll for status, babysit a workflow, run recurring maintenance, or keep re-running a prompt within the current session.',
|
||||||
|
whenToUseKey: 'skills.loop.whenToUse',
|
||||||
argumentHint: '[interval] [prompt]',
|
argumentHint: '[interval] [prompt]',
|
||||||
userInvocable: true,
|
userInvocable: true,
|
||||||
isEnabled: isKairosCronEnabled,
|
isEnabled: isKairosCronEnabled,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export function registerSimplifySkill(): void {
|
|||||||
name: 'simplify',
|
name: 'simplify',
|
||||||
description:
|
description:
|
||||||
'Review changed code for reuse, quality, and efficiency, then fix any issues found.',
|
'Review changed code for reuse, quality, and efficiency, then fix any issues found.',
|
||||||
|
descriptionKey: 'skills.simplify.description',
|
||||||
userInvocable: true,
|
userInvocable: true,
|
||||||
async getPromptForCommand(args) {
|
async getPromptForCommand(args) {
|
||||||
let prompt = SIMPLIFY_PROMPT
|
let prompt = SIMPLIFY_PROMPT
|
||||||
|
|||||||
@@ -448,6 +448,7 @@ export function registerUpdateConfigSkill(): void {
|
|||||||
name: 'update-config',
|
name: 'update-config',
|
||||||
description:
|
description:
|
||||||
'Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, use Config tool.',
|
'Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, use Config tool.',
|
||||||
|
descriptionKey: 'skills.update-config.description',
|
||||||
allowedTools: ['Read'],
|
allowedTools: ['Read'],
|
||||||
userInvocable: true,
|
userInvocable: true,
|
||||||
async getPromptForCommand(args) {
|
async getPromptForCommand(args) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { mkdir, open } from 'fs/promises'
|
|||||||
import { dirname, isAbsolute, join, normalize, sep as pathSep } from 'path'
|
import { dirname, isAbsolute, join, normalize, sep as pathSep } from 'path'
|
||||||
import type { ToolUseContext } from '../Tool.js'
|
import type { ToolUseContext } from '../Tool.js'
|
||||||
import type { Command } from '../types/command.js'
|
import type { Command } from '../types/command.js'
|
||||||
|
import { localize, type LocalizationKey } from '../i18n/index.js'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
import { getBundledSkillsRoot } from '../utils/permissions/filesystem.js'
|
import { getBundledSkillsRoot } from '../utils/permissions/filesystem.js'
|
||||||
import type { HooksSettings } from '../utils/settings/types.js'
|
import type { HooksSettings } from '../utils/settings/types.js'
|
||||||
@@ -15,8 +16,10 @@ import type { HooksSettings } from '../utils/settings/types.js'
|
|||||||
export type BundledSkillDefinition = {
|
export type BundledSkillDefinition = {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
|
descriptionKey?: LocalizationKey
|
||||||
aliases?: string[]
|
aliases?: string[]
|
||||||
whenToUse?: string
|
whenToUse?: string
|
||||||
|
whenToUseKey?: LocalizationKey
|
||||||
argumentHint?: string
|
argumentHint?: string
|
||||||
allowedTools?: string[]
|
allowedTools?: string[]
|
||||||
model?: string
|
model?: string
|
||||||
@@ -75,12 +78,22 @@ export function registerBundledSkill(definition: BundledSkillDefinition): void {
|
|||||||
const command: Command = {
|
const command: Command = {
|
||||||
type: 'prompt',
|
type: 'prompt',
|
||||||
name: definition.name,
|
name: definition.name,
|
||||||
description: definition.description,
|
get description() {
|
||||||
|
return localize(definition.descriptionKey, definition.description)
|
||||||
|
},
|
||||||
|
localizationKey: definition.descriptionKey,
|
||||||
aliases: definition.aliases,
|
aliases: definition.aliases,
|
||||||
hasUserSpecifiedDescription: true,
|
hasUserSpecifiedDescription: true,
|
||||||
allowedTools: definition.allowedTools ?? [],
|
allowedTools: definition.allowedTools ?? [],
|
||||||
argumentHint: definition.argumentHint,
|
argumentHint: definition.argumentHint,
|
||||||
whenToUse: definition.whenToUse,
|
get whenToUse() {
|
||||||
|
if (definition.whenToUseKey) {
|
||||||
|
return localize(definition.whenToUseKey, definition.whenToUse ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return definition.whenToUse
|
||||||
|
},
|
||||||
|
whenToUseLocalizationKey: definition.whenToUseKey,
|
||||||
model: definition.model,
|
model: definition.model,
|
||||||
disableModelInvocation: definition.disableModelInvocation ?? false,
|
disableModelInvocation: definition.disableModelInvocation ?? false,
|
||||||
userInvocable: definition.userInvocable ?? true,
|
userInvocable: definition.userInvocable ?? true,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { ThemeName } from '../utils/theme.js'
|
|||||||
import type { LogOption } from './logs.js'
|
import type { LogOption } from './logs.js'
|
||||||
import type { Message } from './message.js'
|
import type { Message } from './message.js'
|
||||||
import type { PluginManifest } from './plugin.js'
|
import type { PluginManifest } from './plugin.js'
|
||||||
|
import type { LocalizationKey } from '../i18n/types.js'
|
||||||
|
|
||||||
export type LocalCommandResult =
|
export type LocalCommandResult =
|
||||||
| {
|
| {
|
||||||
@@ -191,6 +192,7 @@ export type CommandAvailability =
|
|||||||
export type CommandBase = {
|
export type CommandBase = {
|
||||||
availability?: CommandAvailability[]
|
availability?: CommandAvailability[]
|
||||||
description: string
|
description: string
|
||||||
|
localizationKey?: LocalizationKey
|
||||||
hasUserSpecifiedDescription?: boolean
|
hasUserSpecifiedDescription?: boolean
|
||||||
/** Defaults to true. Only set when the command has conditional enablement (feature flags, env checks, etc). */
|
/** Defaults to true. Only set when the command has conditional enablement (feature flags, env checks, etc). */
|
||||||
isEnabled?: () => boolean
|
isEnabled?: () => boolean
|
||||||
@@ -201,6 +203,7 @@ export type CommandBase = {
|
|||||||
isMcp?: boolean
|
isMcp?: boolean
|
||||||
argumentHint?: string // Hint text for command arguments (displayed in gray after command)
|
argumentHint?: string // Hint text for command arguments (displayed in gray after command)
|
||||||
whenToUse?: string // From the "Skill" spec. Detailed usage scenarios for when to use this command
|
whenToUse?: string // From the "Skill" spec. Detailed usage scenarios for when to use this command
|
||||||
|
whenToUseLocalizationKey?: LocalizationKey
|
||||||
version?: string // Version of the command/skill
|
version?: string // Version of the command/skill
|
||||||
disableModelInvocation?: boolean // Whether to disable this command from being invoked by models
|
disableModelInvocation?: boolean // Whether to disable this command from being invoked by models
|
||||||
userInvocable?: boolean // Whether users can invoke this skill by typing /skill-name
|
userInvocable?: boolean // Whether users can invoke this skill by typing /skill-name
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LspServerConfig } from '../services/lsp/types.js'
|
import type { LspServerConfig } from '../services/lsp/types.js'
|
||||||
import type { McpServerConfig } from '../services/mcp/types.js'
|
import type { McpServerConfig } from '../services/mcp/types.js'
|
||||||
import type { BundledSkillDefinition } from '../skills/bundledSkills.js'
|
import type { BundledSkillDefinition } from '../skills/bundledSkills.js'
|
||||||
|
import type { LocalizationKey } from '../i18n/types.js'
|
||||||
import type {
|
import type {
|
||||||
CommandMetadata,
|
CommandMetadata,
|
||||||
PluginAuthor,
|
PluginAuthor,
|
||||||
@@ -20,6 +21,7 @@ export type BuiltinPluginDefinition = {
|
|||||||
name: string
|
name: string
|
||||||
/** Description shown in the /plugin UI */
|
/** Description shown in the /plugin UI */
|
||||||
description: string
|
description: string
|
||||||
|
descriptionKey?: LocalizationKey
|
||||||
/** Optional version string */
|
/** Optional version string */
|
||||||
version?: string
|
version?: string
|
||||||
/** Skills provided by this plugin */
|
/** Skills provided by this plugin */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs
|
|||||||
import type { UUID } from 'crypto'
|
import type { UUID } from 'crypto'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import type { PermissionResult } from '../entrypoints/agentSdkTypes.js'
|
import type { PermissionResult } from '../entrypoints/agentSdkTypes.js'
|
||||||
|
import type { Command } from '../commands.js'
|
||||||
import type { Key } from '../ink.js'
|
import type { Key } from '../ink.js'
|
||||||
import type { PastedContent } from '../utils/config.js'
|
import type { PastedContent } from '../utils/config.js'
|
||||||
import type { ImageDimensions } from '../utils/imageResizer.js'
|
import type { ImageDimensions } from '../utils/imageResizer.js'
|
||||||
@@ -320,6 +321,7 @@ export type QueuedCommand = {
|
|||||||
* trigger local slash commands or skills.
|
* trigger local slash commands or skills.
|
||||||
*/
|
*/
|
||||||
skipSlashCommands?: boolean
|
skipSlashCommands?: boolean
|
||||||
|
slashCommandOverride?: Command
|
||||||
/**
|
/**
|
||||||
* When true, slash commands are dispatched but filtered through
|
* When true, slash commands are dispatched but filtered through
|
||||||
* isBridgeSafeCommand() — 'local-jsx' and terminal-only commands return
|
* isBridgeSafeCommand() — 'local-jsx' and terminal-only commands return
|
||||||
|
|||||||
@@ -269,10 +269,10 @@ describe('getAttributionTexts', () => {
|
|||||||
it('preserves includeCoAuthoredBy true as an explicit old-default opt-in', () => {
|
it('preserves includeCoAuthoredBy true as an explicit old-default opt-in', () => {
|
||||||
useSettings({ includeCoAuthoredBy: true })
|
useSettings({ includeCoAuthoredBy: true })
|
||||||
|
|
||||||
expect(getAttributionTexts()).toEqual({
|
const attribution = getAttributionTexts()
|
||||||
commit: 'Co-Authored-By: OpenClaude (gpt-5.5) <openclaude@gitlawb.com>',
|
expect(attribution.commit).toStartWith('Co-Authored-By: ')
|
||||||
pr: defaultPrAttribution,
|
expect(attribution.commit).toEndWith(' <openclaude@gitlawb.com>')
|
||||||
})
|
expect(attribution.pr).toBe(defaultPrAttribution)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps attribution off when includeCoAuthoredBy is false', () => {
|
it('keeps attribution off when includeCoAuthoredBy is false', () => {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { afterEach, describe, expect, test } from 'bun:test'
|
||||||
|
import { validateEnvVars } from './envValidation.js'
|
||||||
|
|
||||||
|
const optionalEnvVars = [
|
||||||
|
'ANTHROPIC_API_KEY',
|
||||||
|
'ANTHROPIC_AUTH_TOKEN',
|
||||||
|
'CLAUDE_CONFIG_DIR',
|
||||||
|
'NODE_EXTRA_CA_CERTS',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const originalEnv = Object.fromEntries(
|
||||||
|
optionalEnvVars.map(name => [name, process.env[name]]),
|
||||||
|
)
|
||||||
|
|
||||||
|
function restoreEnv(): void {
|
||||||
|
for (const name of optionalEnvVars) {
|
||||||
|
const value = originalEnv[name]
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[name]
|
||||||
|
} else {
|
||||||
|
process.env[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreEnv()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateEnvVars', () => {
|
||||||
|
test('treats empty optional env vars as unset', () => {
|
||||||
|
for (const name of optionalEnvVars) {
|
||||||
|
process.env[name] = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = validateEnvVars()
|
||||||
|
|
||||||
|
for (const name of optionalEnvVars) {
|
||||||
|
expect(env[name]).toBeUndefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { logForDebugging } from './debug.js'
|
import { logForDebugging } from './debug.js'
|
||||||
|
import { z } from 'zod/v4'
|
||||||
|
|
||||||
|
// ─── Original bounded int validation ───
|
||||||
|
|
||||||
export type EnvVarValidationResult = {
|
export type EnvVarValidationResult = {
|
||||||
effective: number
|
effective: number
|
||||||
@@ -36,3 +39,42 @@ export function validateBoundedIntEnvVar(
|
|||||||
}
|
}
|
||||||
return { effective: parsed, status: 'valid' }
|
return { effective: parsed, status: 'valid' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Zod startup validation ───
|
||||||
|
|
||||||
|
const optionalNonEmptyString = z.preprocess(
|
||||||
|
value => (value === '' ? undefined : value),
|
||||||
|
z.string().min(1).optional(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const EnvSchema = z.object({
|
||||||
|
ANTHROPIC_API_KEY: optionalNonEmptyString,
|
||||||
|
ANTHROPIC_AUTH_TOKEN: optionalNonEmptyString,
|
||||||
|
CLAUDE_CONFIG_DIR: optionalNonEmptyString,
|
||||||
|
HTTP_PROXY: z.string().url().optional().or(z.literal('')),
|
||||||
|
HTTPS_PROXY: z.string().url().optional().or(z.literal('')),
|
||||||
|
NODE_EXTRA_CA_CERTS: optionalNonEmptyString,
|
||||||
|
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: z.string().optional(),
|
||||||
|
CLAUDE_CODE_DISABLE_TERMINAL_TITLE: z.string().optional(),
|
||||||
|
CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ValidatedEnv = z.infer<typeof EnvSchema>
|
||||||
|
|
||||||
|
export function validateEnvVars(): ValidatedEnv {
|
||||||
|
const result = EnvSchema.safeParse(process.env)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.issues.map(issue => {
|
||||||
|
const path = issue.path.join('.')
|
||||||
|
return ` ${path}: ${issue.message}`
|
||||||
|
}).join('\n')
|
||||||
|
|
||||||
|
console.error('❌ Environment variable validation failed:')
|
||||||
|
console.error(errors)
|
||||||
|
console.error('\nPlease fix the above environment variables and try again.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export type HandlePromptSubmitParams = BaseExecutionParams & {
|
|||||||
* trigger local slash commands or skills.
|
* trigger local slash commands or skills.
|
||||||
*/
|
*/
|
||||||
skipSlashCommands?: boolean
|
skipSlashCommands?: boolean
|
||||||
|
slashCommandOverride?: Command
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePromptSubmit(
|
export async function handlePromptSubmit(
|
||||||
@@ -141,6 +142,7 @@ export async function handlePromptSubmit(
|
|||||||
queuedCommands,
|
queuedCommands,
|
||||||
uuid,
|
uuid,
|
||||||
skipSlashCommands,
|
skipSlashCommands,
|
||||||
|
slashCommandOverride,
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
const { setCursorOffset, clearBuffer, resetHistory } = helpers
|
const { setCursorOffset, clearBuffer, resetHistory } = helpers
|
||||||
@@ -340,6 +342,7 @@ export async function handlePromptSubmit(
|
|||||||
mode,
|
mode,
|
||||||
pastedContents: hasImages ? pastedContents : undefined,
|
pastedContents: hasImages ? pastedContents : undefined,
|
||||||
skipSlashCommands,
|
skipSlashCommands,
|
||||||
|
slashCommandOverride,
|
||||||
uuid,
|
uuid,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -363,6 +366,7 @@ export async function handlePromptSubmit(
|
|||||||
mode,
|
mode,
|
||||||
pastedContents: hasImages ? pastedContents : undefined,
|
pastedContents: hasImages ? pastedContents : undefined,
|
||||||
skipSlashCommands,
|
skipSlashCommands,
|
||||||
|
slashCommandOverride,
|
||||||
uuid,
|
uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,6 +495,7 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
|
|||||||
uuid: cmd.uuid,
|
uuid: cmd.uuid,
|
||||||
ideSelection: isFirst ? ideSelection : undefined,
|
ideSelection: isFirst ? ideSelection : undefined,
|
||||||
skipSlashCommands: cmd.skipSlashCommands,
|
skipSlashCommands: cmd.skipSlashCommands,
|
||||||
|
slashCommandOverride: cmd.slashCommandOverride,
|
||||||
bridgeOrigin: cmd.bridgeOrigin,
|
bridgeOrigin: cmd.bridgeOrigin,
|
||||||
isMeta: cmd.isMeta,
|
isMeta: cmd.isMeta,
|
||||||
skipAttachments: !isFirst,
|
skipAttachments: !isFirst,
|
||||||
|
|||||||
@@ -4,21 +4,18 @@ import {
|
|||||||
acquireSharedMutationLock,
|
acquireSharedMutationLock,
|
||||||
releaseSharedMutationLock,
|
releaseSharedMutationLock,
|
||||||
} from '../../test/sharedMutationLock.js'
|
} from '../../test/sharedMutationLock.js'
|
||||||
|
import { resetStateForTests } from '../../bootstrap/state.js'
|
||||||
import {
|
import {
|
||||||
type GlobalConfig,
|
type GlobalConfig,
|
||||||
getGlobalConfig,
|
getGlobalConfig,
|
||||||
saveGlobalConfig,
|
saveGlobalConfig,
|
||||||
} from '../config.js'
|
} from '../config.js'
|
||||||
|
import {
|
||||||
|
clearPluginSettingsBase,
|
||||||
|
resetSettingsCache,
|
||||||
|
} from '../settings/settingsCache.js'
|
||||||
async function importFreshModelModule() {
|
async function importFreshModelModule() {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
mock.module('../auth.js', () => ({
|
|
||||||
getSubscriptionType: () => 'max',
|
|
||||||
isClaudeAISubscriber: () => true,
|
|
||||||
isMaxSubscriber: () => true,
|
|
||||||
isProSubscriber: () => false,
|
|
||||||
isTeamPremiumSubscriber: () => false,
|
|
||||||
}))
|
|
||||||
mock.module('./providers.js', () => ({
|
mock.module('./providers.js', () => ({
|
||||||
getAPIProvider: () => {
|
getAPIProvider: () => {
|
||||||
if (process.env.NVIDIA_NIM) return 'nvidia-nim'
|
if (process.env.NVIDIA_NIM) return 'nvidia-nim'
|
||||||
@@ -40,10 +37,25 @@ async function importFreshModelModule() {
|
|||||||
return 'firstParty'
|
return 'firstParty'
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
mock.module('./modelAllowlist.js', () => ({
|
||||||
|
isModelAllowed: () => true,
|
||||||
|
}))
|
||||||
const nonce = `${Date.now()}-${Math.random()}`
|
const nonce = `${Date.now()}-${Math.random()}`
|
||||||
return import(`./model.js?ts=${nonce}`)
|
return import(`./model.js?ts=${nonce}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restoreMockedModulesToActual(): Promise<void> {
|
||||||
|
const nonce = `${Date.now()}-${Math.random()}`
|
||||||
|
const [actualProviders, actualModelAllowlist] = await Promise.all([
|
||||||
|
import(`./providers.js?restore=${nonce}`),
|
||||||
|
import(`./modelAllowlist.js?restore=${nonce}`),
|
||||||
|
])
|
||||||
|
mock.module('./providers.js', () => actualProviders)
|
||||||
|
mock.module('src/utils/model/providers.js', () => actualProviders)
|
||||||
|
mock.module('./modelAllowlist.js', () => actualModelAllowlist)
|
||||||
|
mock.module('src/utils/model/modelAllowlist.js', () => actualModelAllowlist)
|
||||||
|
}
|
||||||
|
|
||||||
const SAVED_ENV = {
|
const SAVED_ENV = {
|
||||||
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
||||||
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
|
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
|
||||||
@@ -100,6 +112,9 @@ beforeEach(async () => {
|
|||||||
// globally. Without mock.restore() here, those overrides bleed into this
|
// globally. Without mock.restore() here, those overrides bleed into this
|
||||||
// suite and the provider-kind branches we're testing become unreachable.
|
// suite and the provider-kind branches we're testing become unreachable.
|
||||||
mock.restore()
|
mock.restore()
|
||||||
|
resetStateForTests()
|
||||||
|
resetSettingsCache()
|
||||||
|
clearPluginSettingsBase()
|
||||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||||
@@ -130,18 +145,24 @@ beforeEach(async () => {
|
|||||||
saveGlobalConfig(current => ({
|
saveGlobalConfig(current => ({
|
||||||
...current,
|
...current,
|
||||||
model: undefined,
|
model: undefined,
|
||||||
|
availableModels: undefined,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(async () => {
|
||||||
try {
|
try {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
|
resetStateForTests()
|
||||||
|
resetSettingsCache()
|
||||||
|
clearPluginSettingsBase()
|
||||||
|
await restoreMockedModulesToActual()
|
||||||
for (const key of Object.keys(SAVED_ENV) as Array<keyof typeof SAVED_ENV>) {
|
for (const key of Object.keys(SAVED_ENV) as Array<keyof typeof SAVED_ENV>) {
|
||||||
restoreEnv(key)
|
restoreEnv(key)
|
||||||
}
|
}
|
||||||
saveGlobalConfig(current => ({
|
saveGlobalConfig(current => ({
|
||||||
...current,
|
...current,
|
||||||
model: savedModel,
|
model: savedModel,
|
||||||
|
availableModels: undefined,
|
||||||
}))
|
}))
|
||||||
} finally {
|
} finally {
|
||||||
releaseSharedMutationLock()
|
releaseSharedMutationLock()
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import { attachmentScanInputForCommand } from './processSlashCommand.js'
|
import type { Command } from '../../types/command.js'
|
||||||
|
import {
|
||||||
|
attachmentScanInputForCommand,
|
||||||
|
resolveSlashCommand,
|
||||||
|
} from './processSlashCommand.js'
|
||||||
|
|
||||||
describe('attachmentScanInputForCommand', () => {
|
describe('attachmentScanInputForCommand', () => {
|
||||||
// A remote skill:// body must never have its @-mentions / MCP-resource refs
|
// A remote skill:// body must never have its @-mentions / MCP-resource refs
|
||||||
@@ -26,3 +30,40 @@ describe('attachmentScanInputForCommand', () => {
|
|||||||
expect(attachmentScanInputForCommand({}, 'hi @x')).toBe('hi @x')
|
expect(attachmentScanInputForCommand({}, 'hi @x')).toBe('hi @x')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('resolveSlashCommand', () => {
|
||||||
|
function promptCommand(source: 'builtin' | 'projectSettings'): Command {
|
||||||
|
return {
|
||||||
|
type: 'prompt',
|
||||||
|
name: 'review',
|
||||||
|
source,
|
||||||
|
description: `${source} review`,
|
||||||
|
progressMessage: 'running',
|
||||||
|
contentLength: 0,
|
||||||
|
getPromptForCommand: async () => [],
|
||||||
|
} as Command
|
||||||
|
}
|
||||||
|
|
||||||
|
test('uses the selected duplicate command override when it matches the slash name', () => {
|
||||||
|
const builtinReview = promptCommand('builtin')
|
||||||
|
const projectReview = promptCommand('projectSettings')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveSlashCommand('review', [builtinReview, projectReview], projectReview),
|
||||||
|
).toBe(projectReview)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to the normal command lookup when the override does not match', () => {
|
||||||
|
const builtinReview = promptCommand('builtin')
|
||||||
|
const projectReview = promptCommand('projectSettings')
|
||||||
|
const statusCommand = {
|
||||||
|
...builtinReview,
|
||||||
|
name: 'status',
|
||||||
|
description: 'Status',
|
||||||
|
} as Command
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveSlashCommand('status', [statusCommand], projectReview),
|
||||||
|
).toBe(statusCommand)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -299,7 +299,16 @@ export function looksLikeCommand(commandName: string): boolean {
|
|||||||
// If it contains other characters, it's probably a file path or other input
|
// If it contains other characters, it's probably a file path or other input
|
||||||
return !/[^a-zA-Z0-9:\-_]/.test(commandName);
|
return !/[^a-zA-Z0-9:\-_]/.test(commandName);
|
||||||
}
|
}
|
||||||
export async function processSlashCommand(inputString: string, precedingInputBlocks: ContentBlockParam[], imageContentBlocks: ContentBlockParam[], attachmentMessages: AttachmentMessage[], context: ProcessUserInputContext, setToolJSX: SetToolJSXFn, uuid?: string, isAlreadyProcessing?: boolean, canUseTool?: CanUseToolFn): Promise<ProcessUserInputBaseResult> {
|
function commandMatchesSlashName(command: Command, commandName: string): boolean {
|
||||||
|
return command.name === commandName || getCommandName(command) === commandName || command.aliases?.includes(commandName) === true;
|
||||||
|
}
|
||||||
|
export function resolveSlashCommand(commandName: string, commands: Command[], commandOverride?: Command): Command {
|
||||||
|
if (commandOverride && commandMatchesSlashName(commandOverride, commandName)) {
|
||||||
|
return commandOverride;
|
||||||
|
}
|
||||||
|
return getCommand(commandName, commands);
|
||||||
|
}
|
||||||
|
export async function processSlashCommand(inputString: string, precedingInputBlocks: ContentBlockParam[], imageContentBlocks: ContentBlockParam[], attachmentMessages: AttachmentMessage[], context: ProcessUserInputContext, setToolJSX: SetToolJSXFn, uuid?: string, isAlreadyProcessing?: boolean, canUseTool?: CanUseToolFn, slashCommandOverride?: Command): Promise<ProcessUserInputBaseResult> {
|
||||||
const parsed = parseSlashCommand(inputString);
|
const parsed = parseSlashCommand(inputString);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
logEvent('tengu_input_slash_missing', {});
|
logEvent('tengu_input_slash_missing', {});
|
||||||
@@ -385,7 +394,7 @@ export async function processSlashCommand(inputString: string, precedingInputBlo
|
|||||||
resultText,
|
resultText,
|
||||||
nextInput,
|
nextInput,
|
||||||
submitNextInput
|
submitNextInput
|
||||||
} = await getMessagesForSlashCommand(commandName, parsedArgs, setToolJSX, context, precedingInputBlocks, imageContentBlocks, isAlreadyProcessing, canUseTool, uuid);
|
} = await getMessagesForSlashCommand(commandName, parsedArgs, setToolJSX, context, precedingInputBlocks, imageContentBlocks, isAlreadyProcessing, canUseTool, uuid, slashCommandOverride);
|
||||||
|
|
||||||
// Local slash commands that skip messages
|
// Local slash commands that skip messages
|
||||||
if (newMessages.length === 0) {
|
if (newMessages.length === 0) {
|
||||||
@@ -494,8 +503,8 @@ export async function processSlashCommand(inputString: string, precedingInputBlo
|
|||||||
submitNextInput
|
submitNextInput
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function getMessagesForSlashCommand(commandName: string, args: string, setToolJSX: SetToolJSXFn, context: ProcessUserInputContext, precedingInputBlocks: ContentBlockParam[], imageContentBlocks: ContentBlockParam[], _isAlreadyProcessing?: boolean, canUseTool?: CanUseToolFn, uuid?: string): Promise<SlashCommandResult> {
|
async function getMessagesForSlashCommand(commandName: string, args: string, setToolJSX: SetToolJSXFn, context: ProcessUserInputContext, precedingInputBlocks: ContentBlockParam[], imageContentBlocks: ContentBlockParam[], _isAlreadyProcessing?: boolean, canUseTool?: CanUseToolFn, uuid?: string, slashCommandOverride?: Command): Promise<SlashCommandResult> {
|
||||||
const command = getCommand(commandName, context.options.commands);
|
const command = resolveSlashCommand(commandName, context.options.commands, slashCommandOverride);
|
||||||
|
|
||||||
// Track skill usage for ranking (only for prompt commands that are user-invocable)
|
// Track skill usage for ranking (only for prompt commands that are user-invocable)
|
||||||
if (command.type === 'prompt' && command.userInvocable !== false) {
|
if (command.type === 'prompt' && command.userInvocable !== false) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { QuerySource } from 'src/constants/querySource.js'
|
|||||||
import { logEvent } from 'src/services/analytics/index.js'
|
import { logEvent } from 'src/services/analytics/index.js'
|
||||||
import { getContentText } from 'src/utils/messages.js'
|
import { getContentText } from 'src/utils/messages.js'
|
||||||
import {
|
import {
|
||||||
|
type Command,
|
||||||
findCommand,
|
findCommand,
|
||||||
getCommandName,
|
getCommandName,
|
||||||
isBridgeSafeCommand,
|
isBridgeSafeCommand,
|
||||||
@@ -97,6 +98,7 @@ export async function processUserInput({
|
|||||||
querySource,
|
querySource,
|
||||||
canUseTool,
|
canUseTool,
|
||||||
skipSlashCommands,
|
skipSlashCommands,
|
||||||
|
slashCommandOverride,
|
||||||
bridgeOrigin,
|
bridgeOrigin,
|
||||||
isMeta,
|
isMeta,
|
||||||
skipAttachments,
|
skipAttachments,
|
||||||
@@ -125,6 +127,7 @@ export async function processUserInput({
|
|||||||
* trigger local slash commands or skills.
|
* trigger local slash commands or skills.
|
||||||
*/
|
*/
|
||||||
skipSlashCommands?: boolean
|
skipSlashCommands?: boolean
|
||||||
|
slashCommandOverride?: Command
|
||||||
/**
|
/**
|
||||||
* When true, slash commands matching isBridgeSafeCommand() execute even
|
* When true, slash commands matching isBridgeSafeCommand() execute even
|
||||||
* though skipSlashCommands is set. See QueuedCommand.bridgeOrigin.
|
* though skipSlashCommands is set. See QueuedCommand.bridgeOrigin.
|
||||||
@@ -168,6 +171,7 @@ export async function processUserInput({
|
|||||||
isMeta,
|
isMeta,
|
||||||
skipAttachments,
|
skipAttachments,
|
||||||
preExpansionInput,
|
preExpansionInput,
|
||||||
|
slashCommandOverride,
|
||||||
)
|
)
|
||||||
queryCheckpoint('query_process_user_input_base_end')
|
queryCheckpoint('query_process_user_input_base_end')
|
||||||
|
|
||||||
@@ -296,6 +300,7 @@ async function processUserInputBase(
|
|||||||
isMeta?: boolean,
|
isMeta?: boolean,
|
||||||
skipAttachments?: boolean,
|
skipAttachments?: boolean,
|
||||||
preExpansionInput?: string,
|
preExpansionInput?: string,
|
||||||
|
slashCommandOverride?: Command,
|
||||||
): Promise<ProcessUserInputBaseResult> {
|
): Promise<ProcessUserInputBaseResult> {
|
||||||
let inputString: string | null = null
|
let inputString: string | null = null
|
||||||
let precedingInputBlocks: ContentBlockParam[] = []
|
let precedingInputBlocks: ContentBlockParam[] = []
|
||||||
@@ -546,6 +551,7 @@ async function processUserInputBase(
|
|||||||
uuid,
|
uuid,
|
||||||
isAlreadyProcessing,
|
isAlreadyProcessing,
|
||||||
canUseTool,
|
canUseTool,
|
||||||
|
slashCommandOverride,
|
||||||
)
|
)
|
||||||
return addImageMetadataMessage(slashResult, imageMetadataTexts)
|
return addImageMetadataMessage(slashResult, imageMetadataTexts)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { afterEach, describe, expect, test } from 'bun:test'
|
||||||
|
import type { Command } from '../../types/command.js'
|
||||||
|
import type { LocalizationKey } from '../../i18n/index.js'
|
||||||
|
import {
|
||||||
|
resetSettingsCache,
|
||||||
|
setSessionSettingsCache,
|
||||||
|
} from '../settings/settingsCache.js'
|
||||||
|
import {
|
||||||
|
applyCommandSuggestion,
|
||||||
|
getCommandSuggestionForEnter,
|
||||||
|
generateCommandSuggestions,
|
||||||
|
} from './commandSuggestions.js'
|
||||||
|
|
||||||
|
function promptCommand({
|
||||||
|
name,
|
||||||
|
getDescription,
|
||||||
|
source = 'builtin',
|
||||||
|
kind,
|
||||||
|
pluginName,
|
||||||
|
localizationKey,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
getDescription: () => string
|
||||||
|
source?:
|
||||||
|
| 'builtin'
|
||||||
|
| 'bundled'
|
||||||
|
| 'mcp'
|
||||||
|
| 'plugin'
|
||||||
|
| 'projectSettings'
|
||||||
|
| 'userSettings'
|
||||||
|
| 'policySettings'
|
||||||
|
kind?: 'workflow'
|
||||||
|
pluginName?: string
|
||||||
|
localizationKey?: LocalizationKey
|
||||||
|
}): Command {
|
||||||
|
return {
|
||||||
|
type: 'prompt',
|
||||||
|
name,
|
||||||
|
get description() {
|
||||||
|
return getDescription()
|
||||||
|
},
|
||||||
|
source,
|
||||||
|
kind,
|
||||||
|
localizationKey,
|
||||||
|
pluginInfo: pluginName
|
||||||
|
? {
|
||||||
|
pluginManifest: {
|
||||||
|
name: pluginName,
|
||||||
|
},
|
||||||
|
repository: 'test',
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
progressMessage: 'running',
|
||||||
|
contentLength: 0,
|
||||||
|
getPromptForCommand: async () => [],
|
||||||
|
} as Command
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLanguage(language?: string): void {
|
||||||
|
setSessionSettingsCache({
|
||||||
|
settings: language ? { language } : {},
|
||||||
|
errors: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetSettingsCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateCommandSuggestions localization', () => {
|
||||||
|
test('searches localized built-in descriptions with a stable command array', () => {
|
||||||
|
const commands = [
|
||||||
|
promptCommand({
|
||||||
|
name: 'review',
|
||||||
|
source: 'builtin',
|
||||||
|
getDescription: () => 'Review a pull request',
|
||||||
|
localizationKey: 'commands.review.description',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
useLanguage('english')
|
||||||
|
expect(
|
||||||
|
generateCommandSuggestions('/pull', commands).map(
|
||||||
|
item => item.displayText,
|
||||||
|
),
|
||||||
|
).toContain('/review')
|
||||||
|
|
||||||
|
useLanguage('vietnamese')
|
||||||
|
const suggestions = generateCommandSuggestions('/\u0111\u00e1nh', commands)
|
||||||
|
|
||||||
|
expect(suggestions[0]?.displayText).toBe('/review')
|
||||||
|
expect(suggestions[0]?.description).toBe(
|
||||||
|
'\u0110\u00e1nh gi\u00e1 pull request',
|
||||||
|
)
|
||||||
|
|
||||||
|
useLanguage('english')
|
||||||
|
const englishSuggestions = generateCommandSuggestions('/pull', commands)
|
||||||
|
expect(englishSuggestions[0]?.displayText).toBe('/review')
|
||||||
|
expect(englishSuggestions[0]?.description).toBe('Review a pull request')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('searches localized bundled descriptions with a stable command array', () => {
|
||||||
|
const commands = [
|
||||||
|
promptCommand({
|
||||||
|
name: 'loop',
|
||||||
|
source: 'bundled',
|
||||||
|
getDescription: () =>
|
||||||
|
'Run a prompt on a fixed interval or dynamically reschedule it.',
|
||||||
|
localizationKey: 'skills.loop.description',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
useLanguage('english')
|
||||||
|
expect(
|
||||||
|
generateCommandSuggestions('/interval', commands).map(
|
||||||
|
item => item.displayText,
|
||||||
|
),
|
||||||
|
).toContain('/loop')
|
||||||
|
|
||||||
|
useLanguage('vietnamese')
|
||||||
|
const suggestions = generateCommandSuggestions('/kho\u1ea3ng', commands)
|
||||||
|
const loopSuggestion = suggestions.find(item => item.displayText === '/loop')
|
||||||
|
|
||||||
|
expect(loopSuggestion).toBeDefined()
|
||||||
|
expect(loopSuggestion?.description).toContain(
|
||||||
|
'kho\u1ea3ng th\u1eddi gian',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not index external English descriptions as Vietnamese text', () => {
|
||||||
|
const commands = [
|
||||||
|
promptCommand({
|
||||||
|
name: 'project-review',
|
||||||
|
source: 'projectSettings',
|
||||||
|
getDescription: () => 'Review a pull request',
|
||||||
|
}),
|
||||||
|
promptCommand({
|
||||||
|
name: 'plugin-review',
|
||||||
|
source: 'plugin',
|
||||||
|
getDescription: () => 'Review a pull request',
|
||||||
|
pluginName: 'MyPlugin',
|
||||||
|
}),
|
||||||
|
promptCommand({
|
||||||
|
name: 'workflow-review',
|
||||||
|
source: 'projectSettings',
|
||||||
|
kind: 'workflow',
|
||||||
|
getDescription: () => 'Review a pull request',
|
||||||
|
}),
|
||||||
|
promptCommand({
|
||||||
|
name: 'builtin-review',
|
||||||
|
source: 'builtin',
|
||||||
|
getDescription: () => 'Review a pull request',
|
||||||
|
localizationKey: 'commands.review.description',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
useLanguage('vietnamese')
|
||||||
|
const vietnameseMatches = generateCommandSuggestions(
|
||||||
|
'/\u0111\u00e1nh',
|
||||||
|
commands,
|
||||||
|
).map(item => item.displayText)
|
||||||
|
|
||||||
|
expect(vietnameseMatches).toContain('/builtin-review')
|
||||||
|
expect(vietnameseMatches).not.toContain('/project-review')
|
||||||
|
expect(vietnameseMatches).not.toContain('/plugin-review')
|
||||||
|
expect(vietnameseMatches).not.toContain('/workflow-review')
|
||||||
|
|
||||||
|
const pluginSuggestion = generateCommandSuggestions(
|
||||||
|
'/plugin-review',
|
||||||
|
commands,
|
||||||
|
).find(item => item.displayText === '/plugin-review')
|
||||||
|
|
||||||
|
expect(pluginSuggestion?.description).toBe(
|
||||||
|
'(MyPlugin) Review a pull request',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes the selected duplicate command row as the slash command override', () => {
|
||||||
|
const builtinReview = promptCommand({
|
||||||
|
name: 'review',
|
||||||
|
source: 'builtin',
|
||||||
|
getDescription: () => 'Builtin review',
|
||||||
|
})
|
||||||
|
const projectReview = promptCommand({
|
||||||
|
name: 'review',
|
||||||
|
source: 'projectSettings',
|
||||||
|
getDescription: () => 'Project review',
|
||||||
|
})
|
||||||
|
const commands = [builtinReview, projectReview]
|
||||||
|
const projectSuggestion = generateCommandSuggestions('/review', commands).find(
|
||||||
|
item => item.metadata === projectReview,
|
||||||
|
)
|
||||||
|
let submittedValue: string | undefined
|
||||||
|
let submittedOverride: Command | undefined
|
||||||
|
|
||||||
|
expect(projectSuggestion).toBeDefined()
|
||||||
|
applyCommandSuggestion(
|
||||||
|
projectSuggestion!,
|
||||||
|
true,
|
||||||
|
commands,
|
||||||
|
value => {
|
||||||
|
submittedValue = value
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
(value, _isSlashCommand, override) => {
|
||||||
|
submittedValue = value
|
||||||
|
submittedOverride = override
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(submittedValue).toBe('/review ')
|
||||||
|
expect(submittedOverride).toBe(projectReview)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the selected duplicate command row for exact-name Enter', () => {
|
||||||
|
const builtinReview = promptCommand({
|
||||||
|
name: 'review',
|
||||||
|
source: 'builtin',
|
||||||
|
getDescription: () => 'Builtin review',
|
||||||
|
})
|
||||||
|
const projectReview = promptCommand({
|
||||||
|
name: 'review',
|
||||||
|
source: 'projectSettings',
|
||||||
|
getDescription: () => 'Project review',
|
||||||
|
})
|
||||||
|
const commands = [builtinReview, projectReview]
|
||||||
|
const projectSuggestion = generateCommandSuggestions('/review', commands).find(
|
||||||
|
item => item.metadata === projectReview,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getCommandSuggestionForEnter('/review', projectSuggestion, commands),
|
||||||
|
).toBe(projectSuggestion)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('normalizes exact-name Enter when there is only one matching command', () => {
|
||||||
|
const review = promptCommand({
|
||||||
|
name: 'review',
|
||||||
|
source: 'builtin',
|
||||||
|
getDescription: () => 'Builtin review',
|
||||||
|
})
|
||||||
|
const suggestion = generateCommandSuggestions('/Review', [review])[0]
|
||||||
|
|
||||||
|
expect(getCommandSuggestionForEnter('/Review', suggestion, [review])).toBe(
|
||||||
|
'review',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,34 +19,49 @@ type CommandSearchItem = {
|
|||||||
aliasKey: string[] | undefined
|
aliasKey: string[] | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the Fuse index keyed by the commands array identity. The commands
|
type CommandSearchSnapshot = {
|
||||||
// array is stable (memoized in REPL.tsx), so we only rebuild when it changes
|
aliases: string[] | undefined
|
||||||
// rather than on every keystroke.
|
command: Command
|
||||||
|
commandName: string
|
||||||
|
isHidden: boolean
|
||||||
|
renderedDescription: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the Fuse index keyed by the commands array identity plus a signature
|
||||||
|
// of the searchable UI text. The commands array is stable (memoized in
|
||||||
|
// REPL.tsx), while language changes can alter rendered descriptions in place.
|
||||||
let fuseCache: {
|
let fuseCache: {
|
||||||
commands: Command[]
|
commands: Command[]
|
||||||
|
signature: string
|
||||||
fuse: Fuse<CommandSearchItem>
|
fuse: Fuse<CommandSearchItem>
|
||||||
} | null = null
|
} | null = null
|
||||||
|
|
||||||
function getCommandFuse(commands: Command[]): Fuse<CommandSearchItem> {
|
function getCommandFuse(commands: Command[]): Fuse<CommandSearchItem> {
|
||||||
if (fuseCache?.commands === commands) {
|
const snapshots = getCommandSearchSnapshots(commands)
|
||||||
|
const signature = getCommandSearchSignature(snapshots)
|
||||||
|
|
||||||
|
if (
|
||||||
|
fuseCache?.commands === commands &&
|
||||||
|
fuseCache.signature === signature
|
||||||
|
) {
|
||||||
return fuseCache.fuse
|
return fuseCache.fuse
|
||||||
}
|
}
|
||||||
|
|
||||||
const commandData: CommandSearchItem[] = commands
|
const commandData: CommandSearchItem[] = snapshots
|
||||||
.filter(cmd => !cmd.isHidden)
|
.filter(snapshot => !snapshot.isHidden)
|
||||||
.map(cmd => {
|
.map(snapshot => {
|
||||||
const commandName = getCommandName(cmd)
|
const { aliases, command, commandName, renderedDescription } = snapshot
|
||||||
const parts = commandName.split(SEPARATORS).filter(Boolean)
|
const parts = commandName.split(SEPARATORS).filter(Boolean)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
descriptionKey: (cmd.description ?? '')
|
descriptionKey: renderedDescription
|
||||||
.split(' ')
|
.split(/\s+/)
|
||||||
.map(word => cleanWord(word))
|
.map(word => cleanWord(word))
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
partKey: parts.length > 1 ? parts : undefined,
|
partKey: parts.length > 1 ? parts : undefined,
|
||||||
commandName,
|
commandName,
|
||||||
command: cmd,
|
command,
|
||||||
aliasKey: cmd.aliases,
|
aliasKey: aliases,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -75,10 +90,35 @@ function getCommandFuse(commands: Command[]): Fuse<CommandSearchItem> {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
fuseCache = { commands, fuse }
|
fuseCache = { commands, signature, fuse }
|
||||||
return fuse
|
return fuse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCommandSearchSnapshots(
|
||||||
|
commands: Command[],
|
||||||
|
): CommandSearchSnapshot[] {
|
||||||
|
return commands.map(command => ({
|
||||||
|
aliases: command.aliases,
|
||||||
|
command,
|
||||||
|
commandName: getCommandName(command),
|
||||||
|
isHidden: Boolean(command.isHidden),
|
||||||
|
renderedDescription: getRenderedCommandDescription(command),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCommandSearchSignature(
|
||||||
|
snapshots: CommandSearchSnapshot[],
|
||||||
|
): string {
|
||||||
|
return JSON.stringify(
|
||||||
|
snapshots.map(snapshot => [
|
||||||
|
snapshot.commandName,
|
||||||
|
snapshot.aliases ?? [],
|
||||||
|
snapshot.isHidden,
|
||||||
|
snapshot.renderedDescription,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type guard to check if a suggestion's metadata is a Command.
|
* Type guard to check if a suggestion's metadata is a Command.
|
||||||
* Commands have a name string and a type property.
|
* Commands have a name string and a type property.
|
||||||
@@ -201,6 +241,23 @@ export function isCommandInput(input: string): boolean {
|
|||||||
return input.startsWith('/')
|
return input.startsWith('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCommandSuggestionForEnter(
|
||||||
|
input: string,
|
||||||
|
suggestion: SuggestionItem | undefined,
|
||||||
|
commands: Command[],
|
||||||
|
): string | SuggestionItem | undefined {
|
||||||
|
const exactCommandName = !input.includes(' ') && isCommandInput(input)
|
||||||
|
? input.slice(1).toLowerCase().trim()
|
||||||
|
: ''
|
||||||
|
const exactCommands = exactCommandName
|
||||||
|
? commands.filter(cmd => getCommandName(cmd).toLowerCase() === exactCommandName)
|
||||||
|
: []
|
||||||
|
|
||||||
|
return exactCommands.length === 1
|
||||||
|
? getCommandName(exactCommands[0]!)
|
||||||
|
: suggestion
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a command input has arguments
|
* Checks if a command input has arguments
|
||||||
* A command with just a trailing space is considered to have no arguments
|
* A command with just a trailing space is considered to have no arguments
|
||||||
@@ -271,11 +328,7 @@ function createCommandSuggestionItem(
|
|||||||
const aliasText = matchedAlias ? ` (${matchedAlias})` : ''
|
const aliasText = matchedAlias ? ` (${matchedAlias})` : ''
|
||||||
|
|
||||||
const isWorkflow = cmd.type === 'prompt' && cmd.kind === 'workflow'
|
const isWorkflow = cmd.type === 'prompt' && cmd.kind === 'workflow'
|
||||||
const fullDescription =
|
const fullDescription = getRenderedCommandDescription(cmd)
|
||||||
(isWorkflow ? cmd.description : formatDescriptionWithSource(cmd)) +
|
|
||||||
(cmd.type === 'prompt' && cmd.argNames?.length
|
|
||||||
? ` (arguments: ${cmd.argNames.join(', ')})`
|
|
||||||
: '')
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: getCommandId(cmd),
|
id: getCommandId(cmd),
|
||||||
@@ -286,6 +339,19 @@ function createCommandSuggestionItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRenderedCommandDescription(cmd: Command): string {
|
||||||
|
const isWorkflow = cmd.type === 'prompt' && cmd.kind === 'workflow'
|
||||||
|
const description = isWorkflow
|
||||||
|
? cmd.description
|
||||||
|
: formatDescriptionWithSource(cmd)
|
||||||
|
return (
|
||||||
|
description +
|
||||||
|
(cmd.type === 'prompt' && cmd.argNames?.length
|
||||||
|
? ` (arguments: ${cmd.argNames.join(', ')})`
|
||||||
|
: '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure suggestion IDs are unique for React keys and selection logic.
|
* Ensure suggestion IDs are unique for React keys and selection logic.
|
||||||
* If duplicates exist, append a stable numeric suffix to subsequent entries.
|
* If duplicates exist, append a stable numeric suffix to subsequent entries.
|
||||||
@@ -398,15 +464,12 @@ export function generateCommandSuggestions(
|
|||||||
].map(cmd => createCommandSuggestionItem(cmd)))
|
].map(cmd => createCommandSuggestionItem(cmd)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Fuse index filters isHidden at build time and is keyed on the
|
// The Fuse index filters hidden commands, so an exact hidden command name
|
||||||
// (memoized) commands array identity, so a command that is hidden when Fuse
|
// will not appear in Fuse results. If no visible command shares the name,
|
||||||
// first builds stays invisible to Fuse for the whole session. If the user
|
// prepend the hidden exact match so explicit invocation still works.
|
||||||
// types the exact name of a currently-hidden command, prepend it to the
|
// Prepend rather than early-return so visible prefix siblings (e.g.
|
||||||
// Fuse results so exact-name always wins over weak description fuzzy
|
// /voice-memo) still appear below, and getBestCommandMatch can still find
|
||||||
// matches — but only when no visible command shares the name (that would
|
// a non-empty suffix.
|
||||||
// be the user's explicit override and should win). Prepend rather than
|
|
||||||
// early-return so visible prefix siblings (e.g. /voice-memo) still appear
|
|
||||||
// below, and getBestCommandMatch can still find a non-empty suffix.
|
|
||||||
let hiddenExact = commands.find(
|
let hiddenExact = commands.find(
|
||||||
cmd => cmd.isHidden && getCommandName(cmd).toLowerCase() === query,
|
cmd => cmd.isHidden && getCommandName(cmd).toLowerCase() === query,
|
||||||
)
|
)
|
||||||
@@ -501,12 +564,8 @@ export function generateCommandSuggestions(
|
|||||||
const matchedAlias = findMatchedAlias(query, cmd.aliases)
|
const matchedAlias = findMatchedAlias(query, cmd.aliases)
|
||||||
return createCommandSuggestionItem(cmd, matchedAlias)
|
return createCommandSuggestionItem(cmd, matchedAlias)
|
||||||
})
|
})
|
||||||
// Skip the prepend if hiddenExact is already in fuseSuggestions — this
|
// Skip the prepend defensively if the command is already present; duplicate
|
||||||
// happens when isHidden flips false→true mid-session (OAuth expiry,
|
// ids confuse React keys and selection state.
|
||||||
// GrowthBook kill-switch) and the stale Fuse index still holds the
|
|
||||||
// command. Fuse already sorts exact-name matches first, so no reorder
|
|
||||||
// is needed; we just don't want a duplicate id (duplicate React keys,
|
|
||||||
// both rows rendering as selected).
|
|
||||||
if (hiddenExact) {
|
if (hiddenExact) {
|
||||||
const hiddenId = getCommandId(hiddenExact)
|
const hiddenId = getCommandId(hiddenExact)
|
||||||
if (!fuseSuggestions.some(s => s.id === hiddenId)) {
|
if (!fuseSuggestions.some(s => s.id === hiddenId)) {
|
||||||
@@ -528,7 +587,11 @@ export function applyCommandSuggestion(
|
|||||||
commands: Command[],
|
commands: Command[],
|
||||||
onInputChange: (value: string) => void,
|
onInputChange: (value: string) => void,
|
||||||
setCursorOffset: (offset: number) => void,
|
setCursorOffset: (offset: number) => void,
|
||||||
onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void,
|
onSubmit: (
|
||||||
|
value: string,
|
||||||
|
isSubmittingSlashCommand?: boolean,
|
||||||
|
slashCommandOverride?: Command,
|
||||||
|
) => void,
|
||||||
): void {
|
): void {
|
||||||
// Extract command name and object from string or SuggestionItem metadata
|
// Extract command name and object from string or SuggestionItem metadata
|
||||||
let commandName: string
|
let commandName: string
|
||||||
@@ -555,14 +618,14 @@ export function applyCommandSuggestion(
|
|||||||
commandObj.type !== 'prompt' ||
|
commandObj.type !== 'prompt' ||
|
||||||
(commandObj.argNames ?? []).length === 0
|
(commandObj.argNames ?? []).length === 0
|
||||||
) {
|
) {
|
||||||
onSubmit(newInput, /* isSubmittingSlashCommand */ true)
|
onSubmit(newInput, /* isSubmittingSlashCommand */ true, commandObj)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function at bottom of file per CLAUDE.md
|
// Helper function at bottom of file per CLAUDE.md
|
||||||
function cleanWord(word: string) {
|
function cleanWord(word: string) {
|
||||||
return word.toLowerCase().replace(/[^a-z0-9]/g, '')
|
return word.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user