File size: 3,747 Bytes
55847d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
/**
 * SlashCommandMenu — / 命令选择菜单
 *
 * 用户输入 / 时弹出的命令搜索和选择面板。
 */
import { useTranslation } from 'react-i18next'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Sparkles, Zap } from 'lucide-react'
import { searchSlashCommands, type SlashCommand } from '../../../services/agent/intent-router'

interface Props {
  /** 搜索关键词(/ 后面的文字) */
  query: string
  /** 选择命令的回调 */
  onSelect: (command: SlashCommand) => void
  /** 关闭菜单 */
  onClose: () => void
  /** 菜单位置 */
  position?: { bottom: number; left: number }
}

export default function SlashCommandMenu({ query, onSelect, onClose, position }: Props) {
  const { t } = useTranslation('panels')
  const [selectedIndex, setSelectedIndex] = useState(0)
  const menuRef = useRef<HTMLDivElement>(null)

  const results = searchSlashCommands(query)

  const [prevQuery, setPrevQuery] = useState(query)

  // 重置选中索引
  if (query !== prevQuery) {
    setSelectedIndex(0)
    setPrevQuery(query)
  }

  // 键盘导航
  const handleKeyDown = useCallback((e: KeyboardEvent) => {
    if (e.key === 'ArrowDown') {
      e.preventDefault()
      setSelectedIndex(i => Math.min(i + 1, results.length - 1))
    } else if (e.key === 'ArrowUp') {
      e.preventDefault()
      setSelectedIndex(i => Math.max(i - 1, 0))
    } else if (e.key === 'Enter') {
      e.preventDefault()
      if (results[selectedIndex]) {
        onSelect(results[selectedIndex])
      }
    } else if (e.key === 'Escape') {
      e.preventDefault()
      onClose()
    }
  }, [results, selectedIndex, onSelect, onClose])

  useEffect(() => {
    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [handleKeyDown])

  if (results.length === 0) return null

  return (
    <div
      ref={menuRef}
      className="absolute z-50 py-1 rounded-lg shadow-lg"
      style={{
        bottom: position?.bottom ?? 'calc(100% + 4px)',
        left: position?.left ?? 0,
        width: 280,
        maxHeight: 300,
        overflowY: 'auto',
        backgroundColor: 'var(--color-sidebar)',
        border: '1px solid var(--color-border)',
        boxShadow: '0 8px 24px rgba(0,0,0,0.25)',
      }}
    >
      <div className="text-[0.68rem] px-3 py-1" style={{ color: 'var(--color-text-muted)' }}>
        {t('agent.commands')}
      </div>
      {results.map((cmd, i) => (
        <button
          key={cmd.name}
          onClick={() => onSelect(cmd)}
          className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors"
          style={{
            backgroundColor: i === selectedIndex ? 'var(--color-hover)' : 'transparent',
            color: 'var(--color-text)',
          }}
          onMouseEnter={() => setSelectedIndex(i)}
        >
          <span style={{ color: 'var(--color-accent)' }}>
            {cmd.source === 'skill' ? <Sparkles size={13} /> : <Zap size={13} />}
          </span>
          <div className="flex-1 min-w-0">
            <div className="font-medium">/{cmd.name}</div>
            <div
              className="text-[0.68rem] truncate"
              style={{ color: 'var(--color-text-muted)' }}
            >
              {cmd.description}
            </div>
          </div>
          {cmd.source === 'skill' && (
            <span
              className="text-[0.6rem] px-1.5 py-0.5 rounded-full flex-shrink-0"
              style={{
                backgroundColor: 'rgba(34, 197, 94, 0.12)',
                color: '#22c55e',
              }}
            >
              Skill
            </span>
          )}
        </button>
      ))}
    </div>
  )
}