Pay with Crypto
diff --git a/app/src/components/settings/panels/billingHelpers.ts b/app/src/components/settings/panels/billingHelpers.ts
index 7262d8db5..e06d4a929 100644
--- a/app/src/components/settings/panels/billingHelpers.ts
+++ b/app/src/components/settings/panels/billingHelpers.ts
@@ -16,6 +16,8 @@ export interface PlanMeta {
fiveHourCapUsd: number;
discountPercent: number;
features: PlanFeature[];
+ recommended?: boolean;
+ tagline?: string;
}
export const PLANS: PlanMeta[] = [
@@ -28,11 +30,12 @@ export const PLANS: PlanMeta[] = [
weeklyBudgetUsd: 0,
fiveHourCapUsd: 0,
discountPercent: 0,
+ tagline: 'Get started at no cost',
features: [
- { text: 'Base access to integrations and inference', included: true },
+ { text: 'Access to all integrations and inference', included: true },
{ text: 'One-time signup credits when available', included: true },
- { text: 'Pay-as-you-go top-ups when credits run out', included: true },
- { text: 'No subscription discount on premium usage', included: true },
+ { text: 'Pay-as-you-go top-ups', included: true },
+ { text: 'No subscription discount', included: false },
],
},
{
@@ -44,13 +47,12 @@ export const PLANS: PlanMeta[] = [
weeklyBudgetUsd: 10,
fiveHourCapUsd: 3,
discountPercent: 20,
+ recommended: true,
+ tagline: 'Best value for most users',
features: [
- { text: 'Higher included premium usage every billing cycle', included: true },
- {
- text: '20% premium-usage discount across integrations, bandwidth, and inference',
- included: true,
- },
- { text: 'Pay-as-you-go top-ups for overflow usage', included: true },
+ { text: '$20/mo in premium usage included', included: true },
+ { text: '20% discount on all premium usage', included: true },
+ { text: 'Pay-as-you-go top-ups for overflow', included: true },
],
},
{
@@ -62,10 +64,11 @@ export const PLANS: PlanMeta[] = [
weeklyBudgetUsd: 100,
fiveHourCapUsd: 30,
discountPercent: 40,
+ tagline: 'For power users and teams',
features: [
- { text: 'Largest included premium usage allocation', included: true },
- { text: '40% premium-usage discount across integrations and inference', included: true },
- { text: 'Best fit for heavy bandwidth and agent workloads', included: true },
+ { text: '$200/mo in premium usage included', included: true },
+ { text: '40% discount on all premium usage', included: true },
+ { text: 'Best fit for heavy bandwidth and agents', included: true },
],
},
];
diff --git a/app/src/components/skills/AutocompleteSetupModal.tsx b/app/src/components/skills/AutocompleteSetupModal.tsx
new file mode 100644
index 000000000..21cd9f0b6
--- /dev/null
+++ b/app/src/components/skills/AutocompleteSetupModal.tsx
@@ -0,0 +1,189 @@
+/**
+ * Text Auto-Complete setup/enable modal.
+ *
+ * Simple enable flow: shows current state, lets user enable with one click,
+ * and shows a success confirmation — matching the UX of the Screen
+ * Intelligence setup modal.
+ */
+import { useEffect, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { useNavigate } from 'react-router-dom';
+
+import { useCoreState } from '../../providers/CoreStateProvider';
+import {
+ openhumanAutocompleteSetStyle,
+ openhumanAutocompleteStart,
+} from '../../utils/tauriCommands/autocomplete';
+
+type Step = 'enable' | 'success';
+
+interface Props {
+ onClose: () => void;
+}
+
+export default function AutocompleteSetupModal({ onClose }: Props) {
+ const navigate = useNavigate();
+ const { snapshot, refresh } = useCoreState();
+ const status = snapshot.runtime.autocomplete;
+
+ const [step, setStep] = useState('enable');
+ const [isEnabling, setIsEnabling] = useState(false);
+ const [enableError, setEnableError] = useState(null);
+
+ // Close on Escape key
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
+ const handleEnable = async () => {
+ setIsEnabling(true);
+ setEnableError(null);
+ try {
+ // Enable in config
+ await openhumanAutocompleteSetStyle({ enabled: true });
+ // Start the service
+ await openhumanAutocompleteStart();
+ await refresh();
+ setStep('success');
+ } catch (error) {
+ setEnableError(error instanceof Error ? error.message : 'Failed to enable autocomplete');
+ } finally {
+ setIsEnabling(false);
+ }
+ };
+
+ const handleGoToSettings = () => {
+ onClose();
+ navigate('/settings/autocomplete');
+ };
+
+ return createPortal(
+ {
+ if (e.target === e.currentTarget) onClose();
+ }}>
+
+ {/* Header */}
+
+
+
+
+
Text Auto-Complete
+
+ {step === 'enable' && 'Enable inline completions'}
+ {step === 'success' && 'Ready to go'}
+
+
+
+
+
+
+
+
+
+
+ {/* Body */}
+
+ {/* ─── Enable step ─── */}
+ {step === 'enable' && (
+
+
+ Text Auto-Complete suggests inline completions as you type across any app. Suggestions appear as an overlay you can accept with Tab.
+
+
+ {!status?.platform_supported && status !== null && (
+
+ Auto-complete is not supported on this platform.
+
+ )}
+
+
+
+ Style preset
+ Balanced (configurable later)
+
+
+ Accept key
+ Tab
+
+
+ Debounce
+ {status?.debounce_ms ?? 120}ms
+
+
+
+ {enableError && (
+
+ {enableError}
+
+ )}
+
+
void handleEnable()}
+ disabled={isEnabling || (status !== null && !status.platform_supported)}
+ className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
+ {isEnabling ? 'Enabling...' : 'Enable Auto-Complete'}
+
+
+ )}
+
+ {/* ─── Success step ─── */}
+ {step === 'success' && (
+
+
+
+
+
Auto-Complete is Active
+
+ Start typing in any app and suggestions will appear as an inline overlay. Press Tab to accept.
+
+
+
+
+
+ Customize Settings
+
+
+ Done
+
+
+
+ )}
+
+
+
,
+ document.body
+ );
+}
diff --git a/app/src/components/skills/ScreenIntelligenceSetupModal.tsx b/app/src/components/skills/ScreenIntelligenceSetupModal.tsx
new file mode 100644
index 000000000..9f845d72f
--- /dev/null
+++ b/app/src/components/skills/ScreenIntelligenceSetupModal.tsx
@@ -0,0 +1,348 @@
+/**
+ * Screen Intelligence setup/enable modal.
+ *
+ * Guides the user through permission grants, enables the feature,
+ * and shows a success confirmation — matching the UX of third-party
+ * skill setup flows (Gmail, etc.).
+ */
+import { useEffect, useMemo, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { useNavigate } from 'react-router-dom';
+
+import { useScreenIntelligenceState } from '../../features/screen-intelligence/useScreenIntelligenceState';
+import { openhumanUpdateScreenIntelligenceSettings } from '../../utils/tauriCommands';
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+type Step = 'permissions' | 'enable' | 'success';
+
+interface Props {
+ onClose: () => void;
+ /** Skip straight to manage mode when permissions are already granted. */
+ initialStep?: Step;
+}
+
+// ─── Permission badge (reusable) ──────────────────────────────────────────────
+
+const PermissionRow = ({
+ label,
+ value,
+ onRequest,
+ isRequesting,
+}: {
+ label: string;
+ value: string;
+ onRequest: () => void;
+ isRequesting: boolean;
+}) => {
+ const granted = value === 'granted';
+ const badgeColor = granted
+ ? 'bg-sage-50 text-sage-700 border-sage-200'
+ : value === 'denied'
+ ? 'bg-coral-50 text-coral-700 border-coral-200'
+ : 'bg-stone-100 text-stone-600 border-stone-200';
+
+ return (
+
+
+ {granted ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
{label}
+
+ {granted ? (
+
+ Granted
+
+ ) : (
+
+ {isRequesting ? 'Opening...' : 'Grant'}
+
+ )}
+
+ );
+};
+
+// ─── Modal ────────────────────────────────────────────────────────────────────
+
+export default function ScreenIntelligenceSetupModal({ onClose, initialStep }: Props) {
+ const navigate = useNavigate();
+ const {
+ status,
+ isRequestingPermissions,
+ isRestartingCore,
+ lastRestartSummary,
+ lastError,
+ requestPermission,
+ refreshPermissionsWithRestart,
+ refreshStatus,
+ } = useScreenIntelligenceState({ loadVision: false });
+
+ const [isEnabling, setIsEnabling] = useState(false);
+ const [enableError, setEnableError] = useState(null);
+
+ const allGranted = useMemo(() => {
+ if (!status) return false;
+ return (
+ status.permissions.screen_recording === 'granted' &&
+ status.permissions.accessibility === 'granted' &&
+ status.permissions.input_monitoring === 'granted'
+ );
+ }, [status]);
+
+ const anyDenied = useMemo(() => {
+ if (!status) return false;
+ return (
+ status.permissions.screen_recording === 'denied' ||
+ status.permissions.accessibility === 'denied' ||
+ status.permissions.input_monitoring === 'denied'
+ );
+ }, [status]);
+
+ // Derive current step
+ const [step, setStep] = useState(initialStep ?? 'permissions');
+
+ // Auto-advance: when permissions are all granted, move past the permissions step
+ useEffect(() => {
+ if (step === 'permissions' && allGranted) {
+ setStep('enable');
+ }
+ }, [step, allGranted]);
+
+ // Close on Escape key
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
+ const handleEnable = async () => {
+ setIsEnabling(true);
+ setEnableError(null);
+ try {
+ await openhumanUpdateScreenIntelligenceSettings({ enabled: true });
+ await refreshStatus();
+ setStep('success');
+ } catch (error) {
+ setEnableError(error instanceof Error ? error.message : 'Failed to enable Screen Intelligence');
+ } finally {
+ setIsEnabling(false);
+ }
+ };
+
+ const handleGoToSettings = () => {
+ onClose();
+ navigate('/settings/screen-intelligence');
+ };
+
+ return createPortal(
+ {
+ if (e.target === e.currentTarget) onClose();
+ }}>
+
+ {/* Header */}
+
+
+
+
+
Screen Intelligence
+
+ {step === 'permissions' && 'Grant permissions'}
+ {step === 'enable' && 'Enable the skill'}
+ {step === 'success' && 'Ready to go'}
+
+
+
+
+
+
+
+
+
+
+ {/* Body */}
+
+ {/* ─── Step 1: Permissions ─── */}
+ {step === 'permissions' && (
+
+
+ Screen Intelligence needs macOS permissions to capture your screen and provide context to your AI assistant.
+
+
+
+
void requestPermission('screen_recording')}
+ isRequesting={isRequestingPermissions}
+ />
+ void requestPermission('accessibility')}
+ isRequesting={isRequestingPermissions}
+ />
+ void requestPermission('input_monitoring')}
+ isRequesting={isRequestingPermissions}
+ />
+
+
+ {anyDenied && (
+
+
After granting permissions in System Settings, click below to restart and pick up the changes.
+ {status?.permission_check_process_path && (
+
+ macOS applies privacy to:{' '}
+
+ {status.permission_check_process_path}
+
+
+ )}
+
+ )}
+
+ {lastRestartSummary && (
+
+ {lastRestartSummary}
+
+ )}
+
+ {lastError && (
+
+ {lastError}
+
+ )}
+
+
+ {anyDenied ? (
+ void refreshPermissionsWithRestart()}
+ disabled={isRestartingCore}
+ className="flex-1 rounded-xl border border-amber-300 bg-amber-50 px-3 py-2.5 text-sm font-medium text-amber-700 hover:bg-amber-100 disabled:opacity-50 transition-colors">
+ {isRestartingCore ? 'Restarting...' : 'Restart & Refresh'}
+
+ ) : (
+ void refreshStatus()}
+ disabled={isRestartingCore}
+ className="flex-1 rounded-xl border border-stone-200 bg-stone-50 px-3 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-100 disabled:opacity-50 transition-colors">
+ Refresh Status
+
+ )}
+
+
+ )}
+
+ {/* ─── Step 2: Enable ─── */}
+ {step === 'enable' && (
+
+
+
+
+
+
All permissions granted
+
+
+
+ Enable Screen Intelligence to continuously capture what's on your screen and feed useful context into your AI assistant's memory.
+
+
+
+
+ Capture mode
+ All windows (configurable later)
+
+
+ Vision model
+ Enabled
+
+
+ Panic hotkey
+ {status?.session.panic_hotkey ?? 'Cmd+Shift+.'}
+
+
+
+ {enableError && (
+
+ {enableError}
+
+ )}
+
+
void handleEnable()}
+ disabled={isEnabling}
+ className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
+ {isEnabling ? 'Enabling...' : 'Enable Screen Intelligence'}
+
+
+ )}
+
+ {/* ─── Step 3: Success ─── */}
+ {step === 'success' && (
+
+
+
+
+
Screen Intelligence is Enabled
+
+ Screen Intelligence is now enabled. Start a session from the settings panel to begin capturing screen context for your AI assistant.
+
+
+
+
+
+ Advanced Settings
+
+
+ Done
+
+
+
+ )}
+
+
+
,
+ document.body
+ );
+}
diff --git a/app/src/components/skills/VoiceSetupModal.tsx b/app/src/components/skills/VoiceSetupModal.tsx
new file mode 100644
index 000000000..11afa10f0
--- /dev/null
+++ b/app/src/components/skills/VoiceSetupModal.tsx
@@ -0,0 +1,224 @@
+/**
+ * Voice Intelligence setup/enable modal.
+ *
+ * Two-step flow: if STT model isn't downloaded, directs to Local Model
+ * settings. Otherwise, starts the voice server and shows success.
+ */
+import { useEffect, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { useNavigate } from 'react-router-dom';
+
+import type { VoiceSkillStatus } from '../../features/voice/useVoiceSkillStatus';
+import {
+ openhumanVoiceServerStart,
+ openhumanUpdateVoiceServerSettings,
+} from '../../utils/tauriCommands/voice';
+
+type Step = 'setup' | 'enable' | 'success';
+
+interface Props {
+ onClose: () => void;
+ skillStatus: VoiceSkillStatus;
+}
+
+export default function VoiceSetupModal({ onClose, skillStatus }: Props) {
+ const navigate = useNavigate();
+ const { sttModelMissing, serverStatus } = skillStatus;
+
+ const [step, setStep] = useState(sttModelMissing ? 'setup' : 'enable');
+ const [isEnabling, setIsEnabling] = useState(false);
+ const [enableError, setEnableError] = useState(null);
+
+ // Close on Escape key
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
+ const handleEnable = async () => {
+ setIsEnabling(true);
+ setEnableError(null);
+ try {
+ // Enable auto-start in settings
+ await openhumanUpdateVoiceServerSettings({ auto_start: true });
+ // Start the voice server
+ await openhumanVoiceServerStart();
+ setStep('success');
+ } catch (error) {
+ setEnableError(error instanceof Error ? error.message : 'Failed to start voice server');
+ } finally {
+ setIsEnabling(false);
+ }
+ };
+
+ const handleGoToLocalModel = () => {
+ onClose();
+ navigate('/settings/local-model');
+ };
+
+ const handleGoToSettings = () => {
+ onClose();
+ navigate('/settings/voice');
+ };
+
+ return createPortal(
+ {
+ if (e.target === e.currentTarget) onClose();
+ }}>
+
+ {/* Header */}
+
+
+
+
+
Voice Intelligence
+
+ {step === 'setup' && 'Model download required'}
+ {step === 'enable' && 'Start voice server'}
+ {step === 'success' && 'Ready to go'}
+
+
+
+
+
+
+
+
+
+
+ {/* Body */}
+
+ {/* ─── Setup step: STT model missing ─── */}
+ {step === 'setup' && (
+
+
+
+
+
+
+
Speech-to-text model not ready
+
Voice Intelligence requires a local Whisper model for transcription. Download it from the Local Model settings.
+
+
+
+
+ Once the STT model is downloaded, you can return here to enable voice dictation and voice-driven AI chat.
+
+
+
+
+ Download STT Model
+
+
+ Cancel
+
+
+
+ )}
+
+ {/* ─── Enable step ─── */}
+ {step === 'enable' && (
+
+
+
+
+
+
Speech-to-text model ready
+
+
+
+ Start the voice server to use dictation and voice-driven chat. Press the hotkey to toggle recording.
+
+
+
+
+ Hotkey
+ {serverStatus?.hotkey ?? 'Fn'}
+
+
+ Activation
+ {serverStatus?.activation_mode === 'push' ? 'Push-to-talk' : 'Tap to toggle'}
+
+
+
+ {enableError && (
+
+ {enableError}
+
+ )}
+
+
void handleEnable()}
+ disabled={isEnabling}
+ className="w-full rounded-xl bg-primary-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-50 transition-colors">
+ {isEnabling ? 'Starting...' : 'Start Voice Server'}
+
+
+ )}
+
+ {/* ─── Success step ─── */}
+ {step === 'success' && (
+
+
+
+
+
Voice Intelligence is Active
+
+ Press {serverStatus?.hotkey ?? 'Fn'} to start dictating. Your voice will be transcribed and sent to your AI assistant.
+
+
+
+
+
+ Customize Settings
+
+
+ Done
+
+
+
+ )}
+
+
+
,
+ document.body
+ );
+}
diff --git a/app/src/features/autocomplete/useAutocompleteSkillStatus.ts b/app/src/features/autocomplete/useAutocompleteSkillStatus.ts
new file mode 100644
index 000000000..4b054f43e
--- /dev/null
+++ b/app/src/features/autocomplete/useAutocompleteSkillStatus.ts
@@ -0,0 +1,102 @@
+/**
+ * Derives a skill-card-friendly status for Text Auto-Complete,
+ * matching the state vocabulary used by third-party skills (Gmail, etc.).
+ */
+import { useMemo } from 'react';
+
+import type { SkillConnectionStatus } from '../../lib/skills/types';
+import { useCoreState } from '../../providers/CoreStateProvider';
+
+export interface AutocompleteSkillStatus {
+ connectionStatus: SkillConnectionStatus;
+ statusDot: string;
+ statusLabel: string;
+ statusColor: string;
+ ctaLabel: string;
+ ctaVariant: 'primary' | 'sage' | 'amber';
+ /** True when the platform doesn't support autocomplete. */
+ platformUnsupported: boolean;
+}
+
+export function useAutocompleteSkillStatus(): AutocompleteSkillStatus {
+ const { snapshot } = useCoreState();
+ const status = snapshot.runtime.autocomplete;
+
+ return useMemo(() => {
+ // No status yet (core not ready or not in Tauri)
+ if (!status) {
+ return {
+ connectionStatus: 'offline' as SkillConnectionStatus,
+ statusDot: 'bg-stone-400',
+ statusLabel: 'Offline',
+ statusColor: 'text-stone-500',
+ ctaLabel: 'Enable',
+ ctaVariant: 'sage' as const,
+ platformUnsupported: false,
+ };
+ }
+
+ if (!status.platform_supported) {
+ return {
+ connectionStatus: 'offline' as SkillConnectionStatus,
+ statusDot: 'bg-stone-400',
+ statusLabel: 'Unsupported',
+ statusColor: 'text-stone-500',
+ ctaLabel: 'Details',
+ ctaVariant: 'primary' as const,
+ platformUnsupported: true,
+ };
+ }
+
+ // Running — fully active (checked before error so a stale last_error
+ // doesn't mask a successfully running service)
+ if (status.running) {
+ return {
+ connectionStatus: 'connected' as SkillConnectionStatus,
+ statusDot: 'bg-sage-500',
+ statusLabel: 'Active',
+ statusColor: 'text-sage-400',
+ ctaLabel: 'Manage',
+ ctaVariant: 'primary' as const,
+ platformUnsupported: false,
+ };
+ }
+
+ // Error state (only when not running)
+ if (status.last_error) {
+ return {
+ connectionStatus: 'error' as SkillConnectionStatus,
+ statusDot: 'bg-coral-500',
+ statusLabel: 'Error',
+ statusColor: 'text-coral-400',
+ ctaLabel: 'Retry',
+ ctaVariant: 'amber' as const,
+ platformUnsupported: false,
+ };
+ }
+
+ // Enabled in config but not running
+ if (status.enabled) {
+ return {
+ connectionStatus: 'disconnected' as SkillConnectionStatus,
+ statusDot: 'bg-stone-400',
+ statusLabel: 'Enabled',
+ statusColor: 'text-stone-400',
+ ctaLabel: 'Manage',
+ ctaVariant: 'primary' as const,
+ platformUnsupported: false,
+ };
+ }
+
+ // Not enabled
+ return {
+ connectionStatus: 'offline' as SkillConnectionStatus,
+ statusDot: 'bg-stone-400',
+ statusLabel: 'Disabled',
+ statusColor: 'text-stone-500',
+ ctaLabel: 'Enable',
+ ctaVariant: 'sage' as const,
+ platformUnsupported: false,
+ };
+ }, [status]);
+}
diff --git a/app/src/features/screen-intelligence/useScreenIntelligenceSkillStatus.ts b/app/src/features/screen-intelligence/useScreenIntelligenceSkillStatus.ts
new file mode 100644
index 000000000..b6d6121cf
--- /dev/null
+++ b/app/src/features/screen-intelligence/useScreenIntelligenceSkillStatus.ts
@@ -0,0 +1,115 @@
+/**
+ * Derives a skill-card-friendly status for Screen Intelligence,
+ * matching the state vocabulary used by third-party skills (Gmail, etc.).
+ */
+import { useMemo } from 'react';
+
+import type { SkillConnectionStatus } from '../../lib/skills/types';
+import { useCoreState } from '../../providers/CoreStateProvider';
+
+export interface ScreenIntelligenceSkillStatus {
+ connectionStatus: SkillConnectionStatus;
+ statusDot: string;
+ statusLabel: string;
+ statusColor: string;
+ ctaLabel: string;
+ ctaVariant: 'primary' | 'sage' | 'amber';
+ /** True when all three macOS permissions are granted. */
+ allPermissionsGranted: boolean;
+ /** True when the platform doesn't support screen intelligence. */
+ platformUnsupported: boolean;
+}
+
+export function useScreenIntelligenceSkillStatus(): ScreenIntelligenceSkillStatus {
+ const { snapshot } = useCoreState();
+ const status = snapshot.runtime.screenIntelligence;
+
+ return useMemo(() => {
+ // No status yet (core not ready or not in Tauri)
+ if (!status) {
+ return {
+ connectionStatus: 'offline' as SkillConnectionStatus,
+ statusDot: 'bg-stone-400',
+ statusLabel: 'Offline',
+ statusColor: 'text-stone-500',
+ ctaLabel: 'Enable',
+ ctaVariant: 'sage' as const,
+ allPermissionsGranted: false,
+ platformUnsupported: false,
+ };
+ }
+
+ if (!status.platform_supported) {
+ return {
+ connectionStatus: 'offline' as SkillConnectionStatus,
+ statusDot: 'bg-stone-400',
+ statusLabel: 'Unsupported',
+ statusColor: 'text-stone-500',
+ ctaLabel: 'Details',
+ ctaVariant: 'primary' as const,
+ allPermissionsGranted: false,
+ platformUnsupported: true,
+ };
+ }
+
+ const { permissions, session, config } = status;
+ const allGranted =
+ permissions.screen_recording === 'granted' &&
+ permissions.accessibility === 'granted' &&
+ permissions.input_monitoring === 'granted';
+
+ // Permissions missing — needs setup
+ if (!allGranted) {
+ return {
+ connectionStatus: 'setup_required' as SkillConnectionStatus,
+ statusDot: 'bg-primary-400',
+ statusLabel: 'Setup',
+ statusColor: 'text-primary-400',
+ ctaLabel: 'Setup',
+ ctaVariant: 'primary' as const,
+ allPermissionsGranted: false,
+ platformUnsupported: false,
+ };
+ }
+
+ // Session active — fully connected
+ if (session.active) {
+ return {
+ connectionStatus: 'connected' as SkillConnectionStatus,
+ statusDot: 'bg-sage-500',
+ statusLabel: 'Active',
+ statusColor: 'text-sage-400',
+ ctaLabel: 'Manage',
+ ctaVariant: 'primary' as const,
+ allPermissionsGranted: true,
+ platformUnsupported: false,
+ };
+ }
+
+ // Permissions granted, enabled in config, but session not active
+ if (config.enabled) {
+ return {
+ connectionStatus: 'disconnected' as SkillConnectionStatus,
+ statusDot: 'bg-stone-400',
+ statusLabel: 'Enabled',
+ statusColor: 'text-stone-400',
+ ctaLabel: 'Manage',
+ ctaVariant: 'primary' as const,
+ allPermissionsGranted: true,
+ platformUnsupported: false,
+ };
+ }
+
+ // Permissions granted but not enabled
+ return {
+ connectionStatus: 'offline' as SkillConnectionStatus,
+ statusDot: 'bg-stone-400',
+ statusLabel: 'Disabled',
+ statusColor: 'text-stone-500',
+ ctaLabel: 'Enable',
+ ctaVariant: 'sage' as const,
+ allPermissionsGranted: true,
+ platformUnsupported: false,
+ };
+ }, [status]);
+}
diff --git a/app/src/features/voice/useVoiceSkillStatus.ts b/app/src/features/voice/useVoiceSkillStatus.ts
new file mode 100644
index 000000000..36167db21
--- /dev/null
+++ b/app/src/features/voice/useVoiceSkillStatus.ts
@@ -0,0 +1,156 @@
+/**
+ * Derives a skill-card-friendly status for Voice Intelligence,
+ * matching the state vocabulary used by third-party skills (Gmail, etc.).
+ *
+ * Voice has a dependency on Local AI models (STT must be downloaded),
+ * so the status reflects that prerequisite.
+ */
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+import type { SkillConnectionStatus } from '../../lib/skills/types';
+import { useCoreState } from '../../providers/CoreStateProvider';
+import { isTauri } from '../../utils/tauriCommands/common';
+import {
+ openhumanVoiceServerStatus,
+ openhumanVoiceStatus,
+ type VoiceServerStatus,
+ type VoiceStatus,
+} from '../../utils/tauriCommands/voice';
+
+export interface VoiceSkillStatus {
+ connectionStatus: SkillConnectionStatus;
+ statusDot: string;
+ statusLabel: string;
+ statusColor: string;
+ ctaLabel: string;
+ ctaVariant: 'primary' | 'sage' | 'amber';
+ /** True when STT model is not yet downloaded. */
+ sttModelMissing: boolean;
+ /** Voice system availability info (null before first fetch). */
+ voiceStatus: VoiceStatus | null;
+ /** Voice server runtime state (null before first fetch). */
+ serverStatus: VoiceServerStatus | null;
+}
+
+export function useVoiceSkillStatus(): VoiceSkillStatus {
+ const { snapshot } = useCoreState();
+ const localAi = snapshot.runtime.localAi;
+
+ const [voiceStatus, setVoiceStatus] = useState(null);
+ const [serverStatus, setServerStatus] = useState(null);
+
+ const fetchStatuses = useCallback(async () => {
+ if (!isTauri()) return;
+ try {
+ const [vs, ss] = await Promise.all([openhumanVoiceStatus(), openhumanVoiceServerStatus()]);
+ setVoiceStatus(vs);
+ setServerStatus(ss);
+ } catch (err) {
+ console.debug('[voice-skill-status] status fetch failed, will retry on next poll:', err);
+ }
+ }, []);
+
+ // Poll voice status every 3s (lighter than the panel's 2s — just for card state)
+ useEffect(() => {
+ void fetchStatuses();
+ const id = window.setInterval(() => void fetchStatuses(), 3000);
+ return () => window.clearInterval(id);
+ }, [fetchStatuses]);
+
+ const sttReady = useMemo(() => {
+ if (!voiceStatus) return false;
+ if (!voiceStatus.stt_available) return false;
+ // Also check Local AI asset state if available
+ if (localAi && localAi.stt_state !== 'ready') return false;
+ return true;
+ }, [voiceStatus, localAi]);
+
+ return useMemo(() => {
+ // No data yet
+ if (!voiceStatus || !serverStatus) {
+ return {
+ connectionStatus: 'offline' as SkillConnectionStatus,
+ statusDot: 'bg-stone-400',
+ statusLabel: 'Offline',
+ statusColor: 'text-stone-500',
+ ctaLabel: 'Enable',
+ ctaVariant: 'sage' as const,
+ sttModelMissing: false,
+ voiceStatus,
+ serverStatus,
+ };
+ }
+
+ // STT model not downloaded — needs setup
+ if (!sttReady) {
+ return {
+ connectionStatus: 'setup_required' as SkillConnectionStatus,
+ statusDot: 'bg-primary-400',
+ statusLabel: 'Setup',
+ statusColor: 'text-primary-400',
+ ctaLabel: 'Setup',
+ ctaVariant: 'primary' as const,
+ sttModelMissing: true,
+ voiceStatus,
+ serverStatus,
+ };
+ }
+
+ // Error
+ if (serverStatus.last_error) {
+ return {
+ connectionStatus: 'error' as SkillConnectionStatus,
+ statusDot: 'bg-coral-500',
+ statusLabel: 'Error',
+ statusColor: 'text-coral-400',
+ ctaLabel: 'Retry',
+ ctaVariant: 'amber' as const,
+ sttModelMissing: false,
+ voiceStatus,
+ serverStatus,
+ };
+ }
+
+ // Active states: recording, transcribing, or idle (server running)
+ if (serverStatus.state === 'recording' || serverStatus.state === 'transcribing') {
+ return {
+ connectionStatus: 'connecting' as SkillConnectionStatus,
+ statusDot: 'bg-amber-500 animate-pulse',
+ statusLabel: serverStatus.state === 'recording' ? 'Recording' : 'Transcribing',
+ statusColor: 'text-amber-400',
+ ctaLabel: 'Manage',
+ ctaVariant: 'primary' as const,
+ sttModelMissing: false,
+ voiceStatus,
+ serverStatus,
+ };
+ }
+
+ if (serverStatus.state === 'idle') {
+ return {
+ connectionStatus: 'connected' as SkillConnectionStatus,
+ statusDot: 'bg-sage-500',
+ statusLabel: 'Active',
+ statusColor: 'text-sage-400',
+ ctaLabel: 'Manage',
+ ctaVariant: 'primary' as const,
+ sttModelMissing: false,
+ voiceStatus,
+ serverStatus,
+ };
+ }
+
+ // Stopped
+ return {
+ connectionStatus: 'offline' as SkillConnectionStatus,
+ statusDot: 'bg-stone-400',
+ statusLabel: 'Stopped',
+ statusColor: 'text-stone-500',
+ ctaLabel: 'Enable',
+ ctaVariant: 'sage' as const,
+ sttModelMissing: false,
+ voiceStatus,
+ serverStatus,
+ };
+ }, [voiceStatus, serverStatus, sttReady]);
+}
diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx
index 649cce196..3de88dab9 100644
--- a/app/src/pages/Skills.tsx
+++ b/app/src/pages/Skills.tsx
@@ -2,11 +2,17 @@ import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ChannelSetupModal from '../components/channels/ChannelSetupModal';
+import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal';
+import ScreenIntelligenceSetupModal from '../components/skills/ScreenIntelligenceSetupModal';
import { SKILL_ICONS, type SkillListEntry } from '../components/skills/shared';
import UnifiedSkillCard, { ThirdPartySkillCard } from '../components/skills/SkillCard';
import SkillCategoryFilter, { type SkillCategory } from '../components/skills/SkillCategoryFilter';
import SkillSearchBar from '../components/skills/SkillSearchBar';
import SkillSetupModal from '../components/skills/SkillSetupModal';
+import VoiceSetupModal from '../components/skills/VoiceSetupModal';
+import { useAutocompleteSkillStatus } from '../features/autocomplete/useAutocompleteSkillStatus';
+import { useScreenIntelligenceSkillStatus } from '../features/screen-intelligence/useScreenIntelligenceSkillStatus';
+import { useVoiceSkillStatus } from '../features/voice/useVoiceSkillStatus';
import { useChannelDefinitions } from '../hooks/useChannelDefinitions';
import { useAvailableSkills } from '../lib/skills/hooks';
import { installSkill } from '../lib/skills/skillsApi';
@@ -147,6 +153,12 @@ export default function Skills() {
const [activeSkillHasSetup, setActiveSkillHasSetup] = useState(false);
const [channelModalDef, setChannelModalDef] = useState(null);
const [installing, setInstalling] = useState(null);
+ const [screenIntelligenceModalOpen, setScreenIntelligenceModalOpen] = useState(false);
+ const [autocompleteModalOpen, setAutocompleteModalOpen] = useState(false);
+ const [voiceModalOpen, setVoiceModalOpen] = useState(false);
+ const screenIntelligenceStatus = useScreenIntelligenceSkillStatus();
+ const autocompleteStatus = useAutocompleteSkillStatus();
+ const voiceStatus = useVoiceSkillStatus();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState('All');
@@ -318,6 +330,90 @@ export default function Skills() {
{items.map(item => {
if (item.kind === 'builtin') {
+ // Screen Intelligence gets a state-aware card
+ if (item.id === 'screen-intelligence') {
+ return (
+
{
+ if (screenIntelligenceStatus.platformUnsupported) {
+ navigate(item.route!);
+ return;
+ }
+ if (
+ screenIntelligenceStatus.connectionStatus === 'connected' ||
+ screenIntelligenceStatus.connectionStatus === 'disconnected'
+ ) {
+ navigate(item.route!);
+ return;
+ }
+ setScreenIntelligenceModalOpen(true);
+ }}
+ />
+ );
+ }
+ // Text Auto-Complete gets a state-aware card
+ if (item.id === 'text-autocomplete') {
+ return (
+ {
+ if (
+ autocompleteStatus.platformUnsupported ||
+ autocompleteStatus.connectionStatus === 'connected' ||
+ autocompleteStatus.connectionStatus === 'disconnected'
+ ) {
+ navigate(item.route!);
+ return;
+ }
+ setAutocompleteModalOpen(true);
+ }}
+ />
+ );
+ }
+ // Voice Intelligence gets a state-aware card
+ if (item.id === 'voice-stt') {
+ return (
+ {
+ if (
+ voiceStatus.connectionStatus === 'connected' ||
+ voiceStatus.connectionStatus === 'connecting' ||
+ voiceStatus.connectionStatus === 'disconnected'
+ ) {
+ navigate(item.route!);
+ return;
+ }
+ setVoiceModalOpen(true);
+ }}
+ />
+ );
+ }
return (
setChannelModalDef(null)} />
)}
+
+ {screenIntelligenceModalOpen && (
+ setScreenIntelligenceModalOpen(false)}
+ initialStep={screenIntelligenceStatus.allPermissionsGranted ? 'enable' : 'permissions'}
+ />
+ )}
+
+ {autocompleteModalOpen && (
+ setAutocompleteModalOpen(false)} />
+ )}
+
+ {voiceModalOpen && (
+ setVoiceModalOpen(false)} skillStatus={voiceStatus} />
+ )}
);
}