From f36c5b6009bc3bdb4878009c7731161df3a64ec6 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Thu, 14 May 2026 13:54:30 +0300 Subject: [PATCH] fix: improveUI for commands --- server/routes/commands.js | 28 +- .../chat/hooks/useChatComposerState.ts | 136 +++-- src/components/chat/view/ChatInterface.tsx | 10 +- .../view/subcomponents/CommandResultModal.tsx | 539 ++++++++++++++++++ 4 files changed, 665 insertions(+), 48 deletions(-) create mode 100644 src/components/chat/view/subcomponents/CommandResultModal.tsx diff --git a/server/routes/commands.js b/server/routes/commands.js index 4260f5ad..a95b9d84 100644 --- a/server/routes/commands.js +++ b/server/routes/commands.js @@ -50,6 +50,10 @@ export const executeModelsCommand = async (args, context) => { getProviderModelOptions(currentProvider, context), ); const availableModels = catalog.OPTIONS.map((option) => option.value); + const availableOptions = catalog.OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + })); const currentModel = typeof context?.model === 'string' && context.model ? context.model : catalog.DEFAULT; @@ -67,6 +71,8 @@ export const executeModelsCommand = async (args, context) => { [currentProvider]: availableModels, }, availableModels, + availableOptions, + defaultModel: catalog.DEFAULT, message: args.length > 0 ? `Switching to model: ${args[0]}` : `Current model: ${currentModel}` @@ -218,7 +224,12 @@ Custom commands can be created in: action: 'help', data: { content: helpText, - format: 'markdown' + format: 'markdown', + commands: builtInCommands.map((command) => ({ + name: command.name, + description: command.description, + namespace: command.namespace, + })), } }; }, @@ -292,11 +303,17 @@ Custom commands can be created in: total, percentage, }, + tokenBreakdown: { + input: inputTokens, + output: outputTokens, + cache: cacheTokens, + }, cost: { input: inputCost.toFixed(4), output: outputCost.toFixed(4), total: totalCost.toFixed(4), }, + provider, model, }, }; @@ -325,6 +342,7 @@ Custom commands can be created in: const statusProvider = context?.provider || 'claude'; const statusCatalog = await providerModelsService.getProviderModels(statusProvider); + const memoryUsage = process.memoryUsage(); return { type: 'builtin', @@ -337,7 +355,13 @@ Custom commands can be created in: model: context?.model || statusCatalog.DEFAULT, provider: statusProvider, nodeVersion: process.version, - platform: process.platform + platform: process.platform, + pid: process.pid, + memoryUsage: { + rssMb: Math.round(memoryUsage.rss / 1024 / 1024), + heapUsedMb: Math.round(memoryUsage.heapUsed / 1024 / 1024), + heapTotalMb: Math.round(memoryUsage.heapTotal / 1024 / 1024), + } } }; }, diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 79fe23f6..65da1f18 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -76,7 +76,7 @@ interface CommandExecutionResult { hasFileIncludes?: boolean; } -type ModelCommandData = { +export type ModelCommandData = { current?: { provider?: string; providerLabel?: string; @@ -84,40 +84,64 @@ type ModelCommandData = { }; available?: Partial>; availableModels?: string[]; + availableOptions?: Array<{ + value: string; + label?: string; + }>; + defaultModel?: string; }; -const PROVIDER_LABELS: Record = { - claude: 'Claude', - cursor: 'Cursor', - codex: 'Codex', - gemini: 'Gemini', - opencode: 'OpenCode', +export type CostCommandData = { + tokenUsage?: { + used?: number; + total?: number; + percentage?: number; + }; + cost?: { + input?: string; + output?: string; + total?: string; + }; + tokenBreakdown?: { + input?: number; + output?: number; + cache?: number; + }; + provider?: string; + model?: string; }; -const isLLMProvider = (value: unknown): value is LLMProvider => ( - value === 'claude' - || value === 'cursor' - || value === 'codex' - || value === 'gemini' - || value === 'opencode' -); +export type StatusCommandData = { + version?: string; + packageName?: string; + uptime?: string; + model?: string; + provider?: string; + nodeVersion?: string; + platform?: string; + pid?: number; + memoryUsage?: { + rssMb?: number; + heapUsedMb?: number; + heapTotalMb?: number; + }; +}; -const formatModelCommandMessage = (data: ModelCommandData): string => { - const currentProvider = isLLMProvider(data.current?.provider) - ? data.current.provider - : 'claude'; - const providerLabel = data.current?.providerLabel || PROVIDER_LABELS[currentProvider]; - const currentModel = data.current?.model || 'Unknown'; - // `availableModels` is the current response shape; the keyed map keeps older - // server responses readable without reintroducing cross-provider rendering. - const availableModels = Array.isArray(data.availableModels) - ? data.availableModels - : data.available?.[currentProvider] ?? []; - const availableText = availableModels.length > 0 - ? availableModels.join(', ') - : 'No models reported for this provider.'; +export type HelpCommandData = { + content?: string; + format?: string; + commands?: Array<{ + name: string; + description?: string; + namespace?: string; + }>; +}; - return `**Current Model**: ${currentModel}\n\n**Provider**: ${providerLabel}\n\n**Available Models**:\n\n${availableText}`; +export type CommandModalKind = 'help' | 'models' | 'cost' | 'status'; + +export type CommandModalPayload = { + kind: CommandModalKind; + data: HelpCommandData | ModelCommandData | CostCommandData | StatusCommandData; }; const createFakeSubmitEvent = () => { @@ -186,6 +210,7 @@ export function useChatComposerState({ const [imageErrors, setImageErrors] = useState>(new Map()); const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); const [thinkingMode, setThinkingMode] = useState('none'); + const [commandModalPayload, setCommandModalPayload] = useState(null); const textareaRef = useRef(null); const inputHighlightRef = useRef(null); @@ -200,30 +225,32 @@ export function useChatComposerState({ const { action, data } = result; switch (action) { case 'help': - addMessage({ - type: 'assistant', - content: data.content, - timestamp: Date.now(), + setCommandModalPayload({ + kind: 'help', + data: (data || {}) as HelpCommandData, }); break; case 'models': - addMessage({ - type: 'assistant', - content: formatModelCommandMessage(data as ModelCommandData), - timestamp: Date.now(), + setCommandModalPayload({ + kind: 'models', + data: (data || {}) as ModelCommandData, }); break; case 'cost': { - const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`; - addMessage({ type: 'assistant', content: costMessage, timestamp: Date.now() }); + setCommandModalPayload({ + kind: 'cost', + data: (data || {}) as CostCommandData, + }); break; } case 'status': { - const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`; - addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() }); + setCommandModalPayload({ + kind: 'status', + data: (data || {}) as StatusCommandData, + }); break; } @@ -257,6 +284,10 @@ export function useChatComposerState({ [onFileOpen, onShowSettings, addMessage], ); + const closeCommandModal = useCallback(() => { + setCommandModalPayload(null); + }, []); + const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => { const { content, hasBashCommands } = result; @@ -502,13 +533,26 @@ export function useChatComposerState({ } // Intercept slash commands only when "/" is the first input character. + // Also accept exact "help" as a convenience alias for users who expect CLI-style help. const commandInput = currentInput.trimEnd(); - if (commandInput.startsWith('/')) { + const isHelpAlias = commandInput.trim().toLowerCase() === 'help'; + if (commandInput.startsWith('/') || isHelpAlias) { const firstSpace = commandInput.indexOf(' '); - const commandName = firstSpace > 0 ? commandInput.slice(0, firstSpace) : commandInput; - const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName); + const commandName = isHelpAlias + ? '/help' + : firstSpace > 0 ? commandInput.slice(0, firstSpace) : commandInput; + const matchedCommand = + slashCommands.find((cmd: SlashCommand) => cmd.name === commandName) || + (commandName === '/help' + ? ({ + name: '/help', + description: 'Show help documentation for Claude Code', + namespace: 'builtin', + metadata: { type: 'builtin' }, + } as SlashCommand) + : undefined); if (matchedCommand && matchedCommand.type !== 'skill') { - executeCommand(matchedCommand, commandInput); + executeCommand(matchedCommand, isHelpAlias ? '/help' : commandInput); setInput(''); inputValueRef.current = ''; setAttachedImages([]); @@ -1018,5 +1062,7 @@ export function useChatComposerState({ handleGrantToolPermission, handleInputFocusChange, isInputFocused, + commandModalPayload, + closeCommandModal, }; } diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index ea071212..eab9cba9 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -14,6 +14,7 @@ import { useSessionStore } from '../../../stores/useSessionStore'; import ChatMessagesPane from './subcomponents/ChatMessagesPane'; import ChatComposer from './subcomponents/ChatComposer'; +import CommandResultModal from './subcomponents/CommandResultModal'; type PendingViewSession = { @@ -172,7 +173,9 @@ function ChatInterface({ handlePermissionDecision, handleGrantToolPermission, handleInputFocusChange, - isInputFocused, + isInputFocused: _isInputFocused, + commandModalPayload, + closeCommandModal, } = useChatComposerState({ selectedProject, selectedSession, @@ -424,6 +427,11 @@ function ChatInterface({ + + ); } diff --git a/src/components/chat/view/subcomponents/CommandResultModal.tsx b/src/components/chat/view/subcomponents/CommandResultModal.tsx new file mode 100644 index 00000000..cff49957 --- /dev/null +++ b/src/components/chat/view/subcomponents/CommandResultModal.tsx @@ -0,0 +1,539 @@ +import { useMemo, useState } from 'react'; +import { + Activity, + BadgeCheck, + Check, + CircleHelp, + Clipboard, + Coins, + Command as CommandIcon, + Cpu, + Gauge, + Layers3, + Package, + Search, + Server, + Sparkles, + TerminalSquare, + Timer, + X, + Zap, +} from 'lucide-react'; + +import { Badge, Button, Dialog, DialogContent, DialogTitle, Input } from '../../../../shared/view/ui'; +import type { + CommandModalPayload, + CostCommandData, + HelpCommandData, + ModelCommandData, + StatusCommandData, +} from '../../hooks/useChatComposerState'; + +type CommandResultModalProps = { + payload: CommandModalPayload | null; + onClose: () => void; +}; + +type CommandEntry = { + name: string; + description?: string; + namespace?: string; +}; + +type ModelOption = { + value: string; + label?: string; +}; + +const PROVIDER_LABELS: Record = { + claude: 'Claude', + cursor: 'Cursor', + codex: 'Codex', + gemini: 'Gemini', + opencode: 'OpenCode', +}; + +const FALLBACK_COMMANDS: CommandEntry[] = [ + { name: '/models', description: 'Browse available models for the active provider.' }, + { name: '/cost', description: 'Review context usage and estimated token spend.' }, + { name: '/status', description: 'Inspect runtime, version, provider, and environment status.' }, + { name: '/memory', description: 'Open the project CLAUDE.md memory file.' }, + { name: '/config', description: 'Open settings and configuration.' }, + { name: '/help', description: 'Show command documentation and syntax.' }, +]; + +const getProviderLabel = (provider: string | undefined, fallback = 'Unknown') => { + if (!provider) { + return fallback; + } + + return PROVIDER_LABELS[provider] || provider; +}; + +const clampPercentage = (value: number) => { + if (!Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.min(100, value)); +}; + +const formatNumber = (value: number) => { + if (!Number.isFinite(value)) { + return '0'; + } + return value.toLocaleString(); +}; + +const formatCurrency = (value: number | string | undefined) => { + const numeric = Number(value ?? 0); + return `$${Number.isFinite(numeric) ? numeric.toFixed(4) : '0.0000'}`; +}; + +function MetricCard({ + label, + value, + icon: Icon, + tone = 'neutral', +}: { + label: string; + value: string; + icon: typeof Activity; + tone?: 'neutral' | 'primary' | 'success'; +}) { + const toneClass = + tone === 'primary' + ? 'border-primary/35 bg-primary/10 text-primary' + : tone === 'success' + ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' + : 'border-border/70 bg-background/75 text-muted-foreground'; + + return ( +
+
+ +
+

{label}

+

{value}

+
+ ); +} + +function SearchField({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (value: string) => void; + placeholder: string; +}) { + return ( +
+ + onChange(event.target.value)} + placeholder={placeholder} + className="h-10 rounded-xl border-border/70 bg-background/75 pl-9 pr-3 shadow-none focus-visible:ring-primary/40" + /> +
+ ); +} + +function HelpContent({ data }: { data: HelpCommandData }) { + const [query, setQuery] = useState(''); + const commands = (Array.isArray(data.commands) && data.commands.length > 0 + ? data.commands + : FALLBACK_COMMANDS) as CommandEntry[]; + + const filteredCommands = useMemo(() => { + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return commands; + } + + return commands.filter((command) => { + const haystack = `${command.name} ${command.description || ''} ${command.namespace || ''}`.toLowerCase(); + return haystack.includes(normalized); + }); + }, [commands, query]); + + return ( +
+
+ + +
+
+ {filteredCommands.map((command, index) => ( +
+
+ + {command.name} + + + {command.namespace || 'builtin'} + +
+

+ {command.description || 'No description available.'} +

+
+ ))} +
+ + {filteredCommands.length === 0 && ( +
+ No commands match that filter. +
+ )} +
+
+ + +
+ ); +} + +function ModelsContent({ data }: { data: ModelCommandData }) { + const [query, setQuery] = useState(''); + const [copiedModel, setCopiedModel] = useState(null); + const currentProvider = data?.current?.provider || 'claude'; + const currentModel = data?.current?.model || 'Unknown'; + const defaultModel = data?.defaultModel || currentModel; + const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider); + const availableOptions = useMemo(() => { + if (Array.isArray(data?.availableOptions) && data.availableOptions.length > 0) { + return data.availableOptions; + } + + const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : []; + return availableModels.map((model) => ({ value: model, label: model })); + }, [data]); + + const filteredOptions = useMemo(() => { + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return availableOptions; + } + + return availableOptions.filter((option) => { + const haystack = `${option.value} ${option.label || ''}`.toLowerCase(); + return haystack.includes(normalized); + }); + }, [availableOptions, query]); + + const activeOption = availableOptions.find((option) => option.value === currentModel); + + const copyModel = (model: string) => { + if (typeof navigator !== 'undefined' && navigator.clipboard) { + void navigator.clipboard.writeText(model).catch(() => undefined); + } + setCopiedModel(model); + window.setTimeout(() => { + setCopiedModel((current) => (current === model ? null : current)); + }, 1300); + }; + + return ( +
+
+
+
+
+
+

Active model

+

{currentModel}

+ {activeOption?.label && activeOption.label !== currentModel && ( +

{activeOption.label}

+ )} +
+ Live +
+
+ +
+ + +
+
+ +
+
+ + + default: {defaultModel} + +
+ + {filteredOptions.length > 0 ? ( +
+
+ {filteredOptions.map((option, index) => { + const isCurrent = option.value === currentModel; + const wasCopied = copiedModel === option.value; + return ( + + ); + })} +
+
+ ) : ( +
+ No models match that search. +
+ )} +
+
+ ); +} + +function CostContent({ data }: { data: CostCommandData }) { + const used = Number(data.tokenUsage?.used ?? 0); + const total = Number(data.tokenUsage?.total ?? 0); + const percentage = clampPercentage(Number(data.tokenUsage?.percentage ?? 0)); + const model = data.model || 'Unknown'; + const provider = getProviderLabel(data.provider, data.provider || 'Unknown'); + const inputTokens = Number(data.tokenBreakdown?.input ?? 0); + const outputTokens = Number(data.tokenBreakdown?.output ?? 0); + const cacheTokens = Number(data.tokenBreakdown?.cache ?? 0); + const totalCost = Number(data.cost?.total ?? 0); + + return ( +
+
+
+
+
+

{percentage.toFixed(1)}%

+

context

+
+
+
+

+ {formatNumber(used)} of {formatNumber(total)} tokens used +

+
+ +
+
+ + + +
+ +
+ + + +
+ +
+
+
+

Provider

+

{provider}

+
+
+

Model

+

{model}

+
+
+

+ Cost is an estimate based on the available token counters and default provider rates. +

+
+
+
+ ); +} + +function StatusContent({ data }: { data: StatusCommandData }) { + const memoryRssMb = data.memoryUsage?.rssMb; + const rows = [ + { label: 'Package', value: data.packageName || 'claude-code-ui', icon: Package }, + { label: 'Version', value: data.version || 'Unknown', icon: BadgeCheck, tone: 'success' as const }, + { label: 'Uptime', value: data.uptime || 'Unknown', icon: Timer }, + { label: 'Provider', value: getProviderLabel(data.provider, data.provider || 'Unknown'), icon: Server, tone: 'primary' as const }, + { label: 'Model', value: data.model || 'Unknown', icon: Cpu }, + { label: 'Node.js', value: data.nodeVersion || 'Unknown', icon: TerminalSquare }, + { label: 'Platform', value: data.platform || 'Unknown', icon: Activity }, + { label: 'Memory', value: typeof memoryRssMb === 'number' ? `${memoryRssMb} MB RSS` : 'Unknown', icon: Gauge }, + ]; + + return ( +
+
+
+ + + + +
+

Runtime online

+

Process {data.pid ? `#${data.pid}` : 'status'} is responding.

+
+
+ Healthy +
+ +
+ {rows.map((row) => ( + + ))} +
+
+ ); +} + +export default function CommandResultModal({ payload, onClose }: CommandResultModalProps) { + const isOpen = Boolean(payload); + const kind = payload?.kind; + + const modalMeta = { + help: { + eyebrow: 'Command center', + title: 'Help & Shortcuts', + subtitle: 'Search built-ins, syntax patterns, and command usage without leaving the chat.', + icon: CircleHelp, + }, + models: { + eyebrow: 'Model inventory', + title: 'Available Models', + subtitle: 'Browse, search, and copy model IDs for the active provider.', + icon: Cpu, + }, + cost: { + eyebrow: 'Session telemetry', + title: 'Usage & Cost', + subtitle: 'Token budget, context pressure, and estimated spend for this session.', + icon: Coins, + }, + status: { + eyebrow: 'Runtime health', + title: 'System Status', + subtitle: 'Version, provider, runtime, and environment details in one place.', + icon: Activity, + }, + } as const; + + const activeMeta = kind ? modalMeta[kind] : null; + const HeaderIcon = activeMeta?.icon || Sparkles; + + return ( + !open && onClose()}> + + {activeMeta?.title || 'Command Result'} + +
+
+
+ +
+
+
+ +
+
+

+ {activeMeta?.eyebrow} +

+

+ {activeMeta?.title} +

+

+ {activeMeta?.subtitle} +

+
+
+ + +
+
+ +
+ {payload?.kind === 'help' && } + {payload?.kind === 'models' && } + {payload?.kind === 'cost' && } + {payload?.kind === 'status' && } +
+ +
+
+ + Esc closes the modal. +
+ +
+ +
+ ); +}