feat: enable full shell access for AI agent + simplify MCP tools

Remove --tools "" restriction so the agent has native bash/read/write/edit
access. Set vault path as working directory for the subprocess.

Simplify MCP to 4 Laputa-specific tools (search_notes, get_vault_context,
get_note, open_note) — everything else is handled by native tools.

Add file operation detection from Write/Edit tool calls to auto-open
created notes and refresh modified notes in the UI. Enhanced tool call
labels show bash commands, file paths, and note names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-05 12:12:12 +01:00
parent 6a57e83c99
commit 89da970455
12 changed files with 311 additions and 420 deletions

View File

@@ -66,8 +66,8 @@ describe('AiActionCard', () => {
expect(header.getAttribute('tabindex')).toBe('0')
})
it('uses lighter background for ui_ tools', () => {
render(<AiActionCard {...defaults} tool="ui_open_tab" label="Opened tab" />)
it('uses lighter background for open_note tool', () => {
render(<AiActionCard {...defaults} tool="open_note" label="Opening note" />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.06')
})

View File

@@ -1,7 +1,8 @@
import { type KeyboardEvent, type ReactNode, useCallback } from 'react'
import {
PencilSimple, MagnifyingGlass, Link, Trash, ChartBar, Eye, Sparkle,
PencilSimple, MagnifyingGlass, Trash, ChartBar, Eye,
CircleNotch, CheckCircle, XCircle, CaretRight, CaretDown,
Terminal, File, FolderOpen, NotePencil,
} from '@phosphor-icons/react'
export type AiActionStatus = 'pending' | 'done' | 'error'
@@ -23,18 +24,21 @@ const MAX_DETAIL_LENGTH = 800
type IconRenderer = (size: number) => ReactNode
const TOOL_ICON_MAP: Record<string, IconRenderer> = {
create_note: (s) => <PencilSimple size={s} />,
edit_note_frontmatter: (s) => <PencilSimple size={s} />,
append_to_note: (s) => <PencilSimple size={s} />,
// Native Claude Code tools
Bash: (s) => <Terminal size={s} />,
Write: (s) => <PencilSimple size={s} />,
Edit: (s) => <NotePencil size={s} />,
Read: (s) => <File size={s} />,
Glob: (s) => <FolderOpen size={s} />,
Grep: (s) => <MagnifyingGlass size={s} />,
// Laputa MCP tools
search_notes: (s) => <MagnifyingGlass size={s} />,
list_notes: (s) => <MagnifyingGlass size={s} />,
link_notes: (s) => <Link size={s} />,
get_vault_context: (s) => <ChartBar size={s} />,
get_note: (s) => <File size={s} />,
open_note: (s) => <Eye size={s} />,
// Legacy tools (for backward compatibility with existing messages)
create_note: (s) => <PencilSimple size={s} />,
delete_note: (s) => <Trash size={s} />,
vault_context: (s) => <ChartBar size={s} />,
ui_open_note: (s) => <Eye size={s} />,
ui_open_tab: (s) => <Eye size={s} />,
ui_highlight: (s) => <Sparkle size={s} />,
ui_set_filter: (s) => <Sparkle size={s} />,
}
const DEFAULT_ICON: IconRenderer = (s) => <PencilSimple size={s} />
@@ -96,11 +100,15 @@ function DetailBlock({ label, content, isError }: {
)
}
/** Whether this tool is a Laputa UI-only tool (lighter styling). */
function isUiOnlyTool(tool: string): boolean {
return tool === 'open_note'
}
export function AiActionCard({
tool, label, path, status, input, output, expanded, onToggle, onOpenNote,
}: AiActionCardProps) {
const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON
const isUiTool = tool.startsWith('ui_')
const hasDetails = !!(input || output)
const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -129,7 +137,7 @@ export function AiActionCard({
className="rounded"
style={{
fontSize: 12,
background: isUiTool ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
background: isUiOnlyTool(tool) ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
}}
>
<div

View File

@@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { WikilinkChatInput } from './WikilinkChatInput'
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
@@ -11,6 +11,8 @@ export type { AiAgentMessage } from '../hooks/useAiAgent'
interface AiPanelProps {
onClose: () => void
onOpenNote?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
vaultPath: string
activeEntry?: VaultEntry | null
entries?: VaultEntry[]
@@ -107,7 +109,7 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
)
}
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
const [input, setInput] = useState('')
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
const inputRef = useRef<HTMLInputElement>(null)
@@ -131,7 +133,12 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
})
}, [activeEntry, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
const agent = useAiAgent(vaultPath, contextPrompt)
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
onFileCreated,
onFileModified,
}), [onFileCreated, onFileModified])
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
const hasContext = !!activeEntry
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'

View File

@@ -71,6 +71,8 @@ interface EditorProps {
rawToggleRef?: React.MutableRefObject<() => void>
/** Mutable ref that Editor registers its diff-mode toggle into, for command palette access. */
diffToggleRef?: React.MutableRefObject<() => void>
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
}
function useEditorModeExclusion({
@@ -127,6 +129,8 @@ export const Editor = memo(function Editor({
isDarkTheme,
rawToggleRef,
diffToggleRef,
onFileCreated,
onFileModified,
}: EditorProps) {
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
@@ -242,6 +246,8 @@ export const Editor = memo(function Editor({
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onOpenNote={onNavigateWikilink}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
/>
</div>
</div>

View File

@@ -24,6 +24,8 @@ interface EditorRightPanelProps {
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onOpenNote?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
}
export function EditorRightPanel({
@@ -32,6 +34,7 @@ export function EditorRightPanel({
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
onFileCreated, onFileModified,
}: EditorRightPanelProps) {
if (showAIChat) {
return (
@@ -42,6 +45,8 @@ export function EditorRightPanel({
<AiPanel
onClose={() => onToggleAIChat?.()}
onOpenNote={onOpenNote}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
vaultPath={vaultPath}
activeEntry={inspectorEntry}
entries={entries}