From 29bc072e56f9df8c948b3a6c2e5ba8347132f720 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 4 Apr 2026 16:31:05 +0200 Subject: [PATCH] feat: add Claude Code detected/missing badge to status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses the existing check_claude_cli Tauri command to detect the claude binary at app startup and shows a status badge — green/neutral when found, orange warning when missing with a click-to-install link. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 6 ++- src/components/StatusBar.test.tsx | 38 ++++++++++++++++ src/components/StatusBar.tsx | 45 ++++++++++++++++++- src/hooks/useClaudeCodeStatus.test.ts | 63 +++++++++++++++++++++++++++ src/hooks/useClaudeCodeStatus.ts | 41 +++++++++++++++++ 5 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useClaudeCodeStatus.test.ts create mode 100644 src/hooks/useClaudeCodeStatus.ts diff --git a/src/App.tsx b/src/App.tsx index f5ec11af..406c234e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { WelcomeScreen } from './components/WelcomeScreen' import { TelemetryConsentDialog } from './components/TelemetryConsentDialog' import { useTelemetry } from './hooks/useTelemetry' import { useMcpStatus } from './hooks/useMcpStatus' +import { useClaudeCodeStatus } from './hooks/useClaudeCodeStatus' import { useVaultLoader } from './hooks/useVaultLoader' import { useSettings } from './hooks/useSettings' import { useNoteActions } from './hooks/useNoteActions' @@ -129,6 +130,7 @@ function App() { } }, [vault.entries.length, gitRepoState, resolvedPath]) const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage) + const { status: claudeCodeStatus, version: claudeCodeVersion } = useClaudeCodeStatus() const autoSync = useAutoSync({ vaultPath: resolvedPath, @@ -482,6 +484,8 @@ function App() { vaultCount: vaultSwitcher.allVaults.length, mcpStatus, onInstallMcp: installMcp, + claudeCodeStatus, + claudeCodeVersion, onEmptyTrash: deleteActions.handleEmptyTrash, trashedCount: deleteActions.trashedCount, onReloadVault: vault.reloadVault, @@ -637,7 +641,7 @@ function App() { - 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} /> + 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} /> 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 } +}