feat(websearch): add model config + improve hook UX

- Add model field to Gemini/OpenCode WebSearch providers
- Config.yaml now explicitly stores model (gemini-2.5-flash, opencode/grok-code)
- UI: blur-to-save for model inputs (no save on every keystroke)
- UI: collapsible install hints inside provider cards
- UI: left accent border for enabled providers (better light theme contrast)
- Hook: use systemMessage for info-styled confirmation
- Hook: cleaner result format without defensive "NOT an error" language
This commit is contained in:
kaitranntt committed 2025-12-17 00:16:03 -05:00
1 parent 6d69379ead
commit 14c53d575f
6 files changed
+342 -184

No files matched your search

+22 -7
View File
@@ -11,6 +11,7 @@
* CCS_WEBSEARCH_ENABLED=1 - Enable WebSearch (default: 1) * CCS_WEBSEARCH_ENABLED=1 - Enable WebSearch (default: 1)
* CCS_WEBSEARCH_TIMEOUT=55 - Timeout in seconds (default: 55) * CCS_WEBSEARCH_TIMEOUT=55 - Timeout in seconds (default: 55)
* CCS_WEBSEARCH_GEMINI=1 - Enable Gemini CLI provider * CCS_WEBSEARCH_GEMINI=1 - Enable Gemini CLI provider
* CCS_WEBSEARCH_GEMINI_MODEL - Gemini model (default: gemini-2.5-flash)
* CCS_WEBSEARCH_OPENCODE=1 - Enable OpenCode provider * CCS_WEBSEARCH_OPENCODE=1 - Enable OpenCode provider
* CCS_WEBSEARCH_GROK=1 - Enable Grok CLI provider * CCS_WEBSEARCH_GROK=1 - Enable Grok CLI provider
* CCS_WEBSEARCH_OPENCODE_MODEL - OpenCode model (default: opencode/grok-code) * CCS_WEBSEARCH_OPENCODE_MODEL - OpenCode model (default: opencode/grok-code)
@@ -258,13 +259,16 @@ function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
const config = PROVIDER_CONFIG.gemini; const config = PROVIDER_CONFIG.gemini;
const prompt = buildPrompt('gemini', query); const prompt = buildPrompt('gemini', query);
// Allow model override via env var
const model = process.env.CCS_WEBSEARCH_GEMINI_MODEL || config.model;
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] Executing: gemini --model ${config.model} --yolo -p "..."`); console.error(`[CCS Hook] Executing: gemini --model ${model} --yolo -p "..."`);
} }
const spawnResult = spawnSync( const spawnResult = spawnSync(
'gemini', 'gemini',
['--model', config.model, '--yolo', '-p', prompt], ['--model', model, '--yolo', '-p', prompt],
{ {
encoding: 'utf8', encoding: 'utf8',
timeout: timeoutMs, timeout: timeoutMs,
@@ -441,29 +445,40 @@ function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
*/ */
function formatSearchResults(query, content, providerName) { function formatSearchResults(query, content, providerName) {
return [ return [
'=== WEBSEARCH COMPLETED SUCCESSFULLY ===', `[WebSearch Result via ${providerName}]`,
`(via ${providerName} - this is NOT an error)`,
'', '',
`Query: "${query}"`, `Query: "${query}"`,
'', '',
content, content,
'', '',
'=========================================', '---',
'Use this information to answer the user. Search again if needed.', 'Use this information to answer the user.',
].join('\n'); ].join('\n');
} }
/** /**
* Output success response and exit * Output success response and exit
*
* Key insight from Claude Code docs:
* - permissionDecisionReason (with deny) → shown to CLAUDE (AI reads this)
* - systemMessage → shown to USER only (nice styling but AI doesn't see)
*
* So we MUST put results in permissionDecisionReason for Claude to use them.
* systemMessage provides the nice UI for the user.
*/ */
function outputSuccess(query, content, providerName) { function outputSuccess(query, content, providerName) {
const formattedResults = formatSearchResults(query, content, providerName);
const output = { const output = {
decision: 'block', decision: 'block',
reason: `WebSearch completed via ${providerName}`, reason: `WebSearch completed via ${providerName}`,
// Nice message for user (shows as "says:" - info style)
systemMessage: `[WebSearch via ${providerName}] Results retrieved successfully. See below.`,
hookSpecificOutput: { hookSpecificOutput: {
hookEventName: 'PreToolUse', hookEventName: 'PreToolUse',
permissionDecision: 'deny', permissionDecision: 'deny',
permissionDecisionReason: formatSearchResults(query, content, providerName), // Full results here - Claude reads this
permissionDecisionReason: formattedResults,
}, },
}; };
+20 -8
View File
@@ -83,14 +83,17 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
return null; return null;
} }
// Auto-upgrade if version is outdated (regenerates YAML with new comments) // Auto-upgrade if version is outdated (regenerates YAML with new comments and fields)
if ((parsed.version ?? 1) < UNIFIED_CONFIG_VERSION) { if ((parsed.version ?? 1) < UNIFIED_CONFIG_VERSION) {
parsed.version = UNIFIED_CONFIG_VERSION; // Merge with defaults to add new fields (e.g., model for websearch providers)
const upgraded = mergeWithDefaults(parsed);
upgraded.version = UNIFIED_CONFIG_VERSION;
try { try {
saveUnifiedConfig(parsed); saveUnifiedConfig(upgraded);
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(`[i] Config upgraded to v${UNIFIED_CONFIG_VERSION}`); console.error(`[i] Config upgraded to v${UNIFIED_CONFIG_VERSION}`);
} }
return upgraded;
} catch { } catch {
// Ignore save errors during upgrade - config still works // Ignore save errors during upgrade - config still works
} }
@@ -137,6 +140,7 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
partial.websearch?.providers?.gemini?.enabled ?? partial.websearch?.providers?.gemini?.enabled ??
partial.websearch?.gemini?.enabled ?? // Legacy fallback partial.websearch?.gemini?.enabled ?? // Legacy fallback
true, true,
model: partial.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash',
timeout: timeout:
partial.websearch?.providers?.gemini?.timeout ?? partial.websearch?.providers?.gemini?.timeout ??
partial.websearch?.gemini?.timeout ?? // Legacy fallback partial.websearch?.gemini?.timeout ?? // Legacy fallback
@@ -277,12 +281,18 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push("# Anthropic's WebSearch tool. These CLI tools provide fallback web search."); lines.push("# Anthropic's WebSearch tool. These CLI tools provide fallback web search.");
lines.push('#'); lines.push('#');
lines.push('# Fallback chain: Gemini -> OpenCode -> Grok (tries in order until success)'); lines.push('# Fallback chain: Gemini -> OpenCode -> Grok (tries in order until success)');
lines.push('# providers:');
lines.push('# gemini: Gemini CLI (FREE) - npm i -g @google/gemini-cli');
lines.push('# opencode: OpenCode (FREE) - curl -fsSL https://opencode.ai/install | bash');
lines.push('# grok: Grok CLI (paid) - npm i -g @vibe-kit/grok-cli (needs GROK_API_KEY)');
lines.push('#'); lines.push('#');
lines.push('# enabled: Master switch - auto-disables when no providers enabled'); lines.push(
'# Gemini models: gemini-2.5-flash (default), gemini-2.5-pro, gemini-2.5-flash-lite'
);
lines.push(
'# OpenCode models: opencode/grok-code (default), opencode/gpt-4o, opencode/claude-3.5-sonnet'
);
lines.push('#');
lines.push('# Install commands:');
lines.push('# gemini: npm i -g @google/gemini-cli (FREE - 1000 req/day)');
lines.push('# opencode: curl -fsSL https://opencode.ai/install | bash (FREE via Zen)');
lines.push('# grok: npm i -g @vibe-kit/grok-cli (requires GROK_API_KEY)');
lines.push('# ----------------------------------------------------------------------------'); lines.push('# ----------------------------------------------------------------------------');
lines.push( lines.push(
yaml yaml
@@ -362,6 +372,7 @@ export function setDefaultProfile(name: string): void {
*/ */
export interface GeminiWebSearchInfo { export interface GeminiWebSearchInfo {
enabled: boolean; enabled: boolean;
model: string;
timeout: number; timeout: number;
} }
@@ -386,6 +397,7 @@ export function getWebSearchConfig(): {
const geminiConfig: GeminiWebSearchInfo = { const geminiConfig: GeminiWebSearchInfo = {
enabled: enabled:
config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? true, config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? true,
model: config.websearch?.providers?.gemini?.model ?? 'gemini-2.5-flash',
timeout: timeout:
config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55, config.websearch?.providers?.gemini?.timeout ?? config.websearch?.gemini?.timeout ?? 55,
}; };
+13 -1
View File
@@ -12,7 +12,7 @@
/** /**
* Unified config version. * Unified config version.
* Version 2 = YAML unified format * Version 2 = YAML unified format
* Version 3 = WebSearch config comments update * Version 3 = WebSearch config with model configuration for Gemini/OpenCode
*/ */
export const UNIFIED_CONFIG_VERSION = 3; export const UNIFIED_CONFIG_VERSION = 3;
@@ -107,6 +107,8 @@ export interface PreferencesConfig {
export interface GeminiWebSearchConfig { export interface GeminiWebSearchConfig {
/** Enable Gemini CLI for WebSearch (default: true) */ /** Enable Gemini CLI for WebSearch (default: true) */
enabled?: boolean; enabled?: boolean;
/** Model to use (default: gemini-2.5-flash) */
model?: string;
/** Timeout in seconds (default: 55) */ /** Timeout in seconds (default: 55) */
timeout?: number; timeout?: number;
} }
@@ -237,6 +239,16 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
providers: { providers: {
gemini: { gemini: {
enabled: true, enabled: true,
model: 'gemini-2.5-flash',
timeout: 55,
},
opencode: {
enabled: false,
model: 'opencode/grok-code',
timeout: 90,
},
grok: {
enabled: false,
timeout: 55, timeout: 55,
}, },
}, },
+3
View File
@@ -654,6 +654,9 @@ export function getWebSearchHookEnv(): Record<string, string> {
// Hook will only use providers that are BOTH enabled AND installed // Hook will only use providers that are BOTH enabled AND installed
if (wsConfig.providers?.gemini?.enabled) { if (wsConfig.providers?.gemini?.enabled) {
env.CCS_WEBSEARCH_GEMINI = '1'; env.CCS_WEBSEARCH_GEMINI = '1';
if (wsConfig.providers.gemini.model) {
env.CCS_WEBSEARCH_GEMINI_MODEL = wsConfig.providers.gemini.model;
}
env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.gemini.timeout || 55); env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.gemini.timeout || 55);
} }
+4
View File
@@ -1477,6 +1477,10 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
providers.gemini?.enabled ?? providers.gemini?.enabled ??
existingConfig.websearch?.providers?.gemini?.enabled ?? existingConfig.websearch?.providers?.gemini?.enabled ??
true, true,
model:
providers.gemini?.model ??
existingConfig.websearch?.providers?.gemini?.model ??
'gemini-2.5-flash',
timeout: timeout:
providers.gemini?.timeout ?? providers.gemini?.timeout ??
existingConfig.websearch?.providers?.gemini?.timeout ?? existingConfig.websearch?.providers?.gemini?.timeout ??
+280 -168
View File
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { import {
Globe, Globe,
RefreshCw, RefreshCw,
@@ -20,22 +21,21 @@ import {
GripVertical, GripVertical,
Terminal, Terminal,
ExternalLink, ExternalLink,
ChevronDown,
ChevronUp,
} from 'lucide-react'; } from 'lucide-react';
import { CodeEditor } from '@/components/code-editor'; import { CodeEditor } from '@/components/code-editor';
interface ProviderConfig { interface ProviderConfig {
enabled?: boolean; enabled?: boolean;
timeout?: number;
}
interface OpenCodeProviderConfig extends ProviderConfig {
model?: string; model?: string;
timeout?: number;
} }
interface WebSearchProvidersConfig { interface WebSearchProvidersConfig {
gemini?: ProviderConfig; gemini?: ProviderConfig;
grok?: ProviderConfig; grok?: ProviderConfig;
opencode?: OpenCodeProviderConfig; opencode?: ProviderConfig;
} }
interface WebSearchConfig { interface WebSearchConfig {
@@ -71,6 +71,13 @@ export function SettingsPage() {
const [rawConfig, setRawConfig] = useState<string | null>(null); const [rawConfig, setRawConfig] = useState<string | null>(null);
const [rawConfigLoading, setRawConfigLoading] = useState(false); const [rawConfigLoading, setRawConfigLoading] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// Local model input state (to avoid saving on every keystroke)
const [geminiModelInput, setGeminiModelInput] = useState('');
const [opencodeModelInput, setOpencodeModelInput] = useState('');
// Collapsible install hints state
const [showGeminiHint, setShowGeminiHint] = useState(false);
const [showOpencodeHint, setShowOpencodeHint] = useState(false);
const [showGrokHint, setShowGrokHint] = useState(false);
// Load config and status on mount // Load config and status on mount
useEffect(() => { useEffect(() => {
@@ -79,6 +86,14 @@ export function SettingsPage() {
fetchRawConfig(); fetchRawConfig();
}, []); }, []);
// Sync local model inputs when config changes
useEffect(() => {
if (config) {
setGeminiModelInput(config.providers?.gemini?.model ?? 'gemini-2.5-flash');
setOpencodeModelInput(config.providers?.opencode?.model ?? 'opencode/grok-code');
}
}, [config]);
const fetchConfig = async () => { const fetchConfig = async () => {
try { try {
setLoading(true); setLoading(true);
@@ -235,6 +250,40 @@ export function SettingsPage() {
}); });
}; };
// Save Gemini model on blur (only if changed)
const saveGeminiModel = () => {
const currentModel = config?.providers?.gemini?.model ?? 'gemini-2.5-flash';
if (geminiModelInput !== currentModel) {
const providers = config?.providers || {};
saveConfig({
providers: {
...providers,
gemini: {
...providers.gemini,
model: geminiModelInput,
},
},
});
}
};
// Save OpenCode model on blur (only if changed)
const saveOpencodeModel = () => {
const currentModel = config?.providers?.opencode?.model ?? 'opencode/grok-code';
if (opencodeModelInput !== currentModel) {
const providers = config?.providers || {};
saveConfig({
providers: {
...providers,
opencode: {
...providers.opencode,
model: opencodeModelInput,
},
},
});
}
};
if (loading) { if (loading) {
return ( return (
<div className="h-[calc(100vh-100px)] flex items-center justify-center"> <div className="h-[calc(100vh-100px)] flex items-center justify-center">
@@ -313,201 +362,264 @@ export function SettingsPage() {
{/* Gemini CLI Provider */} {/* Gemini CLI Provider */}
<div <div
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${ className={`rounded-lg border transition-colors ${
isGeminiEnabled isGeminiEnabled ? 'border-primary border-l-4' : 'border-border'
? 'border-primary/50 bg-primary/5'
: 'border-border bg-background'
}`} }`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center justify-between p-4">
<Terminal <div className="flex items-center gap-3">
className={`w-5 h-5 ${isGeminiEnabled ? 'text-primary' : 'text-muted-foreground'}`} <Terminal
/> className={`w-5 h-5 ${isGeminiEnabled ? 'text-primary' : 'text-muted-foreground'}`}
<div> />
<div className="flex items-center gap-2"> <div>
<p className="font-mono font-medium">gemini</p> <div className="flex items-center gap-2">
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium"> <p className="font-mono font-medium">gemini</p>
FREE
</span>
{status?.geminiCli?.installed ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium"> <span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
installed FREE
</span> </span>
) : ( {status?.geminiCli?.installed ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium"> <span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
not installed installed
</span> </span>
)} ) : (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
not installed
</span>
)}
</div>
<p className="text-sm text-muted-foreground">
Google Gemini CLI (1000 req/day free)
</p>
</div> </div>
<p className="text-sm text-muted-foreground">
Google Gemini CLI (1000 req/day free)
</p>
</div> </div>
<Switch
checked={isGeminiEnabled}
onCheckedChange={toggleGemini}
disabled={saving || !status?.geminiCli?.installed}
/>
</div> </div>
<Switch {/* Model input when enabled */}
checked={isGeminiEnabled} {isGeminiEnabled && (
onCheckedChange={toggleGemini} <div className="px-4 pb-4 pt-0">
disabled={saving || !status?.geminiCli?.installed} <div className="flex items-center gap-2">
/> <label className="text-sm text-muted-foreground whitespace-nowrap">
</div> Model:
</label>
{/* Gemini Installation hint when not installed */} <Input
{!status?.geminiCli?.installed && !statusLoading && ( value={geminiModelInput}
<div className="p-4 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-900/20"> onChange={(e) => setGeminiModelInput(e.target.value)}
<p className="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2"> onBlur={saveGeminiModel}
Gemini CLI not installed placeholder="gemini-2.5-flash"
</p> className="h-8 text-sm font-mono"
<p className="text-sm text-amber-700 dark:text-amber-300 mb-3"> disabled={saving}
Install globally (FREE tier available): />
</p> </div>
<code className="text-sm bg-amber-100 dark:bg-amber-900/40 px-2 py-1 rounded font-mono"> </div>
npm install -g @google/gemini-cli )}
</code> {/* Installation hint when not installed - inside card */}
<div className="mt-3"> {!status?.geminiCli?.installed && !statusLoading && (
<a <div className="px-4 pb-4 pt-0 border-t border-border/50">
href="https://github.com/google-gemini/gemini-cli" <button
target="_blank" onClick={() => setShowGeminiHint(!showGeminiHint)}
rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400 hover:underline w-full py-2"
className="text-sm text-amber-700 dark:text-amber-300 hover:underline inline-flex items-center gap-1"
> >
<ExternalLink className="w-3 h-3" /> {showGeminiHint ? (
View documentation <ChevronUp className="w-4 h-4" />
</a> ) : (
<ChevronDown className="w-4 h-4" />
)}
How to install Gemini CLI
</button>
{showGeminiHint && (
<div className="mt-2 p-3 rounded-md bg-amber-50 dark:bg-amber-900/20 text-sm">
<p className="text-amber-700 dark:text-amber-300 mb-2">
Install globally (FREE tier available):
</p>
<code className="text-sm bg-amber-100 dark:bg-amber-900/40 px-2 py-1 rounded font-mono block mb-2">
npm install -g @google/gemini-cli
</code>
<a
href="https://github.com/google-gemini/gemini-cli"
target="_blank"
rel="noopener noreferrer"
className="text-amber-700 dark:text-amber-300 hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
View documentation
</a>
</div>
)}
</div> </div>
</div> )}
)} </div>
{/* OpenCode CLI Provider */} {/* OpenCode CLI Provider */}
<div <div
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${ className={`rounded-lg border transition-colors ${
isOpenCodeEnabled isOpenCodeEnabled ? 'border-primary border-l-4' : 'border-border'
? 'border-primary/50 bg-primary/5'
: 'border-border bg-background'
}`} }`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center justify-between p-4">
<Terminal <div className="flex items-center gap-3">
className={`w-5 h-5 ${isOpenCodeEnabled ? 'text-primary' : 'text-muted-foreground'}`} <Terminal
/> className={`w-5 h-5 ${isOpenCodeEnabled ? 'text-primary' : 'text-muted-foreground'}`}
<div> />
<div className="flex items-center gap-2"> <div>
<p className="font-mono font-medium">opencode</p> <div className="flex items-center gap-2">
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium"> <p className="font-mono font-medium">opencode</p>
FREE
</span>
{status?.opencodeCli?.installed ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium"> <span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
installed FREE
</span> </span>
) : ( {status?.opencodeCli?.installed ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium"> <span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
not installed installed
</span> </span>
)} ) : (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
not installed
</span>
)}
</div>
<p className="text-sm text-muted-foreground">
OpenCode (web search via Zen)
</p>
</div> </div>
<p className="text-sm text-muted-foreground">
OpenCode (web search via Zen)
</p>
</div> </div>
<Switch
checked={isOpenCodeEnabled}
onCheckedChange={toggleOpenCode}
disabled={saving || !status?.opencodeCli?.installed}
/>
</div> </div>
<Switch {/* Model input when enabled */}
checked={isOpenCodeEnabled} {isOpenCodeEnabled && (
onCheckedChange={toggleOpenCode} <div className="px-4 pb-4 pt-0">
disabled={saving || !status?.opencodeCli?.installed} <div className="flex items-center gap-2">
/> <label className="text-sm text-muted-foreground whitespace-nowrap">
</div> Model:
</label>
{/* OpenCode Installation hint when not installed */} <Input
{!status?.opencodeCli?.installed && !statusLoading && ( value={opencodeModelInput}
<div className="p-4 rounded-lg border border-purple-200 bg-purple-50 dark:border-purple-900/50 dark:bg-purple-900/20"> onChange={(e) => setOpencodeModelInput(e.target.value)}
<p className="text-sm font-medium text-purple-800 dark:text-purple-200 mb-2"> onBlur={saveOpencodeModel}
OpenCode not installed placeholder="opencode/grok-code"
</p> className="h-8 text-sm font-mono"
<p className="text-sm text-purple-700 dark:text-purple-300 mb-3"> disabled={saving}
Install globally (FREE tier available): />
</p> </div>
<code className="text-sm bg-purple-100 dark:bg-purple-900/40 px-2 py-1 rounded font-mono"> </div>
curl -fsSL https://opencode.ai/install | bash )}
</code> {/* Installation hint when not installed - inside card */}
<div className="mt-3"> {!status?.opencodeCli?.installed && !statusLoading && (
<a <div className="px-4 pb-4 pt-0 border-t border-border/50">
href="https://github.com/sst/opencode" <button
target="_blank" onClick={() => setShowOpencodeHint(!showOpencodeHint)}
rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-purple-600 dark:text-purple-400 hover:underline w-full py-2"
className="text-sm text-purple-700 dark:text-purple-300 hover:underline inline-flex items-center gap-1"
> >
<ExternalLink className="w-3 h-3" /> {showOpencodeHint ? (
View documentation <ChevronUp className="w-4 h-4" />
</a> ) : (
<ChevronDown className="w-4 h-4" />
)}
How to install OpenCode
</button>
{showOpencodeHint && (
<div className="mt-2 p-3 rounded-md bg-purple-50 dark:bg-purple-900/20 text-sm">
<p className="text-purple-700 dark:text-purple-300 mb-2">
Install globally (FREE tier available):
</p>
<code className="text-sm bg-purple-100 dark:bg-purple-900/40 px-2 py-1 rounded font-mono block mb-2">
curl -fsSL https://opencode.ai/install | bash
</code>
<a
href="https://github.com/sst/opencode"
target="_blank"
rel="noopener noreferrer"
className="text-purple-700 dark:text-purple-300 hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
View documentation
</a>
</div>
)}
</div> </div>
</div> )}
)} </div>
{/* Grok CLI Provider */} {/* Grok CLI Provider */}
<div <div
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${ className={`rounded-lg border transition-colors ${
isGrokEnabled isGrokEnabled ? 'border-primary border-l-4' : 'border-border'
? 'border-primary/50 bg-primary/5'
: 'border-border bg-background'
}`} }`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center justify-between p-4">
<Terminal <div className="flex items-center gap-3">
className={`w-5 h-5 ${isGrokEnabled ? 'text-primary' : 'text-muted-foreground'}`} <Terminal
/> className={`w-5 h-5 ${isGrokEnabled ? 'text-primary' : 'text-muted-foreground'}`}
<div> />
<div className="flex items-center gap-2"> <div>
<p className="font-mono font-medium">grok</p> <div className="flex items-center gap-2">
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 font-medium"> <p className="font-mono font-medium">grok</p>
GROK_API_KEY <span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 font-medium">
</span> GROK_API_KEY
{status?.grokCli?.installed ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
installed
</span> </span>
) : ( {status?.grokCli?.installed ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium"> <span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
not installed installed
</span> </span>
)} ) : (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
not installed
</span>
)}
</div>
<p className="text-sm text-muted-foreground">
xAI Grok CLI (web + X search)
</p>
</div> </div>
<p className="text-sm text-muted-foreground">
xAI Grok CLI (web + X search)
</p>
</div> </div>
<Switch
checked={isGrokEnabled}
onCheckedChange={toggleGrok}
disabled={saving || !status?.grokCli?.installed}
/>
</div> </div>
<Switch {/* Installation hint when not installed - inside card */}
checked={isGrokEnabled} {!status?.grokCli?.installed && !statusLoading && (
onCheckedChange={toggleGrok} <div className="px-4 pb-4 pt-0 border-t border-border/50">
disabled={saving || !status?.grokCli?.installed} <button
/> onClick={() => setShowGrokHint(!showGrokHint)}
</div> className="flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline w-full py-2"
{/* Grok Installation hint when not installed */}
{!status?.grokCli?.installed && !statusLoading && (
<div className="p-4 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/20">
<p className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
Grok CLI not installed
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mb-3">
Install globally (requires xAI API key):
</p>
<code className="text-sm bg-blue-100 dark:bg-blue-900/40 px-2 py-1 rounded font-mono">
npm install -g @vibe-kit/grok-cli
</code>
<div className="mt-3">
<a
href="https://github.com/superagent-ai/grok-cli"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-700 dark:text-blue-300 hover:underline inline-flex items-center gap-1"
> >
<ExternalLink className="w-3 h-3" /> {showGrokHint ? (
View documentation <ChevronUp className="w-4 h-4" />
</a> ) : (
<ChevronDown className="w-4 h-4" />
)}
How to install Grok CLI
</button>
{showGrokHint && (
<div className="mt-2 p-3 rounded-md bg-blue-50 dark:bg-blue-900/20 text-sm">
<p className="text-blue-700 dark:text-blue-300 mb-2">
Install globally (requires xAI API key):
</p>
<code className="text-sm bg-blue-100 dark:bg-blue-900/40 px-2 py-1 rounded font-mono block mb-2">
npm install -g @vibe-kit/grok-cli
</code>
<a
href="https://github.com/superagent-ai/grok-cli"
target="_blank"
rel="noopener noreferrer"
className="text-blue-700 dark:text-blue-300 hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
View documentation
</a>
</div>
)}
</div> </div>
</div> )}
)} </div>
</div> </div>
</div> </div>
</ScrollArea> </ScrollArea>