diff --git a/docs/hermes-gateway.md b/docs/hermes-gateway.md new file mode 100644 index 00000000..f0bf247b --- /dev/null +++ b/docs/hermes-gateway.md @@ -0,0 +1,45 @@ +# Hermes Gateway Controls + +CloudCLI can manage the Hermes Gateway process from **Settings -> Agents -> Hermes -> Gateway**. + +The gateway is optional. Normal CloudCLI chat and automation should continue to use `POST /api/agent` with `provider: "hermes"`. The gateway is for long-running Hermes integrations such as Telegram, Discord, WhatsApp, and other messaging surfaces configured by Hermes. + +## What The Gateway Tab Does + +- Shows whether Hermes is installed and whether the gateway is running. +- Starts Hermes with `hermes gateway run` in the current CloudCLI server environment. +- Stops or restarts a gateway process started by CloudCLI. +- Opens `hermes gateway setup` in the terminal for platform configuration. +- Shows detected Hermes profiles for visibility. +- Shows recent logs from the gateway process managed by CloudCLI. + +## What It Does Not Do + +- It does not expose the Hermes HTTP gateway as a raw public API. +- It does not add a new authentication model. +- It does not replace the CloudCLI Agent API. +- It does not create Docker containers or require Docker. + +## API Boundaries + +CloudCLI has two separate surfaces: + +- **CloudCLI Agent API**: `POST /api/agent` + Use this for programmatic CloudCLI tasks, including Hermes tasks. It supports `projectPath`, `githubUrl`, sessions, branches, and other CloudCLI workflow options. + +- **Hermes Gateway controls**: `/api/providers/hermes/gateway/*` + These are browser-authenticated UI control endpoints used by the settings page. + +The gateway controls intentionally stay inside the authenticated provider API. They are not a customer-facing replacement for `POST /api/agent`. + +## Hosted Environments + +In hosted or containerized environments, CloudCLI runs the gateway in foreground mode because system service managers such as systemd or launchd may not be available. This matches Hermes' recommended foreground mode for containers and similar runtimes. + +If Docker is not available, the Gateway tab still works for the local Hermes process. Docker is only relevant for advanced deployments where a user chooses to run Hermes separately. + +## References + +- [Hermes Agent releases](https://github.com/NousResearch/hermes-agent/releases) for current gateway and messaging platform capabilities. +- [Hermes hooks documentation](https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks) for non-interactive gateway runs and hook approval behavior. +- [Hermes environment variables](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) for Docker-image-specific gateway supervision details. diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 5a60b303..578af5cb 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -2,6 +2,7 @@ import express, { type Request, type Response } from 'express'; import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js'; import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js'; +import { hermesGatewayService } from '@/modules/providers/services/hermes-gateway.service.js'; import { providerMcpService } from '@/modules/providers/services/mcp.service.js'; import { providerModelsService } from '@/modules/providers/services/provider-models.service.js'; import { providerSkillsService } from '@/modules/providers/services/skills.service.js'; @@ -340,6 +341,18 @@ const parseProvider = (value: unknown): LLMProvider => { }); }; +const parseHermesProvider = (value: unknown): 'hermes' => { + const provider = parseProvider(value); + if (provider !== 'hermes') { + throw new AppError('Gateway controls are only available for Hermes.', { + code: 'HERMES_GATEWAY_UNSUPPORTED_PROVIDER', + statusCode: 404, + }); + } + + return provider; +}; + const parseSessionRenameSummary = (payload: unknown): string => { if (!payload || typeof payload !== 'object') { throw new AppError('Request body must be an object.', { @@ -637,6 +650,51 @@ router.get( }), ); +// ----------------- Hermes gateway routes ----------------- +router.get( + '/:provider/gateway/status', + asyncHandler(async (req: Request, res: Response) => { + parseHermesProvider(req.params.provider); + const status = await hermesGatewayService.getStatus(); + res.json(createApiSuccessResponse(status)); + }), +); + +router.get( + '/:provider/gateway/logs', + asyncHandler(async (req: Request, res: Response) => { + parseHermesProvider(req.params.provider); + res.json(createApiSuccessResponse({ logs: hermesGatewayService.getLogs() })); + }), +); + +router.post( + '/:provider/gateway/start', + asyncHandler(async (req: Request, res: Response) => { + parseHermesProvider(req.params.provider); + const status = await hermesGatewayService.start(); + res.json(createApiSuccessResponse(status)); + }), +); + +router.post( + '/:provider/gateway/stop', + asyncHandler(async (req: Request, res: Response) => { + parseHermesProvider(req.params.provider); + const status = await hermesGatewayService.stop(); + res.json(createApiSuccessResponse(status)); + }), +); + +router.post( + '/:provider/gateway/restart', + asyncHandler(async (req: Request, res: Response) => { + parseHermesProvider(req.params.provider); + const status = await hermesGatewayService.restart(); + res.json(createApiSuccessResponse(status)); + }), +); + // ----------------- Session routes ----------------- /** * Session gateway entry point: allocates the stable app-facing session id for diff --git a/server/modules/providers/services/hermes-gateway.service.ts b/server/modules/providers/services/hermes-gateway.service.ts new file mode 100644 index 00000000..aa823189 --- /dev/null +++ b/server/modules/providers/services/hermes-gateway.service.ts @@ -0,0 +1,317 @@ +import { spawn, execFile, type ChildProcess } from 'node:child_process'; +import { promisify } from 'node:util'; + +import { AppError } from '@/shared/utils.js'; + +const execFileAsync = promisify(execFile); + +const gatewayCommandParts = (process.env.HERMES_GATEWAY_COMMAND || '').trim().split(/\s+/).filter(Boolean); +const fallbackHermesCommand = ( + process.env.HERMES_COMMAND_PATH + || process.env.HERMES_CLI_PATH + || 'hermes' +).trim().split(/\s+/)[0] || 'hermes'; +const HERMES_COMMAND = gatewayCommandParts[0] || fallbackHermesCommand; +const HERMES_BASE_ARGS = gatewayCommandParts.slice(1); + +const MAX_LOG_LINES = 300; +const COMMAND_TIMEOUT_MS = 10_000; + +type CommandResult = { + stdout: string; + stderr: string; + exitCode: number | null; + error?: string; +}; + +export type HermesGatewayProfile = { + name: string; + current: boolean; + model: string | null; + gateway: string | null; + alias: string | null; + distribution: string | null; +}; + +export type HermesGatewayStatus = { + installed: boolean; + command: string; + version: string | null; + running: boolean; + managedByCloudCLI: boolean; + state: 'running' | 'stopped' | 'unknown'; + statusOutput: string; + profiles: HermesGatewayProfile[]; + logs: string[]; + lastExit: { + code: number | null; + signal: NodeJS.Signals | null; + at: string; + } | null; + commands: { + setup: string; + run: string; + }; +}; + +const removeAnsi = (value: string): string => value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); + +const compactOutput = (result: CommandResult): string => ( + [result.stdout, result.stderr].filter(Boolean).join('\n').trim() +); + +const parseGatewayRunning = (output: string, managedByCloudCLI: boolean): boolean => { + if (managedByCloudCLI) { + return true; + } + + const normalized = output.toLowerCase(); + if (/\b(not running|stopped|inactive|failed)\b/.test(normalized)) { + return false; + } + + return /\b(running|active)\b/.test(normalized); +}; + +const parseProfiles = (output: string): HermesGatewayProfile[] => { + const profiles: HermesGatewayProfile[] = []; + for (const rawLine of removeAnsi(output).split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('Profile ') || /^[-─\s]+$/.test(line)) { + continue; + } + + const current = line.startsWith('◆'); + const cleaned = line.replace(/^[◆*]\s*/, '').trim(); + const columns = cleaned.split(/\s{2,}/).map((column) => column.trim()); + const [name, model, gateway, alias, distribution] = columns; + if (!name || name.toLowerCase() === 'profile') { + continue; + } + + profiles.push({ + name, + current, + model: model && model !== '—' ? model : null, + gateway: gateway && gateway !== '—' ? gateway : null, + alias: alias && alias !== '—' ? alias : null, + distribution: distribution && distribution !== '—' ? distribution : null, + }); + } + + return profiles; +}; + +class HermesGatewayService { + private gatewayProcess: ChildProcess | null = null; + private readonly logs: string[] = []; + private lastExit: HermesGatewayStatus['lastExit'] = null; + + async getStatus(): Promise { + const versionResult = await this.runHermes(['--version'], { timeout: 5000 }); + const installed = versionResult.exitCode === 0; + const version = installed ? compactOutput(versionResult).split(/\r?\n/)[0] || null : null; + + const statusResult = installed + ? await this.runHermes(['gateway', 'status'], { timeout: COMMAND_TIMEOUT_MS }) + : { stdout: '', stderr: versionResult.error || 'Hermes is not installed.', exitCode: 1 }; + const profilesResult = installed + ? await this.runHermes(['profile', 'list'], { timeout: COMMAND_TIMEOUT_MS }) + : { stdout: '', stderr: '', exitCode: 1 }; + + const statusOutput = compactOutput(statusResult); + const managedByCloudCLI = this.isManagedProcessRunning(); + const running = parseGatewayRunning(statusOutput, managedByCloudCLI); + + return { + installed, + command: this.commandPrefix().join(' '), + version, + running, + managedByCloudCLI, + state: running ? 'running' : statusOutput ? 'stopped' : 'unknown', + statusOutput, + profiles: parseProfiles(profilesResult.stdout), + logs: this.getLogs(), + lastExit: this.lastExit, + commands: { + setup: [...this.commandPrefix(), 'gateway', 'setup'].join(' '), + run: [...this.commandPrefix(), 'gateway', 'run'].join(' '), + }, + }; + } + + async start(): Promise { + await this.assertInstalled(); + if (this.isManagedProcessRunning()) { + return this.getStatus(); + } + + const currentStatus = await this.getStatus(); + if (currentStatus.running) { + return currentStatus; + } + + const args = [...HERMES_BASE_ARGS, 'gateway', 'run', '--accept-hooks']; + this.appendLog(`[cloudcli] starting Hermes gateway: ${HERMES_COMMAND} ${args.join(' ')}`); + this.lastExit = null; + const child = spawn(HERMES_COMMAND, args, { + env: { + ...process.env, + HERMES_ACCEPT_HOOKS: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + this.gatewayProcess = child; + + child.stdout?.on('data', (chunk) => this.appendLog(String(chunk))); + child.stderr?.on('data', (chunk) => this.appendLog(String(chunk))); + child.on('error', (error) => { + this.appendLog(`[cloudcli] gateway process error: ${error.message}`); + }); + child.on('exit', (code, signal) => { + this.lastExit = { + code, + signal, + at: new Date().toISOString(), + }; + this.appendLog(`[cloudcli] gateway exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}`); + this.gatewayProcess = null; + }); + + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + if (!this.isManagedProcessRunning()) { + throw new AppError('Hermes gateway exited before it could start.', { + code: 'HERMES_GATEWAY_START_FAILED', + statusCode: 500, + details: { + logs: this.getLogs().slice(-20), + lastExit: this.lastExit, + }, + }); + } + + return this.getStatus(); + } + + async stop(): Promise { + if (this.isManagedProcessRunning() && this.gatewayProcess) { + this.appendLog('[cloudcli] stopping managed Hermes gateway'); + await this.stopManagedProcess(); + return this.getStatus(); + } + + await this.runHermes(['gateway', 'stop'], { timeout: COMMAND_TIMEOUT_MS }); + return this.getStatus(); + } + + async restart(): Promise { + if (this.isManagedProcessRunning()) { + await this.stopManagedProcess(); + } else { + await this.runHermes(['gateway', 'stop'], { timeout: COMMAND_TIMEOUT_MS }); + } + + return this.start(); + } + + getLogs(): string[] { + return [...this.logs]; + } + + private async assertInstalled(): Promise { + const result = await this.runHermes(['--version'], { timeout: 5000 }); + if (result.exitCode !== 0) { + throw new AppError('Hermes is not installed or is not available on PATH.', { + code: 'HERMES_NOT_INSTALLED', + statusCode: 400, + details: compactOutput(result), + }); + } + } + + private isManagedProcessRunning(): boolean { + return Boolean(this.gatewayProcess && !this.gatewayProcess.killed && this.gatewayProcess.exitCode === null); + } + + private async stopManagedProcess(): Promise { + const child = this.gatewayProcess; + if (!child) { + return; + } + + const exited = new Promise((resolve) => { + child.once('exit', () => resolve()); + }); + child.kill('SIGTERM'); + + await Promise.race([ + exited, + new Promise((resolve) => { + setTimeout(() => { + if (this.gatewayProcess === child && this.isManagedProcessRunning()) { + this.appendLog('[cloudcli] gateway did not stop after SIGTERM; sending SIGKILL'); + child.kill('SIGKILL'); + } + resolve(); + }, 5000); + }), + ]); + } + + private async runHermes(args: string[], options: { timeout: number }): Promise { + try { + const result = await execFileAsync(HERMES_COMMAND, [...HERMES_BASE_ARGS, ...args], { + timeout: options.timeout, + maxBuffer: 1024 * 1024, + env: { + ...process.env, + HERMES_ACCEPT_HOOKS: '1', + }, + }); + + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + exitCode: 0, + }; + } catch (error) { + const execError = error as NodeJS.ErrnoException & { + stdout?: string; + stderr?: string; + code?: number | string | null; + }; + + return { + stdout: execError.stdout ?? '', + stderr: execError.stderr ?? '', + exitCode: typeof execError.code === 'number' ? execError.code : 1, + error: execError.message, + }; + } + } + + private appendLog(chunk: string): void { + const lines = removeAnsi(chunk) + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter(Boolean); + if (lines.length === 0) { + return; + } + + this.logs.push(...lines.map((line) => `${new Date().toISOString()} ${line}`)); + if (this.logs.length > MAX_LOG_LINES) { + this.logs.splice(0, this.logs.length - MAX_LOG_LINES); + } + } + + private commandPrefix(): string[] { + return [HERMES_COMMAND, ...HERMES_BASE_ARGS]; + } +} + +export const hermesGatewayService = new HermesGatewayService(); diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts index 7d8f7c37..cae97251 100644 --- a/src/components/settings/types/types.ts +++ b/src/components/settings/types/types.ts @@ -5,7 +5,7 @@ import type { ProviderAuthStatus } from '../../provider-auth/types'; export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'voice' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about'; export type AgentProvider = LLMProvider; -export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills'; +export type AgentCategory = 'account' | 'permissions' | 'gateway' | 'mcp' | 'skills'; export type ProjectSortOrder = 'name' | 'date'; export type SaveStatus = 'success' | 'error' | null; export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions'; diff --git a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx index 19ce5181..4d5f6630 100644 --- a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx +++ b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx @@ -25,7 +25,9 @@ export default function AgentsSettingsTab({ const visibleCategories = useMemo(() => ( selectedAgent === 'opencode' ? ['account', 'permissions', 'mcp'] - : ['account', 'permissions', 'mcp', 'skills'] + : selectedAgent === 'hermes' + ? ['account', 'gateway', 'mcp', 'skills'] + : ['account', 'permissions', 'mcp', 'skills'] ), [selectedAgent]); const visibleAgents = useMemo(() => { diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx index 5bf0de2a..172d6d18 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx @@ -5,6 +5,7 @@ import type { SkillsProject } from '../../../../../skills/types'; import { ProviderSkills } from '../../../../../skills'; import AccountContent from './content/AccountContent'; +import GatewayContent from './content/GatewayContent'; import PermissionsContent from './content/PermissionsContent'; export default function AgentCategoryContentSection({ @@ -29,6 +30,12 @@ export default function AgentCategoryContentSection({ /> )} + {selectedCategory === 'gateway' && selectedAgent === 'hermes' && ( + + )} + {selectedCategory === 'permissions' && selectedAgent === 'claude' && ( {category === 'account' && t('tabs.account')} {category === 'permissions' && t('tabs.permissions')} + {category === 'gateway' && t('tabs.gateway', { defaultValue: 'Gateway' })} {category === 'mcp' && t('tabs.mcpServers')} {category === 'skills' && t('tabs.skills', { defaultValue: selectedAgent === 'opencode' ? 'Shared Skills' : 'Skills', diff --git a/src/components/settings/view/tabs/agents-settings/sections/content/GatewayContent.tsx b/src/components/settings/view/tabs/agents-settings/sections/content/GatewayContent.tsx new file mode 100644 index 00000000..a8a2ae00 --- /dev/null +++ b/src/components/settings/view/tabs/agents-settings/sections/content/GatewayContent.tsx @@ -0,0 +1,314 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Activity, + Layers3, + Play, + RefreshCw, + RotateCcw, + Square, + Terminal, +} from 'lucide-react'; + +import { Badge, Button, HelpTooltip } from '../../../../../../../shared/view/ui'; +import { authenticatedFetch } from '../../../../../../../utils/api'; +import SettingsCard from '../../../../SettingsCard'; +import SettingsSection from '../../../../SettingsSection'; + +type GatewayProfile = { + name: string; + current: boolean; + model: string | null; + gateway: string | null; + alias: string | null; + distribution: string | null; +}; + +type GatewayStatus = { + installed: boolean; + command: string; + version: string | null; + running: boolean; + managedByCloudCLI: boolean; + state: 'running' | 'stopped' | 'unknown'; + statusOutput: string; + profiles: GatewayProfile[]; + logs: string[]; + lastExit: { + code: number | null; + signal: string | null; + at: string; + } | null; + commands: { + setup: string; + run: string; + }; +}; + +type ApiSuccess = { + success: boolean; + data: T; +}; + +type GatewayContentProps = { + onOpenSetup: (customCommand?: string, customTitle?: string) => void; +}; + +const gatewayTooltip = 'Use this when tools outside CloudCLI need to reach Hermes through messaging integrations. Normal CloudCLI chat uses the built-in agent API.'; +const setupTooltip = 'Opens Hermes setup in the terminal so you can connect Telegram, Discord, WhatsApp, or another supported platform.'; +const profilesTooltip = 'Profiles are isolated Hermes configurations. This page shows them for visibility and controls the active gateway process.'; +const logsTooltip = 'These logs come from the gateway process started by CloudCLI in this server session.'; + +async function readGatewayResponse(response: Response): Promise { + const payload = await response.json().catch(() => null) as ApiSuccess | { error?: string } | null; + if (!response.ok || !payload || !('success' in payload) || !payload.success) { + throw new Error((payload && 'error' in payload && payload.error) || 'Gateway request failed'); + } + + return payload.data; +} + +export default function GatewayContent({ onOpenSetup }: GatewayContentProps) { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [action, setAction] = useState<'start' | 'stop' | 'restart' | null>(null); + const [error, setError] = useState(null); + + const refreshStatus = useCallback(async () => { + setError(null); + try { + const response = await authenticatedFetch('/api/providers/hermes/gateway/status'); + const nextStatus = await readGatewayResponse(response); + setStatus(nextStatus); + } catch (refreshError) { + setError(refreshError instanceof Error ? refreshError.message : 'Could not load gateway status'); + } finally { + setLoading(false); + } + }, []); + + const runAction = useCallback(async (nextAction: 'start' | 'stop' | 'restart') => { + setAction(nextAction); + setError(null); + try { + const response = await authenticatedFetch(`/api/providers/hermes/gateway/${nextAction}`, { + method: 'POST', + }); + const nextStatus = await readGatewayResponse(response); + setStatus(nextStatus); + } catch (actionError) { + setError(actionError instanceof Error ? actionError.message : `Could not ${nextAction} gateway`); + void refreshStatus(); + } finally { + setAction(null); + } + }, [refreshStatus]); + + useEffect(() => { + void refreshStatus(); + }, [refreshStatus]); + + const busy = Boolean(action); + const running = Boolean(status?.running); + + return ( +
+
+
+ +
+
+
+

Hermes Gateway

+ +
+

+ Manage Hermes messaging gateway runtime for the active environment. +

+
+
+ + + +
+
+
+ + {loading ? 'Checking' : running ? 'Running' : 'Stopped'} + + {status?.managedByCloudCLI && ( + Managed by CloudCLI + )} + {status && !status.installed && ( + Hermes not installed + )} +
+ +
+
+ Command: {status?.command ?? 'hermes'} +
+
+ {status?.version ?? 'Hermes status will appear after refresh.'} +
+ {status?.lastExit && ( +
+ Last exit: code {status.lastExit.code ?? 'null'} + {status.lastExit.signal ? `, signal ${status.lastExit.signal}` : ''} +
+ )} +
+
+ +
+ + + + +
+
+ + {status?.statusOutput && ( +
+
+                {status.statusOutput}
+              
+
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+ + + +
+
+
+ Platform setup + +
+
+ Opens hermes gateway setup in the shell. +
+
+ +
+
+
+ + + +
+ + Hermes profiles + +
+ {status?.profiles.length ? ( +
+ {status.profiles.map((profile) => ( +
+
+
+ {profile.name} + {profile.current && Current} +
+
+ {profile.model ?? 'No model configured'} +
+
+
+ Gateway: {profile.gateway ?? 'unknown'} +
+
+ Alias: {profile.alias ?? 'none'} +
+
+ ))} +
+ ) : ( +
+ {loading ? 'Loading profiles...' : 'No Hermes profiles were found.'} +
+ )} +
+
+ + + +
+ Gateway output + +
+
+            {status?.logs.length ? status.logs.join('\n') : 'No CloudCLI-managed gateway logs yet.'}
+          
+
+
+
+ ); +} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 1622b916..53522413 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -3,6 +3,7 @@ "tabs": { "account": "Account", "permissions": "Permissions", + "gateway": "Gateway", "mcpServers": "MCP Servers", "skills": "Skills", "appearance": "Appearance" diff --git a/src/shared/view/ui/HelpTooltip.tsx b/src/shared/view/ui/HelpTooltip.tsx new file mode 100644 index 00000000..5fa48bec --- /dev/null +++ b/src/shared/view/ui/HelpTooltip.tsx @@ -0,0 +1,32 @@ +import { CircleHelp } from 'lucide-react'; + +import Tooltip from './Tooltip'; + +type HelpTooltipProps = { + content: string; + label?: string; + position?: 'top' | 'bottom' | 'left' | 'right'; +}; + +export default function HelpTooltip({ + content, + label = 'Help', + position = 'top', +}: HelpTooltipProps) { + return ( + + + + + + ); +} diff --git a/src/shared/view/ui/index.ts b/src/shared/view/ui/index.ts index 2665fcd7..53bd3987 100644 --- a/src/shared/view/ui/index.ts +++ b/src/shared/view/ui/index.ts @@ -9,6 +9,7 @@ export { Collapsible, CollapsibleTrigger, CollapsibleContent } from './Collapsib export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator } from './Command'; export { default as DarkModeToggle } from './DarkModeToggle'; export { Dialog, DialogTrigger, DialogContent, DialogTitle } from './Dialog'; +export { default as HelpTooltip } from './HelpTooltip'; export { Input } from './Input'; export { ScrollArea } from './ScrollArea'; export { Reasoning, ReasoningTrigger, ReasoningContent, useReasoning } from './Reasoning';