feat: add inline AI prompts to command palette

This commit is contained in:
lucaronin
2026-04-12 13:14:18 +02:00
parent d16aa29d18
commit 7933fe1bfb
23 changed files with 2123 additions and 657 deletions

View File

@@ -64,6 +64,7 @@ import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents
import { trackEvent } from './lib/telemetry'
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
import { hasNoteIconValue } from './utils/noteIcon'
import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge'
import {
INBOX_SELECTION,
isExplicitOrganizationEnabled,
@@ -97,10 +98,20 @@ function App() {
const visibleNotesRef = useRef<VaultEntry[]>([])
const [toastMessage, setToastMessage] = useState<string | null>(null)
const dialogs = useDialogs()
const { showAIChat, toggleAIChat } = dialogs
const [showFeedback, setShowFeedback] = useState(false)
const openFeedback = useCallback(() => setShowFeedback(true), [])
const closeFeedback = useCallback(() => setShowFeedback(false), [])
useEffect(() => {
const handleOpenAiChat = () => {
if (!showAIChat) toggleAIChat()
}
window.addEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
return () => window.removeEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
}, [showAIChat, toggleAIChat])
// onSwitch closure captures `notes` declared below — safe because it's only
// called on user interaction, never during render (refs inside the hook
// guarantee the latest closure is always used).
@@ -729,7 +740,13 @@ function App() {
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
<CommandPalette
open={dialogs.showCommandPalette}
commands={commands}
entries={vault.entries}
claudeCodeReady={claudeCodeStatus === 'installed'}
onClose={dialogs.closeCommandPalette}
/>
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />

View File

@@ -2,16 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { AiPanel } from './AiPanel'
import type { VaultEntry } from '../types'
import { queueAiPrompt } from '../utils/aiPromptBridge'
// Mock the hooks and utils to isolate component tests
let mockMessages: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['messages'] = []
let mockStatus: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['status'] = 'idle'
const mockSendMessage = vi.fn()
const mockClearConversation = vi.fn()
vi.mock('../hooks/useAiAgent', () => ({
useAiAgent: () => ({
messages: mockMessages,
status: mockStatus,
sendMessage: vi.fn(),
clearConversation: vi.fn(),
sendMessage: mockSendMessage,
clearConversation: mockClearConversation,
}),
}))
@@ -48,6 +52,8 @@ describe('AiPanel', () => {
beforeEach(() => {
mockMessages = []
mockStatus = 'idle'
mockSendMessage.mockReset()
mockClearConversation.mockReset()
})
it('renders panel with AI Chat header', () => {
@@ -114,7 +120,7 @@ describe('AiPanel', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
const input = screen.getByTestId('agent-input')
expect(input).toBeTruthy()
expect((input as HTMLInputElement).disabled).toBe(false)
expect(input).toHaveAttribute('contenteditable', 'true')
})
it('has send button disabled when input is empty', () => {
@@ -128,14 +134,14 @@ describe('AiPanel', () => {
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
)
const input = screen.getByTestId('agent-input') as HTMLInputElement
expect(input.placeholder).toBe('Ask about this note...')
const input = screen.getByTestId('agent-input')
expect(input).toHaveAttribute('aria-placeholder', 'Ask about this note...')
})
it('shows generic placeholder when no active entry', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
const input = screen.getByTestId('agent-input') as HTMLInputElement
expect(input.placeholder).toBe('Ask the AI agent...')
const input = screen.getByTestId('agent-input')
expect(input).toHaveAttribute('aria-placeholder', 'Ask the AI agent...')
})
it('auto-focuses input on mount', async () => {
@@ -203,4 +209,20 @@ describe('AiPanel', () => {
fireEvent.click(wikilinks[1])
expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara')
})
it('auto-sends a queued prompt from the command palette bridge', async () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" entries={[makeEntry({ path: '/vault/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project' })]} />)
await act(async () => {
queueAiPrompt('summarize [[alpha]]', [
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
])
})
expect(mockClearConversation).toHaveBeenCalledOnce()
expect(mockSendMessage).toHaveBeenCalledWith('summarize [[alpha]]', [
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
])
expect(screen.getByTestId('agent-send')).toBeDisabled()
})
})

View File

@@ -1,10 +1,14 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useEffect, useRef } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { WikilinkChatInput } from './WikilinkChatInput'
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
import { type AiAgentMessage } from '../hooks/useAiAgent'
import { type NoteReference, type NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
import { useAiPanelController } from './useAiPanelController'
import { useAiPanelPromptQueue } from './useAiPanelPromptQueue'
import { useAiPanelFocus } from './useAiPanelFocus'
export type { AiAgentMessage } from '../hooks/useAiAgent'
@@ -111,75 +115,89 @@ function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, ha
)
}
function AiPanelComposer({
entries,
hasContext,
input,
inputRef,
isActive,
onChange,
onSend,
}: {
entries: VaultEntry[]
hasContext: boolean
input: string
inputRef: React.RefObject<HTMLDivElement | null>
isActive: boolean
onChange: (value: string) => void
onSend: (text: string, references: NoteReference[]) => void
}) {
return (
<div
className="flex shrink-0 flex-col border-t border-border"
style={{ padding: '8px 12px' }}
>
<div className="flex items-end gap-2">
<div className="flex-1">
<WikilinkChatInput
entries={entries}
value={input}
onChange={onChange}
onSend={onSend}
disabled={isActive}
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
inputRef={inputRef}
/>
</div>
<button
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={{
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
borderRadius: 8, width: 32, height: 34,
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
}}
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
disabled={isActive || !input.trim()}
title="Send message"
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</div>
</div>
)
}
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, openTabs, noteList, noteListFilter }: AiPanelProps) {
const [input, setInput] = useState('')
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const inputRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLElement>(null)
const linkedEntries = useMemo(() => {
if (!activeEntry || !entries) return []
return collectLinkedEntries(activeEntry, entries)
}, [activeEntry, entries])
const contextPrompt = useMemo(() => {
if (!activeEntry || !entries) return undefined
return buildContextSnapshot({
activeEntry,
activeNoteContent: activeNoteContent ?? undefined,
openTabs,
noteList,
noteListFilter,
entries,
references: pendingRefs.length > 0 ? pendingRefs : undefined,
})
}, [activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
const {
agent,
input,
setInput,
linkedEntries,
hasContext,
isActive,
handleSend,
handleNavigateWikilink,
} = useAiPanelController({
vaultPath,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
onOpenNote,
onFileCreated,
onFileModified,
onVaultChanged,
}), [onFileCreated, onFileModified, onVaultChanged])
})
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
const hasContext = !!activeEntry
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
useEffect(() => {
const timer = setTimeout(() => inputRef.current?.focus(), 0)
return () => clearTimeout(timer)
}, [])
useEffect(() => {
if (isActive) {
panelRef.current?.focus()
} else {
inputRef.current?.focus()
}
}, [isActive])
const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape' && panelRef.current?.contains(document.activeElement)) {
e.preventDefault()
onClose()
}
}, [onClose])
useEffect(() => {
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [handleEscape])
const handleNavigateWikilink = useCallback((target: string) => {
onOpenNote?.(target)
}, [onOpenNote])
const handleSend = useCallback((text: string, references: NoteReference[]) => {
if (!text.trim() || isActive) return
setPendingRefs(references)
agent.sendMessage(text, references)
setInput('')
}, [isActive, agent])
useAiPanelPromptQueue({ agent, input, isActive, setInput })
useAiPanelFocus({ inputRef, panelRef, isActive, onClose })
return (
<aside
@@ -208,39 +226,15 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on
onNavigateWikilink={handleNavigateWikilink}
hasContext={hasContext}
/>
<div
className="flex shrink-0 flex-col border-t border-border"
style={{ padding: '8px 12px' }}
>
<div className="flex items-end gap-2">
<div className="flex-1">
<WikilinkChatInput
entries={entries ?? []}
value={input}
onChange={setInput}
onSend={handleSend}
disabled={isActive}
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
inputRef={inputRef}
/>
</div>
<button
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={{
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
borderRadius: 8, width: 32, height: 34,
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
}}
onClick={() => handleSend(input, pendingRefs)}
disabled={isActive || !input.trim()}
title="Send message"
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</div>
</div>
<AiPanelComposer
entries={entries ?? []}
hasContext={hasContext}
input={input}
inputRef={inputRef}
isActive={isActive}
onChange={setInput}
onSend={handleSend}
/>
</aside>
)
}

View File

@@ -1,11 +1,18 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import type { VaultEntry } from '../types'
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
import { CommandPalette } from './CommandPalette'
import type { CommandAction } from '../hooks/useCommandRegistry'
// jsdom doesn't implement scrollIntoView
Element.prototype.scrollIntoView = vi.fn()
vi.mock('../utils/aiPromptBridge', () => ({
queueAiPrompt: vi.fn(),
requestOpenAiChat: vi.fn(),
}))
const makeCommand = (overrides: Partial<CommandAction> = {}): CommandAction => ({
id: 'test-cmd',
label: 'Test Command',
@@ -25,6 +32,59 @@ const commands: CommandAction[] = [
makeCommand({ id: 'disabled-cmd', label: 'Disabled Command', group: 'Note', enabled: false }),
]
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
title: 'Test Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
...overrides,
})
const entries: VaultEntry[] = [
makeEntry({ path: '/vault/alpha.md', filename: 'alpha.md', title: 'Alpha', isA: 'Project' }),
]
function setSelection(editor: HTMLElement, offset: number) {
const selection = window.getSelection()
if (!selection) return
const targetNode = editor.firstChild ?? editor
const safeOffset = targetNode.nodeType === Node.TEXT_NODE
? Math.min(offset, targetNode.textContent?.length ?? 0)
: Math.min(offset, targetNode.childNodes.length)
const range = document.createRange()
range.setStart(targetNode, safeOffset)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}
function updateAiInput(text: string) {
const editor = screen.getByTestId('command-palette-ai-input')
editor.textContent = text
setSelection(editor, text.length)
fireEvent.input(editor)
return editor
}
describe('CommandPalette', () => {
const onClose = vi.fn()
@@ -166,6 +226,54 @@ describe('CommandPalette', () => {
expect(screen.getByText('esc close')).toBeInTheDocument()
})
it('switches into AI mode when the query starts with a leading space', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
expect(screen.getByTestId('command-palette-ai-input')).toBeInTheDocument()
expect(screen.getAllByText('Ask Claude Code').length).toBeGreaterThan(0)
expect(screen.queryByText('Search Notes')).not.toBeInTheDocument()
})
it('returns to command mode when the leading space is deleted', () => {
render(
<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />,
)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
updateAiInput('new')
const input = screen.getByPlaceholderText('Type a command...') as HTMLInputElement
expect(screen.queryByTestId('command-palette-ai-input')).toBeNull()
expect(input.value).toBe('new')
expect(screen.getByText('New Note')).toBeInTheDocument()
})
it('queues a stripped AI prompt and closes on Enter in AI mode', () => {
render(
<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />,
)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
const editor = updateAiInput(' hello world')
fireEvent.keyDown(editor, { key: 'Enter' })
expect(queueAiPrompt).toHaveBeenCalledWith('hello world', [])
expect(requestOpenAiChat).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
it('closes without queueing when AI mode only contains the trigger space', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: ' ' } })
fireEvent.keyDown(screen.getByTestId('command-palette-ai-input'), { key: 'Enter' })
expect(queueAiPrompt).not.toHaveBeenCalled()
expect(requestOpenAiChat).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalledOnce()
})
describe('relevance ranking', () => {
const relevanceCommands: CommandAction[] = [
makeCommand({ id: 'create-note', label: 'New Note', group: 'Note' }),

View File

@@ -1,12 +1,18 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import type { VaultEntry } from '../types'
import { fuzzyMatch } from '../utils/fuzzyMatch'
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
import type { NoteReference } from '../utils/ai-context'
import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry'
import { groupSortKey } from '../hooks/useCommandRegistry'
import { CommandPaletteAiMode } from './CommandPaletteAiMode'
interface CommandPaletteProps {
open: boolean
commands: CommandAction[]
entries?: VaultEntry[]
claudeCodeReady?: boolean
onClose: () => void
}
@@ -15,17 +21,17 @@ interface ScoredCommand {
score: number
}
function matchCommand(query: string, cmd: CommandAction): ScoredCommand | null {
const labelResult = fuzzyMatch(query, cmd.label)
if (labelResult.match) return { command: cmd, score: labelResult.score }
function matchCommand(query: string, command: CommandAction): ScoredCommand | null {
const labelResult = fuzzyMatch(query, command.label)
if (labelResult.match) return { command, score: labelResult.score }
for (const kw of cmd.keywords ?? []) {
const kwResult = fuzzyMatch(query, kw)
if (kwResult.match) return { command: cmd, score: kwResult.score - 1 }
for (const keyword of command.keywords ?? []) {
const keywordResult = fuzzyMatch(query, keyword)
if (keywordResult.match) return { command, score: keywordResult.score - 1 }
}
const groupResult = fuzzyMatch(query, cmd.group)
if (groupResult.match) return { command: cmd, score: groupResult.score - 2 }
const groupResult = fuzzyMatch(query, command.group)
if (groupResult.match) return { command, score: groupResult.score - 2 }
return null
}
@@ -34,83 +40,262 @@ function groupResults(
commands: CommandAction[],
byRelevance: boolean,
): { group: CommandGroup; items: CommandAction[] }[] {
const map = new Map<CommandGroup, CommandAction[]>()
for (const cmd of commands) {
const list = map.get(cmd.group)
if (list) list.push(cmd)
else map.set(cmd.group, [cmd])
const groupedCommands = new Map<CommandGroup, CommandAction[]>()
for (const command of commands) {
const existing = groupedCommands.get(command.group)
if (existing) {
existing.push(command)
continue
}
groupedCommands.set(command.group, [command])
}
const entries = Array.from(map.entries())
const entries = Array.from(groupedCommands.entries())
if (!byRelevance) {
entries.sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0]))
entries.sort((left, right) => groupSortKey(left[0]) - groupSortKey(right[0]))
}
return entries.map(([group, items]) => ({ group, items }))
}
export function CommandPalette({ open, commands, onClose }: CommandPaletteProps) {
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (open) {
setQuery('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on open
setSelectedIndex(0)
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open])
function usePaletteResults(commands: CommandAction[], query: string) {
const enabledCommands = useMemo(
() => commands.filter(c => c.enabled),
() => commands.filter((command) => command.enabled),
[commands],
)
const filtered = useMemo(() => {
const filteredCommands = useMemo(() => {
if (!query.trim()) return enabledCommands
return enabledCommands
.map(cmd => matchCommand(query, cmd))
.filter((r): r is ScoredCommand => r !== null)
.sort((a, b) => b.score - a.score)
.map(r => r.command)
.map((command) => matchCommand(query, command))
.filter((result): result is ScoredCommand => result !== null)
.sort((left, right) => right.score - left.score)
.map((result) => result.command)
}, [enabledCommands, query])
const hasQuery = !!query.trim()
const groups = useMemo(() => groupResults(filtered, hasQuery), [filtered, hasQuery])
const flatList = useMemo(() => groups.flatMap(g => g.items), [groups])
const hasQuery = query.trim().length > 0
const groups = useMemo(
() => groupResults(filteredCommands, hasQuery),
[filteredCommands, hasQuery],
)
return {
groups,
flatList: groups.flatMap((group) => group.items),
}
}
function CommandPaletteInput({
inputRef,
query,
onChange,
}: {
inputRef: React.RefObject<HTMLInputElement | null>
query: string
onChange: (value: string) => void
}) {
return (
<input
ref={inputRef}
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
type="text"
placeholder="Type a command..."
value={query}
onChange={(event) => onChange(event.target.value)}
/>
)
}
function CommandPaletteResults({
groups,
selectedIndex,
listRef,
onHover,
onSelect,
}: {
groups: { group: CommandGroup; items: CommandAction[] }[]
selectedIndex: number
listRef: React.RefObject<HTMLDivElement | null>
onHover: (index: number) => void
onSelect: (command: CommandAction) => void
}) {
const flatList = groups.flatMap((group) => group.items)
if (flatList.length === 0) {
return (
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
No matching commands
</div>
</div>
)
}
const sections = groups.reduce<Array<{ group: CommandGroup; items: CommandAction[]; startIndex: number }>>(
(acc, group) => {
const previous = acc.at(-1)
acc.push({
...group,
startIndex: previous ? previous.startIndex + previous.items.length : 0,
})
return acc
},
[],
)
return (
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
{sections.map(({ group, items, startIndex }) => {
return (
<div key={group}>
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
{group}
</div>
{items.map((command, index) => {
const globalIndex = startIndex + index
return (
<CommandRow
key={command.id}
command={command}
selected={globalIndex === selectedIndex}
onHover={() => onHover(globalIndex)}
onSelect={() => onSelect(command)}
/>
)
})}
</div>
)
})}
</div>
)
}
function CommandPaletteFooter({
aiMode,
}: {
aiMode: boolean
}) {
return (
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
<span>{aiMode ? 'Claude mode' : '↑↓ navigate'}</span>
<span>{aiMode ? '↵ send' : '↵ select'}</span>
<span>esc close</span>
</div>
)
}
export function CommandPalette({ open, ...props }: CommandPaletteProps) {
if (!open) return null
return <OpenCommandPalette {...props} />
}
function OpenCommandPalette({
commands,
entries = [],
claudeCodeReady = true,
onClose,
}: Omit<CommandPaletteProps, 'open'>) {
const [query, setQuery] = useState('')
const [aiValue, setAiValue] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const aiInputRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const aiMode = aiValue.startsWith(' ')
const { groups, flatList } = usePaletteResults(commands, query)
useEffect(() => {
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change
}, [query])
const focusTimer = window.setTimeout(() => {
if (aiMode) {
aiInputRef.current?.focus()
return
}
inputRef.current?.focus()
}, 50)
return () => window.clearTimeout(focusTimer)
}, [aiMode])
useEffect(() => {
if (!listRef.current) return
const el = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | undefined
el?.scrollIntoView({ block: 'nearest' })
}, [selectedIndex])
if (aiMode || !listRef.current) return
const selectedElement = listRef.current.querySelector('[data-selected="true"]') as HTMLElement | null
selectedElement?.scrollIntoView({ block: 'nearest' })
}, [aiMode, selectedIndex])
useEffect(() => {
if (!open) return
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault(); onClose()
} else if (e.key === 'ArrowDown') {
e.preventDefault(); setSelectedIndex(i => Math.min(i + 1, flatList.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
const cmd = flatList[selectedIndex]
if (cmd) { onClose(); cmd.execute() }
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
if (aiMode) return
if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex((current) => Math.min(current + 1, flatList.length - 1))
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSelectedIndex((current) => Math.max(current - 1, 0))
return
}
if (event.key === 'Enter') {
event.preventDefault()
const command = flatList[selectedIndex]
if (!command) return
onClose()
command.execute()
}
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [open, flatList, selectedIndex, onClose])
if (!open) return null
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [aiMode, flatList, onClose, selectedIndex])
let runningIndex = 0
const handleQueryChange = (nextQuery: string) => {
setSelectedIndex(0)
if (nextQuery.startsWith(' ')) {
setAiValue(nextQuery)
setQuery('')
return
}
setQuery(nextQuery)
}
const handleAiValueChange = (nextValue: string) => {
setSelectedIndex(0)
if (nextValue.startsWith(' ')) {
setAiValue(nextValue)
return
}
setAiValue('')
setQuery(nextValue)
}
const handleSelectCommand = (command: CommandAction) => {
onClose()
command.execute()
}
const handleSubmitAiPrompt = (text: string, references: NoteReference[]) => {
if (!text.trim()) {
onClose()
return
}
if (!claudeCodeReady) return
queueAiPrompt(text, references)
requestOpenAiChat()
onClose()
}
return (
<div
@@ -118,53 +303,33 @@ export function CommandPalette({ open, commands, onClose }: CommandPaletteProps)
onClick={onClose}
>
<div
className="flex w-[520px] max-w-[90vw] max-h-[440px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
onClick={e => e.stopPropagation()}
className={cn(
'flex w-[520px] max-h-[440px] max-w-[90vw] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]',
aiMode && 'min-h-[220px]',
)}
onClick={(event) => event.stopPropagation()}
>
<input
ref={inputRef}
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
type="text"
placeholder="Type a command..."
value={query}
onChange={e => setQuery(e.target.value)}
/>
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
{flatList.length === 0 ? (
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
No matching commands
</div>
) : (
groups.map(({ group, items }) => {
const startIndex = runningIndex
runningIndex += items.length
return (
<div key={group}>
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
{group}
</div>
{items.map((cmd, i) => {
const globalIdx = startIndex + i
return (
<CommandRow
key={cmd.id}
command={cmd}
selected={globalIdx === selectedIndex}
onHover={() => setSelectedIndex(globalIdx)}
onSelect={() => { onClose(); cmd.execute() }}
/>
)
})}
</div>
)
})
)}
</div>
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
<span> navigate</span>
<span> select</span>
<span>esc close</span>
</div>
{aiMode ? (
<CommandPaletteAiMode
entries={entries}
value={aiValue}
claudeCodeReady={claudeCodeReady}
onChange={handleAiValueChange}
onSubmit={handleSubmitAiPrompt}
/>
) : (
<>
<CommandPaletteInput inputRef={inputRef} query={query} onChange={handleQueryChange} />
<CommandPaletteResults
groups={groups}
selectedIndex={selectedIndex}
listRef={listRef}
onHover={setSelectedIndex}
onSelect={handleSelectCommand}
/>
<CommandPaletteFooter aiMode={false} />
</>
)}
</div>
</div>
)

View File

@@ -0,0 +1,67 @@
import { Sparkle } from '@phosphor-icons/react'
import type { VaultEntry } from '../types'
import type { NoteReference } from '../utils/ai-context'
import { InlineWikilinkInput } from './InlineWikilinkInput'
interface CommandPaletteAiModeProps {
entries: VaultEntry[]
value: string
claudeCodeReady: boolean
onChange: (value: string) => void
onSubmit: (text: string, references: NoteReference[]) => void
}
function stripLeadingSpace(value: string): string {
return value.startsWith(' ') ? value.slice(1) : value
}
export function CommandPaletteAiMode({
entries,
value,
claudeCodeReady,
onChange,
onSubmit,
}: CommandPaletteAiModeProps) {
return (
<InlineWikilinkInput
entries={entries}
value={value}
onChange={onChange}
onSubmit={(text, references) => onSubmit(stripLeadingSpace(text), references)}
submitOnEmpty={true}
placeholder="Ask Claude Code..."
dataTestId="command-palette-ai-input"
editorClassName="border-none px-0 py-0 text-[15px]"
suggestionListVariant="palette"
paletteHeader={(
<div className="mb-2 flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
<Sparkle size={12} weight="fill" />
<span>Ask Claude Code</span>
</div>
)}
paletteEmptyState={(
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
{!claudeCodeReady ? (
'Claude Code is not available on this machine.'
) : (
<>
<div className="mb-1 font-medium text-foreground">Ask Claude Code</div>
<div>
{value.trim().length === 0
? 'Type your prompt after the leading space.'
: 'Type [[ to insert a note reference inline.'}
</div>
</>
)}
</div>
)}
paletteFooter={(
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
<span>Claude mode</span>
<span> send</span>
<span>esc close</span>
</div>
)}
/>
)
}

View File

@@ -0,0 +1,198 @@
import {
useMemo,
type ReactNode,
} from 'react'
import type { VaultEntry } from '../types'
import type { NoteReference } from '../utils/ai-context'
import { buildTypeEntryMap } from '../utils/typeColors'
import {
buildInlineWikilinkSegments,
extractInlineWikilinkReferences,
findActiveWikilinkQuery,
findInlineChipDeletionRange,
} from './inlineWikilinkText'
import {
InlineWikilinkEditorField,
InlineWikilinkPaletteLayout,
InlineWikilinkSuggestionList,
} from './InlineWikilinkParts'
import { handleInlineWikilinkKeyDown } from './inlineWikilinkKeydown'
import { useInlineWikilinkSelection } from './useInlineWikilinkSelection'
import { useInlineWikilinkSuggestionsState } from './useInlineWikilinkSuggestionsState'
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
interface InlineWikilinkInputProps {
entries: VaultEntry[]
value: string
onChange: (value: string) => void
onSubmit?: (text: string, references: NoteReference[]) => void
submitOnEmpty?: boolean
disabled?: boolean
placeholder?: string
inputRef?: React.RefObject<HTMLDivElement | null>
dataTestId?: string
editorClassName?: string
suggestionListVariant?: 'floating' | 'palette'
suggestionEmptyLabel?: string
paletteHeader?: ReactNode
paletteEmptyState?: ReactNode
paletteFooter?: ReactNode
}
function deleteInlineChip({
direction,
segments,
selectionIndex,
value,
onChange,
onSelectionIndexChange,
}: {
direction: 'backward' | 'forward'
segments: ReturnType<typeof buildInlineWikilinkSegments>
selectionIndex: number
value: string
onChange: (value: string) => void
onSelectionIndexChange: (selectionIndex: number) => void
}) {
const deletionRange = findInlineChipDeletionRange(segments, selectionIndex, direction)
if (!deletionRange) return false
onChange(value.slice(0, deletionRange.start) + value.slice(deletionRange.end))
onSelectionIndexChange(deletionRange.start)
return true
}
function submitInlineValue({
onSubmit,
submitOnEmpty,
value,
references,
}: {
onSubmit?: (text: string, references: NoteReference[]) => void
submitOnEmpty: boolean
value: string
references: NoteReference[]
}) {
if (!onSubmit) return
const normalizedValue = normalizeInlineWikilinkValue(value)
if (!submitOnEmpty && !normalizedValue.trim()) return
onSubmit(normalizedValue, references)
}
export function InlineWikilinkInput({
entries,
value,
onChange,
onSubmit,
submitOnEmpty = false,
disabled = false,
placeholder,
inputRef,
dataTestId = 'agent-input',
editorClassName,
suggestionListVariant = 'floating',
suggestionEmptyLabel = 'No matching notes',
paletteHeader,
paletteEmptyState,
paletteFooter,
}: InlineWikilinkInputProps) {
const segments = useMemo(
() => buildInlineWikilinkSegments(value, entries),
[entries, value],
)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const {
selectionIndex,
setSelectionIndex,
setCombinedRef,
syncSelectionIndex,
commitValueFromEditor,
focusSelectionAt,
} = useInlineWikilinkSelection({
value,
onChange,
inputRef,
})
const activeQuery = useMemo(
() => findActiveWikilinkQuery(value, selectionIndex),
[selectionIndex, value],
)
const references = useMemo(() => extractInlineWikilinkReferences(value, entries), [entries, value])
const {
suggestions,
selectedSuggestionIndex,
setSuggestionIndex,
selectSuggestion,
cycleSuggestions,
} = useInlineWikilinkSuggestionsState({
activeQueryKey: activeQuery ? `${activeQuery.start}:${activeQuery.query}` : '',
entries,
query: activeQuery?.query ?? null,
value,
selectionIndex,
onChange,
onSelectionIndexChange: setSelectionIndex,
focusSelectionAt,
})
const deleteAdjacentChip = (direction: 'backward' | 'forward') => {
return deleteInlineChip({
direction,
segments,
selectionIndex,
value,
onChange,
onSelectionIndexChange: setSelectionIndex,
})
}
const submitValue = () =>
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) =>
handleInlineWikilinkKeyDown({
event,
disabled,
suggestionsOpen: suggestions.length > 0,
onCycleSuggestions: cycleSuggestions,
onSelectSuggestion: () => selectSuggestion(selectedSuggestionIndex),
onDeleteAdjacentChip: deleteAdjacentChip,
canSubmit: onSubmit !== undefined,
onSubmit: submitValue,
})
const editor = (
<InlineWikilinkEditorField
value={value}
placeholder={placeholder}
disabled={disabled}
inputRef={setCombinedRef}
dataTestId={dataTestId}
editorClassName={editorClassName}
onInput={commitValueFromEditor}
onKeyDown={handleKeyDown}
onSelectionChange={syncSelectionIndex}
segments={segments}
typeEntryMap={typeEntryMap}
/>
)
const suggestionList = suggestions.length > 0 ? (
<InlineWikilinkSuggestionList
suggestions={suggestions}
selectedIndex={selectedSuggestionIndex}
onHover={setSuggestionIndex}
onSelect={selectSuggestion}
typeEntryMap={typeEntryMap}
variant={suggestionListVariant}
emptyLabel={suggestionEmptyLabel}
/>
) : null
if (suggestionListVariant === 'palette') {
return (
<InlineWikilinkPaletteLayout
header={paletteHeader}
editor={editor}
suggestionList={suggestionList}
emptyState={paletteEmptyState}
footer={paletteFooter}
/>
)
}
return <div className="relative">{editor}{suggestionList}</div>
}

View File

@@ -0,0 +1,251 @@
import { createElement } from 'react'
import type { VaultEntry } from '../types'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { NoteTitleIcon } from './NoteTitleIcon'
import { getTypeIcon } from './note-item/typeIcon'
import type {
InlineWikilinkChip,
InlineWikilinkSegment,
} from './inlineWikilinkText'
import type { InlineWikilinkSuggestion } from './inlineWikilinkSuggestions'
import { cn } from '@/lib/utils'
export function InlineWikilinkChipView({
chip,
typeEntryMap,
}: {
chip: InlineWikilinkChip
typeEntryMap: Record<string, VaultEntry>
}) {
const typeEntry = chip.entry.isA ? typeEntryMap[chip.entry.isA] : undefined
const color = getTypeColor(chip.entry.isA, typeEntry?.color)
const backgroundColor = getTypeLightColor(chip.entry.isA, typeEntry?.color)
const typeIcon = getTypeIcon(chip.entry.isA, typeEntry?.icon)
return (
<span
contentEditable={false}
data-chip-target={chip.target}
data-testid="inline-wikilink-chip"
className="mx-[1px] inline-flex max-w-full items-center gap-1 rounded-full align-baseline"
style={{
backgroundColor,
color,
padding: '1px 8px 1px 6px',
fontSize: 12,
fontWeight: 500,
lineHeight: 1.5,
}}
>
{chip.entry.icon ? (
<NoteTitleIcon icon={chip.entry.icon} size={11} color={color} />
) : (
createElement(typeIcon, {
'aria-hidden': true,
width: 11,
height: 11,
className: 'shrink-0',
})
)}
<span className="truncate">{chip.entry.title}</span>
</span>
)
}
function InlineSuggestionRow({
suggestion,
selected,
onHover,
onSelect,
typeEntryMap,
}: {
suggestion: InlineWikilinkSuggestion
selected: boolean
onHover: () => void
onSelect: () => void
typeEntryMap: Record<string, VaultEntry>
}) {
const typeEntry = suggestion.entry.isA ? typeEntryMap[suggestion.entry.isA] : undefined
const color = getTypeColor(suggestion.entry.isA, typeEntry?.color)
const backgroundColor = getTypeLightColor(suggestion.entry.isA, typeEntry?.color)
const typeIcon = getTypeIcon(suggestion.entry.isA, typeEntry?.icon)
return (
<div
className={cn(
'mx-1 flex cursor-pointer items-center justify-between rounded-md px-3 py-2 transition-colors',
selected ? 'bg-accent' : 'hover:bg-secondary',
)}
onMouseDown={(event) => event.preventDefault()}
onClick={onSelect}
onMouseEnter={onHover}
>
<div className="flex min-w-0 items-center gap-2">
<span
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor, color }}
>
{suggestion.entry.icon ? (
<NoteTitleIcon icon={suggestion.entry.icon} size={11} color={color} />
) : (
createElement(typeIcon, {
'aria-hidden': true,
width: 11,
height: 11,
className: 'shrink-0',
})
)}
</span>
<span className="truncate text-sm text-foreground">{suggestion.title}</span>
</div>
<span className="ml-3 shrink-0 text-[11px] text-muted-foreground">
{suggestion.entry.isA ?? 'Note'}
</span>
</div>
)
}
export function InlineWikilinkSuggestionList({
suggestions,
selectedIndex,
onHover,
onSelect,
typeEntryMap,
variant = 'floating',
emptyLabel = 'No matching notes',
}: {
suggestions: InlineWikilinkSuggestion[]
selectedIndex: number
onHover: (index: number) => void
onSelect: (index: number) => void
typeEntryMap: Record<string, VaultEntry>
variant?: 'floating' | 'palette'
emptyLabel?: string
}) {
if (suggestions.length === 0) {
return (
<div className="px-4 py-5 text-center text-[13px] text-muted-foreground">
{emptyLabel}
</div>
)
}
return (
<div
className={variant === 'floating'
? 'absolute bottom-full left-0 right-0 z-10 mb-1 max-h-64 overflow-y-auto rounded-lg border border-border bg-popover py-1 shadow-lg'
: 'py-1'}
data-testid="wikilink-menu"
>
{suggestions.map((suggestion, index) => (
<InlineSuggestionRow
key={`${suggestion.entry.path}:${suggestion.target}`}
suggestion={suggestion}
selected={index === selectedIndex}
onHover={() => onHover(index)}
onSelect={() => onSelect(index)}
typeEntryMap={typeEntryMap}
/>
))}
</div>
)
}
export function InlineWikilinkEditorField({
value,
placeholder,
disabled,
inputRef,
dataTestId,
editorClassName,
onInput,
onKeyDown,
onSelectionChange,
segments,
typeEntryMap,
}: {
value: string
placeholder?: string
disabled: boolean
inputRef: React.Ref<HTMLDivElement>
dataTestId: string
editorClassName?: string
onInput: () => void
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onSelectionChange: () => void
segments: InlineWikilinkSegment[]
typeEntryMap: Record<string, VaultEntry>
}) {
return (
<div className="relative">
{value.length === 0 && placeholder && (
<div
className="pointer-events-none absolute inset-0 flex items-center text-muted-foreground"
style={{ padding: '8px 10px', fontSize: 13 }}
>
{placeholder}
</div>
)}
<div
ref={inputRef}
contentEditable={!disabled}
suppressContentEditableWarning={true}
role="textbox"
aria-multiline="false"
aria-disabled={disabled || undefined}
aria-placeholder={placeholder}
data-testid={dataTestId}
className={cn(
'min-h-[34px] w-full rounded-lg border border-border bg-transparent px-[10px] py-[8px] text-[13px] text-foreground outline-none',
disabled && 'cursor-not-allowed opacity-60',
editorClassName,
)}
onInput={onInput}
onKeyDown={onKeyDown}
onClick={onSelectionChange}
onKeyUp={onSelectionChange}
onMouseUp={onSelectionChange}
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
>
{segments.map((segment, index) => (
segment.kind === 'text'
? <span key={`text-${index}`}>{segment.text}</span>
: (
<InlineWikilinkChipView
key={`chip-${segment.chip.entry.path}-${segment.chip.target}`}
chip={segment.chip}
typeEntryMap={typeEntryMap}
/>
)
))}
</div>
</div>
)
}
export function InlineWikilinkPaletteLayout({
header,
editor,
suggestionList,
emptyState,
footer,
}: {
header?: React.ReactNode
editor: React.ReactNode
suggestionList: React.ReactNode
emptyState?: React.ReactNode
footer?: React.ReactNode
}) {
return (
<>
<div className="border-b border-border px-4 py-3">
{header}
{editor}
</div>
<div className="flex-1 overflow-y-auto py-1">
{suggestionList ?? emptyState}
</div>
{footer}
</>
)
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { render, screen, fireEvent } from '@testing-library/react'
import { WikilinkChatInput } from './WikilinkChatInput'
import type { VaultEntry } from '../types'
@@ -31,249 +31,154 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
const entries: VaultEntry[] = [
makeEntry({ path: '/vault/alpha.md', title: 'Alpha', filename: 'alpha.md', isA: 'Project' }),
makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person' }),
makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person', aliases: ['BLT'] }),
makeEntry({ path: '/vault/gamma.md', title: 'Gamma', filename: 'gamma.md' }),
makeEntry({ path: '/vault/archived.md', title: 'Archived', filename: 'archived.md', archived: true }),
]
/** Wrapper that manages controlled value state so onChange/selectSuggestion work correctly. */
function Controlled({ onSend, ...props }: {
entries: VaultEntry[]
function Controlled({
onSend,
disabled = false,
placeholder,
}: {
onSend?: (text: string, refs: Array<{ title: string; path: string; type: string | null }>) => void
disabled?: boolean
placeholder?: string
}) {
const [value, setValue] = useState('')
return (
<WikilinkChatInput
entries={props.entries}
entries={entries}
value={value}
onChange={setValue}
onSend={onSend ?? vi.fn()}
disabled={props.disabled}
placeholder={props.placeholder}
disabled={disabled}
placeholder={placeholder}
/>
)
}
/** Helper: type in the input and wait for debounce to flush. */
async function typeAndWait(text: string) {
fireEvent.change(screen.getByTestId('agent-input'), {
target: { value: text, selectionStart: text.length },
})
await act(() => { vi.advanceTimersByTime(150) })
function setSelection(editor: HTMLElement, offset: number) {
const selection = window.getSelection()
if (!selection) return
const targetNode = editor.firstChild ?? editor
const safeOffset = targetNode.nodeType === Node.TEXT_NODE
? Math.min(offset, targetNode.textContent?.length ?? 0)
: Math.min(offset, targetNode.childNodes.length)
const range = document.createRange()
range.setStart(targetNode, safeOffset)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}
function updateEditorText(text: string) {
const editor = screen.getByTestId('agent-input')
fireEvent.focus(editor)
editor.textContent = text
setSelection(editor, text.length)
fireEvent.input(editor)
}
function clickFirstSuggestion() {
const rows = screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')
expect(rows.length).toBeGreaterThan(0)
fireEvent.click(rows[0])
}
describe('WikilinkChatInput', () => {
it('renders input with placeholder', () => {
render(<Controlled entries={entries} placeholder="Ask something..." />)
const input = screen.getByTestId('agent-input') as HTMLInputElement
expect(input.placeholder).toBe('Ask something...')
it('renders the placeholder overlay for an empty draft', () => {
render(<Controlled placeholder="Ask something..." />)
expect(screen.getByText('Ask something...')).toBeInTheDocument()
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Ask something...')
})
it('calls onChange when typing', () => {
it('calls onChange when the draft changes', () => {
const onChange = vi.fn()
render(
<WikilinkChatInput entries={entries} value="" onChange={onChange} onSend={vi.fn()} />,
)
fireEvent.change(screen.getByTestId('agent-input'), { target: { value: 'hello' } })
updateEditorText('hello')
expect(onChange).toHaveBeenCalledWith('hello')
})
it('shows wikilink menu when [[ is typed', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[a')
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
vi.useRealTimers()
})
it('shows wikilink suggestions after typing [[', () => {
render(<Controlled />)
updateEditorText('[[a')
it('filters suggestions matching query', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
const menu = screen.getByTestId('wikilink-menu')
expect(menu.textContent).toContain('Alpha')
expect(menu.textContent).not.toContain('Beta')
vi.useRealTimers()
})
it('excludes archived entries from suggestions', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[arch')
const menu = screen.queryByTestId('wikilink-menu')
if (menu) {
expect(menu.textContent).not.toContain('Archived')
}
vi.useRealTimers()
it('matches suggestions by alias', () => {
render(<Controlled />)
updateEditorText('[[BLT')
expect(screen.getByTestId('wikilink-menu').textContent).toContain('Beta')
})
it('selects suggestion on click and creates pill', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[a')
it('renders selected wikilinks inline instead of in a separate pill strip', () => {
render(<Controlled />)
updateEditorText('edit my [[alp')
clickFirstSuggestion()
const menu = screen.getByTestId('wikilink-menu')
const items = menu.querySelectorAll('[class*="cursor-pointer"]')
expect(items.length).toBeGreaterThan(0)
fireEvent.click(items[0])
const pills = screen.queryAllByTestId('reference-pill')
expect(pills.length).toBe(1)
vi.useRealTimers()
expect(screen.queryByTestId('reference-pill')).toBeNull()
expect(screen.getByTestId('inline-wikilink-chip')).toBeInTheDocument()
expect(screen.getByTestId('agent-input').textContent).toContain('Alpha')
})
it('does not duplicate pills for same note', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
// First selection
await typeAndWait('[[alp')
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
// Second selection of same note
await typeAndWait('[[alp')
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
expect(screen.queryAllByTestId('reference-pill').length).toBe(1) // No duplicate
vi.useRealTimers()
})
it('removes pill when x button is clicked', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
const pill = screen.getByTestId('reference-pill')
fireEvent.click(pill.querySelector('button')!)
expect(screen.queryAllByTestId('reference-pill').length).toBe(0)
vi.useRealTimers()
})
it('navigates suggestions with keyboard', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[a')
const input = screen.getByTestId('agent-input')
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'ArrowUp' })
// Escape closes menu
fireEvent.keyDown(input, { key: 'Escape' })
expect(screen.queryByTestId('wikilink-menu')).toBeNull()
vi.useRealTimers()
})
it('selects suggestion with Enter key', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
it('selects a suggestion with Enter before sending the draft', () => {
const onSend = vi.fn()
render(<Controlled onSend={onSend} />)
updateEditorText('edit my [[alp')
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
vi.useRealTimers()
expect(screen.getByTestId('inline-wikilink-chip')).toBeInTheDocument()
expect(onSend).not.toHaveBeenCalled()
})
it('calls onSend with text and references on Enter without menu', async () => {
vi.useFakeTimers()
const onSend = vi.fn()
render(<Controlled entries={entries} onSend={onSend} />)
it('deletes an inline chip with a single Backspace', () => {
render(<Controlled />)
updateEditorText('edit my [[alp')
clickFirstSuggestion()
// Type non-wikilink text
fireEvent.change(screen.getByTestId('agent-input'), {
target: { value: 'hello', selectionStart: 5 },
})
const editor = screen.getByTestId('agent-input')
fireEvent.keyDown(editor, { key: 'Backspace' })
expect(screen.queryByTestId('inline-wikilink-chip')).toBeNull()
})
it('submits serialized wikilink text and resolved references', () => {
const onSend = vi.fn()
render(<Controlled onSend={onSend} />)
updateEditorText('edit my [[alpha]] essay')
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
expect(onSend).toHaveBeenCalledWith('hello', [])
vi.useRealTimers()
expect(onSend).toHaveBeenCalledWith('edit my [[alpha]] essay', [
{ title: 'Alpha', path: '/vault/alpha.md', type: 'Project' },
])
})
it('does not send on Enter+Shift', () => {
it('does not send on Shift+Enter', () => {
const onSend = vi.fn()
render(
<WikilinkChatInput entries={entries} value="hello" onChange={vi.fn()} onSend={onSend} />,
)
render(<Controlled onSend={onSend} />)
updateEditorText('hello')
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true })
expect(onSend).not.toHaveBeenCalled()
})
it('does not send on Enter when input is empty', () => {
const onSend = vi.fn()
render(
<WikilinkChatInput entries={entries} value="" onChange={vi.fn()} onSend={onSend} />,
)
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
expect(onSend).not.toHaveBeenCalled()
})
it('marks the editor disabled when disabled is true', () => {
render(<Controlled disabled />)
it('disables input when disabled prop is true', () => {
render(<Controlled entries={entries} disabled />)
expect((screen.getByTestId('agent-input') as HTMLInputElement).disabled).toBe(true)
})
it('closes menu when ]] is typed', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
// Type ]] to close
fireEvent.change(screen.getByTestId('agent-input'), {
target: { value: '[[alp]]', selectionStart: 7 },
})
expect(screen.queryByTestId('wikilink-menu')).toBeNull()
vi.useRealTimers()
})
it('shows type badge for non-Note types in suggestions', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
const menu = screen.getByTestId('wikilink-menu')
expect(menu.textContent).toContain('Project')
vi.useRealTimers()
})
it('matches by alias', async () => {
vi.useFakeTimers()
const entriesWithAlias = [
...entries,
makeEntry({ path: '/vault/delta.md', title: 'Delta', filename: 'delta.md', aliases: ['DLT'] }),
]
render(<Controlled entries={entriesWithAlias} />)
await typeAndWait('[[DLT')
const menu = screen.getByTestId('wikilink-menu')
expect(menu.textContent).toContain('Delta')
vi.useRealTimers()
})
it('sends references with onSend after selecting pills', async () => {
vi.useFakeTimers()
const onSend = vi.fn()
render(<Controlled entries={entries} onSend={onSend} />)
// Select a pill first
await typeAndWait('[[alp')
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
// Type a message and send
fireEvent.change(screen.getByTestId('agent-input'), {
target: { value: 'tell me about it', selectionStart: 16 },
})
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
expect(onSend).toHaveBeenCalledOnce()
const [text, refs] = onSend.mock.calls[0]
expect(text).toBe('tell me about it')
expect(refs).toHaveLength(1)
expect(refs[0].title).toBe('Alpha')
expect(refs[0].path).toBe('/vault/alpha.md')
vi.useRealTimers()
const editor = screen.getByTestId('agent-input')
expect(editor).toHaveAttribute('contenteditable', 'false')
expect(editor).toHaveAttribute('aria-disabled', 'true')
})
})

View File

@@ -1,18 +1,6 @@
/**
* Chat input with [[wikilink]] autocomplete.
*
* When the user types `[[`, a dropdown appears with vault note suggestions.
* Selecting a note inserts a colored pill in the input and records a reference.
*/
import { useState, useRef, useMemo, useEffect } from 'react'
import type { VaultEntry } from '../types'
import type { NoteReference } from '../utils/ai-context'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { bestSearchRank } from '../utils/fuzzyMatch'
const MAX_SUGGESTIONS = 20
const MIN_QUERY_LENGTH = 1
const DEBOUNCE_MS = 100
import { InlineWikilinkInput } from './InlineWikilinkInput'
interface WikilinkChatInputProps {
entries: VaultEntry[]
@@ -21,248 +9,27 @@ interface WikilinkChatInputProps {
onSend: (text: string, references: NoteReference[]) => void
disabled?: boolean
placeholder?: string
inputRef?: React.RefObject<HTMLInputElement | null>
}
interface Pill {
title: string
path: string
type: string | null
color?: string
lightColor?: string
}
interface SuggestionEntry {
title: string
path: string
isA: string | null
color?: string
lightColor?: string
}
function matchEntries(
entries: VaultEntry[],
query: string,
typeEntryMap: Record<string, VaultEntry>,
): SuggestionEntry[] {
if (query.length < MIN_QUERY_LENGTH) return []
const lower = query.toLowerCase()
const matches = entries
.filter(e =>
!e.archived && (
e.title.toLowerCase().includes(lower) ||
e.aliases.some(a => a.toLowerCase().includes(lower))
),
)
.map(e => ({ entry: e, rank: bestSearchRank(query, e.title, e.aliases) }))
.sort((a, b) => a.rank - b.rank)
return matches.slice(0, MAX_SUGGESTIONS).map(({ entry: e }) => {
const te = typeEntryMap[e.isA ?? '']
return {
title: e.title,
path: e.path,
isA: e.isA,
color: e.isA ? getTypeColor(e.isA, te?.color) : undefined,
lightColor: e.isA ? getTypeLightColor(e.isA, te?.color) : undefined,
}
})
inputRef?: React.RefObject<HTMLDivElement | null>
}
export function WikilinkChatInput({
entries, value, onChange, onSend, disabled, placeholder, inputRef: externalRef,
entries,
value,
onChange,
onSend,
disabled,
placeholder,
inputRef,
}: WikilinkChatInputProps) {
const [pills, setPills] = useState<Pill[]>([])
const [showMenu, setShowMenu] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const [debouncedQuery, setDebouncedQuery] = useState('')
const internalRef = useRef<HTMLInputElement>(null)
const inputRefToUse = externalRef ?? internalRef
const menuRef = useRef<HTMLDivElement>(null)
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const suggestions = useMemo(
() => showMenu ? matchEntries(entries, debouncedQuery, typeEntryMap) : [],
[entries, debouncedQuery, typeEntryMap, showMenu],
)
// Clamp selection to valid range
const clampedIndex = suggestions.length > 0
? Math.min(selectedIndex, suggestions.length - 1)
: 0
useEffect(() => {
if (!menuRef.current || clampedIndex < 0) return
const el = menuRef.current.children[clampedIndex] as HTMLElement | undefined
el?.scrollIntoView?.({ block: 'nearest' })
}, [clampedIndex])
function selectSuggestion(suggestion: SuggestionEntry) {
const cursor = inputRefToUse.current?.selectionStart ?? value.length
const textBefore = value.slice(0, cursor)
const bracketIdx = textBefore.lastIndexOf('[[')
if (bracketIdx < 0) return
const textAfter = value.slice(cursor)
const newValue = textBefore.slice(0, bracketIdx) + textAfter
onChange(newValue)
setPills(prev => {
if (prev.some(p => p.path === suggestion.path)) return prev
return [...prev, {
title: suggestion.title,
path: suggestion.path,
type: suggestion.isA,
color: suggestion.color,
lightColor: suggestion.lightColor,
}]
})
setShowMenu(false)
setTimeout(() => inputRefToUse.current?.focus(), 0)
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const newValue = e.target.value
onChange(newValue)
const cursor = e.target.selectionStart ?? newValue.length
const textBefore = newValue.slice(0, cursor)
const bracketIdx = textBefore.lastIndexOf('[[')
if (bracketIdx >= 0 && !textBefore.slice(bracketIdx).includes(']]')) {
const query = textBefore.slice(bracketIdx + 2)
setShowMenu(true)
setSelectedIndex(0)
clearTimeout(debounceTimer.current)
debounceTimer.current = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS)
} else {
setShowMenu(false)
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (showMenu && suggestions.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex(i => (i + 1) % suggestions.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex(i => (i <= 0 ? suggestions.length - 1 : i - 1))
return
}
if (e.key === 'Enter') {
e.preventDefault()
selectSuggestion(suggestions[clampedIndex])
return
}
if (e.key === 'Escape') {
e.preventDefault()
setShowMenu(false)
return
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (!value.trim() && pills.length === 0) return
const references: NoteReference[] = pills.map(p => ({
title: p.title,
path: p.path,
type: p.type,
}))
onSend(value, references)
setPills([])
}
}
return (
<div style={{ position: 'relative' }}>
{pills.length > 0 && (
<div className="flex flex-wrap gap-1" style={{ marginBottom: 4 }}>
{pills.map(pill => (
<span
key={pill.path}
className="inline-flex items-center gap-1 text-xs"
style={{
background: pill.lightColor ?? 'var(--muted)',
color: pill.color ?? 'var(--foreground)',
borderRadius: 9999,
padding: '1px 8px 1px 6px',
fontWeight: 500,
}}
data-testid="reference-pill"
>
{pill.title}
<button
className="border-none bg-transparent p-0 cursor-pointer"
style={{ color: 'inherit', opacity: 0.6, fontSize: 10, lineHeight: 1 }}
onClick={() => setPills(prev => prev.filter(p => p.path !== pill.path))}
tabIndex={-1}
>
×
</button>
</span>
))}
</div>
)}
<input
ref={inputRefToUse}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
className="flex-1 border border-border bg-transparent text-foreground"
style={{
fontSize: 13, borderRadius: 8, padding: '8px 10px',
outline: 'none', fontFamily: 'inherit', width: '100%', boxSizing: 'border-box',
}}
placeholder={placeholder}
disabled={disabled}
data-testid="agent-input"
/>
{showMenu && suggestions.length > 0 && (
<div
ref={menuRef}
className="wikilink-menu"
style={{
position: 'absolute', bottom: '100%', left: 0, right: 0,
marginBottom: 4, maxHeight: 260, overflowY: 'auto',
}}
data-testid="wikilink-menu"
>
{suggestions.map((s, i) => (
<div
key={s.path}
className="flex items-center justify-between gap-2 cursor-pointer transition-colors"
style={{
padding: '6px 10px',
fontSize: 13,
background: i === clampedIndex ? 'var(--accent)' : undefined,
}}
onMouseDown={e => e.preventDefault()}
onClick={() => selectSuggestion(s)}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="truncate">{s.title}</span>
{s.isA && s.isA !== 'Note' && (
<span
className="shrink-0 text-xs"
style={{
color: s.color,
backgroundColor: s.lightColor,
borderRadius: 9999,
padding: '1px 8px',
}}
>
{s.isA}
</span>
)}
</div>
))}
</div>
)}
</div>
<InlineWikilinkInput
entries={entries}
value={value}
onChange={onChange}
onSubmit={onSend}
disabled={disabled}
placeholder={placeholder}
inputRef={inputRef}
/>
)
}

View File

@@ -0,0 +1,131 @@
import {
chipToken,
normalizeInlineWikilinkValue,
} from './inlineWikilinkTokens'
export function serializeInlineNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return normalizeInlineWikilinkValue(node.textContent ?? '')
}
if (node instanceof HTMLElement) {
if (node.dataset.chipTarget) {
return chipToken(node.dataset.chipTarget)
}
if (node.tagName === 'BR') return ''
}
return Array.from(node.childNodes).map(serializeInlineNode).join('')
}
function selectionFallsOutsideEditor(
selection: Selection | null,
root: HTMLDivElement,
): boolean {
return (
!selection ||
selection.rangeCount === 0 ||
!selection.anchorNode ||
!root.contains(selection.anchorNode)
)
}
export function readSelectionIndex(root: HTMLDivElement): number {
const selection = window.getSelection()
if (selectionFallsOutsideEditor(selection, root)) {
return serializeInlineNode(root).length
}
const range = selection.getRangeAt(0).cloneRange()
range.setStart(root, 0)
return serializeInlineNode(range.cloneContents()).length
}
export function applySelectionIndex(
root: HTMLDivElement,
selectionIndex: number,
) {
const selection = window.getSelection()
if (!selection) return
const range = document.createRange()
const clampedIndex = Math.max(0, selectionIndex)
const found = placeRangeAtIndex(root, clampedIndex, range)
if (!found) {
range.selectNodeContents(root)
range.collapse(false)
}
selection.removeAllRanges()
selection.addRange(range)
}
function placeRangeAtIndex(
node: Node,
selectionIndex: number,
range: Range,
): boolean {
let remaining = selectionIndex
for (const child of Array.from(node.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
const textPlacement = placeRangeInTextNode(child, remaining, range)
if (textPlacement.placed) return true
remaining = textPlacement.remaining
continue
}
if (!(child instanceof HTMLElement)) continue
if (child.dataset.chipTarget) {
const chipPlacement = placeRangeAroundChip(child, remaining, range)
if (chipPlacement.placed) return true
remaining = chipPlacement.remaining
continue
}
if (placeRangeAtIndex(child, remaining, range)) return true
remaining -= serializeInlineNode(child).length
}
return false
}
function placeRangeInTextNode(
node: Node,
remaining: number,
range: Range,
) {
const textLength = normalizeInlineWikilinkValue(node.textContent ?? '').length
if (remaining <= textLength) {
range.setStart(node, remaining)
range.collapse(true)
return { placed: true, remaining: 0 }
}
return { placed: false, remaining: remaining - textLength }
}
function placeRangeAroundChip(
node: HTMLElement,
remaining: number,
range: Range,
) {
const tokenLength = chipToken(node.dataset.chipTarget ?? '').length
if (remaining <= 0) {
range.setStartBefore(node)
range.collapse(true)
return { placed: true, remaining: 0 }
}
if (remaining <= tokenLength) {
range.setStartAfter(node)
range.collapse(true)
return { placed: true, remaining: 0 }
}
return { placed: false, remaining: remaining - tokenLength }
}

View File

@@ -0,0 +1,117 @@
import type React from 'react'
interface HandleSuggestionKeysArgs {
event: React.KeyboardEvent<HTMLDivElement>
suggestionsOpen: boolean
onCycleSuggestions: (direction: 1 | -1) => void
onSelectSuggestion: () => void
}
function handleSuggestionKeys({
event,
suggestionsOpen,
onCycleSuggestions,
onSelectSuggestion,
}: HandleSuggestionKeysArgs): boolean {
if (!suggestionsOpen) return false
if (event.key === 'ArrowDown') {
event.preventDefault()
onCycleSuggestions(1)
return true
}
if (event.key === 'ArrowUp') {
event.preventDefault()
onCycleSuggestions(-1)
return true
}
if (event.key === 'Enter') {
event.preventDefault()
onSelectSuggestion()
return true
}
return false
}
interface HandleDeleteKeysArgs {
event: React.KeyboardEvent<HTMLDivElement>
onDeleteAdjacentChip: (direction: 'backward' | 'forward') => boolean
}
function handleDeleteKeys({
event,
onDeleteAdjacentChip,
}: HandleDeleteKeysArgs): boolean {
if (event.key === 'Backspace' && onDeleteAdjacentChip('backward')) {
event.preventDefault()
return true
}
if (event.key === 'Delete' && onDeleteAdjacentChip('forward')) {
event.preventDefault()
return true
}
return false
}
interface HandleSubmitKeyArgs {
event: React.KeyboardEvent<HTMLDivElement>
canSubmit: boolean
onSubmit: () => void
}
function handleSubmitKey({
event,
canSubmit,
onSubmit,
}: HandleSubmitKeyArgs): boolean {
if (!canSubmit) return false
if (event.key !== 'Enter' || event.shiftKey) return false
event.preventDefault()
onSubmit()
return true
}
interface HandleInlineWikilinkKeyDownArgs {
event: React.KeyboardEvent<HTMLDivElement>
disabled: boolean
suggestionsOpen: boolean
onCycleSuggestions: (direction: 1 | -1) => void
onSelectSuggestion: () => void
onDeleteAdjacentChip: (direction: 'backward' | 'forward') => boolean
canSubmit: boolean
onSubmit: () => void
}
export function handleInlineWikilinkKeyDown({
event,
disabled,
suggestionsOpen,
onCycleSuggestions,
onSelectSuggestion,
onDeleteAdjacentChip,
canSubmit,
onSubmit,
}: HandleInlineWikilinkKeyDownArgs) {
if (disabled) return
if (handleSuggestionKeys({
event,
suggestionsOpen,
onCycleSuggestions,
onSelectSuggestion,
})) {
return
}
if (handleDeleteKeys({ event, onDeleteAdjacentChip })) {
return
}
handleSubmitKey({ event, canSubmit, onSubmit })
}

View File

@@ -0,0 +1,78 @@
import type { VaultEntry } from '../types'
import {
MAX_RESULTS,
deduplicateByPath,
disambiguateTitles,
preFilterWikilinks,
type WikilinkBaseItem,
} from '../utils/wikilinkSuggestions'
import { toInlineWikilinkTarget } from './inlineWikilinkTokens'
export interface InlineWikilinkSuggestion {
entry: VaultEntry
target: string
title: string
}
interface SuggestionItem extends WikilinkBaseItem {
entry: VaultEntry
target: string
}
function toSuggestionItems(entries: VaultEntry[]): SuggestionItem[] {
return entries
.filter((entry) => !entry.archived)
.map((entry) => ({
entry,
target: toInlineWikilinkTarget(entry),
title: entry.title,
aliases: entry.aliases,
group: entry.isA ?? 'Note',
entryTitle: entry.title,
path: entry.path,
}))
}
function toSuggestion(item: SuggestionItem): InlineWikilinkSuggestion {
return {
entry: item.entry,
target: item.target,
title: item.title,
}
}
function matchSingleCharacterQuery(
items: SuggestionItem[],
query: string,
): SuggestionItem[] {
const lowerQuery = query.toLowerCase()
return items.filter((item) =>
item.title.toLowerCase().includes(lowerQuery) ||
item.aliases.some((alias) => alias.toLowerCase().includes(lowerQuery)) ||
item.group.toLowerCase().includes(lowerQuery) ||
item.path.toLowerCase().includes(lowerQuery),
)
}
function buildTopSuggestions(items: SuggestionItem[]): InlineWikilinkSuggestion[] {
return [...items]
.sort((left, right) => left.title.localeCompare(right.title))
.slice(0, MAX_RESULTS)
.map(toSuggestion)
}
export function buildInlineWikilinkSuggestions(
entries: VaultEntry[],
query: string,
): InlineWikilinkSuggestion[] {
const items = toSuggestionItems(entries)
if (query.length === 0) return buildTopSuggestions(items)
const matchedItems = query.length === 1
? matchSingleCharacterQuery(items, query)
: preFilterWikilinks(items, query)
return disambiguateTitles(deduplicateByPath(matchedItems))
.slice(0, MAX_RESULTS)
.map(toSuggestion)
}

View File

@@ -0,0 +1,149 @@
import type { VaultEntry } from '../types'
import type { NoteReference } from '../utils/ai-context'
import { resolveEntry } from '../utils/wikilink'
import {
chipToken,
normalizeInlineWikilinkValue,
toInlineWikilinkTarget,
} from './inlineWikilinkTokens'
export interface InlineWikilinkChip {
entry: VaultEntry
target: string
}
export type InlineWikilinkSegment =
| { kind: 'text'; text: string }
| { kind: 'chip'; chip: InlineWikilinkChip }
export interface ActiveWikilinkQuery {
start: number
query: string
}
const INLINE_WIKILINK_PATTERN = /\[\[([^[\]\r\n]+?)\]\]/g
export function buildInlineWikilinkSegments(
value: string,
entries: VaultEntry[],
): InlineWikilinkSegment[] {
const normalizedValue = normalizeInlineWikilinkValue(value)
const segments: InlineWikilinkSegment[] = []
let cursor = 0
INLINE_WIKILINK_PATTERN.lastIndex = 0
for (const match of normalizedValue.matchAll(INLINE_WIKILINK_PATTERN)) {
const fullMatch = match[0]
const target = match[1]
const start = match.index ?? 0
if (start > cursor) {
segments.push({ kind: 'text', text: normalizedValue.slice(cursor, start) })
}
const entry = resolveEntry(entries, target)
if (!entry) {
segments.push({ kind: 'text', text: fullMatch })
} else {
segments.push({
kind: 'chip',
chip: { entry, target: toInlineWikilinkTarget(entry) },
})
}
cursor = start + fullMatch.length
}
if (cursor < normalizedValue.length) {
segments.push({ kind: 'text', text: normalizedValue.slice(cursor) })
}
return segments.length > 0 ? segments : [{ kind: 'text', text: '' }]
}
export function extractInlineWikilinkReferences(
value: string,
entries: VaultEntry[],
): NoteReference[] {
const references: NoteReference[] = []
const seenPaths = new Set<string>()
for (const segment of buildInlineWikilinkSegments(value, entries)) {
if (segment.kind !== 'chip') continue
if (seenPaths.has(segment.chip.entry.path)) continue
seenPaths.add(segment.chip.entry.path)
references.push({
title: segment.chip.entry.title,
path: segment.chip.entry.path,
type: segment.chip.entry.isA,
})
}
return references
}
function hasClosedQuery(openText: string): boolean {
return openText.includes(']]') || /[\r\n]/.test(openText)
}
export function findActiveWikilinkQuery(
value: string,
selectionIndex: number,
): ActiveWikilinkQuery | null {
const clampedIndex = Math.max(0, Math.min(selectionIndex, value.length))
const textBeforeCursor = value.slice(0, clampedIndex)
const triggerStart = textBeforeCursor.lastIndexOf('[[')
if (triggerStart < 0) return null
const openText = textBeforeCursor.slice(triggerStart + 2)
if (hasClosedQuery(openText)) return null
return { start: triggerStart, query: openText }
}
export function replaceActiveWikilinkQuery(
value: string,
selectionIndex: number,
target: string,
): { value: string; nextSelectionIndex: number } | null {
const activeQuery = findActiveWikilinkQuery(value, selectionIndex)
if (!activeQuery) return null
const token = chipToken(target)
return {
value: value.slice(0, activeQuery.start) + token + value.slice(selectionIndex),
nextSelectionIndex: activeQuery.start + token.length,
}
}
function segmentLength(segment: InlineWikilinkSegment): number {
return segment.kind === 'text'
? segment.text.length
: chipToken(segment.chip.target).length
}
export function findInlineChipDeletionRange(
segments: InlineWikilinkSegment[],
selectionIndex: number,
direction: 'backward' | 'forward',
): { start: number; end: number } | null {
let cursor = 0
for (const segment of segments) {
const nextCursor = cursor + segmentLength(segment)
if (segment.kind === 'chip') {
const removePreviousChip = direction === 'backward' && selectionIndex === nextCursor
const removeNextChip = direction === 'forward' && selectionIndex === cursor
if (removePreviousChip || removeNextChip) {
return { start: cursor, end: nextCursor }
}
}
cursor = nextCursor
}
return null
}

View File

@@ -0,0 +1,16 @@
import type { VaultEntry } from '../types'
export function toInlineWikilinkTarget(entry: VaultEntry): string {
return entry.filename.replace(/\.md$/i, '')
}
export function chipToken(target: string): string {
return `[[${target}]]`
}
export function normalizeInlineWikilinkValue(value: string): string {
return value
.replace(/\u00A0/g, ' ')
.replace(/\u200B/g, '')
.replace(/\r?\n/g, ' ')
}

View File

@@ -0,0 +1,53 @@
import { useMemo } from 'react'
import type { VaultEntry } from '../types'
import {
buildContextSnapshot,
collectLinkedEntries,
type NoteListItem,
} from '../utils/ai-context'
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
interface UseAiPanelContextSnapshotArgs {
activeEntry?: VaultEntry | null
activeNoteContent?: string | null
entries?: VaultEntry[]
input: string
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
}
export function useAiPanelContextSnapshot({
activeEntry,
activeNoteContent,
entries,
input,
openTabs,
noteList,
noteListFilter,
}: UseAiPanelContextSnapshotArgs) {
const linkedEntries = useMemo(() => {
if (!activeEntry || !entries) return []
return collectLinkedEntries(activeEntry, entries)
}, [activeEntry, entries])
const draftReferences = useMemo(
() => extractInlineWikilinkReferences(input, entries ?? []),
[entries, input],
)
const contextPrompt = useMemo(() => {
if (!activeEntry || !entries) return undefined
return buildContextSnapshot({
activeEntry,
activeNoteContent: activeNoteContent ?? undefined,
openTabs,
noteList,
noteListFilter,
entries,
references: draftReferences.length > 0 ? draftReferences : undefined,
})
}, [activeEntry, activeNoteContent, draftReferences, entries, noteList, noteListFilter, openTabs])
return { linkedEntries, contextPrompt }
}

View File

@@ -0,0 +1,78 @@
import { useCallback, useMemo, useState } from 'react'
import { useAiAgent, type AgentFileCallbacks } from '../hooks/useAiAgent'
import type { VaultEntry } from '../types'
import {
type NoteListItem,
type NoteReference,
} from '../utils/ai-context'
import { useAiPanelContextSnapshot } from './useAiPanelContextSnapshot'
interface UseAiPanelControllerArgs {
vaultPath: string
activeEntry?: VaultEntry | null
activeNoteContent?: string | null
entries?: VaultEntry[]
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
onOpenNote?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
}
export function useAiPanelController({
vaultPath,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
onOpenNote,
onFileCreated,
onFileModified,
onVaultChanged,
}: UseAiPanelControllerArgs) {
const [input, setInput] = useState('')
const { linkedEntries, contextPrompt } = useAiPanelContextSnapshot({
activeEntry,
activeNoteContent,
entries,
input,
openTabs,
noteList,
noteListFilter,
})
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
onFileCreated,
onFileModified,
onVaultChanged,
}), [onFileCreated, onFileModified, onVaultChanged])
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
const hasContext = !!activeEntry
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
const handleSend = useCallback((text: string, references: NoteReference[]) => {
if (!text.trim() || isActive) return
agent.sendMessage(text, references)
setInput('')
}, [agent, isActive])
const handleNavigateWikilink = useCallback((target: string) => {
onOpenNote?.(target)
}, [onOpenNote])
return {
agent,
input,
setInput,
linkedEntries,
hasContext,
isActive,
handleSend,
handleNavigateWikilink,
}
}

View File

@@ -0,0 +1,42 @@
import { useCallback, useEffect } from 'react'
interface UseAiPanelFocusArgs {
inputRef: React.RefObject<HTMLDivElement | null>
panelRef: React.RefObject<HTMLElement | null>
isActive: boolean
onClose: () => void
}
export function useAiPanelFocus({
inputRef,
panelRef,
isActive,
onClose,
}: UseAiPanelFocusArgs) {
useEffect(() => {
const timer = setTimeout(() => inputRef.current?.focus(), 0)
return () => clearTimeout(timer)
}, [inputRef])
useEffect(() => {
if (isActive) {
panelRef.current?.focus()
return
}
inputRef.current?.focus()
}, [inputRef, isActive, panelRef])
const handleEscape = useCallback((event: KeyboardEvent) => {
if (event.key !== 'Escape') return
if (!panelRef.current?.contains(document.activeElement)) return
event.preventDefault()
onClose()
}, [onClose, panelRef])
useEffect(() => {
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [handleEscape])
}

View File

@@ -0,0 +1,44 @@
import { startTransition, useCallback, useEffect, useState } from 'react'
import type { NoteReference } from '../utils/ai-context'
import type { QueuedAiPrompt } from '../utils/aiPromptBridge'
import { useQueuedAiPrompt } from './useQueuedAiPrompt'
interface AiAgentBridge {
clearConversation: () => void
sendMessage: (text: string, references: NoteReference[]) => void
}
interface UseAiPanelPromptQueueArgs {
agent: AiAgentBridge
input: string
isActive: boolean
setInput: (value: string) => void
}
export function useAiPanelPromptQueue({
agent,
input,
isActive,
setInput,
}: UseAiPanelPromptQueueArgs) {
const [queuedPrompt, setQueuedPrompt] = useState<QueuedAiPrompt | null>(null)
const handleQueuedPrompt = useCallback((prompt: QueuedAiPrompt) => {
setInput(prompt.text)
setQueuedPrompt(prompt)
agent.clearConversation()
}, [agent, setInput])
useQueuedAiPrompt(handleQueuedPrompt)
useEffect(() => {
if (!queuedPrompt || isActive) return
if (input !== queuedPrompt.text) return
agent.sendMessage(queuedPrompt.text, queuedPrompt.references)
startTransition(() => {
setInput('')
setQueuedPrompt(null)
})
}, [agent, input, isActive, queuedPrompt, setInput])
}

View File

@@ -0,0 +1,72 @@
import {
useCallback,
useLayoutEffect,
useRef,
useState,
} from 'react'
import {
applySelectionIndex,
readSelectionIndex,
serializeInlineNode,
} from './inlineWikilinkDom'
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
interface UseInlineWikilinkSelectionArgs {
value: string
onChange: (value: string) => void
inputRef?: React.RefObject<HTMLDivElement | null>
}
export function useInlineWikilinkSelection({
value,
onChange,
inputRef,
}: UseInlineWikilinkSelectionArgs) {
const editorRef = useRef<HTMLDivElement | null>(null)
const [selectionIndex, setSelectionIndex] = useState(value.length)
const setCombinedRef = useCallback((node: HTMLDivElement | null) => {
editorRef.current = node
if (inputRef) {
inputRef.current = node
}
}, [inputRef])
const syncSelectionIndex = useCallback(() => {
if (!editorRef.current) return
setSelectionIndex(readSelectionIndex(editorRef.current))
}, [])
const focusSelectionAt = useCallback((nextSelectionIndex: number) => {
const editor = editorRef.current
if (!editor) return
editor.focus()
applySelectionIndex(editor, nextSelectionIndex)
}, [])
const commitValueFromEditor = useCallback(() => {
if (!editorRef.current) return
const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editorRef.current))
const nextSelectionIndex = readSelectionIndex(editorRef.current)
onChange(nextValue)
setSelectionIndex(Math.min(nextSelectionIndex, nextValue.length))
}, [onChange])
useLayoutEffect(() => {
const editor = editorRef.current
if (!editor) return
if (document.activeElement !== editor) return
applySelectionIndex(editor, selectionIndex)
}, [selectionIndex, value])
return {
selectionIndex,
setSelectionIndex,
setCombinedRef,
syncSelectionIndex,
focusSelectionAt,
commitValueFromEditor,
}
}

View File

@@ -0,0 +1,137 @@
import { useCallback, useMemo, useState } from 'react'
import type { VaultEntry } from '../types'
import {
buildInlineWikilinkSuggestions,
type InlineWikilinkSuggestion,
} from './inlineWikilinkSuggestions'
import { replaceActiveWikilinkQuery } from './inlineWikilinkText'
interface UseInlineWikilinkSuggestionsStateArgs {
activeQueryKey: string
entries: VaultEntry[]
query: string | null
value: string
selectionIndex: number
onChange: (value: string) => void
onSelectionIndexChange: (selectionIndex: number) => void
focusSelectionAt: (selectionIndex: number) => void
}
function selectedIndexForQuery(
queryKey: string,
currentState: { queryKey: string; index: number },
suggestionCount: number,
) {
if (currentState.queryKey !== queryKey) return 0
return Math.min(currentState.index, Math.max(suggestionCount - 1, 0))
}
function buildInitialSuggestionState(
queryKey: string,
direction: 1 | -1,
suggestionCount: number,
) {
return {
queryKey,
index: direction > 0 ? 0 : suggestionCount - 1,
}
}
function buildCycledSuggestionState(
currentIndex: number,
queryKey: string,
direction: 1 | -1,
suggestionCount: number,
) {
const nextIndex = direction > 0
? (currentIndex + 1) % suggestionCount
: (currentIndex <= 0 ? suggestionCount - 1 : currentIndex - 1)
return { queryKey, index: nextIndex }
}
function replacementForSuggestion(
suggestions: InlineWikilinkSuggestion[],
index: number,
value: string,
selectionIndex: number,
) {
const suggestion = suggestions[index]
if (!suggestion) return null
return replaceActiveWikilinkQuery(value, selectionIndex, suggestion.target)
}
export function useInlineWikilinkSuggestionsState({
activeQueryKey,
entries,
query,
value,
selectionIndex,
onChange,
onSelectionIndexChange,
focusSelectionAt,
}: UseInlineWikilinkSuggestionsStateArgs) {
const [suggestionState, setSuggestionState] = useState({ queryKey: '', index: 0 })
const suggestions = useMemo<InlineWikilinkSuggestion[]>(
() => (query === null ? [] : buildInlineWikilinkSuggestions(entries, query)),
[entries, query],
)
const selectedSuggestionIndex = selectedIndexForQuery(
activeQueryKey,
suggestionState,
suggestions.length,
)
const setSuggestionIndex = useCallback((index: number) => {
setSuggestionState({ queryKey: activeQueryKey, index })
}, [activeQueryKey])
const selectSuggestion = useCallback((index: number) => {
const replacement = replacementForSuggestion(
suggestions,
index,
value,
selectionIndex,
)
if (!replacement) return
onChange(replacement.value)
onSelectionIndexChange(replacement.nextSelectionIndex)
setSuggestionState({ queryKey: '', index: 0 })
window.setTimeout(() => focusSelectionAt(replacement.nextSelectionIndex), 0)
}, [
focusSelectionAt,
onChange,
onSelectionIndexChange,
selectionIndex,
suggestions,
value,
])
const cycleSuggestions = useCallback((direction: 1 | -1) => {
if (suggestions.length === 0) return
setSuggestionState((current) => {
if (current.queryKey !== activeQueryKey) {
return buildInitialSuggestionState(activeQueryKey, direction, suggestions.length)
}
return buildCycledSuggestionState(
current.index,
activeQueryKey,
direction,
suggestions.length,
)
})
}, [activeQueryKey, suggestions.length])
return {
suggestions,
selectedSuggestionIndex,
setSuggestionIndex,
selectSuggestion,
cycleSuggestions,
}
}

View File

@@ -0,0 +1,21 @@
import { useEffect } from 'react'
import {
AI_PROMPT_QUEUED_EVENT,
takeQueuedAiPrompt,
type QueuedAiPrompt,
} from '../utils/aiPromptBridge'
export function useQueuedAiPrompt(
onPrompt: (prompt: QueuedAiPrompt) => void,
) {
useEffect(() => {
const consumePrompt = () => {
const queuedPrompt = takeQueuedAiPrompt()
if (queuedPrompt) onPrompt(queuedPrompt)
}
consumePrompt()
window.addEventListener(AI_PROMPT_QUEUED_EVENT, consumePrompt)
return () => window.removeEventListener(AI_PROMPT_QUEUED_EVENT, consumePrompt)
}, [onPrompt])
}

View File

@@ -0,0 +1,34 @@
import type { NoteReference } from './ai-context'
export const OPEN_AI_CHAT_EVENT = 'tolaria:open-ai-chat'
export const AI_PROMPT_QUEUED_EVENT = 'tolaria:ai-prompt-queued'
export interface QueuedAiPrompt {
id: number
text: string
references: NoteReference[]
}
let nextQueuedPromptId = 1
let pendingPrompt: QueuedAiPrompt | null = null
export function queueAiPrompt(text: string, references: NoteReference[]): QueuedAiPrompt {
const queuedPrompt = {
id: nextQueuedPromptId++,
text,
references,
}
pendingPrompt = queuedPrompt
window.dispatchEvent(new Event(AI_PROMPT_QUEUED_EVENT))
return queuedPrompt
}
export function takeQueuedAiPrompt(): QueuedAiPrompt | null {
const queuedPrompt = pendingPrompt
pendingPrompt = null
return queuedPrompt
}
export function requestOpenAiChat() {
window.dispatchEvent(new Event(OPEN_AI_CHAT_EVENT))
}