Files
Hermes-ui/packages/client/src/components/hermes/chat/ChatInput.vue
T

895 lines
24 KiB
Vue
Raw Normal View History

2026-04-11 15:59:14 +08:00
<script setup lang="ts">
import type { Attachment } from '@/stores/hermes/chat'
import { useChatStore } from '@/stores/hermes/chat'
import { useAppStore } from '@/stores/hermes/app'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { fetchContextLength } from '@/api/hermes/sessions'
import { setModelContext } from '@/api/hermes/model-context'
import { NButton, NTooltip, NSwitch, NModal, NInputNumber, useMessage } from 'naive-ui'
2026-05-15 12:04:03 +08:00
import { computed, ref, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
2026-04-11 15:59:14 +08:00
const chatStore = useChatStore()
const { t } = useI18n()
const message = useMessage()
2026-04-11 15:59:14 +08:00
const inputText = ref('')
const textareaRef = ref<HTMLTextAreaElement>()
2026-05-15 12:04:03 +08:00
const commandDropdownRef = ref<HTMLDivElement>()
const fileInputRef = ref<HTMLInputElement>()
const attachments = ref<Attachment[]>([])
const isDragging = ref(false)
const dragCounter = ref(0)
const isComposing = ref(false)
2026-05-15 12:04:03 +08:00
const bridgeCommands = computed(() => [
{ name: 'usage', args: '', description: t('chat.slashCommands.usage') },
{ name: 'status', args: '', description: t('chat.slashCommands.status') },
{ name: 'abort', args: '', description: t('chat.slashCommands.abort') },
{ name: 'queue', args: t('chat.slashCommandArgs.message'), description: t('chat.slashCommands.queue') },
{ name: 'clear', args: '', description: t('chat.slashCommands.clear') },
{ name: 'clear', args: '--history', insertText: 'clear --history', description: t('chat.slashCommands.clearHistory') },
{ name: 'title', args: t('chat.slashCommandArgs.title'), description: t('chat.slashCommands.title') },
{ name: 'compress', args: '', description: t('chat.slashCommands.compress') },
{ name: 'steer', args: t('chat.slashCommandArgs.text'), description: t('chat.slashCommands.steer') },
{ name: 'destroy', args: '', description: t('chat.slashCommands.destroy') },
])
const slashActive = ref(false)
const slashQuery = ref('')
const slashActiveIndex = ref(0)
const isBridgeSession = computed(() => chatStore.activeSession?.source === 'cli')
const filteredBridgeCommands = computed(() => {
const query = slashQuery.value.toLowerCase()
return bridgeCommands.value.filter(command =>
command.name.includes(query) || command.insertText?.includes(query),
)
})
// 自定义高度拖拽
const textareaHeight = ref<number | null>(null) // null = auto
function startResize(e: MouseEvent) {
e.preventDefault()
const el = textareaRef.value
if (!el) return
// 如果当前是 auto,用实际 clientHeight 作为起始值
const startHeight = el.clientHeight
const startY = e.clientY
function onMouseMove(e: MouseEvent) {
const deltaY = e.clientY - startY
// 往上拖 (deltaY < 0) → 高度增加
const newHeight = startHeight - deltaY
textareaHeight.value = Math.max(20, Math.min(400, Math.round(newHeight)))
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'row-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// 自动播放语音开关
const autoPlaySpeech = ref(false)
// 从 localStorage 读取设置
onMounted(() => {
const saved = localStorage.getItem('autoPlaySpeech')
if (saved !== null) {
autoPlaySpeech.value = saved === 'true'
// 同步到 chat store
chatStore.setAutoPlaySpeech(autoPlaySpeech.value)
}
})
// 监听变化并保存
watch(autoPlaySpeech, (value) => {
localStorage.setItem('autoPlaySpeech', String(value))
// 通知 chat store
chatStore.setAutoPlaySpeech(value)
})
const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0)
2026-04-11 15:59:14 +08:00
2026-05-15 12:04:03 +08:00
function scrollCommandIntoView() {
nextTick(() => {
if (!commandDropdownRef.value) return
const active = commandDropdownRef.value.querySelector('.active') as HTMLElement | null
active?.scrollIntoView({ block: 'nearest', behavior: 'instant' })
})
}
function updateSlashState() {
if (!isBridgeSession.value) {
slashActive.value = false
return
}
const el = textareaRef.value
if (!el) return
const cursorPos = el.selectionStart
const beforeCursor = inputText.value.slice(0, cursorPos)
if (!beforeCursor.startsWith('/') || beforeCursor.includes(' ') || beforeCursor.includes('\n')) {
slashActive.value = false
return
}
slashQuery.value = beforeCursor.slice(1)
slashActiveIndex.value = 0
slashActive.value = filteredBridgeCommands.value.length > 0
}
function selectBridgeCommand(command: { name: string; args: string; insertText?: string }) {
inputText.value = `/${command.insertText || command.name} `
slashActive.value = false
nextTick(() => {
const el = textareaRef.value
if (!el) return
const pos = inputText.value.length
el.setSelectionRange(pos, pos)
el.focus()
})
}
// --- Context info ---
const contextLength = ref(200000)
const FALLBACK_CONTEXT = 200000
// Context length editing
const showContextEditModal = ref(false)
const editingContextLimit = ref(200000)
const isSavingContextLimit = ref(false)
async function handleEditContextLimit() {
editingContextLimit.value = contextLength.value
showContextEditModal.value = true
}
async function saveContextLimit() {
if (!editingContextLimit.value || editingContextLimit.value <= 0) {
message.error(t('chat.contextEditInvalid'))
return
}
isSavingContextLimit.value = true
try {
const appStore = useAppStore()
const provider = appStore.selectedProvider || ''
const model = appStore.selectedModel || ''
if (!provider || !model) {
message.error(t('chat.contextEditFailed'))
return
}
await setModelContext(provider, model, editingContextLimit.value)
contextLength.value = editingContextLimit.value
showContextEditModal.value = false
message.success(t('chat.contextEditSuccess'))
} catch (err: any) {
message.error(`${t('chat.contextEditFailed')}: ${err.message || ''}`)
} finally {
isSavingContextLimit.value = false
}
}
async function loadContextLength() {
try {
const profile = useProfilesStore().activeProfileName || undefined
contextLength.value = await fetchContextLength(profile)
} catch {
contextLength.value = FALLBACK_CONTEXT
}
}
onMounted(loadContextLength)
watch(() => useProfilesStore().activeProfileName, loadContextLength)
watch(() => useAppStore().selectedModel, loadContextLength)
const totalTokens = computed(() => {
const input = chatStore.activeSession?.inputTokens ?? 0
const output = chatStore.activeSession?.outputTokens ?? 0
return input + output
})
const remainingTokens = computed(() => Math.max(0, contextLength.value - totalTokens.value))
const usagePercent = computed(() =>
Math.min((totalTokens.value / contextLength.value) * 100, 100),
)
function formatTokens(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
return String(n)
}
// --- File attachment helpers ---
function addFile(file: File) {
if (attachments.value.find(a => a.name === file.name)) return
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
const url = URL.createObjectURL(file)
attachments.value.push({
id,
name: file.name,
type: file.type,
size: file.size,
url,
file,
})
}
function handleAttachClick() {
fileInputRef.value?.click()
}
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
for (const file of input.files) addFile(file)
input.value = ''
}
// --- Paste image ---
function handlePaste(e: ClipboardEvent) {
const items = Array.from(e.clipboardData?.items || [])
const imageItems = items.filter(i => i.type.startsWith('image/'))
if (!imageItems.length) return
e.preventDefault()
for (const item of imageItems) {
const blob = item.getAsFile()
if (!blob) continue
const ext = item.type.split('/')[1] || 'png'
const file = new File([blob], `pasted-${Date.now()}.${ext}`, { type: item.type })
addFile(file)
}
}
// --- Drag and drop ---
function handleDragOver(e: DragEvent) {
e.preventDefault()
}
function handleDragEnter(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer?.types.includes('Files')) {
dragCounter.value++
isDragging.value = true
}
}
function handleDragLeave() {
dragCounter.value--
if (dragCounter.value <= 0) {
dragCounter.value = 0
isDragging.value = false
}
}
function handleDrop(e: DragEvent) {
e.preventDefault()
dragCounter.value = 0
isDragging.value = false
const files = Array.from(e.dataTransfer?.files || [])
if (!files.length) return
for (const file of files) addFile(file)
textareaRef.value?.focus()
}
// --- Send ---
2026-04-11 15:59:14 +08:00
function handleSend() {
const text = inputText.value.trim()
if (!text && attachments.value.length === 0) return
2026-04-11 15:59:14 +08:00
chatStore.sendMessage(text, attachments.value.length > 0 ? attachments.value : undefined)
2026-04-11 15:59:14 +08:00
inputText.value = ''
attachments.value = []
2026-05-15 12:04:03 +08:00
slashActive.value = false
2026-04-11 15:59:14 +08:00
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
}
}
function handleCompositionStart() {
isComposing.value = true
}
function handleCompositionEnd() {
requestAnimationFrame(() => {
isComposing.value = false
2026-05-15 12:04:03 +08:00
updateSlashState()
})
}
function isImeEnter(e: KeyboardEvent): boolean {
return isComposing.value || e.isComposing || e.keyCode === 229
}
2026-04-11 15:59:14 +08:00
function handleKeydown(e: KeyboardEvent) {
2026-05-15 12:04:03 +08:00
if (slashActive.value && filteredBridgeCommands.value.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
slashActiveIndex.value = (slashActiveIndex.value + 1) % filteredBridgeCommands.value.length
scrollCommandIntoView()
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
slashActiveIndex.value = (slashActiveIndex.value - 1 + filteredBridgeCommands.value.length) % filteredBridgeCommands.value.length
scrollCommandIntoView()
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
selectBridgeCommand(filteredBridgeCommands.value[slashActiveIndex.value])
return
}
if (e.key === 'Escape') {
e.preventDefault()
slashActive.value = false
return
}
}
if (e.key !== 'Enter' || e.shiftKey) return
if (isImeEnter(e)) return
e.preventDefault()
handleSend()
2026-04-11 15:59:14 +08:00
}
function handleInput(e: Event) {
2026-05-15 12:04:03 +08:00
const el = e.target as HTMLTextAreaElement
if (!isComposing.value) updateSlashState()
// 用户手动拖拽自定义高度时,不覆盖
if (textareaHeight.value !== null) return
2026-04-11 15:59:14 +08:00
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 100) + 'px'
}
2026-05-15 12:04:03 +08:00
function handleCommandHover(index: number) {
slashActiveIndex.value = index
}
function onDocumentMousedown(e: MouseEvent) {
if (!slashActive.value) return
const target = e.target as HTMLElement
if (!target.closest('.slash-command-dropdown') && !target.closest('.input-wrapper')) {
slashActive.value = false
}
}
onMounted(() => {
document.addEventListener('mousedown', onDocumentMousedown)
})
onUnmounted(() => {
document.removeEventListener('mousedown', onDocumentMousedown)
})
function removeAttachment(id: string) {
const idx = attachments.value.findIndex(a => a.id === id)
if (idx !== -1) {
URL.revokeObjectURL(attachments.value[idx].url)
attachments.value.splice(idx, 1)
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function isImage(type: string): boolean {
return type.startsWith('image/')
}
2026-04-11 15:59:14 +08:00
</script>
<template>
<div class="chat-input-area">
<!-- Top bar: attach + auto play speech + context info -->
<div class="input-top-bar">
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="tiny" @click="handleAttachClick" circle>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</template>
</NButton>
</template>
{{ t('chat.attachFiles') }}
</NTooltip>
<div class="auto-play-speech-switch">
<NTooltip trigger="hover">
<template #trigger>
<div class="switch-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
</div>
</template>
{{ t('chat.autoPlaySpeech') }}
</NTooltip>
<NSwitch
size="small"
v-model:value="autoPlaySpeech"
:round="false"
/>
</div>
<span v-if="totalTokens > 0" class="context-info" :class="{ 'context-warning': usagePercent > 80 }">
{{ formatTokens(totalTokens) }} /
<NTooltip trigger="hover">
<template #trigger>
<span class="context-limit-editable" @click="handleEditContextLimit">
{{ formatTokens(contextLength) }}
</span>
</template>
<span>{{ t('chat.contextClickToEdit') }}</span>
</NTooltip>
· {{ t('chat.contextRemaining') }} {{ formatTokens(remainingTokens) }}
</span>
<div v-if="totalTokens > 0" class="context-bar">
<div
class="context-bar-fill"
:class="{
'context-bar-warn': usagePercent > 60 && usagePercent <= 80,
'context-bar-danger': usagePercent > 80,
}"
:style="{ width: `${usagePercent}%` }"
/>
</div>
</div>
<!-- Attachment previews -->
<div v-if="attachments.length > 0" class="attachment-previews">
<div
v-for="att in attachments"
:key="att.id"
class="attachment-preview"
:class="{ image: isImage(att.type) }"
>
<template v-if="isImage(att.type)">
<img :src="att.url" :alt="att.name" class="attachment-thumb" />
</template>
<template v-else>
<div class="attachment-file">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<span class="file-name">{{ att.name }}</span>
<span class="file-size">{{ formatSize(att.size) }}</span>
</div>
</template>
<button class="attachment-remove" @click="removeAttachment(att.id)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
<div
class="input-wrapper"
:class="{ 'drag-over': isDragging }"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<input
ref="fileInputRef"
type="file"
multiple
class="file-input-hidden"
@change="handleFileChange"
/>
<div class="resize-handle" @mousedown="startResize"></div>
2026-04-11 15:59:14 +08:00
<textarea
ref="textareaRef"
v-model="inputText"
class="input-textarea"
:style="textareaHeight ? { height: textareaHeight + 'px' } : {}"
:placeholder="t('chat.inputPlaceholder')"
2026-04-11 15:59:14 +08:00
rows="1"
@keydown="handleKeydown"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
2026-04-11 15:59:14 +08:00
@input="handleInput"
@paste="handlePaste"
2026-04-11 15:59:14 +08:00
></textarea>
2026-05-15 12:04:03 +08:00
<Transition name="dropdown-fade">
<div
v-if="slashActive && filteredBridgeCommands.length > 0"
ref="commandDropdownRef"
class="slash-command-dropdown"
>
<div
v-for="(command, i) in filteredBridgeCommands"
:key="command.name"
class="slash-command-item"
:class="{ active: i === slashActiveIndex }"
@mousedown.prevent="selectBridgeCommand(command)"
@mouseenter="handleCommandHover(i)"
>
<span class="slash-command-name">/{{ command.name }}</span>
<span v-if="command.args" class="slash-command-args">{{ command.args }}</span>
<span class="slash-command-desc">{{ command.description }}</span>
</div>
</div>
</Transition>
2026-04-11 15:59:14 +08:00
<div class="input-actions">
<NButton
v-if="chatStore.isStreaming"
size="small"
type="error"
:disabled="chatStore.isAborting"
2026-04-11 15:59:14 +08:00
@click="chatStore.stopStreaming()"
>
{{ t('chat.stop') }}
2026-04-11 15:59:14 +08:00
</NButton>
<NButton
size="small"
type="primary"
:disabled="!canSend"
2026-04-11 15:59:14 +08:00
@click="handleSend"
>
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</template>
{{ t('chat.send') }}
2026-04-11 15:59:14 +08:00
</NButton>
</div>
</div>
<!-- Context Length Edit Modal -->
<NModal
v-model:show="showContextEditModal"
:title="t('chat.contextEditTitle')"
:mask-closable="true"
preset="card"
style="width: 400px"
>
<div class="context-edit-content">
<p style="margin-bottom: 16px; color: #666;">
{{ t('chat.contextEditDesc') }}
</p>
<NInputNumber
v-model:value="editingContextLimit"
:min="1000"
:max="10000000"
:step="1000"
:show-button="false"
:placeholder="t('chat.contextEditPlaceholder')"
style="width: 100%"
>
<template #suffix>
<span style="color: #999;">tokens</span>
</template>
</NInputNumber>
<div style="margin-top: 12px; font-size: 12px; color: #999;">
{{ t('chat.contextEditHint') }}
</div>
</div>
<template #footer>
<div style="display: flex; justify-content: flex-end; gap: 8px;">
<NButton @click="showContextEditModal = false" :disabled="isSavingContextLimit">
{{ t('chat.contextEditCancel') }}
</NButton>
<NButton type="primary" @click="saveContextLimit" :loading="isSavingContextLimit">
{{ t('chat.contextEditSave') }}
</NButton>
</div>
</template>
</NModal>
2026-04-11 15:59:14 +08:00
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.chat-input-area {
padding: 12px 20px 16px;
border-top: 1px solid $border-color;
flex-shrink: 0;
}
.input-top-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 0 6px;
}
.auto-play-speech-switch {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
border-left: 1px solid $border-light;
margin-left: 4px;
.switch-label {
display: flex;
align-items: center;
color: $text-muted;
font-size: 12px;
svg {
opacity: 0.7;
}
}
}
.context-info {
font-size: 11px;
color: $text-muted;
&.context-warning {
color: #e8a735;
}
}
.context-limit-editable {
cursor: pointer;
border-bottom: 1px dashed transparent;
transition: all 0.2s ease;
padding: 0 2px;
&:hover {
border-bottom-color: $text-muted;
background: rgba(128, 128, 128, 0.1);
border-radius: 2px;
}
}
.context-bar {
width: 60px;
height: 4px;
background: rgba(128, 128, 128, 0.2);
border-radius: 2px;
overflow: hidden;
}
.context-bar-fill {
height: 100%;
background: linear-gradient(90deg, rgba(128, 128, 128, 0.3), rgba(128, 128, 128, 0.6));
border-radius: 2px;
transition: width 0.3s ease;
&.context-bar-warn {
background: linear-gradient(90deg, #c98a1a, #e8a735);
}
&.context-bar-danger {
background: linear-gradient(90deg, #c43a2a, #e85d4a);
}
}
.attachment-previews {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 10px;
}
.attachment-preview {
position: relative;
border-radius: $radius-sm;
overflow: hidden;
background-color: $bg-secondary;
border: 1px solid $border-color;
&.image {
width: 64px;
height: 64px;
}
}
.attachment-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.attachment-file {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 12px;
min-width: 80px;
max-width: 140px;
color: $text-secondary;
.file-name {
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.file-size {
font-size: 10px;
color: $text-muted;
}
}
.attachment-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.5);
color: var(--text-on-overlay);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity $transition-fast;
.attachment-preview:hover & {
opacity: 1;
}
}
.file-input-hidden {
display: none;
}
2026-04-11 15:59:14 +08:00
.input-wrapper {
display: flex;
align-items: center;
gap: 10px;
background-color: $bg-input;
border: 1px solid $border-color;
border-radius: $radius-md;
padding: 10px 12px;
position: relative;
transition: border-color $transition-fast, background-color $transition-fast;
2026-04-11 15:59:14 +08:00
&:focus-within {
border-color: $accent-primary;
}
.dark & {
background-color: #333333;
}
2026-04-11 15:59:14 +08:00
}
.resize-handle {
position: absolute;
top: -4px;
left: 0;
right: 0;
height: 8px;
cursor: row-resize;
z-index: 2;
&:hover {
background: rgba($accent-primary, 0.15);
border-radius: 4px;
}
}
2026-04-11 15:59:14 +08:00
.input-textarea {
flex: 1;
background: none;
border: none;
outline: none;
color: $text-primary;
font-family: $font-ui;
font-size: 14px;
line-height: 1.5;
resize: none;
max-height: 400px;
2026-04-11 15:59:14 +08:00
min-height: 20px;
overflow-y: auto;
&::placeholder {
color: $text-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
2026-04-11 15:59:14 +08:00
}
}
.input-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
align-items: center;
}
2026-05-15 12:04:03 +08:00
.slash-command-dropdown {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(100% + 8px);
max-height: 240px;
overflow-y: auto;
background: $bg-primary;
border: 1px solid $border-color;
border-radius: $radius-sm;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
z-index: 20;
padding: 4px;
.dark & {
background: #2a2a2a;
}
}
.slash-command-item {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: $radius-sm;
cursor: pointer;
min-height: 36px;
&.active,
&:hover {
background: rgba(var(--accent-primary-rgb), 0.1);
}
}
.slash-command-name {
font-family: $font-code;
font-size: 13px;
color: $accent-primary;
white-space: nowrap;
}
.slash-command-args {
font-family: $font-code;
font-size: 12px;
color: $text-muted;
white-space: nowrap;
}
.slash-command-desc {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: $text-secondary;
font-size: 12px;
}
.dropdown-fade-enter-active,
.dropdown-fade-leave-active {
transition: opacity 0.12s ease, transform 0.12s ease;
}
.dropdown-fade-enter-from,
.dropdown-fade-leave-to {
opacity: 0;
transform: translateY(4px);
}
// Drag-over state
.input-wrapper.drag-over {
border-color: var(--accent-info);
border-style: dashed;
background-color: rgba(var(--accent-info-rgb), 0.04);
}
2026-04-11 15:59:14 +08:00
</style>