diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 72e1e589..b0757881 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -30,6 +30,7 @@ import * as composioHandler from './composio-handler.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; +import { search } from '@x/core/dist/search/search.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -497,5 +498,9 @@ export function setupIpcHandlers() { const mimeType = mimeMap[ext] || 'application/octet-stream'; return { data: buffer.toString('base64'), mimeType, size: stat.size }; }, + // Search handler + 'search:query': async (_event, args) => { + return search(args.query, args.limit, args.types); + }, }); } diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7658c4c8..ed3dd739 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; import { Button } from './components/ui/button'; -import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon, SquarePen } from 'lucide-react'; +import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatInputBar } from './components/chat-button'; @@ -50,6 +50,7 @@ import { TooltipProvider } from "@/components/ui/tooltip" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { OnboardingModal } from '@/components/onboarding-modal' +import { SearchDialog } from '@/components/search-dialog' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' @@ -675,6 +676,9 @@ function App() { // Onboarding state const [showOnboarding, setShowOnboarding] = useState(false) + // Search state + const [isSearchOpen, setIsSearchOpen] = useState(false) + // Background tasks state type BackgroundTaskItem = { name: string @@ -1829,6 +1833,18 @@ function App() { return () => document.removeEventListener('keydown', handleKeyDown) }, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat]) + // Keyboard shortcut: Cmd+K / Ctrl+K to open search + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault() + setIsSearchOpen(true) + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, []) + const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { navigateToFile(path) @@ -2336,6 +2352,14 @@ function App() { {headerTitle} + {selectedPath && (
{isSaving ? ( @@ -2568,6 +2592,12 @@ function App() { /> )}
+ { void navigateToView({ type: 'chat', runId: id }) }} + /> void + onSelectFile: (path: string) => void + onSelectRun: (runId: string) => void +} + +export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) { + const { activeSection } = useSidebarSection() + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [isSearching, setIsSearching] = useState(false) + const [activeTypes, setActiveTypes] = useState>( + () => new Set(activeTabToTypes(activeSection)) + ) + const debouncedQuery = useDebounce(query, 250) + + // Sync filter preselection when dialog opens + useEffect(() => { + if (open) { + setActiveTypes(new Set(activeTabToTypes(activeSection))) + } + }, [open, activeSection]) + + const toggleType = useCallback((type: SearchType) => { + setActiveTypes(new Set([type])) + }, []) + + useEffect(() => { + if (!debouncedQuery.trim()) { + setResults([]) + return + } + + let cancelled = false + setIsSearching(true) + + const types = Array.from(activeTypes) as ('knowledge' | 'chat')[] + window.ipc.invoke('search:query', { query: debouncedQuery, limit: 20, types }) + .then((res) => { + if (!cancelled) { + setResults(res.results) + } + }) + .catch((err) => { + console.error('Search failed:', err) + if (!cancelled) { + setResults([]) + } + }) + .finally(() => { + if (!cancelled) { + setIsSearching(false) + } + }) + + return () => { cancelled = true } + }, [debouncedQuery, activeTypes]) + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + setQuery('') + setResults([]) + } + }, [open]) + + const handleSelect = useCallback((result: SearchResult) => { + onOpenChange(false) + if (result.type === 'knowledge') { + onSelectFile(result.path) + } else { + onSelectRun(result.path) + } + }, [onOpenChange, onSelectFile, onSelectRun]) + + const knowledgeResults = results.filter(r => r.type === 'knowledge') + const chatResults = results.filter(r => r.type === 'chat') + + return ( + + +
+ toggleType('knowledge')} + icon={} + label="Knowledge" + /> + toggleType('chat')} + icon={} + label="Chats" + /> +
+ + {!query.trim() && ( + Type to search... + )} + {query.trim() && !isSearching && results.length === 0 && ( + No results found. + )} + {knowledgeResults.length > 0 && ( + + {knowledgeResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} + {chatResults.length > 0 && ( + + {chatResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} +
+
+ ) +} + +function FilterToggle({ + active, + onClick, + icon, + label, +}: { + active: boolean + onClick: () => void + icon: React.ReactNode + label: string +}) { + return ( + + ) +} diff --git a/apps/x/packages/core/src/search/search.ts b/apps/x/packages/core/src/search/search.ts new file mode 100644 index 00000000..d68449f5 --- /dev/null +++ b/apps/x/packages/core/src/search/search.ts @@ -0,0 +1,375 @@ +import path from 'path'; +import fs from 'fs'; +import fsp from 'fs/promises'; +import readline from 'readline'; +import { execFile } from 'child_process'; +import { WorkDir } from '../config/config.js'; + +interface SearchResult { + type: 'knowledge' | 'chat'; + title: string; + preview: string; + path: string; +} + +const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge'); +const RUNS_DIR = path.join(WorkDir, 'runs'); + +type SearchType = 'knowledge' | 'chat'; + +/** + * Search across knowledge files and chat history. + * @param types - optional filter to search only specific types (default: both) + */ +export async function search(query: string, limit = 20, types?: SearchType[]): Promise<{ results: SearchResult[] }> { + const trimmed = query.trim(); + if (!trimmed) { + return { results: [] }; + } + + const searchKnowledgeEnabled = !types || types.includes('knowledge'); + const searchChatsEnabled = !types || types.includes('chat'); + + const [knowledgeResults, chatResults] = await Promise.all([ + searchKnowledgeEnabled ? searchKnowledge(trimmed, limit) : Promise.resolve([]), + searchChatsEnabled ? searchChats(trimmed, limit) : Promise.resolve([]), + ]); + + const results = [...knowledgeResults, ...chatResults].slice(0, limit); + return { results }; +} + +/** + * Search knowledge markdown files by content and filename. + */ +async function searchKnowledge(query: string, limit: number): Promise { + if (!fs.existsSync(KNOWLEDGE_DIR)) { + return []; + } + + const results: SearchResult[] = []; + const seenPaths = new Set(); + const lowerQuery = query.toLowerCase(); + + // Content search via grep + try { + const grepMatches = await grepFiles(query, KNOWLEDGE_DIR, '*.md'); + for (const match of grepMatches) { + if (results.length >= limit) break; + const relPath = path.relative(WorkDir, match.file); + if (seenPaths.has(relPath)) continue; + seenPaths.add(relPath); + + const title = path.basename(match.file, '.md'); + results.push({ + type: 'knowledge', + title, + preview: match.line.trim().substring(0, 150), + path: relPath, + }); + } + } catch { + // grep failed (no matches or dir issue) — continue + } + + // Filename search — check files whose name matches the query + try { + const allFiles = await listMarkdownFiles(KNOWLEDGE_DIR); + for (const file of allFiles) { + if (results.length >= limit) break; + const relPath = path.relative(WorkDir, file); + if (seenPaths.has(relPath)) continue; + + const basename = path.basename(file, '.md'); + if (basename.toLowerCase().includes(lowerQuery)) { + seenPaths.add(relPath); + const preview = await readFirstLines(file, 2); + results.push({ + type: 'knowledge', + title: basename, + preview, + path: relPath, + }); + } + } + } catch { + // ignore errors + } + + return results; +} + +/** + * Search chat history by title and message content. + */ +async function searchChats(query: string, limit: number): Promise { + if (!fs.existsSync(RUNS_DIR)) { + return []; + } + + const results: SearchResult[] = []; + const seenIds = new Set(); + const lowerQuery = query.toLowerCase(); + + // Content search via grep on JSONL files + try { + const grepMatches = await grepFiles(query, RUNS_DIR, '*.jsonl'); + for (const match of grepMatches) { + if (results.length >= limit) break; + const runId = path.basename(match.file, '.jsonl'); + if (seenIds.has(runId)) continue; + + const meta = await readRunMetadata(match.file); + if (meta.agentName !== 'copilot') { + seenIds.add(runId); + continue; + } + seenIds.add(runId); + + // Extract a content preview from the matching line + let preview = ''; + try { + const parsed = JSON.parse(match.line); + if (parsed.message?.content && typeof parsed.message.content === 'string') { + preview = parsed.message.content.replace(/[\s\S]*?<\/attached-files>/g, '').trim().substring(0, 150); + } + } catch { + preview = match.line.substring(0, 150); + } + + results.push({ + type: 'chat', + title: meta.title || runId, + preview, + path: runId, + }); + } + } catch { + // grep failed — continue + } + + // Title search — scan run files for matching titles + try { + const entries = await fsp.readdir(RUNS_DIR, { withFileTypes: true }); + const jsonlFiles = entries + .filter(e => e.isFile() && e.name.endsWith('.jsonl')) + .map(e => e.name) + .sort() + .reverse(); // newest first + + for (const name of jsonlFiles) { + if (results.length >= limit) break; + const runId = path.basename(name, '.jsonl'); + if (seenIds.has(runId)) continue; + + const filePath = path.join(RUNS_DIR, name); + const meta = await readRunMetadata(filePath); + if (meta.agentName !== 'copilot') { + seenIds.add(runId); + continue; + } + if (meta.title && meta.title.toLowerCase().includes(lowerQuery)) { + seenIds.add(runId); + results.push({ + type: 'chat', + title: meta.title, + preview: meta.title, + path: runId, + }); + } + } + } catch { + // ignore errors + } + + return results; +} + +/** + * Use grep to find files matching a query. + */ +function grepFiles(query: string, dir: string, includeGlob: string): Promise> { + return new Promise((resolve, reject) => { + execFile( + 'grep', + ['-ril', '--include=' + includeGlob, query, dir], + { maxBuffer: 1024 * 1024 }, + (error, stdout) => { + if (error) { + // Exit code 1 = no matches + if (error.code === 1) { + resolve([]); + return; + } + reject(error); + return; + } + + const files = stdout.trim().split('\n').filter(Boolean); + // For each matching file, get the first matching line + const promises = files.map(file => + getFirstMatchingLine(file, query).then(line => ({ file, line })) + ); + Promise.all(promises).then(resolve).catch(reject); + } + ); + }); +} + +/** + * Get the first line in a file that matches the query (case-insensitive). + */ +function getFirstMatchingLine(filePath: string, query: string): Promise { + return new Promise((resolve) => { + let resolved = false; + const done = (value: string) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + + const lowerQuery = query.toLowerCase(); + const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + rl.on('line', (line) => { + if (line.toLowerCase().includes(lowerQuery)) { + done(line); + rl.close(); + stream.destroy(); + } + }); + + rl.on('close', () => done('')); + stream.on('error', () => done('')); + }); +} + +interface RunMetadata { + title: string | undefined; + agentName: string | undefined; +} + +/** + * Read metadata from a run JSONL file (agent name from start event, title from first user message). + */ +function readRunMetadata(filePath: string): Promise { + return new Promise((resolve) => { + let resolved = false; + const done = (value: RunMetadata) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + + const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + let lineIndex = 0; + let agentName: string | undefined; + + rl.on('line', (line) => { + if (resolved) return; + const trimmed = line.trim(); + if (!trimmed) return; + + try { + if (lineIndex === 0) { + // Start event — extract agentName + const start = JSON.parse(trimmed); + agentName = start.agentName; + lineIndex++; + return; + } + + const event = JSON.parse(trimmed); + if (event.type === 'message') { + const msg = event.message; + if (msg?.role === 'user') { + const content = msg.content; + if (typeof content === 'string' && content.trim()) { + let cleaned = content.replace(/[\s\S]*?<\/attached-files>/g, ''); + cleaned = cleaned.replace(/\s+/g, ' ').trim(); + if (cleaned) { + done({ title: cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned, agentName }); + rl.close(); + stream.destroy(); + return; + } + } + done({ title: undefined, agentName }); + rl.close(); + stream.destroy(); + return; + } else if (msg?.role === 'assistant') { + done({ title: undefined, agentName }); + rl.close(); + stream.destroy(); + return; + } + } + lineIndex++; + } catch { + lineIndex++; + } + }); + + rl.on('close', () => done({ title: undefined, agentName })); + rl.on('error', () => done({ title: undefined, agentName: undefined })); + stream.on('error', () => { + rl.close(); + done({ title: undefined, agentName: undefined }); + }); + }); +} + +/** + * Recursively list all .md files in a directory. + */ +async function listMarkdownFiles(dir: string): Promise { + const results: string[] = []; + try { + const entries = await fsp.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const nested = await listMarkdownFiles(fullPath); + results.push(...nested); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + results.push(fullPath); + } + } + } catch { + // ignore + } + return results; +} + +/** + * Read the first N non-empty lines of a file for preview. + */ +async function readFirstLines(filePath: string, n: number): Promise { + return new Promise((resolve) => { + const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + const lines: string[] = []; + + rl.on('line', (line) => { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + lines.push(trimmed); + } + if (lines.length >= n) { + rl.close(); + stream.destroy(); + } + }); + + rl.on('close', () => { + resolve(lines.join(' ').substring(0, 150)); + }); + + stream.on('error', () => { + resolve(''); + }); + }); +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 7af39efb..1fa9b423 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -396,6 +396,22 @@ const ipcSchemas = { req: z.object({ path: z.string() }), res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }), }, + // Search channels + 'search:query': { + req: z.object({ + query: z.string(), + limit: z.number().optional(), + types: z.array(z.enum(['knowledge', 'chat'])).optional(), + }), + res: z.object({ + results: z.array(z.object({ + type: z.enum(['knowledge', 'chat']), + title: z.string(), + preview: z.string(), + path: z.string(), + })), + }), + }, } as const; // ============================================================================