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;
// ============================================================================