setToastMessage(null)} />
diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx
index 2b207e60..14a4347f 100644
--- a/src/components/StatusBar.test.tsx
+++ b/src/components/StatusBar.test.tsx
@@ -352,4 +352,42 @@ describe('StatusBar', () => {
expect(screen.queryByTestId('status-commit-push')).not.toBeInTheDocument()
})
+ it('shows Claude Code badge when installed', () => {
+ render()
+ const badge = screen.getByTestId('status-claude-code')
+ expect(badge).toBeInTheDocument()
+ expect(screen.getByText('Claude Code')).toBeInTheDocument()
+ expect(badge.getAttribute('title')).toBe('Claude Code 1.0.20')
+ })
+
+ it('shows Claude Code missing badge with warning when missing', () => {
+ render()
+ const badge = screen.getByTestId('status-claude-code')
+ expect(badge).toBeInTheDocument()
+ expect(screen.getByText('Claude Code missing')).toBeInTheDocument()
+ expect(badge.getAttribute('title')).toBe('Claude Code not found — click to install')
+ })
+
+ it('opens install URL when clicking missing Claude Code badge', () => {
+ render()
+ fireEvent.click(screen.getByTestId('status-claude-code'))
+ expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
+ })
+
+ it('opens install URL on Enter key for missing Claude Code badge', () => {
+ render()
+ fireEvent.keyDown(screen.getByTestId('status-claude-code'), { key: 'Enter' })
+ expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
+ })
+
+ it('hides Claude Code badge when status is checking', () => {
+ render()
+ expect(screen.queryByTestId('status-claude-code')).not.toBeInTheDocument()
+ })
+
+ it('hides Claude Code badge when no claudeCodeStatus prop provided', () => {
+ render()
+ expect(screen.queryByTestId('status-claude-code')).not.toBeInTheDocument()
+ })
+
})
diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx
index 954ff5ed..42f98595 100644
--- a/src/components/StatusBar.tsx
+++ b/src/components/StatusBar.tsx
@@ -1,8 +1,9 @@
import { useState, useRef, useEffect } from 'react'
-import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, AlertTriangle, Loader2, GitCommitHorizontal, X, Cpu, ArrowDown, GitBranch } from 'lucide-react'
+import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, AlertTriangle, Loader2, GitCommitHorizontal, X, Cpu, ArrowDown, GitBranch, Terminal } from 'lucide-react'
import { GitDiff, Pulse } from '@phosphor-icons/react'
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
import type { McpStatus } from '../hooks/useMcpStatus'
+import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
import { openExternalUrl } from '../utils/url'
export interface VaultOption {
@@ -40,6 +41,8 @@ interface StatusBarProps {
onRemoveVault?: (path: string) => void
mcpStatus?: McpStatus
onInstallMcp?: () => void
+ claudeCodeStatus?: ClaudeCodeStatus
+ claudeCodeVersion?: string | null
}
function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) {
@@ -436,7 +439,44 @@ function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () =>
)
}
-export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, onClickPulse, onCommitPush, isGitVault = false, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
+const CLAUDE_INSTALL_URL = 'https://docs.anthropic.com/en/docs/claude-code'
+
+function ClaudeCodeBadge({ status, version }: { status: ClaudeCodeStatus; version?: string | null }) {
+ if (status === 'checking') return null
+ const isMissing = status === 'missing'
+ const tooltip = isMissing
+ ? 'Claude Code not found — click to install'
+ : `Claude Code${version ? ` ${version}` : ''}`
+ return (
+ <>
+ |
+ openExternalUrl(CLAUDE_INSTALL_URL) : undefined}
+ onKeyDown={isMissing ? (e) => { if (e.key === 'Enter') openExternalUrl(CLAUDE_INSTALL_URL) } : undefined}
+ style={{
+ ...ICON_STYLE,
+ color: isMissing ? 'var(--accent-orange)' : 'var(--muted-foreground)',
+ cursor: isMissing ? 'pointer' : 'default',
+ padding: '2px 4px',
+ borderRadius: 3,
+ background: 'transparent',
+ }}
+ title={tooltip}
+ data-testid="status-claude-code"
+ onMouseEnter={isMissing ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
+ onMouseLeave={isMissing ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
+ >
+
+ {isMissing ? 'Claude Code missing' : 'Claude Code'}
+ {isMissing && }
+
+ >
+ )
+}
+
+export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, onClickPulse, onCommitPush, isGitVault = false, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, onRemoveVault, mcpStatus, onInstallMcp, claudeCodeStatus, claudeCodeVersion }: StatusBarProps) {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
@@ -464,6 +504,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
{mcpStatus && }
+ {claudeCodeStatus && }
{noteCount.toLocaleString()} notes
diff --git a/src/hooks/useClaudeCodeStatus.test.ts b/src/hooks/useClaudeCodeStatus.test.ts
new file mode 100644
index 00000000..020c709f
--- /dev/null
+++ b/src/hooks/useClaudeCodeStatus.test.ts
@@ -0,0 +1,63 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderHook, waitFor } from '@testing-library/react'
+import { useClaudeCodeStatus } from './useClaudeCodeStatus'
+
+vi.mock('@tauri-apps/api/core', () => ({
+ invoke: vi.fn(),
+}))
+
+vi.mock('../mock-tauri', () => ({
+ isTauri: () => false,
+ mockInvoke: vi.fn(),
+}))
+
+const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType }
+
+describe('useClaudeCodeStatus', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('starts in checking state and resolves to installed', async () => {
+ mockInvoke.mockImplementation((cmd: string) => {
+ if (cmd === 'check_claude_cli') return Promise.resolve({ installed: true, version: '1.0.20' })
+ return Promise.resolve(null)
+ })
+
+ const { result } = renderHook(() => useClaudeCodeStatus())
+
+ expect(result.current.status).toBe('checking')
+
+ await waitFor(() => {
+ expect(result.current.status).toBe('installed')
+ expect(result.current.version).toBe('1.0.20')
+ })
+ })
+
+ it('resolves to missing when claude is not installed', async () => {
+ mockInvoke.mockImplementation((cmd: string) => {
+ if (cmd === 'check_claude_cli') return Promise.resolve({ installed: false, version: null })
+ return Promise.resolve(null)
+ })
+
+ const { result } = renderHook(() => useClaudeCodeStatus())
+
+ await waitFor(() => {
+ expect(result.current.status).toBe('missing')
+ expect(result.current.version).toBeNull()
+ })
+ })
+
+ it('resolves to missing on error', async () => {
+ mockInvoke.mockImplementation((cmd: string) => {
+ if (cmd === 'check_claude_cli') return Promise.reject(new Error('failed'))
+ return Promise.resolve(null)
+ })
+
+ const { result } = renderHook(() => useClaudeCodeStatus())
+
+ await waitFor(() => {
+ expect(result.current.status).toBe('missing')
+ })
+ })
+})
diff --git a/src/hooks/useClaudeCodeStatus.ts b/src/hooks/useClaudeCodeStatus.ts
new file mode 100644
index 00000000..1125ae1b
--- /dev/null
+++ b/src/hooks/useClaudeCodeStatus.ts
@@ -0,0 +1,41 @@
+import { useEffect, useState } from 'react'
+import { invoke } from '@tauri-apps/api/core'
+import { isTauri, mockInvoke } from '../mock-tauri'
+
+export type ClaudeCodeStatus = 'checking' | 'installed' | 'missing'
+
+interface ClaudeCliResult {
+ installed: boolean
+ version: string | null
+}
+
+function tauriCall(command: string): Promise {
+ return isTauri() ? invoke(command) : mockInvoke(command)
+}
+
+/**
+ * Checks once on mount whether the `claude` CLI binary is available.
+ * Returns a status suitable for the status bar badge.
+ */
+export function useClaudeCodeStatus(): { status: ClaudeCodeStatus; version: string | null } {
+ const [status, setStatus] = useState('checking')
+ const [version, setVersion] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+
+ tauriCall('check_claude_cli')
+ .then((result) => {
+ if (cancelled) return
+ setStatus(result.installed ? 'installed' : 'missing')
+ setVersion(result.version ?? null)
+ })
+ .catch(() => {
+ if (!cancelled) setStatus('missing')
+ })
+
+ return () => { cancelled = true }
+ }, [])
+
+ return { status, version }
+}