feat(ui): 实现 @ 文件提及自动补全功能
- Core: 添加 file-index 模块,使用 ripgrep 索引文件,fuzzysort 模糊搜索 - Server: 添加 /api/files/search 端点,支持文件模糊搜索 - Server: WebSocket 消息处理中将 @filepath 转换为 ./filepath 格式 - UI: 新增 FileMenu 组件,显示文件搜索结果列表 - UI: 新增 FileMentionTag 组件,高亮显示文件提及 - UI: 新增 useFileMention hook,管理文件提及状态 - UI: ChatInput 集成 @ 触发的文件自动补全 - UI: ChatMessage 用户消息中高亮显示 @filepath
This commit is contained in:
@@ -71,6 +71,7 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.15",
|
||||
"fuzzysort": "^3.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^4.0.15"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* File Index Module
|
||||
*
|
||||
* 使用 ripgrep 索引项目文件,支持模糊搜索
|
||||
*/
|
||||
|
||||
import fuzzysort from 'fuzzysort';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export interface FileIndexEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export interface FileSearchOptions {
|
||||
query: string;
|
||||
limit?: number;
|
||||
type?: 'file' | 'directory' | 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件索引类
|
||||
*/
|
||||
class FileIndex {
|
||||
private files: FileIndexEntry[] = [];
|
||||
private dirs: Set<string> = new Set();
|
||||
private cwd: string;
|
||||
private initialized = false;
|
||||
private initializing: Promise<void> | null = null;
|
||||
|
||||
constructor(cwd: string) {
|
||||
this.cwd = cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化索引(使用 ripgrep 或 fallback 到 find)
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// 防止并发初始化
|
||||
if (this.initializing) {
|
||||
return this.initializing;
|
||||
}
|
||||
|
||||
this.initializing = this._doInitialize();
|
||||
await this.initializing;
|
||||
this.initializing = null;
|
||||
}
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
// 尝试使用 ripgrep
|
||||
const paths = await this.listFilesWithRipgrep();
|
||||
this.processFilePaths(paths);
|
||||
this.initialized = true;
|
||||
} catch {
|
||||
// ripgrep 不可用,使用 find 命令 (macOS/Linux) 或 fallback
|
||||
try {
|
||||
const paths = await this.listFilesWithFind();
|
||||
this.processFilePaths(paths);
|
||||
this.initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize file index:', error);
|
||||
this.files = [];
|
||||
this.dirs = new Set();
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private processFilePaths(paths: string[]): void {
|
||||
this.files = [];
|
||||
this.dirs = new Set();
|
||||
|
||||
for (const path of paths) {
|
||||
if (!path) continue;
|
||||
|
||||
const name = path.split('/').pop() || path;
|
||||
const ext = name.includes('.') ? name.split('.').pop() : undefined;
|
||||
|
||||
this.files.push({
|
||||
path,
|
||||
name,
|
||||
type: 'file',
|
||||
extension: ext,
|
||||
});
|
||||
|
||||
// 收集目录
|
||||
const parts = path.split('/');
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const dir = parts.slice(0, i).join('/');
|
||||
if (dir) this.dirs.add(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private listFilesWithRipgrep(): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
'--files',
|
||||
'--follow',
|
||||
'--hidden',
|
||||
'--glob=!.git/*',
|
||||
'--glob=!node_modules/*',
|
||||
'--glob=!.next/*',
|
||||
'--glob=!dist/*',
|
||||
'--glob=!build/*',
|
||||
'--glob=!coverage/*',
|
||||
'--glob=!.turbo/*',
|
||||
'--glob=!*.lock',
|
||||
'--glob=!package-lock.json',
|
||||
];
|
||||
|
||||
const proc = spawn('rg', args, {
|
||||
cwd: this.cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let error = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
error += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0 || code === 1) {
|
||||
// ripgrep returns 1 if no matches, which is fine
|
||||
const paths = output.trim().split('\n').filter(Boolean);
|
||||
resolve(paths);
|
||||
} else {
|
||||
reject(new Error(`ripgrep failed: ${error}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
private listFilesWithFind(): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
'.',
|
||||
'-type', 'f',
|
||||
'-not', '-path', '*/.git/*',
|
||||
'-not', '-path', '*/node_modules/*',
|
||||
'-not', '-path', '*/.next/*',
|
||||
'-not', '-path', '*/dist/*',
|
||||
'-not', '-path', '*/build/*',
|
||||
'-not', '-path', '*/coverage/*',
|
||||
'-not', '-name', '*.lock',
|
||||
'-not', '-name', 'package-lock.json',
|
||||
];
|
||||
|
||||
const proc = spawn('find', args, {
|
||||
cwd: this.cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let error = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
error += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
const paths = output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((p) => p.replace(/^\.\//, '')); // 移除开头的 ./
|
||||
resolve(paths);
|
||||
} else {
|
||||
reject(new Error(`find failed: ${error}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 模糊搜索文件
|
||||
*/
|
||||
async search(options: FileSearchOptions): Promise<FileIndexEntry[]> {
|
||||
await this.initialize();
|
||||
|
||||
const { query, limit = 10, type = 'file' } = options;
|
||||
|
||||
// 构建搜索列表
|
||||
let items: FileIndexEntry[];
|
||||
|
||||
if (type === 'directory') {
|
||||
items = Array.from(this.dirs).map((d) => ({
|
||||
path: d,
|
||||
name: d.split('/').pop()!,
|
||||
type: 'directory' as const,
|
||||
}));
|
||||
} else if (type === 'all') {
|
||||
const dirItems = Array.from(this.dirs).map((d) => ({
|
||||
path: d,
|
||||
name: d.split('/').pop()!,
|
||||
type: 'directory' as const,
|
||||
}));
|
||||
items = [...this.files, ...dirItems];
|
||||
} else {
|
||||
items = this.files;
|
||||
}
|
||||
|
||||
// 如果没有查询,返回前 N 个
|
||||
if (!query) {
|
||||
return items.slice(0, limit);
|
||||
}
|
||||
|
||||
// 模糊搜索
|
||||
const results = fuzzysort.go(query, items, {
|
||||
key: 'path',
|
||||
limit,
|
||||
threshold: -10000,
|
||||
});
|
||||
|
||||
return results.map((r) => r.obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新索引
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
this.initialized = false;
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取索引统计
|
||||
*/
|
||||
getStats(): { files: number; directories: number } {
|
||||
return {
|
||||
files: this.files.length,
|
||||
directories: this.dirs.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 索引实例缓存
|
||||
const indexCache = new Map<string, FileIndex>();
|
||||
|
||||
/**
|
||||
* 获取文件索引实例
|
||||
*/
|
||||
export function getFileIndex(cwd: string): FileIndex {
|
||||
let index = indexCache.get(cwd);
|
||||
if (!index) {
|
||||
index = new FileIndex(cwd);
|
||||
indexCache.set(cwd, index);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索文件(便捷函数)
|
||||
*/
|
||||
export async function searchFiles(options: FileSearchOptions & { cwd: string }): Promise<FileIndexEntry[]> {
|
||||
const { cwd, ...searchOptions } = options;
|
||||
const index = getFileIndex(cwd);
|
||||
return index.search(searchOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新文件索引
|
||||
*/
|
||||
export async function refreshFileIndex(cwd: string): Promise<void> {
|
||||
const index = getFileIndex(cwd);
|
||||
await index.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取索引统计
|
||||
*/
|
||||
export async function getFileIndexStats(cwd: string): Promise<{ files: number; directories: number }> {
|
||||
const index = getFileIndex(cwd);
|
||||
await index.initialize();
|
||||
return index.getStats();
|
||||
}
|
||||
@@ -226,3 +226,16 @@ export type {
|
||||
ProviderListItem,
|
||||
ProviderDetail,
|
||||
} from './provider/index.js';
|
||||
|
||||
// File Index
|
||||
export {
|
||||
getFileIndex,
|
||||
searchFiles,
|
||||
refreshFileIndex,
|
||||
getFileIndexStats,
|
||||
} from './file-index/index.js';
|
||||
|
||||
export type {
|
||||
FileIndexEntry,
|
||||
FileSearchOptions,
|
||||
} from './file-index/index.js';
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.15",
|
||||
"fuzzysort": "^3.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^4.0.15"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { readdir, stat, readFile } from 'node:fs/promises';
|
||||
import { join, resolve, basename, extname, dirname } from 'node:path';
|
||||
import { searchFiles as coreSearchFiles, type FileIndexEntry } from '@ai-assistant/core';
|
||||
|
||||
const filesRouter = new Hono();
|
||||
|
||||
@@ -368,4 +369,33 @@ filesRouter.get('/tree', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/files/search?query=&limit=&type= - 模糊搜索文件
|
||||
// ============================================================================
|
||||
filesRouter.get('/search', async (c) => {
|
||||
const query = c.req.query('query') || '';
|
||||
const limit = parseInt(c.req.query('limit') || '10', 10);
|
||||
const type = (c.req.query('type') || 'file') as 'file' | 'directory' | 'all';
|
||||
|
||||
try {
|
||||
const results = await coreSearchFiles({
|
||||
query,
|
||||
limit,
|
||||
type,
|
||||
cwd: workingDirectory,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: {
|
||||
files: results,
|
||||
total: results.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return c.json({ success: false, error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { filesRouter };
|
||||
|
||||
@@ -105,13 +105,16 @@ export async function handleWebSocketMessage(
|
||||
switch (message.type) {
|
||||
case 'message': {
|
||||
// 用户发送消息
|
||||
const content = message.payload?.content || '';
|
||||
let content = message.payload?.content || '';
|
||||
|
||||
// 将 @filepath 转换为 ./filepath 格式(方便 AI 识别为文件路径)
|
||||
content = content.replace(/@([\w./-]+)/g, './$1');
|
||||
|
||||
// 广播确认收到消息
|
||||
broadcastToSession(sessionId, {
|
||||
type: 'message_received',
|
||||
sessionId,
|
||||
payload: { content },
|
||||
payload: { content: message.payload?.content || '' }, // 广播原始内容
|
||||
});
|
||||
|
||||
// 调用 Agent 处理消息(异步,不阻塞)
|
||||
|
||||
@@ -48,6 +48,8 @@ import type {
|
||||
// Context types
|
||||
ContextUsageInfo,
|
||||
CompressionResult,
|
||||
// File search types
|
||||
FileSearchResponse,
|
||||
} from './types.js';
|
||||
|
||||
// Re-export types
|
||||
@@ -124,6 +126,9 @@ export type {
|
||||
CompressionResult,
|
||||
// WebSocket error types
|
||||
ConfigErrorPayload,
|
||||
// File search types
|
||||
FileSearchResult,
|
||||
FileSearchResponse,
|
||||
} from './types.js';
|
||||
|
||||
// API Configuration
|
||||
@@ -974,3 +979,21 @@ export async function compressContext(
|
||||
}> {
|
||||
return request('POST', `/sessions/${encodeURIComponent(sessionId)}/compress`, options || {});
|
||||
}
|
||||
|
||||
// ============ File Search API ============
|
||||
|
||||
/**
|
||||
* 模糊搜索项目文件
|
||||
*/
|
||||
export async function searchFiles(
|
||||
query: string = '',
|
||||
limit: number = 10,
|
||||
type: 'file' | 'directory' | 'all' = 'file'
|
||||
): Promise<FileSearchResponse> {
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
limit: String(limit),
|
||||
type,
|
||||
});
|
||||
return request('GET', `/files/search?${params}`);
|
||||
}
|
||||
|
||||
@@ -854,3 +854,26 @@ export interface ConfigErrorPayload {
|
||||
action: 'open_providers_panel' | 'open_settings';
|
||||
}
|
||||
|
||||
// ============ 文件搜索相关 ============
|
||||
|
||||
/** 文件搜索结果 */
|
||||
export interface FileSearchResult {
|
||||
/** 相对路径 */
|
||||
path: string;
|
||||
/** 文件名 */
|
||||
name: string;
|
||||
/** 文件类型 */
|
||||
type: 'file' | 'directory';
|
||||
/** 扩展名 */
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
/** 文件搜索响应 */
|
||||
export interface FileSearchResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
files: FileSearchResult[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
*
|
||||
* 支持响应式:responsive=true 时适配移动端键盘和触摸操作
|
||||
* 支持斜杠命令:输入 / 时显示命令菜单
|
||||
* 支持文件提及:输入 @ 时显示文件搜索菜单
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Send, Square } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { CommandMenu, type CommandMenuItem } from './CommandMenu.js';
|
||||
import { FileMenu, type FileMenuItem } from './FileMenu.js';
|
||||
import { FileMentionTag } from './FileMentionTag.js';
|
||||
import { useCommands } from '../hooks/useCommands.js';
|
||||
import { useFileMention } from '../hooks/useFileMention.js';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string) => void;
|
||||
@@ -20,6 +24,8 @@ interface ChatInputProps {
|
||||
responsive?: boolean;
|
||||
/** 是否启用斜杠命令 */
|
||||
enableCommands?: boolean;
|
||||
/** 是否启用文件提及 (@) */
|
||||
enableFileMention?: boolean;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
@@ -29,6 +35,7 @@ export function ChatInput({
|
||||
disabled,
|
||||
responsive = false,
|
||||
enableCommands = true,
|
||||
enableFileMention = true,
|
||||
}: ChatInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
||||
@@ -42,6 +49,41 @@ export function ChatInput({
|
||||
filterCommands,
|
||||
} = useCommands({ autoLoad: enableCommands });
|
||||
|
||||
// 文件提及系统
|
||||
const {
|
||||
isOpen: showFileMenu,
|
||||
files: filteredFiles,
|
||||
isLoading: filesLoading,
|
||||
selectedIndex: fileSelectedIndex,
|
||||
setSelectedIndex: setFileSelectedIndex,
|
||||
checkTrigger: checkFileTrigger,
|
||||
getReplacementText,
|
||||
close: closeFileMenu,
|
||||
mentionStart,
|
||||
} = useFileMention({ enabled: enableFileMention });
|
||||
|
||||
// 解析输入中已存在的文件提及 (匹配 @path/to/file 格式)
|
||||
const mentionedFiles = useMemo(() => {
|
||||
const regex = /@([\w./-]+)/g;
|
||||
const files: string[] = [];
|
||||
let match;
|
||||
while ((match = regex.exec(input)) !== null) {
|
||||
files.push(match[1]);
|
||||
}
|
||||
return files;
|
||||
}, [input]);
|
||||
|
||||
// 移除指定的文件提及
|
||||
const handleRemoveFile = useCallback(
|
||||
(filePath: string) => {
|
||||
// 移除 @filepath 和后面的空格
|
||||
const regex = new RegExp(`@${filePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s?`, 'g');
|
||||
setInput((prev) => prev.replace(regex, '').trim());
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 自动调整高度
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
@@ -77,21 +119,55 @@ export function ChatInput({
|
||||
// 处理输入变化
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const cursorPos = e.target.selectionStart;
|
||||
setInput(value);
|
||||
|
||||
// 检查命令触发
|
||||
checkCommandTrigger(value);
|
||||
|
||||
// 检查文件提及触发(只在非命令输入模式下)
|
||||
if (enableFileMention && !value.startsWith('/')) {
|
||||
checkFileTrigger(value, cursorPos);
|
||||
} else {
|
||||
closeFileMenu();
|
||||
}
|
||||
};
|
||||
|
||||
// 选择命令
|
||||
const handleCommandSelect = useCallback(
|
||||
(command: CommandMenuItem) => {
|
||||
const handleCommandSelect = useCallback((command: CommandMenuItem) => {
|
||||
// 替换输入内容为 /command + 空格,准备输入参数
|
||||
setInput(`/${command.name} `);
|
||||
setShowCommandMenu(false);
|
||||
|
||||
// 聚焦输入框
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// 选择文件
|
||||
const handleFileSelect = useCallback(
|
||||
(file: FileMenuItem) => {
|
||||
if (mentionStart === null) return;
|
||||
|
||||
// 获取当前光标位置
|
||||
const cursorPos = textareaRef.current?.selectionStart || input.length;
|
||||
|
||||
// 替换 @ 到光标之间的内容
|
||||
const before = input.slice(0, mentionStart);
|
||||
const after = input.slice(cursorPos);
|
||||
const replacement = getReplacementText(file);
|
||||
|
||||
const newInput = before + replacement + after;
|
||||
setInput(newInput);
|
||||
closeFileMenu();
|
||||
|
||||
// 聚焦输入框并设置光标位置
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
const newPos = before.length + replacement.length;
|
||||
textareaRef.current?.setSelectionRange(newPos, newPos);
|
||||
}, 0);
|
||||
},
|
||||
[]
|
||||
[input, mentionStart, getReplacementText, closeFileMenu]
|
||||
);
|
||||
|
||||
// 关闭命令菜单
|
||||
@@ -103,8 +179,9 @@ export function ChatInput({
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || isLoading || disabled) return;
|
||||
|
||||
// 关闭命令菜单
|
||||
// 关闭菜单
|
||||
setShowCommandMenu(false);
|
||||
closeFileMenu();
|
||||
|
||||
onSend(trimmed);
|
||||
setInput('');
|
||||
@@ -116,7 +193,22 @@ export function ChatInput({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// 如果命令菜单打开,让菜单处理键盘事件
|
||||
// 文件菜单优先处理(因为可以在任意位置触发)
|
||||
if (showFileMenu && filteredFiles.length > 0) {
|
||||
if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) {
|
||||
// 这些键由 FileMenu 处理
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (filteredFiles[fileSelectedIndex]) {
|
||||
handleFileSelect(filteredFiles[fileSelectedIndex]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 命令菜单处理
|
||||
if (showCommandMenu && filteredCommands.length > 0) {
|
||||
if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape'].includes(e.key)) {
|
||||
// 这些键由 CommandMenu 处理,阻止默认行为
|
||||
@@ -159,7 +251,37 @@ export function ChatInput({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="max-w-4xl mx-auto flex gap-2">
|
||||
{/* File Menu */}
|
||||
{enableFileMention && (
|
||||
<FileMenu
|
||||
files={filteredFiles}
|
||||
isOpen={showFileMenu}
|
||||
selectedIndex={fileSelectedIndex}
|
||||
onSelect={handleFileSelect}
|
||||
onClose={closeFileMenu}
|
||||
onSelectedIndexChange={setFileSelectedIndex}
|
||||
isLoading={filesLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 已选文件标签 */}
|
||||
{mentionedFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{mentionedFiles.map((file, index) => (
|
||||
<FileMentionTag
|
||||
key={`${file}-${index}`}
|
||||
path={file}
|
||||
size="sm"
|
||||
removable
|
||||
onRemove={() => handleRemoveFile(file)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入区域 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -168,8 +290,8 @@ export function ChatInput({
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
responsive
|
||||
? 'Type a message or /command...'
|
||||
: 'Type a message or /command... (Shift+Enter for new line)'
|
||||
? 'Type a message, /command, or @file...'
|
||||
: 'Type a message, /command, or @file... (Shift+Enter for new line)'
|
||||
}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
@@ -187,7 +309,7 @@ export function ChatInput({
|
||||
onClick={isLoading ? onCancel : handleSubmit}
|
||||
disabled={!isLoading && (!input.trim() || disabled)}
|
||||
className={clsx(
|
||||
'rounded-lg flex items-center justify-center transition-colors',
|
||||
'rounded-lg flex items-center justify-center transition-colors self-end',
|
||||
responsive
|
||||
? 'px-3 py-2.5 md:px-4 md:py-3 min-w-[44px] min-h-[44px]' // 最小触摸目标 44x44
|
||||
: 'px-4 py-3',
|
||||
@@ -200,10 +322,11 @@ export function ChatInput({
|
||||
{isLoading ? <Square size={20} /> : <Send size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 响应式模式下桌面端显示提示文字 */}
|
||||
{responsive && (
|
||||
<p className="hidden md:block text-xs text-fg-subtle text-center mt-2">
|
||||
Press Enter to send, Shift+Enter for new line, / for commands
|
||||
Press Enter to send, Shift+Enter for new line, / for commands, @ for files
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useState, forwardRef } from 'react';
|
||||
import { cn } from '../utils/cn';
|
||||
import { fadeInUp, smoothTransition } from '../utils/animations';
|
||||
import { Markdown } from './Markdown';
|
||||
import { FileMentionText } from './FileMentionTag';
|
||||
import type { Message, ToolCallInfo, ToolCallStatus, ToolMessagePart } from '../api/types.js';
|
||||
|
||||
interface ChatMessageProps {
|
||||
@@ -48,8 +49,8 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
||||
case 'text':
|
||||
if (!part.text) return null;
|
||||
return isUser ? (
|
||||
<div key={part.id} className="whitespace-pre-wrap break-words">
|
||||
{part.text}
|
||||
<div key={part.id}>
|
||||
<FileMentionText text={part.text} />
|
||||
</div>
|
||||
) : (
|
||||
<Markdown key={part.id} content={part.text} />
|
||||
@@ -78,7 +79,9 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
||||
)}
|
||||
<div className="message-content text-fg-secondary">
|
||||
{isUser ? (
|
||||
<div className="whitespace-pre-wrap break-words">{message.content ?? ''}</div>
|
||||
<div>
|
||||
<FileMentionText text={message.content ?? ''} />
|
||||
</div>
|
||||
) : (
|
||||
<Markdown content={message.content ?? ''} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* File Mention Tag Component
|
||||
*
|
||||
* 显示文件提及的标签样式组件
|
||||
*/
|
||||
|
||||
import { File, Folder, X } from 'lucide-react';
|
||||
import { cn } from '../utils/cn.js';
|
||||
|
||||
export interface FileMentionTagProps {
|
||||
/** 文件路径 */
|
||||
path: string;
|
||||
/** 是否可删除 */
|
||||
removable?: boolean;
|
||||
/** 删除回调 */
|
||||
onRemove?: () => void;
|
||||
/** 点击回调 */
|
||||
onClick?: () => void;
|
||||
/** 尺寸 */
|
||||
size?: 'sm' | 'md';
|
||||
/** 额外的 class */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 根据扩展名获取图标颜色
|
||||
function getFileColor(path: string): string {
|
||||
const ext = path.split('.').pop()?.toLowerCase();
|
||||
const colors: Record<string, string> = {
|
||||
ts: 'text-blue-400',
|
||||
tsx: 'text-blue-400',
|
||||
js: 'text-yellow-400',
|
||||
jsx: 'text-yellow-400',
|
||||
json: 'text-yellow-500',
|
||||
md: 'text-fg-muted',
|
||||
css: 'text-pink-400',
|
||||
scss: 'text-pink-400',
|
||||
html: 'text-orange-400',
|
||||
py: 'text-green-400',
|
||||
go: 'text-cyan-400',
|
||||
rs: 'text-orange-500',
|
||||
vue: 'text-emerald-400',
|
||||
svelte: 'text-orange-500',
|
||||
};
|
||||
return colors[ext || ''] || 'text-fg-muted';
|
||||
}
|
||||
|
||||
// 判断是否是目录
|
||||
function isDirectory(path: string): boolean {
|
||||
return path.endsWith('/');
|
||||
}
|
||||
|
||||
// 获取文件名
|
||||
function getFileName(path: string): string {
|
||||
const parts = path.replace(/\/$/, '').split('/');
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
export function FileMentionTag({
|
||||
path,
|
||||
removable = false,
|
||||
onRemove,
|
||||
onClick,
|
||||
size = 'md',
|
||||
className,
|
||||
}: FileMentionTagProps) {
|
||||
const isDir = isDirectory(path);
|
||||
const fileName = getFileName(path);
|
||||
const iconColor = isDir ? 'text-yellow-400' : getFileColor(path);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs px-1.5 py-0.5 gap-1',
|
||||
md: 'text-sm px-2 py-1 gap-1.5',
|
||||
};
|
||||
|
||||
const iconSize = size === 'sm' ? 10 : 12;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md',
|
||||
'bg-primary-600/20 text-primary-300 border border-primary-600/30',
|
||||
'font-mono',
|
||||
sizeClasses[size],
|
||||
onClick && 'cursor-pointer hover:bg-primary-600/30 transition-colors',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{isDir ? (
|
||||
<Folder size={iconSize} className={iconColor} />
|
||||
) : (
|
||||
<File size={iconSize} className={iconColor} />
|
||||
)}
|
||||
<span className="truncate max-w-[200px]" title={path}>
|
||||
{fileName}
|
||||
</span>
|
||||
{removable && onRemove && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="ml-0.5 p-0.5 rounded hover:bg-primary-600/40 transition-colors"
|
||||
>
|
||||
<X size={iconSize} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析文本中的文件提及
|
||||
* 返回分段后的内容数组
|
||||
*/
|
||||
export interface ParsedSegment {
|
||||
type: 'text' | 'file';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function parseFileMentions(text: string): ParsedSegment[] {
|
||||
const segments: ParsedSegment[] = [];
|
||||
// 匹配 @filepath 格式(路径可以包含 / 和 . 但不能有空格)
|
||||
const regex = /@([\w./-]+)/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// 添加前面的普通文本
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
type: 'text',
|
||||
content: text.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
// 添加文件提及 (不包含 @ 符号的路径)
|
||||
segments.push({
|
||||
type: 'file',
|
||||
content: match[1],
|
||||
});
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
// 添加剩余的文本
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({
|
||||
type: 'text',
|
||||
content: text.slice(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染带有文件提及高亮的文本
|
||||
*/
|
||||
interface FileMentionTextProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FileMentionText({ text, className }: FileMentionTextProps) {
|
||||
const segments = parseFileMentions(text);
|
||||
|
||||
if (segments.length === 0) {
|
||||
return <span className={className}>{text}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn('whitespace-pre-wrap break-words', className)}>
|
||||
{segments.map((segment, index) =>
|
||||
segment.type === 'file' ? (
|
||||
<FileMentionTag key={index} path={segment.content} size="sm" className="mx-0.5" />
|
||||
) : (
|
||||
<span key={index}>{segment.content}</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* File Menu Component
|
||||
*
|
||||
* 文件自动补全菜单,支持键盘导航
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { File, Folder } from 'lucide-react';
|
||||
import { cn } from '../utils/cn.js';
|
||||
|
||||
export interface FileMenuItem {
|
||||
path: string;
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
interface FileMenuProps {
|
||||
/** 文件列表 */
|
||||
files: FileMenuItem[];
|
||||
/** 是否显示 */
|
||||
isOpen: boolean;
|
||||
/** 当前选中索引 */
|
||||
selectedIndex: number;
|
||||
/** 选择文件回调 */
|
||||
onSelect: (file: FileMenuItem) => void;
|
||||
/** 关闭菜单回调 */
|
||||
onClose: () => void;
|
||||
/** 选中索引变化回调 */
|
||||
onSelectedIndexChange: (index: number) => void;
|
||||
/** 是否正在加载 */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
// 文件图标颜色
|
||||
function getFileIconColor(extension?: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
ts: 'text-blue-400',
|
||||
tsx: 'text-blue-400',
|
||||
js: 'text-yellow-400',
|
||||
jsx: 'text-yellow-400',
|
||||
json: 'text-yellow-500',
|
||||
md: 'text-fg-muted',
|
||||
css: 'text-pink-400',
|
||||
scss: 'text-pink-400',
|
||||
html: 'text-orange-400',
|
||||
py: 'text-green-400',
|
||||
go: 'text-cyan-400',
|
||||
rs: 'text-orange-500',
|
||||
vue: 'text-emerald-400',
|
||||
svelte: 'text-orange-500',
|
||||
};
|
||||
return colors[extension || ''] || 'text-fg-muted';
|
||||
}
|
||||
|
||||
export function FileMenu({
|
||||
files,
|
||||
isOpen,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
onClose,
|
||||
onSelectedIndexChange,
|
||||
isLoading = false,
|
||||
}: FileMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const selectedRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// 滚动选中项到可见区域
|
||||
useEffect(() => {
|
||||
if (isOpen && selectedRef.current) {
|
||||
selectedRef.current.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, [selectedIndex, isOpen]);
|
||||
|
||||
// 键盘导航
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
onSelectedIndexChange(selectedIndex < files.length - 1 ? selectedIndex + 1 : 0);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
onSelectedIndexChange(selectedIndex > 0 ? selectedIndex - 1 : files.length - 1);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (files[selectedIndex]) {
|
||||
onSelect(files[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
if (files[selectedIndex]) {
|
||||
onSelect(files[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, files, selectedIndex, onSelect, onClose, onSelectedIndexChange]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
ref={menuRef}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.96 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute bottom-full left-0 right-0 mb-2 mx-4 md:mx-0 z-50"
|
||||
>
|
||||
<div className="bg-surface-subtle border border-line rounded-lg shadow-xl overflow-hidden max-h-64 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-line bg-surface-subtle/80 sticky top-0">
|
||||
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<File size={12} />
|
||||
<span>Files</span>
|
||||
{files.length > 0 && <span className="text-fg-subtle">({files.length})</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && files.length === 0 && (
|
||||
<div className="px-3 py-4 text-center text-fg-muted text-sm">Searching files...</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && files.length === 0 && (
|
||||
<div className="px-3 py-4 text-center text-fg-muted text-sm">No files found</div>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
{files.length > 0 && (
|
||||
<div className="py-1">
|
||||
{files.map((file, index) => (
|
||||
<button
|
||||
key={file.path}
|
||||
ref={index === selectedIndex ? selectedRef : null}
|
||||
onClick={() => onSelect(file)}
|
||||
onMouseEnter={() => onSelectedIndexChange(index)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 flex items-center gap-3 text-left transition-colors',
|
||||
index === selectedIndex ? 'bg-primary-600/20' : 'hover:bg-surface-muted/50'
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
{file.type === 'directory' ? (
|
||||
<Folder size={14} className="text-yellow-400 flex-shrink-0" />
|
||||
) : (
|
||||
<File
|
||||
size={14}
|
||||
className={cn(getFileIconColor(file.extension), 'flex-shrink-0')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Path */}
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-sm truncate',
|
||||
index === selectedIndex ? 'text-primary-300' : 'text-fg-secondary'
|
||||
)}
|
||||
>
|
||||
{file.path}
|
||||
</span>
|
||||
|
||||
{/* Keyboard hint */}
|
||||
{index === selectedIndex && (
|
||||
<div className="ml-auto flex items-center gap-1 text-[10px] text-fg-subtle flex-shrink-0">
|
||||
<kbd className="px-1 py-0.5 bg-surface-muted rounded">Enter</kbd>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="px-3 py-1.5 border-t border-line bg-surface-subtle/80 sticky bottom-0">
|
||||
<div className="flex items-center gap-3 text-[10px] text-fg-subtle">
|
||||
<span>
|
||||
<kbd className="px-1 py-0.5 bg-surface-muted rounded mr-1">↑</kbd>
|
||||
<kbd className="px-1 py-0.5 bg-surface-muted rounded">↓</kbd> navigate
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-1 py-0.5 bg-surface-muted rounded">Tab</kbd> select
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-1 py-0.5 bg-surface-muted rounded">Esc</kbd> close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* useFileMention Hook
|
||||
*
|
||||
* 管理文件提及的状态和逻辑
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { searchFiles } from '../api/client.js';
|
||||
import type { FileSearchResult } from '../api/types.js';
|
||||
|
||||
interface UseFileMentionOptions {
|
||||
/** 是否启用 */
|
||||
enabled?: boolean;
|
||||
/** 搜索结果限制 */
|
||||
limit?: number;
|
||||
/** 防抖延迟 (ms) */
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
interface UseFileMentionReturn {
|
||||
/** 是否显示文件菜单 */
|
||||
isOpen: boolean;
|
||||
/** 搜索结果 */
|
||||
files: FileSearchResult[];
|
||||
/** 是否正在加载 */
|
||||
isLoading: boolean;
|
||||
/** 当前选中索引 */
|
||||
selectedIndex: number;
|
||||
/** 设置选中索引 */
|
||||
setSelectedIndex: (index: number) => void;
|
||||
/** 检查并处理 @ 触发 */
|
||||
checkTrigger: (value: string, cursorPos: number) => void;
|
||||
/** 选择文件后获取替换文本 */
|
||||
getReplacementText: (file: FileSearchResult) => string;
|
||||
/** 关闭菜单 */
|
||||
close: () => void;
|
||||
/** @ 符号在输入中的位置 */
|
||||
mentionStart: number | null;
|
||||
}
|
||||
|
||||
export function useFileMention(options: UseFileMentionOptions = {}): UseFileMentionReturn {
|
||||
const { enabled = true, limit = 10, debounceMs = 150 } = options;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [files, setFiles] = useState<FileSearchResult[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [mentionStart, setMentionStart] = useState<number | null>(null);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 搜索文件
|
||||
const search = useCallback(
|
||||
async (query: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await searchFiles(query, limit, 'all');
|
||||
if (result.success) {
|
||||
setFiles(result.data.files);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File search error:', error);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[limit]
|
||||
);
|
||||
|
||||
// 检查 @ 触发
|
||||
const checkTrigger = useCallback(
|
||||
(value: string, cursorPos: number) => {
|
||||
if (!enabled) return;
|
||||
|
||||
// 找到光标前最近的 @ 符号
|
||||
const beforeCursor = value.slice(0, cursorPos);
|
||||
const lastAtIndex = beforeCursor.lastIndexOf('@');
|
||||
|
||||
if (lastAtIndex === -1) {
|
||||
setIsOpen(false);
|
||||
setMentionStart(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 @ 前面是否为空白或行首
|
||||
const charBefore = lastAtIndex > 0 ? beforeCursor[lastAtIndex - 1] : '';
|
||||
if (charBefore && !/\s/.test(charBefore)) {
|
||||
setIsOpen(false);
|
||||
setMentionStart(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 @ 后面到光标之间是否有空格(如果有,说明已经完成输入)
|
||||
const afterAt = beforeCursor.slice(lastAtIndex + 1);
|
||||
if (afterAt.includes(' ')) {
|
||||
setIsOpen(false);
|
||||
setMentionStart(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 触发文件搜索
|
||||
setIsOpen(true);
|
||||
setMentionStart(lastAtIndex);
|
||||
|
||||
// 防抖搜索
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
search(afterAt);
|
||||
}, debounceMs);
|
||||
},
|
||||
[enabled, search, debounceMs]
|
||||
);
|
||||
|
||||
// 获取替换文本
|
||||
const getReplacementText = useCallback((file: FileSearchResult): string => {
|
||||
// 返回 @filepath 格式(用户友好的显示格式)
|
||||
return `@${file.path} `;
|
||||
}, []);
|
||||
|
||||
// 关闭菜单
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setMentionStart(null);
|
||||
}, []);
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
files,
|
||||
isLoading,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
checkTrigger,
|
||||
getReplacementText,
|
||||
close,
|
||||
mentionStart,
|
||||
};
|
||||
}
|
||||
@@ -93,6 +93,8 @@ export {
|
||||
// Context API
|
||||
getContextUsage,
|
||||
compressContext,
|
||||
// File Search API
|
||||
searchFiles,
|
||||
} from './api/client.js';
|
||||
|
||||
// Types
|
||||
@@ -169,6 +171,9 @@ export type {
|
||||
ContextUsageInfo,
|
||||
CompressionStatus,
|
||||
CompressionResult,
|
||||
// File Search types
|
||||
FileSearchResult,
|
||||
FileSearchResponse,
|
||||
} from './api/client.js';
|
||||
|
||||
// Primitives (shadcn/ui style)
|
||||
@@ -182,6 +187,14 @@ export * from './utils/animations.js';
|
||||
export { ChatMessage, StreamingMessage, TypingIndicator } from './components/ChatMessage.js';
|
||||
export { ChatInput } from './components/ChatInput.js';
|
||||
export { CommandMenu, type CommandMenuItem } from './components/CommandMenu.js';
|
||||
export { FileMenu, type FileMenuItem } from './components/FileMenu.js';
|
||||
export {
|
||||
FileMentionTag,
|
||||
FileMentionText,
|
||||
parseFileMentions,
|
||||
type FileMentionTagProps,
|
||||
type ParsedSegment,
|
||||
} from './components/FileMentionTag.js';
|
||||
export { CommandPanel } from './components/CommandPanel.js';
|
||||
export { CommandEditor } from './components/CommandEditor.js';
|
||||
export { MCPPanel } from './components/MCPPanel.js';
|
||||
@@ -212,6 +225,7 @@ export { toast } from 'sonner';
|
||||
// Hooks
|
||||
export { useChat } from './hooks/useChat.js';
|
||||
export { useCommands } from './hooks/useCommands.js';
|
||||
export { useFileMention } from './hooks/useFileMention.js';
|
||||
export { useTheme, ThemeProvider, themeInitScript, type Theme, type ResolvedTheme } from './hooks/useTheme.js';
|
||||
|
||||
// Theme Components
|
||||
|
||||
Generated
+11
@@ -118,6 +118,9 @@ importers:
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.15
|
||||
version: 4.0.15(vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2))
|
||||
fuzzysort:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
typescript:
|
||||
specifier: ^5.6.0
|
||||
version: 5.9.3
|
||||
@@ -219,6 +222,9 @@ importers:
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.15
|
||||
version: 4.0.15(vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2))
|
||||
fuzzysort:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
typescript:
|
||||
specifier: ^5.6.0
|
||||
version: 5.9.3
|
||||
@@ -2946,6 +2952,9 @@ packages:
|
||||
functions-have-names@1.2.3:
|
||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||
|
||||
fuzzysort@3.1.0:
|
||||
resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==}
|
||||
|
||||
generator-function@2.0.1:
|
||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -7755,6 +7764,8 @@ snapshots:
|
||||
|
||||
functions-have-names@1.2.3: {}
|
||||
|
||||
fuzzysort@3.1.0: {}
|
||||
|
||||
generator-function@2.0.1: {}
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
Reference in New Issue
Block a user