Implement fuzzy search command palette with superhuman-style interactions

 Major enhancements:
- Added fuzzy search with highlighting (inspired by Superhuman/Linear)
- Command palette (Cmd+K) now only shows 'Create Session'
- Search mode (/) provides fuzzy search across all sessions
- Compact UI limited to 5 results to prevent scrolling
- Smart keyboard navigation with up/down arrows
- Enter key opens selected session in search mode
- Fixed 'c' hotkey to properly open launcher and show input view

🔧 Technical improvements:
- Created reusable FuzzySearchInput component
- Implemented fuzzy matching algorithm with smart scoring
- Enhanced session configuration with working directory, model, max turns
- Directory path fuzzy search for working directory field
- Improved focus management and hotkey detection

🐛 Bug fixes:
- Fixed 'c' hotkey not working in session table
- Fixed ESC key handling in command palette
- Fixed keyboard navigation in search input

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
dexhorthy
2025-06-17 10:02:41 -07:00
parent 41ee7f8a2b
commit 5f30d58fd5
8 changed files with 1028 additions and 107 deletions

View File

@@ -0,0 +1,4 @@
- 'c' on session table doesn't launch the session creator
- selecting "create new session" from the cmd+k launch takes me to a blank screen
- search view still doesn't work - the max height should be 80% of the screen height, and only display as many items as can fit
- selecting a session from the search view should navigate to the session details

View File

@@ -1,6 +1,14 @@
import React, { useState, useRef, useEffect } from 'react'
import { Button } from './ui/button'
import { cn } from '@/lib/utils'
import FuzzySearchInput from './FuzzySearchInput'
interface SessionConfig {
query: string
workingDir: string
model?: string
maxTurns?: number
}
interface CommandInputProps {
value: string
@@ -8,6 +16,8 @@ interface CommandInputProps {
onSubmit: () => void
placeholder?: string
isLoading?: boolean
config?: SessionConfig
onConfigChange?: (config: SessionConfig) => void
}
export default function CommandInput({
@@ -16,9 +26,25 @@ export default function CommandInput({
onSubmit,
placeholder = 'Enter your command...',
isLoading = false,
config = { query: '', workingDir: '' },
onConfigChange,
}: CommandInputProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [isFocused, setIsFocused] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
// Common directory suggestions for fuzzy search
const commonDirectories = [
'~/',
'~/Desktop',
'~/Documents',
'~/Downloads',
'~/Projects',
'/usr/local',
'/opt',
'/tmp',
process.cwd() || './',
]
useEffect(() => {
if (inputRef.current) {
@@ -27,14 +53,21 @@ export default function CommandInput({
}, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
if (e.key === 'Enter' && !e.shiftKey && !showAdvanced) {
e.preventDefault()
onSubmit()
}
}
const updateConfig = (updates: Partial<SessionConfig>) => {
if (onConfigChange) {
onConfigChange({ ...config, ...updates })
}
}
return (
<div className="space-y-3">
<div className="space-y-4">
{/* Main query input */}
<div className="relative">
<input
ref={inputRef}
@@ -68,12 +101,86 @@ export default function CommandInput({
)}
</div>
{/* Working Directory Field with Fuzzy Search */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Working Directory</label>
<FuzzySearchInput
items={commonDirectories}
value={config.workingDir}
onChange={value => updateConfig({ workingDir: value })}
onSelect={directory => updateConfig({ workingDir: directory })}
placeholder="/path/to/directory or leave empty for current directory"
maxResults={6}
emptyMessage="Type a directory path..."
renderItem={(item) => (
<div className="flex items-center space-x-2">
<span className="text-blue-500">📁</span>
<span className="font-mono">{item}</span>
</div>
)}
/>
</div>
{/* Advanced Options Toggle */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{showAdvanced ? '← Hide' : 'Advanced Options →'}
</button>
</div>
{/* Advanced Options */}
{showAdvanced && (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Model</label>
<select
value={config.model || ''}
onChange={e => updateConfig({ model: e.target.value || undefined })}
className={cn(
'w-full h-9 px-3 text-sm',
'bg-background border rounded-md',
'border-border hover:border-primary/50 focus:border-primary focus:ring-2 focus:ring-primary/20',
)}
>
<option value="">Default Model</option>
<option value="claude-3-5-sonnet-20241022">Claude 3.5 Sonnet</option>
<option value="claude-3-opus-20240229">Claude 3 Opus</option>
<option value="claude-3-haiku-20240307">Claude 3 Haiku</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Max Turns</label>
<input
type="number"
value={config.maxTurns || ''}
onChange={e => updateConfig({ maxTurns: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="Default"
min="1"
max="100"
className={cn(
'w-full h-9 px-3 text-sm',
'bg-background border rounded-md',
'border-border hover:border-primary/50 focus:border-primary focus:ring-2 focus:ring-primary/20',
)}
/>
</div>
</div>
</div>
)}
{/* Action Bar */}
{value.trim() && (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between pt-2">
<div className="flex items-center space-x-2 text-xs text-muted-foreground">
{value.startsWith('/') && (
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded">
Working directory mode
{config.workingDir && (
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded font-mono text-xs">
{config.workingDir}
</span>
)}
</div>

View File

@@ -1,7 +1,9 @@
import React, { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useSessionLauncher } from '@/hooks/useSessionLauncher'
import { useStore } from '@/App'
import { fuzzySearch, highlightMatches, type FuzzyMatch } from '@/lib/fuzzy-search'
import { cn } from '@/lib/utils'
interface MenuOption {
id: string
@@ -10,33 +12,51 @@ interface MenuOption {
action: () => void
}
interface CommandPaletteMenuProps {
onClose: () => void
}
export default function CommandPaletteMenu() {
const { createNewSession, openSessionById, selectedMenuIndex, setSelectedMenuIndex } =
const { createNewSession, openSessionById, selectedMenuIndex, setSelectedMenuIndex, mode } =
useSessionLauncher()
const [searchQuery, setSearchQuery] = useState('')
// Get sessions from the main app store
const sessions = useStore(state => state.sessions)
// Build menu options
const menuOptions: MenuOption[] = [
// Build base menu options
const baseOptions: MenuOption[] = [
{
id: 'create-session',
label: 'Create Session',
description: 'Start a new session with AI assistance',
action: createNewSession,
},
...sessions.slice(0, 5).map(session => ({
id: `open-${session.id}`,
label: `Open ${session.query.slice(0, 40)}${session.query.length > 40 ? '...' : ''}`,
description: `${session.status}${session.model || 'Unknown model'}`,
action: () => openSessionById(session.id),
})),
]
// Command mode: Only Create Session
// Search mode: All sessions (for fuzzy search) but limit display to 5
const sessionOptions: MenuOption[] = mode === 'search'
? sessions.map(session => ({
id: `open-${session.id}`,
label: `${session.query.slice(0, 40)}${session.query.length > 40 ? '...' : ''}`,
description: `${session.status}${session.model || 'Unknown model'}`,
action: () => openSessionById(session.id),
}))
: [] // No sessions in command mode
// Apply fuzzy search if in search mode and there's a query
const filteredSessions = searchQuery && mode === 'search'
? fuzzySearch(sessionOptions, searchQuery, {
keys: ['label', 'description'],
threshold: 0.1,
includeMatches: true,
})
: sessionOptions.map(session => ({ item: session, matches: [], score: 1, indices: [] }))
// Combine options based on mode
const menuOptions: MenuOption[] = mode === 'command'
? baseOptions // Command: Only Create Session
: filteredSessions.slice(0, 5).map(result => result.item) // Search: Only sessions (no Create Session), limit to 5
// Keyboard navigation
useHotkeys(
'up',
@@ -71,48 +91,112 @@ export default function CommandPaletteMenu() {
}
}, [menuOptions.length, selectedMenuIndex, setSelectedMenuIndex])
// Render highlighted text for search results
const renderHighlightedText = (text: string, matches: FuzzyMatch['matches'], targetKey?: string) => {
const match = matches.find(m => m.key === targetKey)
if (match && match.indices && searchQuery) {
const segments = highlightMatches(text, match.indices)
return (
<>
{segments.map((segment, i) => (
<span
key={i}
className={segment.highlighted ? 'bg-yellow-200/80 dark:bg-yellow-900/60 font-medium' : ''}
>
{segment.text}
</span>
))}
</>
)
}
return text
}
return (
<div className="space-y-2">
<div className="text-sm text-muted-foreground mb-3">
Select an option or use arrow keys to navigate
</div>
{menuOptions.map((option, index) => (
<div
key={option.id}
className={`
p-3 rounded-lg cursor-pointer transition-all duration-150
${
index === selectedMenuIndex
? 'bg-primary text-primary-foreground'
: 'bg-muted/50 hover:bg-muted'
{/* Search input for search mode */}
{mode === 'search' && (
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => {
// Prevent up/down from moving cursor, let them control the list
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault()
}
// Enter should trigger selected option
if (e.key === 'Enter' && menuOptions[selectedMenuIndex]) {
e.preventDefault()
menuOptions[selectedMenuIndex].action()
}
`}
onClick={() => {
setSelectedMenuIndex(index)
option.action()
}}
onMouseEnter={() => setSelectedMenuIndex(index)}
>
<div className="font-medium">{option.label}</div>
{option.description && (
<div
className={`text-xs mt-1 ${
index === selectedMenuIndex ? 'text-primary-foreground/80' : 'text-muted-foreground'
}`}
>
{option.description}
</div>
placeholder="Search sessions..."
className={cn(
'w-full h-9 px-3 py-2 text-sm',
'font-mono',
'bg-background border rounded-md',
'transition-all duration-200',
'placeholder:text-muted-foreground/60',
'border-border hover:border-primary/50 focus:border-primary focus:ring-2 focus:ring-primary/20',
)}
</div>
))}
{menuOptions.length === 1 && (
<div className="text-xs text-muted-foreground text-center mt-4">No recent sessions found</div>
autoComplete="off"
autoFocus
/>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground mt-4 pt-3 border-t">
<div className="flex items-center space-x-4">
{/* Results counter for search mode - compact */}
{mode === 'search' && (
<div className="text-xs text-muted-foreground">
{menuOptions.length} of {filteredSessions.length} sessions
</div>
)}
{menuOptions.map((option, index) => {
// Find the corresponding match data for highlighting
const matchData = mode === 'search' && searchQuery
? filteredSessions.find(result => result.item.id === option.id)
: null
return (
<div
key={option.id}
className={cn(
'p-2 rounded cursor-pointer transition-all duration-150',
index === selectedMenuIndex
? 'bg-primary text-primary-foreground'
: 'bg-muted/30 hover:bg-muted/60'
)}
onClick={() => {
setSelectedMenuIndex(index)
option.action()
}}
onMouseEnter={() => setSelectedMenuIndex(index)}
>
<div className="text-sm font-medium truncate">
{matchData ? renderHighlightedText(option.label, matchData.matches, 'label') : option.label}
</div>
{option.description && (
<div
className={cn(
'text-xs mt-0.5 truncate',
index === selectedMenuIndex ? 'text-primary-foreground/70' : 'text-muted-foreground'
)}
>
{matchData ? renderHighlightedText(option.description, matchData.matches, 'description') : option.description}
</div>
)}
</div>
)
})}
{menuOptions.length === 0 && mode === 'search' && (
<div className="text-xs text-muted-foreground text-center py-4">No sessions found</div>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t border-border/30">
<div className="flex items-center space-x-3">
<span> Navigate</span>
<span> Select</span>
</div>

View File

@@ -0,0 +1,207 @@
import React, { useState, useRef, useEffect, useMemo } from 'react'
import { cn } from '@/lib/utils'
import { fuzzySearch, highlightMatches, type FuzzyMatch } from '@/lib/fuzzy-search'
interface FuzzySearchInputProps<T> {
items: T[]
value: string
onChange: (value: string) => void
onSelect?: (item: T) => void
placeholder?: string
searchKeys?: string[]
renderItem?: (item: T, matches: FuzzyMatch['matches']) => React.ReactNode
className?: string
maxResults?: number
emptyMessage?: string
disabled?: boolean
}
export default function FuzzySearchInput<T>({
items,
value,
onChange,
onSelect,
placeholder = 'Search...',
searchKeys = [],
renderItem,
className,
maxResults = 10,
emptyMessage = 'No results found',
disabled = false,
}: FuzzySearchInputProps<T>) {
const [isOpen, setIsOpen] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
// Perform fuzzy search
const searchResults = useMemo(() => {
if (!value.trim()) return []
const results = fuzzySearch(items, value, {
keys: searchKeys,
threshold: 0.1,
includeMatches: true,
})
return results.slice(0, maxResults)
}, [items, value, searchKeys, maxResults])
// Reset selection when results change
useEffect(() => {
setSelectedIndex(0)
}, [searchResults])
// Open dropdown when typing
useEffect(() => {
setIsOpen(value.length > 0 && searchResults.length > 0)
}, [value, searchResults.length])
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen || !searchResults.length) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex(prev => (prev + 1) % searchResults.length)
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex(prev => (prev - 1 + searchResults.length) % searchResults.length)
break
case 'Enter':
e.preventDefault()
if (searchResults[selectedIndex] && onSelect) {
onSelect(searchResults[selectedIndex].item)
setIsOpen(false)
}
break
case 'Escape':
e.preventDefault()
setIsOpen(false)
inputRef.current?.blur()
break
}
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, searchResults, selectedIndex, onSelect])
// Scroll selected item into view
useEffect(() => {
if (listRef.current && isOpen) {
const selectedElement = listRef.current.children[selectedIndex] as HTMLElement
selectedElement?.scrollIntoView({ block: 'nearest' })
}
}, [selectedIndex, isOpen])
const handleItemClick = (item: T, index: number) => {
setSelectedIndex(index)
if (onSelect) {
onSelect(item)
setIsOpen(false)
}
}
const defaultRenderItem = (item: T, matches: FuzzyMatch['matches']) => {
const text = String(item)
const match = matches[0]
if (match && match.indices) {
const segments = highlightMatches(text, match.indices)
return defaultRenderHighlight(segments)
}
return <span>{text}</span>
}
const defaultRenderHighlight = (segments: Array<{ text: string; highlighted: boolean }>) => (
<>
{segments.map((segment, i) => (
<span
key={i}
className={segment.highlighted ? 'bg-yellow-200 dark:bg-yellow-900/50 font-medium' : ''}
>
{segment.text}
</span>
))}
</>
)
return (
<div className="relative">
<input
ref={inputRef}
type="text"
value={value}
onChange={e => onChange(e.target.value)}
onFocus={() => value && searchResults.length > 0 && setIsOpen(true)}
placeholder={placeholder}
disabled={disabled}
className={cn(
'w-full h-10 px-3 py-2 text-sm',
'font-mono',
'bg-background border rounded-md',
'transition-all duration-200',
'placeholder:text-muted-foreground/60',
'disabled:opacity-50 disabled:cursor-not-allowed',
'border-border hover:border-primary/50 focus:border-primary focus:ring-2 focus:ring-primary/20',
className
)}
autoComplete="off"
/>
{isOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
{/* Results dropdown */}
<div
ref={listRef}
className="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-md shadow-lg z-20 max-h-60 overflow-y-auto"
>
{searchResults.length > 0 ? (
searchResults.map((result, index) => (
<div
key={index}
className={cn(
'px-3 py-2 cursor-pointer transition-colors text-sm',
'border-b border-border/50 last:border-b-0',
index === selectedIndex
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted/50'
)}
onClick={() => handleItemClick(result.item, index)}
onMouseEnter={() => setSelectedIndex(index)}
>
{renderItem ? renderItem(result.item, result.matches) : defaultRenderItem(result.item, result.matches)}
</div>
))
) : (
<div className="px-3 py-2 text-sm text-muted-foreground text-center">
{emptyMessage}
</div>
)}
{/* Footer with navigation hints */}
{searchResults.length > 0 && (
<div className="px-3 py-1 text-xs text-muted-foreground bg-muted/30 border-t border-border/50 flex items-center justify-between">
<span> Navigate</span>
<span> Select ESC Close</span>
</div>
)}
</div>
</>
)}
</div>
)
}

View File

@@ -13,7 +13,7 @@ interface SessionLauncherProps {
export function SessionLauncher({ isOpen, onClose }: SessionLauncherProps) {
const modalRef = useRef<HTMLDivElement>(null)
const { query, setQuery, launchSession, isLaunching, error, mode, view, setView } =
const { query, setQuery, config, setConfig, launchSession, isLaunching, error, mode, view, setView } =
useSessionLauncher()
// Escape key to close - enable even when input is focused
@@ -92,7 +92,7 @@ export function SessionLauncher({ isOpen, onClose }: SessionLauncherProps) {
</div>
{view === 'menu' ? (
<CommandPaletteMenu onClose={onClose} />
<CommandPaletteMenu />
) : (
<>
<CommandInput
@@ -101,6 +101,8 @@ export function SessionLauncher({ isOpen, onClose }: SessionLauncherProps) {
onSubmit={handleSubmit}
placeholder="Enter your prompt to start a session..."
isLoading={isLaunching}
config={config}
onConfigChange={setConfig}
/>
{error && (

View File

@@ -2,9 +2,12 @@ import { create } from 'zustand'
import { daemonClient } from '@/lib/daemon'
import type { LaunchSessionRequest } from '@/lib/daemon/types'
interface ParsedQuery {
interface SessionConfig {
query: string
workingDir?: string
workingDir: string
model?: string
maxTurns?: number
}
interface LauncherState {
@@ -12,6 +15,7 @@ interface LauncherState {
mode: 'command' | 'search'
view: 'menu' | 'input'
query: string
config: SessionConfig
isLaunching: boolean
error?: string
gPrefixMode: boolean
@@ -21,6 +25,7 @@ interface LauncherState {
open: (mode?: 'command' | 'search') => void
close: () => void
setQuery: (query: string) => void
setConfig: (config: SessionConfig) => void
setGPrefixMode: (enabled: boolean) => void
setView: (view: 'menu' | 'input') => void
setSelectedMenuIndex: (index: number) => void
@@ -30,34 +35,13 @@ interface LauncherState {
reset: () => void
}
// Parse basic query patterns
function parseQuery(query: string): ParsedQuery {
const trimmed = query.trim()
// Check for working directory pattern: "/path rest of query"
if (trimmed.startsWith('/')) {
const spaceIndex = trimmed.indexOf(' ')
if (spaceIndex > 0) {
return {
query: trimmed.slice(spaceIndex + 1).trim(),
workingDir: trimmed.slice(0, spaceIndex),
}
}
// If just "/path" with no additional query, treat as working dir change
return {
query: '',
workingDir: trimmed,
}
}
return { query: trimmed }
}
export const useSessionLauncher = create<LauncherState>((set, get) => ({
isOpen: false,
mode: 'command',
view: 'menu',
query: '',
config: { query: '', workingDir: '' },
isLaunching: false,
gPrefixMode: false,
selectedMenuIndex: 0,
@@ -76,6 +60,7 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
isOpen: false,
view: 'menu',
query: '',
config: { query: '', workingDir: '' },
selectedMenuIndex: 0,
error: undefined,
gPrefixMode: false,
@@ -83,6 +68,8 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
setQuery: query => set({ query, error: undefined }),
setConfig: config => set({ config, error: undefined }),
setGPrefixMode: enabled => set({ gPrefixMode: enabled }),
setView: view => set({ view }),
@@ -90,10 +77,9 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
setSelectedMenuIndex: index => set({ selectedMenuIndex: index }),
launchSession: async () => {
const { query } = get()
const parsed = parseQuery(query)
const { query, config } = get()
if (!parsed.query && !parsed.workingDir) {
if (!query.trim()) {
set({ error: 'Please enter a query to launch a session' })
return
}
@@ -102,8 +88,10 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
set({ isLaunching: true, error: undefined })
const request: LaunchSessionRequest = {
query: parsed.query || 'Help me with the current directory',
working_dir: parsed.workingDir,
query: query.trim(),
working_dir: config.workingDir || undefined,
model: config.model || undefined,
max_turns: config.maxTurns || undefined,
}
const response = await daemonClient.launchSession(request)
@@ -139,6 +127,7 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
mode: 'command',
view: 'menu',
query: '',
config: { query: '', workingDir: '' },
selectedMenuIndex: 0,
isLaunching: false,
error: undefined,
@@ -150,28 +139,17 @@ export const useSessionLauncher = create<LauncherState>((set, get) => ({
export function useSessionLauncherHotkeys() {
const { open, close, isOpen, gPrefixMode, setGPrefixMode, createNewSession } = useSessionLauncher()
// Helper to check if user is typing in an input
const isInputFocused = () => {
// Helper to check if user is actively typing in a text input
const isTypingInInput = () => {
const active = document.activeElement
if (!active) return false
// Check for input and textarea elements
if (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') {
return true
}
// Check for contentEditable elements
if ((active as HTMLElement).contentEditable === 'true') {
return true
}
// Check if we're inside the command palette modal (more specific check)
const commandPalette = document.querySelector('[data-command-palette]')
if (commandPalette && commandPalette.contains(active)) {
return true
}
return false
// Only block hotkeys when actively typing in actual input fields
return (
active.tagName === 'INPUT' ||
active.tagName === 'TEXTAREA' ||
(active as HTMLElement).contentEditable === 'true'
)
}
return {
@@ -188,21 +166,25 @@ export function useSessionLauncherHotkeys() {
}
// C - Create new session directly (bypasses command palette)
if (e.key === 'c' && !e.metaKey && !e.ctrlKey && !isInputFocused()) {
if (e.key === 'c' && !e.metaKey && !e.ctrlKey && !isTypingInInput()) {
e.preventDefault()
// Open launcher if not already open
if (!isOpen) {
open('command')
}
createNewSession()
return
}
// / - Search sessions and approvals (only when not typing)
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !isInputFocused()) {
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !isTypingInInput()) {
e.preventDefault()
open('search')
return
}
// G prefix navigation (prepare for Phase 2)
if (e.key === 'g' && !e.metaKey && !e.ctrlKey && !isInputFocused()) {
if (e.key === 'g' && !e.metaKey && !e.ctrlKey && !isTypingInInput()) {
e.preventDefault()
setGPrefixMode(true)
setTimeout(() => setGPrefixMode(false), 2000)

View File

@@ -0,0 +1,197 @@
/**
* Fuzzy search utilities inspired by Superhuman and Linear
* Provides fast, intuitive search with highlighting and scoring
*/
export interface FuzzyMatch {
score: number
indices: number[]
item: any
matches: Array<{ indices: number[]; key?: string }>
}
export interface FuzzySearchOptions {
keys?: string[]
threshold?: number
includeScore?: boolean
includeMatches?: boolean
minMatchCharLength?: number
ignoreLocation?: boolean
}
/**
* Calculate fuzzy match score for a single string
*/
function fuzzyMatchString(pattern: string, text: string): { score: number; indices: number[] } {
if (!pattern) return { score: 1, indices: [] }
if (!text) return { score: 0, indices: [] }
pattern = pattern.toLowerCase()
text = text.toLowerCase()
let patternIdx = 0
let textIdx = 0
const indices: number[] = []
let score = 0
let consecutiveMatches = 0
while (patternIdx < pattern.length && textIdx < text.length) {
if (pattern[patternIdx] === text[textIdx]) {
indices.push(textIdx)
consecutiveMatches++
// Bonus for consecutive matches (like Superhuman)
score += consecutiveMatches * 2
// Bonus for start of word matches
if (textIdx === 0 || text[textIdx - 1] === ' ' || text[textIdx - 1] === '/') {
score += 5
}
patternIdx++
} else {
consecutiveMatches = 0
}
textIdx++
}
// Penalty for unmatched pattern characters
if (patternIdx < pattern.length) {
score = 0
} else {
// Bonus for shorter strings (exact matches rank higher)
score += Math.max(0, 100 - text.length)
// Bonus for match density
score += (indices.length / text.length) * 50
}
return { score, indices }
}
/**
* Search through an array of items with fuzzy matching
*/
export function fuzzySearch<T>(
items: T[],
pattern: string,
options: FuzzySearchOptions = {}
): FuzzyMatch[] {
const {
keys = [],
threshold = 0.1,
minMatchCharLength = 1,
} = options
if (!pattern || pattern.length < minMatchCharLength) {
return items.map(item => ({
score: 1,
indices: [],
item,
matches: [],
}))
}
const results: FuzzyMatch[] = []
for (const item of items) {
let bestScore = 0
let bestMatches: Array<{ indices: number[]; key?: string }> = []
if (keys.length === 0) {
// Search in the item itself (assume it's a string)
const match = fuzzyMatchString(pattern, String(item))
if (match.score > threshold) {
results.push({
score: match.score,
indices: match.indices,
item,
matches: [{ indices: match.indices }],
})
}
} else {
// Search in specified keys
for (const key of keys) {
const value = (item as any)[key]
if (typeof value === 'string') {
const match = fuzzyMatchString(pattern, value)
if (match.score > bestScore) {
bestScore = match.score
bestMatches = [{ indices: match.indices, key }]
} else if (match.score === bestScore && match.score > threshold) {
bestMatches.push({ indices: match.indices, key })
}
}
}
if (bestScore > threshold) {
results.push({
score: bestScore,
indices: bestMatches[0]?.indices || [],
item,
matches: bestMatches,
})
}
}
}
// Sort by score (descending)
return results.sort((a, b) => b.score - a.score)
}
/**
* Highlight matched characters in text (for rendering)
*/
export function highlightMatches(text: string, indices: number[]): Array<{ text: string; highlighted: boolean }> {
if (!indices.length) {
return [{ text, highlighted: false }]
}
const result: Array<{ text: string; highlighted: boolean }> = []
let lastIndex = 0
for (const index of indices) {
// Add non-highlighted text before this match
if (index > lastIndex) {
result.push({
text: text.slice(lastIndex, index),
highlighted: false,
})
}
// Add highlighted character
result.push({
text: text[index],
highlighted: true,
})
lastIndex = index + 1
}
// Add remaining non-highlighted text
if (lastIndex < text.length) {
result.push({
text: text.slice(lastIndex),
highlighted: false,
})
}
return result
}
/**
* Directory-specific fuzzy search with path intelligence
*/
export function fuzzySearchDirectories(directories: string[], pattern: string): FuzzyMatch[] {
// Enhance pattern matching for directory paths
const enhancedPattern = pattern.replace(/^~/, process.env.HOME || '')
return fuzzySearch(directories, enhancedPattern, {
threshold: 0.1,
minMatchCharLength: 1,
}).map(match => ({
...match,
// Boost score for exact directory name matches
score: match.item.endsWith('/' + pattern) ? match.score + 100 : match.score,
}))
}

338
prompt.md Normal file
View File

@@ -0,0 +1,338 @@
# Phase 1: Core Command Palette Implementation
## Objective
Build the minimal viable command palette launcher - a full-screen overlay triggered by `Cmd+K` with a single input field that can launch sessions with basic parsing and instant feedback.
## Deliverables
1. **SessionLauncher.tsx** - Full-screen command palette overlay
2. **CommandInput.tsx** - Smart input with basic parsing
3. **useSessionLauncher.ts** - Zustand state management
4. **Global hotkey integration** - Cmd+K trigger
5. **Basic session launch** - Integration with daemon client
## Technical Specs
### SessionLauncher Component
```typescript
interface SessionLauncherProps {
isOpen: boolean
onClose: () => void
}
// Features:
// - Full-screen overlay (backdrop blur)
// - Centered modal with monospace font
// - High contrast design
// - Escape key to close
// - Click outside to close
```
### CommandInput Component
```typescript
interface CommandInputProps {
value: string
onChange: (value: string) => void
onSubmit: () => void
placeholder?: string
}
// Features:
// - Large input field with monospace font
// - Enter key to submit
// - Real-time character count
// - Focus management
// - Smooth animations
```
### State Management
```typescript
interface LauncherState {
isOpen: boolean
mode: 'command' | 'search' // command = launch sessions, search = find sessions/approvals
query: string
isLaunching: boolean
error?: string
gPrefixMode: boolean
// Actions
open: (mode?: 'command' | 'search') => void
close: () => void
setQuery: (query: string) => void
setGPrefixMode: (enabled: boolean) => void
launchSession: () => Promise<void>
reset: () => void
}
```
### Basic Query Parsing
```typescript
interface ParsedQuery {
query: string // Main text
workingDir?: string // If query starts with /path
}
// Parse patterns:
// "debug login component" → { query: "debug login component" }
// "/src debug login" → { query: "debug login", workingDir: "/src" }
```
## File Structure
```
humanlayer-wui/src/
├── components/
│ ├── SessionLauncher.tsx # Main overlay component
│ └── CommandInput.tsx # Input field component
├── hooks/
│ └── useSessionLauncher.ts # State management
└── stores/
└── sessionStore.ts # Extended zustand store
```
## Implementation Steps
### Step 1: Create SessionLauncher Component (1 hour)
```typescript
// SessionLauncher.tsx
export function SessionLauncher({ isOpen, onClose }: SessionLauncherProps) {
// Full-screen overlay with backdrop
// Centered modal with session input
// Escape key handling
// Animation on open/close
}
```
**Styling Requirements**:
- Full viewport overlay with backdrop-blur
- Centered modal (max-width: 600px)
- Monospace font family
- High contrast colors (dark background, white text)
- Smooth fade-in/out animations
- Focus trap when open
### Step 2: Create CommandInput Component (1 hour)
```typescript
// CommandInput.tsx
export function CommandInput({ value, onChange, onSubmit }: CommandInputProps) {
// Large input field
// Enter key handling
// Character count display
// Loading state
}
```
**Features**:
- Large text input (48px height minimum)
- Monospace font
- Placeholder text with examples
- Real-time character count
- Loading spinner when launching
- Submit on Enter key
### Step 3: State Management Hook (1 hour)
```typescript
// useSessionLauncher.ts
export const useSessionLauncher = create<LauncherState>((set, get) => ({
isOpen: false,
query: '',
isLaunching: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false, query: '', error: undefined }),
setQuery: (query) => set({ query }),
launchSession: async () => {
// Basic session launch logic
// Error handling
// Success navigation
}
}))
```
### Step 4: Global Hotkey Integration (45 minutes)
```typescript
// Add to App.tsx or main component
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Cmd+K - Global command palette
if (e.metaKey && e.key === 'k') {
e.preventDefault()
openLauncher('command')
}
// / - Search sessions and approvals
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !isInputFocused()) {
e.preventDefault()
openLauncher('search')
}
// G prefix navigation (prepare for Phase 2)
if (e.key === 'g' && !e.metaKey && !e.ctrlKey && !isInputFocused()) {
e.preventDefault()
setGPrefixMode(true)
setTimeout(() => setGPrefixMode(false), 2000)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
// Helper to check if user is typing in an input
const isInputFocused = () => {
const active = document.activeElement
return active?.tagName === 'INPUT' || active?.tagName === 'TEXTAREA' || active?.contentEditable === 'true'
}
```
**Enhanced Hotkey Features**:
- `Cmd+K` - Opens command palette in "command" mode (launch sessions)
- `/` - Opens command palette in "search" mode (find sessions/approvals)
- `g` prefix - Sets up for vim-style navigation (Phase 2: g+a = approvals, g+s = sessions)
- Smart input detection to avoid conflicts when user is typing
### Step 5: Session Launch Integration (30 minutes)
```typescript
const launchSession = async () => {
try {
set({ isLaunching: true, error: undefined })
const parsed = parseQuery(get().query)
const response = await daemonClient.launchSession({
query: parsed.query,
working_dir: parsed.workingDir || process.cwd()
})
// Navigate to new session
navigate(`/session/${response.session_id}`)
// Close launcher
get().close()
} catch (error) {
set({ error: error.message })
} finally {
set({ isLaunching: false })
}
}
```
## UI Design Requirements
### Visual Style
- **Background**: Full-screen overlay with backdrop-blur-sm
- **Modal**: Centered, rounded corners, dark background
- **Typography**: Monospace font (ui-monospace, Monaco, "Cascadia Code")
- **Colors**: High contrast - white text on dark backgrounds
- **Spacing**: Generous padding and margins for breathing room
### Layout Structure
```
┌─────────────────────────────────────────────────┐
│ [OVERLAY] │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ > [INPUT FIELD] │ │
│ │ │ │
│ │ [CHARACTER COUNT] │ │
│ │ │ │
│ │ ↵ Launch ⌘K Close │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────┘
```
### Interaction States
- **Default**: Clean input with placeholder
- **Typing**: Real-time character count
- **Loading**: Spinner + "Launching..." text
- **Error**: Red error message below input
- **Success**: Quick flash before navigation
## Integration Points
### App.tsx Integration
```typescript
function App() {
const { isOpen, close } = useSessionLauncher()
return (
<>
{/* Existing app content */}
<SessionTable />
<SessionDetail />
{/* Command palette overlay */}
<SessionLauncher isOpen={isOpen} onClose={close} />
</>
)
}
```
### SessionTable Button
Add floating action button or header button:
```typescript
<Button
onClick={() => useSessionLauncher.getState().open()}
className="fixed bottom-6 right-6"
>
<Plus className="h-4 w-4" />
</Button>
```
## Testing Requirements
### Unit Tests
- SessionLauncher renders correctly
- CommandInput handles input changes
- Hotkey triggers launcher open
- Session launch calls daemon client
- Error states display properly
### Integration Tests
- End-to-end session creation flow
- Keyboard navigation works
- Mobile responsiveness
- Focus management
## Acceptance Criteria
1.`Cmd+K` opens full-screen command palette
2. ✅ Single input field with monospace font
3. ✅ Enter key launches session with daemon client
4. ✅ Escape key closes launcher
5. ✅ Loading states during session creation
6. ✅ Error handling with user feedback
7. ✅ Navigation to new session on success
8. ✅ Clean, high-contrast design
9. ✅ Smooth animations and interactions
10. ✅ Mobile responsive layout
## Success Metrics
- Launcher opens in <50ms from keypress
- Session launches successfully 95% of the time
- Error messages are clear and actionable
- Interface is intuitive without documentation
- Works on desktop and mobile
## Next Phase Preparation
This implementation should be designed to easily extend with:
- Template parsing (`:debug`, `:review`)
- Model selection (`@claude-opus`)
- Advanced flags (`--max-turns=5`)
- Context detection and suggestions
- Recent session history
Keep the architecture clean and extensible for Phase 2 enhancements.