@@ -410,7 +586,9 @@ function BudgetTokenStrip() {
)}
diff --git a/web/src/components/chat/SlashCommandMenu.test.tsx b/web/src/components/chat/SlashCommandMenu.test.tsx
index b4f0c3ec..9f124a1b 100644
--- a/web/src/components/chat/SlashCommandMenu.test.tsx
+++ b/web/src/components/chat/SlashCommandMenu.test.tsx
@@ -4,63 +4,70 @@ import SlashCommandMenu from './SlashCommandMenu'
import type { AnySlashCommand } from '@/utils/slashCommands'
const builtin: AnySlashCommand = {
- id: 'compact',
- usage: '/compact',
- description: 'compress context',
- hasArgument: false,
+ id: 'compact',
+ usage: '/compact',
+ description: 'compress context',
+ hasArgument: false,
}
const skill: AnySlashCommand = {
- id: 'skill.demo',
- usage: '/skill.demo',
- description: 'demo skill',
- hasArgument: false,
- isSkill: true,
- skillId: 'skill.demo',
- active: true,
+ id: 'skill.demo',
+ usage: '/skill.demo',
+ description: 'demo skill',
+ hasArgument: false,
+ isSkill: true,
+ skillId: 'skill.demo',
+ active: true,
}
describe('SlashCommandMenu', () => {
- ;(HTMLElement.prototype as any).scrollIntoView = vi.fn()
+ ;(HTMLElement.prototype as { scrollIntoView?: () => void }).scrollIntoView = vi.fn()
- it('returns null when commands is empty', () => {
- const { container } = render(
-
,
- )
- expect(container.firstChild).toBeNull()
- })
+ it('returns null when commands is empty', () => {
+ const { container } = render(
+
,
+ )
+ expect(container.firstChild).toBeNull()
+ })
- it('renders builtin and skill sections and highlights query', () => {
- render(
-
,
- )
- expect(screen.getByText('命令')).toBeInTheDocument()
- expect(screen.getByText('技能')).toBeInTheDocument()
- expect(screen.getByText('已激活')).toBeInTheDocument()
- expect(screen.getAllByText((_, el) => Boolean(el?.textContent?.includes('/compact'))).length).toBeGreaterThan(0)
- })
+ it('renders builtin and skill sections without owning absolute positioning', () => {
+ render(
+
,
+ )
- it('triggers hover/select callbacks', () => {
- const onSelect = vi.fn()
- const onHover = vi.fn()
- render(
-
,
- )
- fireEvent.mouseEnter(screen.getByText('/compact'))
- fireEvent.click(screen.getByText('/skill.demo'))
- expect(onHover).toHaveBeenCalledWith(0)
- expect(onSelect).toHaveBeenCalledWith(skill)
- })
+ const menu = screen.getByTestId('slash-command-menu')
+ expect(menu).toBeInTheDocument()
+ expect(menu).not.toHaveStyle({ position: 'absolute' })
+ expect(screen.getByText('命令')).toBeInTheDocument()
+ expect(screen.getByText('技能')).toBeInTheDocument()
+ expect(screen.getByText('已激活')).toBeInTheDocument()
+ expect(screen.getAllByText((_, el) => Boolean(el?.textContent?.includes('/compact'))).length).toBeGreaterThan(0)
+ })
+
+ it('triggers hover and select callbacks', () => {
+ const onSelect = vi.fn()
+ const onHover = vi.fn()
+
+ render(
+
,
+ )
+
+ fireEvent.mouseEnter(screen.getByText('/compact'))
+ fireEvent.click(screen.getByText('/skill.demo'))
+
+ expect(onHover).toHaveBeenCalledWith(0)
+ expect(onSelect).toHaveBeenCalledWith(skill)
+ })
})
diff --git a/web/src/components/chat/SlashCommandMenu.tsx b/web/src/components/chat/SlashCommandMenu.tsx
index 71869f08..d2a2c582 100644
--- a/web/src/components/chat/SlashCommandMenu.tsx
+++ b/web/src/components/chat/SlashCommandMenu.tsx
@@ -26,41 +26,50 @@ function getCommandIcon(cmd: AnySlashCommand): React.ReactNode {
}
function highlightMatch(text: string, query: string): React.ReactNode {
- if (!query || query === '/') return text
+ const normalizedQuery = query.trim().toLowerCase()
+ if (!normalizedQuery || normalizedQuery === '/') return text
+
const lowerText = text.toLowerCase()
- const lowerQuery = query.toLowerCase().trim()
- const idx = lowerText.indexOf(lowerQuery)
- if (idx === -1) return text
+ const matchIndex = lowerText.indexOf(normalizedQuery)
+ if (matchIndex < 0) return text
return (
<>
- {text.slice(0, idx)}
-
{text.slice(idx, idx + lowerQuery.length)}
- {text.slice(idx + lowerQuery.length)}
+ {text.slice(0, matchIndex)}
+
+ {text.slice(matchIndex, matchIndex + normalizedQuery.length)}
+
+ {text.slice(matchIndex + normalizedQuery.length)}
>
)
}
-/** Slash 命令浮动菜单 */
-export default function SlashCommandMenu({ commands, selectedIndex, onSelect, onHover, query }: SlashCommandMenuProps) {
+/** Slash 命令菜单只负责渲染内容,不再自行决定浮层定位。 */
+export default function SlashCommandMenu({
+ commands,
+ selectedIndex,
+ onSelect,
+ onHover,
+ query,
+}: SlashCommandMenuProps) {
useEffect(() => {
const el = document.querySelector(`[data-slash-index="${selectedIndex}"]`)
- if (el) {
+ if (el instanceof HTMLElement && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ block: 'nearest' })
}
}, [selectedIndex])
if (commands.length === 0) return null
- const builtinCmds = commands.filter(isBuiltinCommand)
- const skillCmds = commands.filter(isSkillCommand)
+ const builtinCommands = commands.filter(isBuiltinCommand)
+ const skillCommands = commands.filter(isSkillCommand)
return (
-
- {builtinCmds.length > 0 && (
+
+ {builtinCommands.length > 0 && (
命令
- {builtinCmds.map((cmd) => {
+ {builtinCommands.map((cmd) => {
const globalIndex = commands.indexOf(cmd)
return (
)}
- {skillCmds.length > 0 && (
+ {skillCommands.length > 0 && (
- {builtinCmds.length > 0 &&
}
+ {builtinCommands.length > 0 &&
}
技能
- {skillCmds.map((cmd) => {
+ {skillCommands.map((cmd) => {
const globalIndex = commands.indexOf(cmd)
return (
= {
container: {
- position: 'absolute',
- bottom: '100%',
- left: 0,
- marginBottom: 8,
minWidth: 280,
maxWidth: 360,
maxHeight: 320,
overflowY: 'auto',
- background: 'var(--bg-secondary)',
+ background: 'var(--bg-overlay)',
border: '1px solid var(--border-primary)',
borderRadius: 'var(--radius-lg)',
- boxShadow: '0 4px 24px rgba(0,0,0,0.15)',
- zIndex: 100,
+ boxShadow: 'var(--shadow-elevated)',
padding: '6px 0',
},
sectionLabel: {
diff --git a/web/src/utils/slashCommands.test.ts b/web/src/utils/slashCommands.test.ts
index 5533139a..04744c1a 100644
--- a/web/src/utils/slashCommands.test.ts
+++ b/web/src/utils/slashCommands.test.ts
@@ -1,68 +1,81 @@
import { describe, expect, it } from 'vitest'
import {
- builtinSlashCommands,
- isBuiltinCommand,
- isKnownSlashCommand,
- isSkillCommand,
- isSlashCommand,
- matchSlashCommands,
- parseSlashCommand,
- type AnySlashCommand,
+ builtinSlashCommands,
+ isBuiltinCommand,
+ isKnownSlashCommand,
+ isSkillCommand,
+ isSlashCommand,
+ matchSlashCommands,
+ parseSlashCommand,
+ type AnySlashCommand,
} from './slashCommands'
describe('slashCommands utils', () => {
- it('parses command with and without argument', () => {
- expect(parseSlashCommand('/help')).toEqual({ command: '/help', argument: '' })
- expect(parseSlashCommand('/remember user is Alice')).toEqual({
- command: '/remember',
- argument: 'user is Alice',
- })
- expect(parseSlashCommand('hello')).toBeNull()
- })
+ it('parses command with and without argument', () => {
+ expect(parseSlashCommand('/help')).toEqual({ command: '/help', argument: '' })
+ expect(parseSlashCommand('/remember user is Alice')).toEqual({
+ command: '/remember',
+ argument: 'user is Alice',
+ })
+ expect(parseSlashCommand('hello')).toBeNull()
+ })
- it('detects slash command shape', () => {
- expect(isSlashCommand('/')).toBe(false)
- expect(isSlashCommand('/a')).toBe(true)
- expect(isSlashCommand(' /skills')).toBe(true)
- expect(isSlashCommand('abc')).toBe(false)
- })
+ it('treats slash trigger as a valid command input shape', () => {
+ expect(isSlashCommand('/')).toBe(true)
+ expect(isSlashCommand('/a')).toBe(true)
+ expect(isSlashCommand(' /skills')).toBe(true)
+ expect(isSlashCommand('abc')).toBe(false)
+ })
- it('matches commands by usage/description/id', () => {
- const skill: AnySlashCommand = {
- id: 'my-skill',
- usage: '/skill.my',
- description: 'my custom skill',
- hasArgument: false,
- isSkill: true,
- skillId: 'my-skill',
- active: false,
- }
- const commands = [...builtinSlashCommands, skill]
- expect(matchSlashCommands('/help', commands).map((c) => c.id)).toContain('help')
- expect(matchSlashCommands('/com', commands).map((c) => c.id)).toContain('compact')
- expect(matchSlashCommands('/my', commands).map((c) => c.id)).toContain('my-skill')
- })
+ it('returns all current web commands for bare slash', () => {
+ const matched = matchSlashCommands('/', builtinSlashCommands)
+ expect(matched.map((command) => command.id)).toEqual(builtinSlashCommands.map((command) => command.id))
+ })
- it('checks known builtin slash command', () => {
- expect(isKnownSlashCommand('/help')).toBe(true)
- expect(isKnownSlashCommand('/help foo')).toBe(true)
- expect(isKnownSlashCommand('/skill.my')).toBe(false)
- })
+ it('matches commands with prefix and fuzzy fallbacks', () => {
+ const skill: AnySlashCommand = {
+ id: 'my-skill',
+ usage: '/skill.my',
+ description: 'my custom skill',
+ hasArgument: false,
+ isSkill: true,
+ skillId: 'my-skill',
+ active: false,
+ }
+ const commands = [...builtinSlashCommands, skill]
- it('guards builtin and skill commands', () => {
- const builtin = builtinSlashCommands[0]
- const skill: AnySlashCommand = {
- id: 'my-skill',
- usage: '/skill.my',
- description: 'desc',
- hasArgument: false,
- isSkill: true,
- skillId: 'my-skill',
- active: true,
- }
- expect(isBuiltinCommand(builtin)).toBe(true)
- expect(isSkillCommand(builtin)).toBe(false)
- expect(isBuiltinCommand(skill)).toBe(false)
- expect(isSkillCommand(skill)).toBe(true)
- })
+ expect(matchSlashCommands('/com', commands).map((command) => command.id)).toContain('compact')
+ expect(matchSlashCommands('/mem', commands).map((command) => command.id)).toContain('memo')
+ expect(matchSlashCommands('/w', commands).length).toBeGreaterThan(0)
+ expect(matchSlashCommands('/my', commands).map((command) => command.id)).toContain('my-skill')
+ })
+
+ it('hides suggestions for complete commands without trailing space', () => {
+ expect(matchSlashCommands('/help', builtinSlashCommands)).toEqual([])
+ expect(matchSlashCommands('/remember', builtinSlashCommands)).toEqual([])
+ expect(matchSlashCommands('/remember ', builtinSlashCommands).map((command) => command.id)).toContain('remember')
+ })
+
+ it('checks known builtin slash command', () => {
+ expect(isKnownSlashCommand('/help')).toBe(true)
+ expect(isKnownSlashCommand('/help foo')).toBe(true)
+ expect(isKnownSlashCommand('/skill.my')).toBe(false)
+ })
+
+ it('guards builtin and skill commands', () => {
+ const builtin = builtinSlashCommands[0]
+ const skill: AnySlashCommand = {
+ id: 'my-skill',
+ usage: '/skill.my',
+ description: 'desc',
+ hasArgument: false,
+ isSkill: true,
+ skillId: 'my-skill',
+ active: true,
+ }
+ expect(isBuiltinCommand(builtin)).toBe(true)
+ expect(isSkillCommand(builtin)).toBe(false)
+ expect(isBuiltinCommand(skill)).toBe(false)
+ expect(isSkillCommand(skill)).toBe(true)
+ })
})
diff --git a/web/src/utils/slashCommands.ts b/web/src/utils/slashCommands.ts
index 7e2c1e36..a48247c3 100644
--- a/web/src/utils/slashCommands.ts
+++ b/web/src/utils/slashCommands.ts
@@ -1,6 +1,6 @@
/**
* Slash Command 定义、解析与匹配工具模块
- * 与 TUI 端 internal/tui/core/app/commands.go 对齐
+ * 与 Web 端当前已支持的命令集保持一致
*/
export interface SlashCommand {
@@ -60,12 +60,10 @@ export const builtinSlashCommands: SlashCommand[] = [
},
]
-/** 所有内置命令的 usage 集合,用于快速判断 */
-const builtinUsages = new Set(builtinSlashCommands.map((c) => c.usage))
+const builtinUsages = new Set(builtinSlashCommands.map((command) => command.usage.toLowerCase()))
/**
- * 解析 slash command 输入
- * 输入 "/remember 用户名是 Alice" → { command: '/remember', argument: '用户名是 Alice' }
+ * 解析 slash command 输入,提取命令与参数部分。
*/
export function parseSlashCommand(input: string): { command: string; argument: string } | null {
const trimmed = input.trim()
@@ -82,34 +80,87 @@ export function parseSlashCommand(input: string): { command: string; argument: s
}
/**
- * 判断输入是否是 slash command(以 / 开头且不止一个字符)
+ * 判断输入是否进入 slash 提示态;单独输入 "/" 也应视为有效触发。
*/
export function isSlashCommand(input: string): boolean {
- const trimmed = input.trim()
- return trimmed.startsWith('/') && trimmed.length > 1
+ return input.trim().startsWith('/')
+}
+
+/**
+ * 判断输入是否已经完整匹配某个命令;完整命令且无尾随空格时不再继续提示。
+ */
+function isCompleteSlashCommand(input: string, commands: AnySlashCommand[]): boolean {
+ const normalizedInput = input.trimLeft().toLowerCase()
+ if (normalizedInput.trimRight() !== normalizedInput) {
+ return false
+ }
+ return commands.some((command) => command.usage.trim().toLowerCase() === normalizedInput)
+}
+
+/**
+ * 计算模糊匹配分值;分值越小表示匹配越优先,返回 null 表示不匹配。
+ */
+function buildFuzzyMatchScore(target: string, needle: string): number | null {
+ if (!target || !needle) return null
+ if (target === needle) return 0
+ if (target.startsWith(needle)) return 100 + target.length
+
+ const includeIndex = target.indexOf(needle)
+ if (includeIndex >= 0) return 200 + includeIndex
+
+ let cursor = 0
+ let spanStart = -1
+ let spanEnd = -1
+ for (const char of needle) {
+ const foundAt = target.indexOf(char, cursor)
+ if (foundAt < 0) return null
+ if (spanStart < 0) spanStart = foundAt
+ spanEnd = foundAt
+ cursor = foundAt + 1
+ }
+
+ if (spanStart < 0 || spanEnd < 0) return null
+ const spanLength = spanEnd - spanStart + 1
+ return 500 + spanLength + spanStart
}
/**
- * 根据输入过滤匹配命令列表
- * 输入 "/com" → 过滤出 /compact
+ * 根据输入过滤匹配命令列表;优先 usage/id,再回退 description。
*/
export function matchSlashCommands(input: string, commands: AnySlashCommand[]): AnySlashCommand[] {
if (!isSlashCommand(input)) return []
- const query = input.trim().toLowerCase()
+ const query = input.trimLeft().toLowerCase()
if (query === '/') return commands
+ if (isCompleteSlashCommand(query, commands)) return []
+
+ const needle = query.slice(1).trim()
+ if (!needle) return commands
+
+ const matches = commands
+ .map((command, index) => {
+ const usageScore = buildFuzzyMatchScore(command.usage.toLowerCase().slice(1), needle)
+ const idScore = buildFuzzyMatchScore(command.id.toLowerCase(), needle)
+ const descriptionScore = buildFuzzyMatchScore(command.description.toLowerCase(), needle)
+
+ const bestScore = [usageScore, idScore, descriptionScore == null ? null : descriptionScore + 1000]
+ .filter((score): score is number => score != null)
+ .sort((left, right) => left - right)[0]
+
+ if (bestScore == null) return null
+ return { command, index, score: bestScore }
+ })
+ .filter((entry): entry is { command: AnySlashCommand; index: number; score: number } => entry != null)
+ .sort((left, right) => left.score - right.score || left.index - right.index)
+ .map((entry) => entry.command)
- return commands.filter(
- (cmd) =>
- cmd.usage.toLowerCase().includes(query) ||
- cmd.description.toLowerCase().includes(query) ||
- cmd.id.toLowerCase().includes(query.slice(1)),
- )
+ if (matches.length > 0) return matches
+ if (needle.length === 1) return commands
+ return []
}
/**
- * 判断输入是否匹配一个已知的完整内置命令
- * 用于决定 Enter 是执行命令还是发送普通消息
+ * 判断输入是否匹配一个已知的完整内置命令。
*/
export function isKnownSlashCommand(input: string): boolean {
const parsed = parseSlashCommand(input)
@@ -118,14 +169,14 @@ export function isKnownSlashCommand(input: string): boolean {
}
/**
- * 判断是否为内置命令(非技能命令)
+ * 判断是否为内置命令(非技能命令)。
*/
export function isBuiltinCommand(cmd: AnySlashCommand): cmd is SlashCommand {
return !('isSkill' in cmd)
}
/**
- * 判断是否为技能命令
+ * 判断是否为技能命令。
*/
export function isSkillCommand(cmd: AnySlashCommand): cmd is SkillSlashCommand {
return 'isSkill' in cmd && cmd.isSkill