diff --git a/.yarn/patches/@ai-sdk-google-npm-2.0.30-3b31632362.patch b/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch similarity index 81% rename from .yarn/patches/@ai-sdk-google-npm-2.0.30-3b31632362.patch rename to .yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch index 2b6fea2d37..75c418e591 100644 --- a/.yarn/patches/@ai-sdk-google-npm-2.0.30-3b31632362.patch +++ b/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch @@ -1,5 +1,5 @@ diff --git a/dist/index.js b/dist/index.js -index cac044aab0255fa72f68b36ecd2c5b12d424c379..ad6ee8ecfc5cbc3ec43ba59a44eda21e8e4d353f 100644 +index ff305b112779b718f21a636a27b1196125a332d9..cf32ff5086d4d9e56f8fe90c98724559083bafc3 100644 --- a/dist/index.js +++ b/dist/index.js @@ -471,7 +471,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { @@ -12,7 +12,7 @@ index cac044aab0255fa72f68b36ecd2c5b12d424c379..ad6ee8ecfc5cbc3ec43ba59a44eda21e // src/google-generative-ai-options.ts diff --git a/dist/index.mjs b/dist/index.mjs -index 0793085005d7968638d355f2f1e127939d965165..1c8bf852baf025d56dc35a0691eb95967de7e5c8 100644 +index 57659290f1cec74878a385626ad75b2a4d5cd3fc..d04e5927ec3725b6ffdb80868bfa1b5a48849537 100644 --- a/dist/index.mjs +++ b/dist/index.mjs @@ -477,7 +477,7 @@ function convertToGoogleGenerativeAIMessages(prompt, options) { diff --git a/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch b/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch similarity index 92% rename from .yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch rename to .yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch index 057443aa43..896b2d4cbf 100644 --- a/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch +++ b/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch @@ -1,5 +1,5 @@ diff --git a/sdk.mjs b/sdk.mjs -index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba81187568e 100755 +index 8cc6aaf0b25bcdf3c579ec95cde12d419fcb2a71..3b3b8beaea5ad2bbac26a15f792058306d0b059f 100755 --- a/sdk.mjs +++ b/sdk.mjs @@ -6213,7 +6213,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) { @@ -11,7 +11,7 @@ index 10162e5b1624f8ce667768943347a6e41089ad2f..32568ae08946590e382270c88d85fba8 import { createInterface } from "readline"; // ../src/utils/fsOperations.ts -@@ -6487,14 +6487,11 @@ class ProcessTransport { +@@ -6505,14 +6505,11 @@ class ProcessTransport { const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`; throw new ReferenceError(errorMessage); } diff --git a/package.json b/package.json index 83c86baa7a..a207b9d8aa 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core npm publish --access public" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.25#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.25-08bbabb5d3.patch", + "@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.30#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch", "@libsql/client": "0.14.0", "@libsql/win32-x64-msvc": "^0.4.7", "@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch", @@ -107,7 +107,7 @@ "@agentic/searxng": "^7.3.3", "@agentic/tavily": "^7.3.3", "@ai-sdk/amazon-bedrock": "^3.0.53", - "@ai-sdk/google-vertex": "^3.0.61", + "@ai-sdk/google-vertex": "^3.0.62", "@ai-sdk/huggingface": "patch:@ai-sdk/huggingface@npm%3A0.0.8#~/.yarn/patches/@ai-sdk-huggingface-npm-0.0.8-d4d0aaac93.patch", "@ai-sdk/mistral": "^2.0.23", "@ai-sdk/perplexity": "^2.0.17", @@ -394,7 +394,6 @@ "undici": "6.21.2", "vite": "npm:rolldown-vite@7.1.5", "tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", - "@ai-sdk/google@npm:2.0.23": "patch:@ai-sdk/google@npm%3A2.0.23#~/.yarn/patches/@ai-sdk-google-npm-2.0.23-81682e07b0.patch", "@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch", "@img/sharp-darwin-arm64": "0.34.3", "@img/sharp-darwin-x64": "0.34.3", @@ -406,9 +405,9 @@ "@langchain/openai@npm:>=0.1.0 <0.6.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", "@langchain/openai@npm:^0.3.16": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", "@langchain/openai@npm:>=0.2.0 <0.7.0": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", - "@ai-sdk/google@npm:2.0.30": "patch:@ai-sdk/google@npm%3A2.0.30#~/.yarn/patches/@ai-sdk-google-npm-2.0.30-3b31632362.patch", "@ai-sdk/openai@npm:2.0.64": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch", - "@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch" + "@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch", + "@ai-sdk/google@npm:2.0.31": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch" }, "packageManager": "yarn@4.9.1", "lint-staged": { diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json index 3973bd9af4..bb673392a2 100644 --- a/packages/aiCore/package.json +++ b/packages/aiCore/package.json @@ -39,6 +39,7 @@ "@ai-sdk/anthropic": "^2.0.43", "@ai-sdk/azure": "^2.0.66", "@ai-sdk/deepseek": "^1.0.27", + "@ai-sdk/google": "patch:@ai-sdk/google@npm%3A2.0.31#~/.yarn/patches/@ai-sdk-google-npm-2.0.31-b0de047210.patch", "@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch", "@ai-sdk/openai-compatible": "^1.0.26", "@ai-sdk/provider": "^2.0.0", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 7704bbaa13..81e9f02929 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -189,6 +189,7 @@ export enum IpcChannel { Fs_ReadText = 'fs:readText', File_OpenWithRelativePath = 'file:openWithRelativePath', File_IsTextFile = 'file:isTextFile', + File_ListDirectory = 'file:listDirectory', File_GetDirectoryStructure = 'file:getDirectoryStructure', File_CheckFileName = 'file:checkFileName', File_ValidateNotesDirectory = 'file:validateNotesDirectory', diff --git a/resources/database/drizzle/0002_wealthy_naoko.sql b/resources/database/drizzle/0002_wealthy_naoko.sql new file mode 100644 index 0000000000..c369ccf61f --- /dev/null +++ b/resources/database/drizzle/0002_wealthy_naoko.sql @@ -0,0 +1 @@ +ALTER TABLE `sessions` ADD `slash_commands` text; \ No newline at end of file diff --git a/resources/database/drizzle/meta/0002_snapshot.json b/resources/database/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000000..ef5eefcb65 --- /dev/null +++ b/resources/database/drizzle/meta/0002_snapshot.json @@ -0,0 +1,346 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0cf3d79e-69bf-4dba-8df4-996b9b67d2e8", + "prevId": "dabab6db-a2cd-4e96-b06e-6cb87d445a87", + "tables": { + "agents": { + "name": "agents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessible_paths": { + "name": "accessible_paths", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_model": { + "name": "plan_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "small_model": { + "name": "small_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcps": { + "name": "mcps", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_messages": { + "name": "session_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "migrations": { + "name": "migrations", + "columns": { + "version": { + "name": "version", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "executed_at": { + "name": "executed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessible_paths": { + "name": "accessible_paths", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_model": { + "name": "plan_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "small_model": { + "name": "small_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcps": { + "name": "mcps", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_tools": { + "name": "allowed_tools", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slash_commands": { + "name": "slash_commands", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/resources/database/drizzle/meta/_journal.json b/resources/database/drizzle/meta/_journal.json index 8648e01703..ac026637aa 100644 --- a/resources/database/drizzle/meta/_journal.json +++ b/resources/database/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1758187378775, "tag": "0001_woozy_captain_flint", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1762526423527, + "tag": "0002_wealthy_naoko", + "breakpoints": true } ] } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 5bf5c73051..fd75d8c7ea 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -551,6 +551,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager)) ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager)) ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager)) + ipcMain.handle(IpcChannel.File_ListDirectory, fileManager.listDirectory.bind(fileManager)) ipcMain.handle(IpcChannel.File_GetDirectoryStructure, fileManager.getDirectoryStructure.bind(fileManager)) ipcMain.handle(IpcChannel.File_CheckFileName, fileManager.fileNameGuard.bind(fileManager)) ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager)) diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 00dda778be..3165fcf27e 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -16,6 +16,7 @@ import type { FSWatcher } from 'chokidar' import chokidar from 'chokidar' import * as crypto from 'crypto' import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron' +import { app } from 'electron' import { dialog, net, shell } from 'electron' import * as fs from 'fs' import { writeFileSync } from 'fs' @@ -30,6 +31,73 @@ import WordExtractor from 'word-extractor' const logger = loggerService.withContext('FileStorage') +// Get ripgrep binary path +const getRipgrepBinaryPath = (): string | null => { + try { + const arch = process.arch === 'arm64' ? 'arm64' : 'x64' + const platform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux' + let ripgrepBinaryPath = path.join( + __dirname, + '../../node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep', + `${arch}-${platform}`, + process.platform === 'win32' ? 'rg.exe' : 'rg' + ) + + if (app.isPackaged) { + ripgrepBinaryPath = ripgrepBinaryPath.replace(/\.asar([\\/])/, '.asar.unpacked$1') + } + + if (fs.existsSync(ripgrepBinaryPath)) { + return ripgrepBinaryPath + } + return null + } catch (error) { + logger.error('Failed to locate ripgrep binary:', error as Error) + return null + } +} + +/** + * Execute ripgrep with captured output + */ +function executeRipgrep(args: string[]): Promise<{ exitCode: number; output: string }> { + return new Promise((resolve, reject) => { + const ripgrepBinaryPath = getRipgrepBinaryPath() + + if (!ripgrepBinaryPath) { + reject(new Error('Ripgrep binary not available')) + return + } + + const { spawn } = require('child_process') + const child = spawn(ripgrepBinaryPath, ['--no-config', '--ignore-case', ...args], { + stdio: ['pipe', 'pipe', 'pipe'] + }) + + let output = '' + let errorOutput = '' + + child.stdout.on('data', (data: Buffer) => { + output += data.toString() + }) + + child.stderr.on('data', (data: Buffer) => { + errorOutput += data.toString() + }) + + child.on('close', (code: number) => { + resolve({ + exitCode: code || 0, + output: output || errorOutput + }) + }) + + child.on('error', (error: Error) => { + reject(error) + }) + }) +} + interface FileWatcherConfig { watchExtensions?: string[] ignoredPatterns?: (string | RegExp)[] @@ -54,6 +122,26 @@ const DEFAULT_WATCHER_CONFIG: Required = { eventChannel: 'file-change' } +interface DirectoryListOptions { + recursive?: boolean + maxDepth?: number + includeHidden?: boolean + includeFiles?: boolean + includeDirectories?: boolean + maxEntries?: number + searchPattern?: string +} + +const DEFAULT_DIRECTORY_LIST_OPTIONS: Required = { + recursive: true, + maxDepth: 3, + includeHidden: false, + includeFiles: true, + includeDirectories: true, + maxEntries: 10, + searchPattern: '.' +} + class FileStorage { private storageDir = getFilesDir() private notesDir = getNotesDir() @@ -748,6 +836,284 @@ class FileStorage { } } + public listDirectory = async ( + _: Electron.IpcMainInvokeEvent, + dirPath: string, + options?: DirectoryListOptions + ): Promise => { + const mergedOptions: Required = { + ...DEFAULT_DIRECTORY_LIST_OPTIONS, + ...options + } + + const resolvedPath = path.resolve(dirPath) + + const stat = await fs.promises.stat(resolvedPath).catch((error) => { + logger.error(`[IPC - Error] Failed to access directory: ${resolvedPath}`, error as Error) + throw error + }) + + if (!stat.isDirectory()) { + throw new Error(`Path is not a directory: ${resolvedPath}`) + } + + // Use ripgrep for file listing with relevance-based sorting + if (!getRipgrepBinaryPath()) { + throw new Error('Ripgrep binary not available') + } + + return await this.listDirectoryWithRipgrep(resolvedPath, mergedOptions) + } + + /** + * Search directories by name pattern + */ + private async searchDirectories( + resolvedPath: string, + options: Required, + currentDepth: number = 0 + ): Promise { + if (!options.includeDirectories) return [] + if (!options.recursive && currentDepth > 0) return [] + if (options.maxDepth > 0 && currentDepth >= options.maxDepth) return [] + + const directories: string[] = [] + const excludedDirs = new Set([ + 'node_modules', + '.git', + '.idea', + '.vscode', + 'dist', + 'build', + '.next', + '.nuxt', + 'coverage', + '.cache' + ]) + + try { + const entries = await fs.promises.readdir(resolvedPath, { withFileTypes: true }) + const searchPatternLower = options.searchPattern.toLowerCase() + + for (const entry of entries) { + if (!entry.isDirectory()) continue + + // Skip hidden directories unless explicitly included + if (!options.includeHidden && entry.name.startsWith('.')) continue + + // Skip excluded directories + if (excludedDirs.has(entry.name)) continue + + const fullPath = path.join(resolvedPath, entry.name).replace(/\\/g, '/') + + // Check if directory name matches search pattern + if (options.searchPattern === '.' || entry.name.toLowerCase().includes(searchPatternLower)) { + directories.push(fullPath) + } + + // Recursively search subdirectories + if (options.recursive && currentDepth < options.maxDepth) { + const subDirs = await this.searchDirectories(fullPath, options, currentDepth + 1) + directories.push(...subDirs) + } + } + } catch (error) { + logger.warn(`Failed to search directories in: ${resolvedPath}`, error as Error) + } + + return directories + } + + /** + * Search files by filename pattern + */ + private async searchByFilename(resolvedPath: string, options: Required): Promise { + const files: string[] = [] + const directories: string[] = [] + + // Search for files using ripgrep + if (options.includeFiles) { + const args: string[] = ['--files'] + + // Handle hidden files + if (!options.includeHidden) { + args.push('--glob', '!.*') + } + + // Use --iglob to let ripgrep filter filenames (case-insensitive) + if (options.searchPattern && options.searchPattern !== '.') { + args.push('--iglob', `*${options.searchPattern}*`) + } + + // Exclude common hidden directories and large directories + args.push('-g', '!**/node_modules/**') + args.push('-g', '!**/.git/**') + args.push('-g', '!**/.idea/**') + args.push('-g', '!**/.vscode/**') + args.push('-g', '!**/.DS_Store') + args.push('-g', '!**/dist/**') + args.push('-g', '!**/build/**') + args.push('-g', '!**/.next/**') + args.push('-g', '!**/.nuxt/**') + args.push('-g', '!**/coverage/**') + args.push('-g', '!**/.cache/**') + + // Handle max depth + if (!options.recursive) { + args.push('--max-depth', '1') + } else if (options.maxDepth > 0) { + args.push('--max-depth', options.maxDepth.toString()) + } + + // Add the directory path + args.push(resolvedPath) + + const { exitCode, output } = await executeRipgrep(args) + + // Exit code 0 means files found, 1 means no files found (still success), 2+ means error + if (exitCode >= 2) { + throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`) + } + + // Parse ripgrep output (no need to filter by filename - ripgrep already did it) + files.push( + ...output + .split('\n') + .filter((line) => line.trim()) + .map((line) => line.replace(/\\/g, '/')) + ) + } + + // Search for directories + if (options.includeDirectories) { + directories.push(...(await this.searchDirectories(resolvedPath, options))) + } + + // Combine and sort: directories first (alphabetically), then files (alphabetically) + const sortedDirectories = directories.sort((a, b) => { + const aName = path.basename(a) + const bName = path.basename(b) + return aName.localeCompare(bName) + }) + + const sortedFiles = files.sort((a, b) => { + const aName = path.basename(a) + const bName = path.basename(b) + return aName.localeCompare(bName) + }) + + return [...sortedDirectories, ...sortedFiles].slice(0, options.maxEntries) + } + + /** + * Search files by content pattern + */ + private async searchByContent(resolvedPath: string, options: Required): Promise { + const args: string[] = ['-l'] + + // Handle hidden files + if (!options.includeHidden) { + args.push('--glob', '!.*') + } + + // Exclude common hidden directories and large directories + args.push('-g', '!**/node_modules/**') + args.push('-g', '!**/.git/**') + args.push('-g', '!**/.idea/**') + args.push('-g', '!**/.vscode/**') + args.push('-g', '!**/.DS_Store') + args.push('-g', '!**/dist/**') + args.push('-g', '!**/build/**') + args.push('-g', '!**/.next/**') + args.push('-g', '!**/.nuxt/**') + args.push('-g', '!**/coverage/**') + args.push('-g', '!**/.cache/**') + + // Handle max depth + if (!options.recursive) { + args.push('--max-depth', '1') + } else if (options.maxDepth > 0) { + args.push('--max-depth', options.maxDepth.toString()) + } + + // Handle max count + if (options.maxEntries > 0) { + args.push('--max-count', options.maxEntries.toString()) + } + + // Add search pattern (search in content) + args.push(options.searchPattern) + + // Add the directory path + args.push(resolvedPath) + + const { exitCode, output } = await executeRipgrep(args) + + // Exit code 0 means files found, 1 means no files found (still success), 2+ means error + if (exitCode >= 2) { + throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`) + } + + // Parse ripgrep output (already sorted by relevance) + const results = output + .split('\n') + .filter((line) => line.trim()) + .map((line) => line.replace(/\\/g, '/')) + .slice(0, options.maxEntries) + + return results + } + + private async listDirectoryWithRipgrep( + resolvedPath: string, + options: Required + ): Promise { + const maxEntries = options.maxEntries + + // Step 1: Search by filename first + logger.debug('Searching by filename pattern', { pattern: options.searchPattern, path: resolvedPath }) + const filenameResults = await this.searchByFilename(resolvedPath, options) + + logger.debug('Found matches by filename', { count: filenameResults.length }) + + // If we have enough filename matches, return them + if (filenameResults.length >= maxEntries) { + return filenameResults.slice(0, maxEntries) + } + + // Step 2: If filename matches are less than maxEntries, search by content to fill up + logger.debug('Filename matches insufficient, searching by content to fill up', { + filenameCount: filenameResults.length, + needed: maxEntries - filenameResults.length + }) + + // Adjust maxEntries for content search to get enough results + const contentOptions = { + ...options, + maxEntries: maxEntries - filenameResults.length + 20 // Request extra to account for duplicates + } + + const contentResults = await this.searchByContent(resolvedPath, contentOptions) + + logger.debug('Found matches by content', { count: contentResults.length }) + + // Combine results: filename matches first, then content matches (deduplicated) + const combined = [...filenameResults] + const filenameSet = new Set(filenameResults) + + for (const filePath of contentResults) { + if (!filenameSet.has(filePath)) { + combined.push(filePath) + if (combined.length >= maxEntries) { + break + } + } + } + + logger.debug('Combined results', { total: combined.length, filenameCount: filenameResults.length }) + return combined.slice(0, maxEntries) + } + public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise => { try { if (!dirPath || typeof dirPath !== 'string') { diff --git a/src/main/services/agents/BaseService.ts b/src/main/services/agents/BaseService.ts index d96ce1b8e4..1c9b438e4a 100644 --- a/src/main/services/agents/BaseService.ts +++ b/src/main/services/agents/BaseService.ts @@ -36,7 +36,14 @@ export abstract class BaseService { protected static db: LibSQLDatabase | null = null protected static isInitialized = false protected static initializationPromise: Promise | null = null - protected jsonFields: string[] = ['tools', 'mcps', 'configuration', 'accessible_paths', 'allowed_tools'] + protected jsonFields: string[] = [ + 'tools', + 'mcps', + 'configuration', + 'accessible_paths', + 'allowed_tools', + 'slash_commands' + ] /** * Initialize database with retry logic and proper error handling diff --git a/src/main/services/agents/database/schema/sessions.schema.ts b/src/main/services/agents/database/schema/sessions.schema.ts index 21ac2fe2c6..4b16a9ec41 100644 --- a/src/main/services/agents/database/schema/sessions.schema.ts +++ b/src/main/services/agents/database/schema/sessions.schema.ts @@ -22,6 +22,7 @@ export const sessionsTable = sqliteTable('sessions', { mcps: text('mcps'), // JSON array of MCP tool IDs allowed_tools: text('allowed_tools'), // JSON array of allowed tool IDs (whitelist) + slash_commands: text('slash_commands'), // JSON array of slash command objects from SDK init configuration: text('configuration'), // JSON, extensible settings diff --git a/src/main/services/agents/services/SessionService.ts b/src/main/services/agents/services/SessionService.ts index 0bb1515696..c9ecf72c32 100644 --- a/src/main/services/agents/services/SessionService.ts +++ b/src/main/services/agents/services/SessionService.ts @@ -1,4 +1,5 @@ -import type { UpdateSessionResponse } from '@types' +import { loggerService } from '@logger' +import type { SlashCommand, UpdateSessionResponse } from '@types' import { AgentBaseSchema, type AgentEntity, @@ -13,6 +14,10 @@ import { and, count, desc, eq, type SQL } from 'drizzle-orm' import { BaseService } from '../BaseService' import { agentsTable, type InsertSessionRow, type SessionRow, sessionsTable } from '../database/schema' import type { AgentModelField } from '../errors' +import { pluginService } from '../plugins/PluginService' +import { builtinSlashCommands } from './claudecode/commands' + +const logger = loggerService.withContext('SessionService') export class SessionService extends BaseService { private static instance: SessionService | null = null @@ -29,6 +34,52 @@ export class SessionService extends BaseService { await BaseService.initialize() } + /** + * Override BaseService.listSlashCommands to merge builtin and plugin commands + */ + async listSlashCommands(agentType: string, agentId?: string): Promise { + const commands: SlashCommand[] = [] + + // Add builtin slash commands + if (agentType === 'claude-code') { + commands.push(...builtinSlashCommands) + } + + // Add local command plugins from .claude/commands/ + if (agentId) { + try { + const installedPlugins = await pluginService.listInstalled(agentId) + + // Filter for command type plugins + const commandPlugins = installedPlugins.filter((p) => p.type === 'command') + + // Convert plugin metadata to SlashCommand format + for (const plugin of commandPlugins) { + const commandName = plugin.metadata.filename.replace(/\.md$/i, '') + commands.push({ + command: `/${commandName}`, + description: plugin.metadata.description + }) + } + + logger.info('Listed slash commands', { + agentType, + agentId, + builtinCount: builtinSlashCommands.length, + localCount: commandPlugins.length, + totalCount: commands.length + }) + } catch (error) { + logger.warn('Failed to list local command plugins', { + agentId, + error: error instanceof Error ? error.message : String(error) + }) + } + } + + return commands + } + async createSession( agentId: string, req: Partial = {} @@ -111,7 +162,13 @@ export class SessionService extends BaseService { const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse session.tools = await this.listMcpTools(session.agent_type, session.mcps) - session.slash_commands = await this.listSlashCommands(session.agent_type) + + // If slash_commands is not in database yet (e.g., first invoke before init message), + // fall back to builtin + local commands. Otherwise, use the merged commands from database. + if (!session.slash_commands || session.slash_commands.length === 0) { + session.slash_commands = await this.listSlashCommands(session.agent_type, agentId) + } + return session } diff --git a/src/main/services/agents/services/claudecode/__tests__/transform.test.ts b/src/main/services/agents/services/claudecode/__tests__/transform.test.ts index 1c5c2ade6b..8f8c1df038 100644 --- a/src/main/services/agents/services/claudecode/__tests__/transform.test.ts +++ b/src/main/services/agents/services/claudecode/__tests__/transform.test.ts @@ -1,7 +1,7 @@ import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk' import { describe, expect, it } from 'vitest' -import { ClaudeStreamState, transformSDKMessageToStreamParts } from '../transform' +import { ClaudeStreamState, stripLocalCommandTags, transformSDKMessageToStreamParts } from '../transform' const baseStreamMetadata = { parent_tool_use_id: null, @@ -10,6 +10,19 @@ const baseStreamMetadata = { const uuid = (n: number) => `00000000-0000-0000-0000-${n.toString().padStart(12, '0')}` +describe('stripLocalCommandTags', () => { + it('removes stdout wrapper while preserving inner text', () => { + const input = 'before echo "hi" after' + expect(stripLocalCommandTags(input)).toBe('before echo "hi" after') + }) + + it('strips multiple stdout/stderr blocks and leaves other content intact', () => { + const input = + 'line1\nkeep\nError' + expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError') + }) +}) + describe('Claude → AiSDK transform', () => { it('handles tool call streaming lifecycle', () => { const state = new ClaudeStreamState() diff --git a/src/main/services/agents/services/claudecode/commands.ts b/src/main/services/agents/services/claudecode/commands.ts index f30d620572..0ce4f4ccef 100644 --- a/src/main/services/agents/services/claudecode/commands.ts +++ b/src/main/services/agents/services/claudecode/commands.ts @@ -1,25 +1,12 @@ import type { SlashCommand } from '@types' export const builtinSlashCommands: SlashCommand[] = [ - { command: '/add-dir', description: 'Add additional working directories' }, - { command: '/agents', description: 'Manage custom AI subagents for specialized tasks' }, - { command: '/bug', description: 'Report bugs (sends conversation to Anthropic)' }, { command: '/clear', description: 'Clear conversation history' }, { command: '/compact', description: 'Compact conversation with optional focus instructions' }, - { command: '/config', description: 'View/modify configuration' }, - { command: '/cost', description: 'Show token usage statistics' }, - { command: '/doctor', description: 'Checks the health of your Claude Code installation' }, - { command: '/help', description: 'Get usage help' }, - { command: '/init', description: 'Initialize project with CLAUDE.md guide' }, - { command: '/login', description: 'Switch Anthropic accounts' }, - { command: '/logout', description: 'Sign out from your Anthropic account' }, - { command: '/mcp', description: 'Manage MCP server connections and OAuth authentication' }, - { command: '/memory', description: 'Edit CLAUDE.md memory files' }, - { command: '/model', description: 'Select or change the AI model' }, - { command: '/permissions', description: 'View or update permissions' }, - { command: '/pr_comments', description: 'View pull request comments' }, - { command: '/review', description: 'Request code review' }, - { command: '/status', description: 'View account and system statuses' }, - { command: '/terminal-setup', description: 'Install Shift+Enter key binding for newlines (iTerm2 and VSCode only)' }, - { command: '/vim', description: 'Enter vim mode for alternating insert and command modes' } + { command: '/context', description: 'Visualize current context usage as a colored grid' }, + { + command: '/cost', + description: 'Show token usage statistics (see cost tracking guide for subscription-specific details)' + }, + { command: '/todos', description: 'List current todo items' } ] diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 4e20520017..a8f3f54fa8 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -12,6 +12,7 @@ import { app } from 'electron' import type { GetAgentSessionResponse } from '../..' import type { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface' +import { sessionService } from '../SessionService' import { promptForToolApproval } from './tool-permissions' import { ClaudeStreamState, transformSDKMessageToStreamParts } from './transform' @@ -19,6 +20,7 @@ const require_ = createRequire(import.meta.url) const logger = loggerService.withContext('ClaudeCodeService') const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep']) const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1' +const NO_RESUME_COMMANDS = ['/clear'] type UserInputMessage = { type: 'user' @@ -197,7 +199,7 @@ class ClaudeCodeService implements AgentServiceInterface { options.strictMcpConfig = true } - if (lastAgentSessionId) { + if (lastAgentSessionId && !NO_RESUME_COMMANDS.some((cmd) => prompt.includes(cmd))) { options.resume = lastAgentSessionId // TODO: use fork session when we support branching sessions // options.forkSession = true @@ -220,7 +222,15 @@ class ClaudeCodeService implements AgentServiceInterface { // Start async processing on the next tick so listeners can subscribe first setImmediate(() => { - this.processSDKQuery(userInputStream, closeUserStream, options, aiStream, errorChunks).catch((error) => { + this.processSDKQuery( + userInputStream, + closeUserStream, + options, + aiStream, + errorChunks, + session.agent_id, + session.id + ).catch((error) => { logger.error('Unhandled Claude Code stream error', { error: error instanceof Error ? { name: error.name, message: error.message } : String(error) }) @@ -329,7 +339,9 @@ class ClaudeCodeService implements AgentServiceInterface { closePromptStream: () => void, options: Options, stream: ClaudeCodeStream, - errorChunks: string[] + errorChunks: string[], + agentId: string, + sessionId: string ): Promise { const jsonOutput: SDKMessage[] = [] let hasCompleted = false @@ -342,6 +354,62 @@ class ClaudeCodeService implements AgentServiceInterface { jsonOutput.push(message) + // Handle init message - merge builtin and SDK slash_commands + if (message.type === 'system' && message.subtype === 'init') { + const sdkSlashCommands = message.slash_commands || [] + logger.info('Received init message with slash commands', { + sessionId, + commands: sdkSlashCommands + }) + + try { + // Get builtin + local slash commands from BaseService + const existingCommands = await sessionService.listSlashCommands('claude-code', agentId) + + // Convert SDK slash_commands (string[]) to SlashCommand[] format + // Ensure all commands start with '/' + const sdkCommands = sdkSlashCommands.map((cmd) => { + const normalizedCmd = cmd.startsWith('/') ? cmd : `/${cmd}` + return { + command: normalizedCmd, + description: undefined + } + }) + + // Merge: existing commands (builtin + local) + SDK commands, deduplicate by command name + const commandMap = new Map() + + for (const cmd of existingCommands) { + commandMap.set(cmd.command, cmd) + } + + for (const cmd of sdkCommands) { + if (!commandMap.has(cmd.command)) { + commandMap.set(cmd.command, cmd) + } + } + + const mergedCommands = Array.from(commandMap.values()) + + // Update session in database + await sessionService.updateSession(agentId, sessionId, { + slash_commands: mergedCommands + }) + + logger.info('Updated session with merged slash commands', { + sessionId, + existingCount: existingCommands.length, + sdkCount: sdkCommands.length, + totalCount: mergedCommands.length + }) + } catch (error) { + logger.error('Failed to update session slash_commands', { + sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } + } + if (message.type === 'assistant' || message.type === 'user') { logger.silly('claude response', { message, @@ -378,7 +446,6 @@ class ClaudeCodeService implements AgentServiceInterface { } } - hasCompleted = true const duration = Date.now() - startTime logger.debug('SDK query completed successfully', { diff --git a/src/main/services/agents/services/claudecode/transform.ts b/src/main/services/agents/services/claudecode/transform.ts index 5905ed6434..41285175b4 100644 --- a/src/main/services/agents/services/claudecode/transform.ts +++ b/src/main/services/agents/services/claudecode/transform.ts @@ -73,13 +73,21 @@ const emptyUsage: LanguageModelUsage = { */ const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}` +/** + * Removes any local command stdout/stderr XML wrappers that should never surface to the UI. + */ +export const stripLocalCommandTags = (text: string): string => { + return text.replace(/(.*?)<\/local-command-\1>/gs, '$2') +} + /** * Filters out command-* tags from text content to prevent internal command * messages from appearing in the user-facing UI. * Removes tags like ... and ... */ const filterCommandTags = (text: string): string => { - return text.replace(/]+>.*?<\/command-[^>]+>/gs, '').trim() + const withoutLocalCommandTags = stripLocalCommandTags(text) + return withoutLocalCommandTags.replace(/]+>.*?<\/command-[^>]+>/gs, '').trim() } /** @@ -102,6 +110,7 @@ const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata => * blocks across calls so that incremental deltas can be correlated correctly. */ export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] { + logger.silly('Transforming SDKMessage', { message: sdkMessage }) switch (sdkMessage.type) { case 'assistant': return handleAssistantMessage(sdkMessage, state) @@ -135,7 +144,8 @@ function handleAssistantMessage( const isStreamingActive = state.hasActiveStep() if (typeof content === 'string') { - if (!content) { + const sanitizedContent = stripLocalCommandTags(content) + if (!sanitizedContent) { return chunks } @@ -157,7 +167,7 @@ function handleAssistantMessage( chunks.push({ type: 'text-delta', id: textId, - text: content, + text: sanitizedContent, providerMetadata }) chunks.push({ @@ -178,7 +188,10 @@ function handleAssistantMessage( switch (block.type) { case 'text': if (!isStreamingActive) { - textBlocks.push(block.text) + const sanitizedText = stripLocalCommandTags(block.text) + if (sanitizedText) { + textBlocks.push(sanitizedText) + } } break case 'tool_use': @@ -537,6 +550,10 @@ function handleContentBlockDelta( logger.warn('Received text_delta for unknown block', { index }) return } + block.text = stripLocalCommandTags(block.text) + if (!block.text) { + break + } chunks.push({ type: 'text-delta', id: block.id, diff --git a/src/preload/index.ts b/src/preload/index.ts index d60e0edfe9..671284d88d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -48,6 +48,16 @@ import type { } from '../renderer/src/types/plugin' import type { ActionItem } from '../renderer/src/types/selectionTypes' +type DirectoryListOptions = { + recursive?: boolean + maxDepth?: number + includeHidden?: boolean + includeFiles?: boolean + includeDirectories?: boolean + maxEntries?: number + searchPattern?: string +} + export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) { if (spanContext) { const data = { type: 'trace', context: spanContext } @@ -201,6 +211,8 @@ const api = { openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file), isTextFile: (filePath: string): Promise => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath), getDirectoryStructure: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_GetDirectoryStructure, dirPath), + listDirectory: (dirPath: string, options?: DirectoryListOptions) => + ipcRenderer.invoke(IpcChannel.File_ListDirectory, dirPath, options), checkFileName: (dirPath: string, fileName: string, isFile: boolean) => ipcRenderer.invoke(IpcChannel.File_CheckFileName, dirPath, fileName, isFile), validateNotesDirectory: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_ValidateNotesDirectory, dirPath), diff --git a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index 6e4288d241..544ec443aa 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -30,18 +30,22 @@ export class AiSdkToChunkAdapter { private onSessionUpdate?: (sessionId: string) => void private responseStartTimestamp: number | null = null private firstTokenTimestamp: number | null = null + private hasTextContent = false + private getSessionWasCleared?: () => boolean constructor( private onChunk: (chunk: Chunk) => void, mcpTools: MCPTool[] = [], accumulate?: boolean, enableWebSearch?: boolean, - onSessionUpdate?: (sessionId: string) => void + onSessionUpdate?: (sessionId: string) => void, + getSessionWasCleared?: () => boolean ) { this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools) this.accumulate = accumulate this.enableWebSearch = enableWebSearch || false this.onSessionUpdate = onSessionUpdate + this.getSessionWasCleared = getSessionWasCleared } private markFirstTokenIfNeeded() { @@ -84,8 +88,9 @@ export class AiSdkToChunkAdapter { } this.resetTimingState() this.responseStartTimestamp = Date.now() - // Reset link converter state at the start of stream + // Reset state at the start of stream this.isFirstChunk = true + this.hasTextContent = false try { while (true) { @@ -129,6 +134,8 @@ export class AiSdkToChunkAdapter { const agentRawMessage = chunk.rawValue as ClaudeCodeRawValue if (agentRawMessage.type === 'init' && agentRawMessage.session_id) { this.onSessionUpdate?.(agentRawMessage.session_id) + } else if (agentRawMessage.type === 'compact' && agentRawMessage.session_id) { + this.onSessionUpdate?.(agentRawMessage.session_id) } this.onChunk({ type: ChunkType.RAW, @@ -143,6 +150,7 @@ export class AiSdkToChunkAdapter { }) break case 'text-delta': { + this.hasTextContent = true const processedText = chunk.text || '' let finalText: string @@ -301,6 +309,25 @@ export class AiSdkToChunkAdapter { } case 'finish': { + // Check if session was cleared (e.g., /clear command) and no text was output + const sessionCleared = this.getSessionWasCleared?.() ?? false + if (sessionCleared && !this.hasTextContent) { + // Inject a "context cleared" message for the user + const clearMessage = '✨ Context cleared. Starting fresh conversation.' + this.onChunk({ + type: ChunkType.TEXT_START + }) + this.onChunk({ + type: ChunkType.TEXT_DELTA, + text: clearMessage + }) + this.onChunk({ + type: ChunkType.TEXT_COMPLETE, + text: clearMessage + }) + final.text = clearMessage + } + const usage = { completion_tokens: chunk.totalUsage?.outputTokens || 0, prompt_tokens: chunk.totalUsage?.inputTokens || 0, diff --git a/src/renderer/src/components/QuickPanel/defaultStrategies.ts b/src/renderer/src/components/QuickPanel/defaultStrategies.ts new file mode 100644 index 0000000000..22f46db98d --- /dev/null +++ b/src/renderer/src/components/QuickPanel/defaultStrategies.ts @@ -0,0 +1,104 @@ +import * as tinyPinyin from 'tiny-pinyin' + +import type { QuickPanelFilterFn, QuickPanelListItem, QuickPanelSortFn } from './types' + +/** + * Default filter function + * Implements standard filtering logic with pinyin support + */ +export const defaultFilterFn: QuickPanelFilterFn = (item, searchText, fuzzyRegex, pinyinCache) => { + if (!searchText) return true + + let filterText = item.filterText || '' + if (typeof item.label === 'string') { + filterText += item.label + } + if (typeof item.description === 'string') { + filterText += item.description + } + + const lowerFilterText = filterText.toLowerCase() + const lowerSearchText = searchText.toLowerCase() + + // Direct substring match + if (lowerFilterText.includes(lowerSearchText)) { + return true + } + + // Pinyin fuzzy match for Chinese characters + if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) { + try { + let pinyinText = pinyinCache.get(item) + if (!pinyinText) { + pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase() + pinyinCache.set(item, pinyinText) + } + return fuzzyRegex.test(pinyinText) + } catch (error) { + return true + } + } else { + return fuzzyRegex.test(filterText.toLowerCase()) + } +} + +/** + * Calculate match score for sorting + * Higher score = better match + */ +const calculateMatchScore = (item: QuickPanelListItem, searchText: string): number => { + let filterText = item.filterText || '' + if (typeof item.label === 'string') { + filterText += item.label + } + if (typeof item.description === 'string') { + filterText += item.description + } + + const lowerFilterText = filterText.toLowerCase() + const lowerSearchText = searchText.toLowerCase() + + // Exact match (highest priority) + if (lowerFilterText === lowerSearchText) { + return 1000 + } + + // Label exact match (very high priority) + if (typeof item.label === 'string' && item.label.toLowerCase() === lowerSearchText) { + return 900 + } + + // Starts with search text (high priority) + if (lowerFilterText.startsWith(lowerSearchText)) { + return 800 + } + + // Label starts with search text + if (typeof item.label === 'string' && item.label.toLowerCase().startsWith(lowerSearchText)) { + return 700 + } + + // Contains search text (medium priority) + if (lowerFilterText.includes(lowerSearchText)) { + // Earlier position = higher score + const position = lowerFilterText.indexOf(lowerSearchText) + return 600 - position + } + + // Pinyin fuzzy match (lower priority) + return 100 +} + +/** + * Default sort function + * Sorts items by match score in descending order + */ +export const defaultSortFn: QuickPanelSortFn = (items, searchText) => { + if (!searchText) return items + + return [...items].sort((a, b) => { + const scoreA = calculateMatchScore(a, searchText) + const scoreB = calculateMatchScore(b, searchText) + return scoreB - scoreA + }) +} diff --git a/src/renderer/src/components/QuickPanel/index.ts b/src/renderer/src/components/QuickPanel/index.ts index ec3bed20ee..4a3a9b3391 100644 --- a/src/renderer/src/components/QuickPanel/index.ts +++ b/src/renderer/src/components/QuickPanel/index.ts @@ -1,3 +1,4 @@ +export * from './defaultStrategies' export * from './hook' export * from './provider' export * from './types' diff --git a/src/renderer/src/components/QuickPanel/provider.tsx b/src/renderer/src/components/QuickPanel/provider.tsx index cda1d0fa9b..08111d0c4f 100644 --- a/src/renderer/src/components/QuickPanel/provider.tsx +++ b/src/renderer/src/components/QuickPanel/provider.tsx @@ -4,11 +4,12 @@ import type { QuickPanelCallBackOptions, QuickPanelCloseAction, QuickPanelContextType, + QuickPanelFilterFn, QuickPanelListItem, QuickPanelOpenOptions, + QuickPanelSortFn, QuickPanelTriggerInfo } from './types' - const QuickPanelContext = createContext(null) export const QuickPanelProvider: React.FC = ({ children }) => { @@ -17,19 +18,39 @@ export const QuickPanelProvider: React.FC = ({ children const [list, setList] = useState([]) const [title, setTitle] = useState() - const [defaultIndex, setDefaultIndex] = useState(0) + const [defaultIndex, setDefaultIndex] = useState(-1) const [pageSize, setPageSize] = useState(7) const [multiple, setMultiple] = useState(false) + const [manageListExternally, setManageListExternally] = useState(false) const [triggerInfo, setTriggerInfo] = useState() + const [filterFn, setFilterFn] = useState() + const [sortFn, setSortFn] = useState() const [onClose, setOnClose] = useState<((Options: Partial) => void) | undefined>() const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>() const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>() + const [onSearchChange, setOnSearchChange] = useState<((searchText: string) => void) | undefined>() + const [lastCloseAction, setLastCloseAction] = useState(undefined) const clearTimer = useRef(null) // 添加更新item选中状态的方法 const updateItemSelection = useCallback((targetItem: QuickPanelListItem, isSelected: boolean) => { - setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item))) + setList((prevList) => { + // 先尝试引用匹配(快速路径) + const refIndex = prevList.findIndex((item) => item === targetItem) + if (refIndex !== -1) { + return prevList.map((item, idx) => (idx === refIndex ? { ...item, isSelected } : item)) + } + + // 如果引用匹配失败,使用内容匹配(兜底方案) + // 通过 label 和 filterText 来识别同一个item + return prevList.map((item) => { + const isSameItem = + (item.label === targetItem.label || item.filterText === targetItem.filterText) && + (!targetItem.filterText || item.filterText === targetItem.filterText) + return isSameItem ? { ...item, isSelected } : item + }) + }) }, []) // 添加更新整个列表的方法 @@ -43,17 +64,23 @@ export const QuickPanelProvider: React.FC = ({ children clearTimer.current = null } + setLastCloseAction(undefined) setTitle(options.title) setList(options.list) - setDefaultIndex(options.defaultIndex ?? 0) + const nextDefaultIndex = typeof options.defaultIndex === 'number' ? Math.max(-1, options.defaultIndex) : -1 + setDefaultIndex(nextDefaultIndex) setPageSize(options.pageSize ?? 7) setMultiple(options.multiple ?? false) + setManageListExternally(options.manageListExternally ?? false) setSymbol(options.symbol) setTriggerInfo(options.triggerInfo) setOnClose(() => options.onClose) setBeforeAction(() => options.beforeAction) setAfterAction(() => options.afterAction) + setOnSearchChange(() => options.onSearchChange) + setFilterFn(() => options.filterFn) + setSortFn(() => options.sortFn) setIsVisible(true) }, []) @@ -61,6 +88,8 @@ export const QuickPanelProvider: React.FC = ({ children const close = useCallback( (action?: QuickPanelCloseAction, searchText?: string) => { setIsVisible(false) + setManageListExternally(false) + setLastCloseAction(action) onClose?.({ action, searchText, item: {} as QuickPanelListItem, context: this }) clearTimer.current = setTimeout(() => { @@ -68,9 +97,13 @@ export const QuickPanelProvider: React.FC = ({ children setOnClose(undefined) setBeforeAction(undefined) setAfterAction(undefined) + setOnSearchChange(undefined) + setFilterFn(undefined) + setSortFn(undefined) setTitle(undefined) setSymbol('') setTriggerInfo(undefined) + setManageListExternally(false) }, 200) }, [onClose] @@ -100,10 +133,15 @@ export const QuickPanelProvider: React.FC = ({ children defaultIndex, pageSize, multiple, + manageListExternally, triggerInfo, + lastCloseAction, + filterFn, + sortFn, onClose, beforeAction, - afterAction + afterAction, + onSearchChange }), [ open, @@ -117,10 +155,15 @@ export const QuickPanelProvider: React.FC = ({ children defaultIndex, pageSize, multiple, + manageListExternally, triggerInfo, + lastCloseAction, + filterFn, + sortFn, onClose, beforeAction, - afterAction + afterAction, + onSearchChange ] ) diff --git a/src/renderer/src/components/QuickPanel/types.ts b/src/renderer/src/components/QuickPanel/types.ts index 519180c5b7..da7b37cee3 100644 --- a/src/renderer/src/components/QuickPanel/types.ts +++ b/src/renderer/src/components/QuickPanel/types.ts @@ -10,7 +10,8 @@ export enum QuickPanelReservedSymbol { WebSearch = '?', Mcp = 'mcp', McpPrompt = 'mcp-prompt', - McpResource = 'mcp-resource' + McpResource = 'mcp-resource', + SlashCommands = 'slash-commands' } export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined @@ -27,6 +28,29 @@ export type QuickPanelCallBackOptions = { searchText?: string } +/** + * Filter function type + * @param item - The item to check + * @param searchText - The search text (without leading symbol) + * @param fuzzyRegex - Fuzzy matching regex + * @param pinyinCache - Cache for pinyin conversions + * @returns true if item matches the search + */ +export type QuickPanelFilterFn = ( + item: QuickPanelListItem, + searchText: string, + fuzzyRegex: RegExp, + pinyinCache: WeakMap +) => boolean + +/** + * Sort function type + * @param items - The filtered items to sort + * @param searchText - The search text (without leading symbol) + * @returns sorted items + */ +export type QuickPanelSortFn = (items: QuickPanelListItem[], searchText: string) => QuickPanelListItem[] + export type QuickPanelOpenOptions = { /** 显示在底部左边,类似于Placeholder */ title?: string @@ -48,6 +72,14 @@ export type QuickPanelOpenOptions = { beforeAction?: (options: QuickPanelCallBackOptions) => void afterAction?: (options: QuickPanelCallBackOptions) => void onClose?: (options: QuickPanelCallBackOptions) => void + /** Callback when search text changes (called with debounced search text) */ + onSearchChange?: (searchText: string) => void + /** Tool manages list + collapse behavior externally (skip filtering/auto-close) */ + manageListExternally?: boolean + /** Custom filter function for items (follows open-closed principle) */ + filterFn?: QuickPanelFilterFn + /** Custom sort function for filtered items (follows open-closed principle) */ + sortFn?: QuickPanelSortFn } export type QuickPanelListItem = { @@ -88,10 +120,15 @@ export interface QuickPanelContextType { readonly pageSize: number readonly multiple: boolean readonly triggerInfo?: QuickPanelTriggerInfo + readonly manageListExternally?: boolean + readonly lastCloseAction?: QuickPanelCloseAction + readonly filterFn?: QuickPanelFilterFn + readonly sortFn?: QuickPanelSortFn readonly onClose?: (Options: QuickPanelCallBackOptions) => void readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void readonly afterAction?: (Options: QuickPanelCallBackOptions) => void + readonly onSearchChange?: (searchText: string) => void } export type QuickPanelScrollTrigger = 'initial' | 'keyboard' | 'none' diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 5c6afcbf61..9ed9b966df 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -10,8 +10,8 @@ import { debounce } from 'lodash' import { Check } from 'lucide-react' import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import styled from 'styled-components' -import * as tinyPinyin from 'tiny-pinyin' +import { defaultFilterFn, defaultSortFn } from './defaultStrategies' import { QuickPanelContext } from './provider' import type { QuickPanelCallBackOptions, @@ -62,21 +62,50 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const [_searchText, setSearchText] = useState('') const searchText = useDeferredValue(_searchText) + const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), []) + const searchTextRef = useRef('') // 缓存:按 item 缓存拼音文本,避免重复转换 const pinyinCacheRef = useRef>(new WeakMap()) - // 轻量防抖:减少高频输入时的过滤调用 - const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), []) - // 跟踪上一次的搜索文本和符号,用于判断是否需要重置index const prevSearchTextRef = useRef('') const prevSymbolRef = useRef('') const { setTimeoutTimer } = useTimer() + + // Use injected filter and sort functions, or fall back to defaults + const filterFn = ctx.filterFn || defaultFilterFn + const sortFn = ctx.sortFn || defaultSortFn // 处理搜索,过滤列表(始终保留 alwaysVisible 项在顶部) const list = useMemo(() => { if (!ctx.isVisible && !ctx.symbol) return [] + + const baseList = (ctx.list || []).filter((item) => !item.hidden) + + if (ctx.manageListExternally) { + const combinedLength = baseList.length + const isSymbolChanged = prevSymbolRef.current !== ctx.symbol + if (isSymbolChanged) { + const maxIndex = combinedLength > 0 ? combinedLength - 1 : -1 + const desiredIndex = + typeof ctx.defaultIndex === 'number' ? Math.min(Math.max(ctx.defaultIndex, -1), maxIndex) : -1 + setIndex(desiredIndex) + } else { + setIndex((prevIndex) => { + if (prevIndex >= combinedLength) { + return combinedLength > 0 ? combinedLength - 1 : -1 + } + return prevIndex + }) + } + + prevSearchTextRef.current = '' + prevSymbolRef.current = ctx.symbol + + return baseList + } + const _searchText = searchText.replace(/^[/@]/, '') const lowerSearchText = _searchText.toLowerCase() const fuzzyPattern = lowerSearchText @@ -86,52 +115,35 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const fuzzyRegex = new RegExp(fuzzyPattern, 'ig') // 拆分:固定显示项(不参与过滤)与普通项 - const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible) - const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible) + const pinnedItems = baseList.filter((item) => item.alwaysVisible) + const normalItems = baseList.filter((item) => !item.alwaysVisible) + // Filter normal items using injected filter function const filteredNormalItems = normalItems.filter((item) => { - if (!_searchText) return true - - let filterText = item.filterText || '' - if (typeof item.label === 'string') { - filterText += item.label - } - if (typeof item.description === 'string') { - filterText += item.description - } - - const lowerFilterText = filterText.toLowerCase() - - if (lowerFilterText.includes(lowerSearchText)) { - return true - } - - if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) { - try { - let pinyinText = pinyinCacheRef.current.get(item) - if (!pinyinText) { - pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase() - pinyinCacheRef.current.set(item, pinyinText) - } - return fuzzyRegex.test(pinyinText) - } catch (error) { - return true - } - } else { - return fuzzyRegex.test(filterText.toLowerCase()) - } + return filterFn(item, _searchText, fuzzyRegex, pinyinCacheRef.current) }) + // Sort filtered items using injected sort function + const sortedNormalItems = sortFn(filteredNormalItems, _searchText) + // 只有在搜索文本变化或面板符号变化时才重置index const isSearchChanged = prevSearchTextRef.current !== searchText const isSymbolChanged = prevSymbolRef.current !== ctx.symbol if (isSearchChanged || isSymbolChanged) { - setIndex(-1) // 不默认高亮任何项,让用户主动选择 + const combinedLength = pinnedItems.length + sortedNormalItems.length + if (isSymbolChanged) { + const maxIndex = combinedLength > 0 ? combinedLength - 1 : -1 + const desiredIndex = + typeof ctx.defaultIndex === 'number' ? Math.min(Math.max(ctx.defaultIndex, -1), maxIndex) : -1 + setIndex(desiredIndex) + } else { + setIndex(-1) // 搜索文本变化时不默认高亮 + } } else { // 如果当前index超出范围,调整到有效范围内 setIndex((prevIndex) => { - const combinedLength = pinnedItems.length + filteredNormalItems.length + const combinedLength = pinnedItems.length + sortedNormalItems.length if (prevIndex >= combinedLength) { return combinedLength > 0 ? combinedLength - 1 : -1 } @@ -142,10 +154,9 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { prevSearchTextRef.current = searchText prevSymbolRef.current = ctx.symbol - // 固定项置顶 + 过滤后的普通项 - const pinnedFiltered = [...pinnedItems, ...filteredNormalItems] - return pinnedFiltered.filter((item) => !item.hidden) - }, [ctx.isVisible, ctx.symbol, ctx.list, searchText]) + // 固定项置顶 + 排序后的普通项 + return [...pinnedItems, ...sortedNormalItems] + }, [ctx.isVisible, ctx.symbol, ctx.manageListExternally, ctx.list, ctx.defaultIndex, searchText, filterFn, sortFn]) const canForwardAndBackward = useMemo(() => { return list.some((item) => item.isMenu) || historyPanel.length > 0 @@ -179,19 +190,64 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { if (deleteStart >= deleteEnd) return - // 删除文本 - const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd) - setInputText(newText) + const activeSearchText = searchTextRef.current ?? '' - // 设置光标位置 - setTimeoutTimer( - 'quickpanel_focus', - () => { - textArea.focus() - textArea.setSelectionRange(deleteStart, deleteStart) - }, - 0 - ) + setInputText((currentText) => { + const safeText = currentText ?? '' + const expectedSegment = includeSymbol ? symbolSegment : symbolSegment.slice(1) + const typedSearch = activeSearchText + const normalizedTyped = includeSymbol + ? typedSearch + : typedSearch.startsWith(symbolSegment[0] ?? '') + ? typedSearch.slice(1) + : typedSearch + + if (normalizedTyped && expectedSegment !== normalizedTyped) { + return safeText + } + + const segmentStart = includeSymbol ? symbolStart : symbolStart + 1 + const segmentEnd = segmentStart + expectedSegment.length + + if (segmentStart < 0 || segmentStart > safeText.length) { + return safeText + } + + if (segmentEnd > safeText.length) { + return safeText + } + + const actualSegment = safeText.slice(segmentStart, segmentEnd) + if (actualSegment !== expectedSegment) { + return safeText + } + + const clampedDeleteStart = Math.max(0, Math.min(deleteStart, safeText.length)) + const clampedDeleteEnd = Math.max(clampedDeleteStart, Math.min(deleteEnd, safeText.length)) + + if (clampedDeleteStart >= clampedDeleteEnd) { + return safeText + } + + const updatedText = safeText.slice(0, clampedDeleteStart) + safeText.slice(clampedDeleteEnd) + + if (updatedText === safeText) { + return safeText + } + + setTimeoutTimer( + 'quickpanel_focus', + () => { + const textareaEl = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null + if (!textareaEl) return + textareaEl.focus() + textareaEl.setSelectionRange(clampedDeleteStart, clampedDeleteStart) + }, + 0 + ) + + return updatedText + }) setSearchText('') }, @@ -211,11 +267,21 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { if (textArea) { setInputText(textArea.value) } - } else if (action && !['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action)) { - clearSearchText(true) + } else if ( + action && + !['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action) && + ctx.triggerInfo?.type === 'input' + ) { + setTimeoutTimer( + 'quickpanel_deferred_clear', + () => { + clearSearchText(true) + }, + 0 + ) } }, - [ctx, clearSearchText, setInputText, searchText] + [ctx, clearSearchText, setInputText, searchText, setTimeoutTimer] ) const handleItemAction = useCallback( @@ -285,12 +351,86 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { searchTextRef.current = searchText }, [searchText]) + // Track onSearchChange callback and search state for debouncing + const prevSearchCallbackTextRef = useRef('') + const isFirstSearchRef = useRef(true) + const searchCallbackTimerRef = useRef(null) + const onSearchChangeRef = useRef(ctx.onSearchChange) + + // Keep onSearchChange ref up to date + useEffect(() => { + onSearchChangeRef.current = ctx.onSearchChange + }, [ctx.onSearchChange]) + + // Reset search history when panel closes + useEffect(() => { + if (!ctx.isVisible) { + prevSearchCallbackTextRef.current = '' + isFirstSearchRef.current = true + if (searchCallbackTimerRef.current) { + clearTimeout(searchCallbackTimerRef.current) + searchCallbackTimerRef.current = null + } + } + }, [ctx.isVisible]) + + // Trigger onSearchChange with debounce (called from handleInput) + const triggerSearchChange = useCallback((searchText: string) => { + if (!onSearchChangeRef.current) return + + // Clean search text: remove leading symbol (/ or @) and trim + const cleanSearchText = searchText.replace(/^[/@]/, '').trim() + + // Don't trigger if search text hasn't changed + if (cleanSearchText === prevSearchCallbackTextRef.current) { + return + } + + // Don't trigger callback for empty search text + if (!cleanSearchText) { + prevSearchCallbackTextRef.current = '' + return + } + + // Clear previous timer + if (searchCallbackTimerRef.current) { + clearTimeout(searchCallbackTimerRef.current) + } + + // First search triggers immediately (0ms), subsequent searches have 300ms debounce + const delay = isFirstSearchRef.current ? 0 : 300 + + searchCallbackTimerRef.current = setTimeout(() => { + prevSearchCallbackTextRef.current = cleanSearchText + isFirstSearchRef.current = false + onSearchChangeRef.current?.(cleanSearchText) + searchCallbackTimerRef.current = null + }, delay) + }, []) + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (searchCallbackTimerRef.current) { + clearTimeout(searchCallbackTimerRef.current) + searchCallbackTimerRef.current = null + } + } + }, []) + // 获取当前输入的搜索词 const isComposing = useRef(false) + useEffect(() => { + return () => { + setSearchTextDebounced.cancel() + } + }, [setSearchTextDebounced]) + useEffect(() => { if (!ctx.isVisible) return const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement + if (!textArea) return const handleInput = (e: Event) => { if (isComposing.current) return @@ -305,6 +445,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { if (lastSymbolIndex !== -1) { const newSearchText = textBeforeCursor.slice(lastSymbolIndex) setSearchTextDebounced(newSearchText) + // Trigger server-side search callback immediately (with its own debounce) + triggerSearchChange(newSearchText) } else { // 使用本地 handleClose,确保在删除触发符时同步受控输入值 handleClose('delete-symbol') @@ -328,16 +470,17 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { textArea.removeEventListener('input', handleInput) textArea.removeEventListener('compositionupdate', handleCompositionUpdate) textArea.removeEventListener('compositionend', handleCompositionEnd) - setSearchTextDebounced.cancel() - setTimeoutTimer( - 'quickpanel_clear_search', - () => { - setSearchText('') - }, - 200 - ) // 等待面板关闭动画结束后,再清空搜索词 } - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ctx.isVisible, ctx.symbol, handleClose, setSearchTextDebounced, triggerSearchChange]) + + useEffect(() => { + if (ctx.isVisible) return + + const timer = setTimeout(() => { + setSearchText('') + }, 200) + + return () => clearTimeout(timer) }, [ctx.isVisible]) useLayoutEffect(() => { @@ -545,19 +688,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const hasSearchText = useMemo(() => searchText.replace(/^[/@]/, '').length > 0, [searchText]) // 折叠仅依据“非固定项”的匹配数;仅剩固定项(如“清除”)时仍视为无匹配,保持折叠 const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list]) - const collapsed = hasSearchText && visibleNonPinnedCount === 0 - - useEffect(() => { - if (!ctx.isVisible) return - if (!collapsed) return - if (ctx.triggerInfo?.type !== 'input') return - if (ctx.multiple) return - - const trimmedSearch = searchText.replace(/^[/@]/, '').trim() - if (!trimmedSearch) return - - handleClose('no_result') - }, [collapsed, ctx.isVisible, ctx.triggerInfo, ctx.multiple, handleClose, searchText]) + const collapsed = !ctx.manageListExternally && hasSearchText && visibleNonPinnedCount === 0 const estimateSize = useCallback(() => ITEM_HEIGHT, []) @@ -616,7 +747,9 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { return prev ? prev : true }) }> - {!collapsed && ( + {collapsed ? ( + {t('settings.quickPanel.noResult', 'No results')} + ) : ( { } return created + } catch (error) { + logger.error('Error creating default session:', error as Error) + return null } finally { setCreatingSession(false) } diff --git a/src/renderer/src/hooks/useInputText.ts b/src/renderer/src/hooks/useInputText.ts new file mode 100644 index 0000000000..6bcd2f7644 --- /dev/null +++ b/src/renderer/src/hooks/useInputText.ts @@ -0,0 +1,63 @@ +import { useCallback, useRef, useState } from 'react' + +export interface UseInputTextOptions { + initialValue?: string + onChange?: (text: string) => void +} + +export interface UseInputTextReturn { + text: string + setText: (text: string | ((prev: string) => string)) => void + prevText: string + isEmpty: boolean + clear: () => void +} + +/** + * 管理文本输入状态的通用 Hook + * + * 提供文本状态管理、历史追踪和便捷方法 + * + * @param options - 配置选项 + * @param options.initialValue - 初始文本值 + * @param options.onChange - 文本变化回调 + * @returns 文本状态和操作方法 + * + * @example + * ```tsx + * const { text, setText, isEmpty, clear } = useInputText({ + * initialValue: '', + * onChange: (text) => console.log('Text changed:', text) + * }) + * + * setText(e.target.value)} /> + * + * + * ``` + */ +export function useInputText(options: UseInputTextOptions = {}): UseInputTextReturn { + const [text, setText] = useState(options.initialValue ?? '') + const prevTextRef = useRef(text) + + const handleSetText = useCallback( + (value: string | ((prev: string) => string)) => { + const newText = typeof value === 'function' ? value(text) : value + prevTextRef.current = text + setText(newText) + options.onChange?.(newText) + }, + [text, options] + ) + + const clear = useCallback(() => { + handleSetText('') + }, [handleSetText]) + + return { + text, + setText: handleSetText, + prevText: prevTextRef.current, + isEmpty: text.trim().length === 0, + clear + } +} diff --git a/src/renderer/src/hooks/useKeyboardHandler.ts b/src/renderer/src/hooks/useKeyboardHandler.ts new file mode 100644 index 0000000000..c3f8e654a5 --- /dev/null +++ b/src/renderer/src/hooks/useKeyboardHandler.ts @@ -0,0 +1,94 @@ +import { useCallback, useRef } from 'react' + +export interface KeyboardHandlerCallbacks { + onSend?: () => void + onEscape?: () => void + onTab?: () => void + onCustom?: (event: React.KeyboardEvent) => void +} + +export interface KeyboardHandlerOptions { + sendShortcut?: 'Enter' | 'Ctrl+Enter' | 'Cmd+Enter' | 'Shift+Enter' + enableTabNavigation?: boolean + enableEscape?: boolean +} + +/** + * 通用键盘事件处理 Hook + * + * 提供常见的键盘快捷键处理(发送、取消、Tab 导航等) + * + * @param callbacks - 键盘事件回调函数 + * @param callbacks.onSend - 发送消息回调(根据 sendShortcut 触发) + * @param callbacks.onEscape - Escape 键回调 + * @param callbacks.onTab - Tab 键回调 + * @param callbacks.onCustom - 自定义键盘处理回调 + * @param options - 配置选项 + * @param options.sendShortcut - 发送快捷键类型(默认 'Enter') + * @param options.enableTabNavigation - 是否启用 Tab 导航(默认 false) + * @param options.enableEscape - 是否启用 Escape 键处理(默认 false) + * @returns 键盘事件处理函数 + * + * @example + * ```tsx + * const handleKeyDown = useKeyboardHandler( + * { + * onSend: () => sendMessage(), + * onEscape: () => closeModal(), + * onTab: () => navigateToNextField() + * }, + * { + * sendShortcut: 'Ctrl+Enter', + * enableTabNavigation: true, + * enableEscape: true + * } + * ) + * + *