Files
claudecodeui/src/components/chat/view/subcomponents/ChatComposer.tsx
Haileyesus ed9f0d74aa fix: refine token usage reporting
The old token UI mixed context pressure, cache counters, and dollar estimates.

That made the percentage look precise even when provider data was incomplete or different.

The composer and /cost view now show concrete counts instead of a pie percentage.

Token payloads now share a smaller shape: used, inputTokens, outputTokens, and breakdown.

Claude uses per-step usage where available and Codex reads total_token_usage events.

Gemini reads its tokens object without inventing a context window.

OpenCode reads opencode.db session totals and includes all token columns in used.

The /cost backend no longer returns cache display fields or input/output dollar estimates.

This avoids derived values that look reliable but are not comparable across providers.

Verification: npm run typecheck; targeted eslint; OpenCode session provider test.
2026-05-29 15:30:21 +03:00

420 lines
16 KiB
TypeScript

import { useTranslation } from 'react-i18next';
import type {
ChangeEvent,
ClipboardEvent,
Dispatch,
FormEvent,
KeyboardEvent,
MouseEvent,
ReactNode,
RefObject,
SetStateAction,
TouchEvent,
} from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsageSummary from './TokenUsageSummary';
import {
PromptInput,
PromptInputHeader,
PromptInputBody,
PromptInputTextarea,
PromptInputFooter,
PromptInputTools,
PromptInputButton,
PromptInputSubmit,
} from '../../../../shared/view/ui';
interface MentionableFile {
name: string;
path: string;
}
interface SlashCommand {
name: string;
description?: string;
namespace?: string;
path?: string;
type?: string;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
interface ChatComposerProps {
pendingPermissionRequests: PendingPermissionRequest[];
handlePermissionDecision: (
requestIds: string | string[],
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => void;
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
isLoading: boolean;
onAbortSession: () => void;
provider: Provider | string;
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
thinkingMode: string;
setThinkingMode: Dispatch<SetStateAction<string>>;
tokenBudget: Record<string, unknown> | null;
slashCommandsCount: number;
onToggleCommandMenu: () => void;
hasInput: boolean;
onClearInput: () => void;
isUserScrolledUp: boolean;
hasMessages: boolean;
onScrollToBottom: () => void;
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
isDragActive: boolean;
attachedImages: File[];
onRemoveImage: (index: number) => void;
uploadingImages: Map<string, number>;
imageErrors: Map<string, string>;
showFileDropdown: boolean;
filteredFiles: MentionableFile[];
selectedFileIndex: number;
onSelectFile: (file: MentionableFile) => void;
filteredCommands: SlashCommand[];
selectedCommandIndex: number;
onCommandSelect: (command: SlashCommand, index: number, isHover: boolean) => void;
onCloseCommandMenu: () => void;
isCommandMenuOpen: boolean;
frequentCommands: SlashCommand[];
getRootProps: (...args: unknown[]) => Record<string, unknown>;
getInputProps: (...args: unknown[]) => Record<string, unknown>;
openImagePicker: () => void;
inputHighlightRef: RefObject<HTMLDivElement>;
renderInputWithMentions: (text: string) => ReactNode;
textareaRef: RefObject<HTMLTextAreaElement>;
input: string;
onInputChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
onTextareaClick: (event: MouseEvent<HTMLTextAreaElement>) => void;
onTextareaKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
onInputFocusChange?: (focused: boolean) => void;
placeholder: string;
isTextareaExpanded: boolean;
sendByCtrlEnter?: boolean;
}
export default function ChatComposer({
pendingPermissionRequests,
handlePermissionDecision,
handleGrantToolPermission,
claudeStatus,
isLoading,
onAbortSession,
provider,
permissionMode,
onModeSwitch,
thinkingMode,
setThinkingMode,
tokenBudget,
slashCommandsCount,
onToggleCommandMenu,
hasInput,
onClearInput,
isUserScrolledUp,
hasMessages,
onScrollToBottom,
onSubmit,
isDragActive,
attachedImages,
onRemoveImage,
uploadingImages,
imageErrors,
showFileDropdown,
filteredFiles,
selectedFileIndex,
onSelectFile,
filteredCommands,
selectedCommandIndex,
onCommandSelect,
onCloseCommandMenu,
isCommandMenuOpen,
frequentCommands,
getRootProps,
getInputProps,
openImagePicker,
inputHighlightRef,
renderInputWithMentions,
textareaRef,
input,
onInputChange,
onTextareaClick,
onTextareaKeyDown,
onTextareaPaste,
onTextareaScrollSync,
onTextareaInput,
onInputFocusChange,
placeholder,
isTextareaExpanded,
sendByCtrlEnter,
}: ChatComposerProps) {
const { t } = useTranslation('chat');
const textareaRect = textareaRef.current?.getBoundingClientRect();
const commandMenuPosition = {
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
};
// Detect if the AskUserQuestion interactive panel is active
const hasQuestionPanel = pendingPermissionRequests.some(
(r) => r.toolName === 'AskUserQuestion'
);
// Hide the thinking/status bar while any permission request is pending
const hasPendingPermissions = pendingPermissionRequests.length > 0;
return (
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
{!hasPendingPermissions && (
<ClaudeStatus
status={claudeStatus}
isLoading={isLoading}
onAbort={onAbortSession}
provider={provider}
/>
)}
{pendingPermissionRequests.length > 0 && (
<div className="mx-auto mb-3 max-w-4xl">
<PermissionRequestsBanner
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
/>
</div>
)}
{!hasQuestionPanel && <div className="relative mx-auto max-w-4xl">
{isUserScrolledUp && hasMessages && (
<div className="absolute -top-10 left-0 right-0 z-10 flex justify-center">
<button
type="button"
onClick={onScrollToBottom}
className="flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
>
<ArrowDownIcon className="h-4 w-4" />
</button>
</div>
)}
{showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
{filteredFiles.map((file, index) => (
<div
key={file.path}
className={`cursor-pointer touch-manipulation border-b border-border/30 px-4 py-3 last:border-b-0 ${
index === selectedFileIndex
? 'bg-primary/8 text-primary'
: 'text-foreground hover:bg-accent/50'
}`}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onSelectFile(file);
}}
>
<div className="text-sm font-medium">{file.name}</div>
<div className="font-mono text-xs text-muted-foreground">{file.path}</div>
</div>
))}
</div>
)}
<CommandMenu
commands={filteredCommands}
selectedIndex={selectedCommandIndex}
onSelect={onCommandSelect}
onClose={onCloseCommandMenu}
position={commandMenuPosition}
isOpen={isCommandMenuOpen}
frequentCommands={frequentCommands}
/>
<PromptInput
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
status={isLoading ? 'streaming' : 'ready'}
className={isTextareaExpanded ? 'chat-input-expanded' : ''}
{...getRootProps()}
>
{isDragActive && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-primary/15">
<div className="rounded-xl border border-border/30 bg-card p-4 shadow-lg">
<svg className="mx-auto mb-2 h-8 w-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm font-medium">Drop images here</p>
</div>
</div>
)}
{attachedImages.length > 0 && (
<PromptInputHeader>
<div className="rounded-xl bg-muted/40 p-2">
<div className="flex flex-wrap gap-2">
{attachedImages.map((file, index) => (
<ImageAttachment
key={index}
file={file}
onRemove={() => onRemoveImage(index)}
uploadProgress={uploadingImages.get(file.name)}
error={imageErrors.get(file.name)}
/>
))}
</div>
</div>
</PromptInputHeader>
)}
<input {...getInputProps()} />
<PromptInputBody>
<div ref={inputHighlightRef} aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl">
<div className="chat-input-placeholder block w-full whitespace-pre-wrap break-words px-4 py-2 text-sm leading-6 text-transparent">
{renderInputWithMentions(input)}
</div>
</div>
<PromptInputTextarea
ref={textareaRef}
value={input}
onChange={onInputChange}
onClick={onTextareaClick}
onKeyDown={onTextareaKeyDown}
onPaste={onTextareaPaste}
onScroll={(event) => onTextareaScrollSync(event.target as HTMLTextAreaElement)}
onFocus={() => onInputFocusChange?.(true)}
onBlur={() => onInputFocusChange?.(false)}
onInput={onTextareaInput}
placeholder={placeholder}
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputButton
tooltip={{ content: t('input.attachImages') }}
onClick={openImagePicker}
>
<ImageIcon />
</PromptInputButton>
<button
type="button"
onClick={onModeSwitch}
className={`rounded-lg border p-2 text-xs font-medium transition-all duration-200 sm:px-2.5 sm:py-1 ${
permissionMode === 'default'
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
: permissionMode === 'acceptEdits'
? 'border-green-300/60 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-600/40 dark:bg-green-900/15 dark:text-green-300 dark:hover:bg-green-900/25'
: permissionMode === 'auto'
? 'border-blue-300/60 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-600/40 dark:bg-blue-900/15 dark:text-blue-300 dark:hover:bg-blue-900/25'
: permissionMode === 'bypassPermissions'
? 'border-orange-300/60 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-600/40 dark:bg-orange-900/15 dark:text-orange-300 dark:hover:bg-orange-900/25'
: 'border-primary/20 bg-primary/5 text-primary hover:bg-primary/10'
}`}
title={t('input.clickToChangeMode')}
>
<div className="flex items-center gap-1.5">
<div
className={`h-2.5 w-2.5 rounded-full sm:h-1.5 sm:w-1.5 ${
permissionMode === 'default'
? 'bg-muted-foreground'
: permissionMode === 'acceptEdits'
? 'bg-green-500'
: permissionMode === 'auto'
? 'bg-blue-500'
: permissionMode === 'bypassPermissions'
? 'bg-orange-500'
: 'bg-primary'
}`}
/>
<span className="hidden whitespace-nowrap sm:inline">
{permissionMode === 'default' && t('codex.modes.default')}
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
{permissionMode === 'auto' && t('codex.modes.auto')}
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
{permissionMode === 'plan' && t('codex.modes.plan')}
</span>
</div>
</button>
{provider === 'claude' && (
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)}
<TokenUsageSummary usage={tokenBudget} />
<PromptInputButton
tooltip={{ content: t('input.showAllCommands') }}
onClick={onToggleCommandMenu}
className="relative"
>
<MessageSquareIcon />
{slashCommandsCount > 0 && (
<span
className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground"
>
{slashCommandsCount}
</span>
)}
</PromptInputButton>
{hasInput && (
<PromptInputButton
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
onClick={onClearInput}
className="hidden sm:No-flex"
>
<XIcon />
</PromptInputButton>
)}
</PromptInputTools>
<div className="flex items-center gap-2">
<div
className={`hidden text-xs text-muted-foreground/50 transition-opacity duration-200 lg:block ${
input.trim() ? 'opacity-0' : 'opacity-100'
}`}
>
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
</div>
<PromptInputSubmit
disabled={!input.trim() || isLoading}
className="h-10 w-10 sm:h-10 sm:w-10"
onMouseDown={(event) => {
event.preventDefault();
onSubmit(event as unknown as MouseEvent<HTMLButtonElement>);
}}
onTouchStart={(event) => {
event.preventDefault();
onSubmit(event as unknown as TouchEvent<HTMLButtonElement>);
}}
/>
</div>
</PromptInputFooter>
</PromptInput>
</div>}
</div>
);
}