Compare commits

..

1 Commits

Author SHA1 Message Date
Haileyesus
ed9cdf0114 fix: include Claude cache tokens in usage 2026-06-05 21:38:05 +03:00
14 changed files with 720 additions and 278 deletions

771
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -67,7 +67,7 @@
"author": "CloudCLI UI Contributors",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",

View File

@@ -304,7 +304,11 @@ function extractTokenBudget(sdkMessage) {
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
if (messageUsage && typeof messageUsage === 'object') {
const inputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
const directInputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
const cacheCreationTokens = readNumber(messageUsage.cache_creation_input_tokens ?? messageUsage.cacheCreationInputTokens ?? messageUsage.cacheCreationTokens);
const cacheReadTokens = readNumber(messageUsage.cache_read_input_tokens ?? messageUsage.cacheReadInputTokens ?? messageUsage.cacheReadTokens);
const cacheTokens = cacheCreationTokens + cacheReadTokens;
const inputTokens = directInputTokens + cacheTokens;
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
const totalUsed = inputTokens + outputTokens;
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
@@ -314,6 +318,9 @@ function extractTokenBudget(sdkMessage) {
total: contextWindow,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
cacheTokens,
breakdown: {
input: inputTokens,
output: outputTokens,

View File

@@ -87,6 +87,11 @@ const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
function readUsageNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
const app = express();
const server = http.createServer(app);
@@ -1386,6 +1391,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheCreationTokens = 0;
// Find the latest assistant message with usage data (scan from end)
for (let i = lines.length - 1; i >= 0; i--) {
@@ -1397,8 +1404,11 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
const usage = entry.message.usage;
// Use token counts from latest assistant message only
inputTokens = usage.input_tokens || 0;
outputTokens = usage.output_tokens || 0;
const directInputTokens = readUsageNumber(usage.input_tokens ?? usage.inputTokens);
cacheReadTokens = readUsageNumber(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? usage.cacheReadTokens);
cacheCreationTokens = readUsageNumber(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens ?? usage.cacheCreationTokens);
inputTokens = directInputTokens + cacheReadTokens + cacheCreationTokens;
outputTokens = readUsageNumber(usage.output_tokens ?? usage.outputTokens);
break; // Stop after finding the latest assistant message
}
@@ -1409,12 +1419,16 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
}
const totalUsed = inputTokens + outputTokens;
const cacheTokens = cacheReadTokens + cacheCreationTokens;
res.json({
used: totalUsed,
total: contextWindow,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
cacheTokens,
breakdown: {
input: inputTokens,
output: outputTokens

View File

@@ -18,7 +18,6 @@ type ShellIncomingMessage = {
provider?: string;
initialCommand?: string;
isPlainShell?: boolean;
forceRestart?: boolean;
};
type PtySessionEntry = {
@@ -181,7 +180,6 @@ export function handleShellConnection(
const hasSession = readBoolean(data.hasSession);
const provider = readString(data.provider, 'claude');
const initialCommand = readString(data.initialCommand);
const forceRestart = readBoolean(data.forceRestart);
const isPlainShell =
readBoolean(data.isPlainShell) ||
(!!initialCommand && !hasSession) ||
@@ -202,7 +200,7 @@ export function handleShellConnection(
: '';
ptySessionKey = `${projectPath}_${sessionId ?? 'default'}${commandSuffix}`;
if (isLoginCommand || forceRestart) {
if (isLoginCommand) {
const oldSession = ptySessionsMap.get(ptySessionKey);
if (oldSession) {
if (oldSession.timeoutId) {
@@ -213,8 +211,7 @@ export function handleShellConnection(
}
}
const existingSession =
isLoginCommand || forceRestart ? null : ptySessionsMap.get(ptySessionKey);
const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);
if (existingSession) {
shellProcess = existingSession.pty;
if (existingSession.timeoutId) {
@@ -371,10 +368,6 @@ export function handleShellConnection(
}
const session = ptySessionsMap.get(ptySessionKey);
if (session && session.pty !== shellProcess) {
return;
}
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
session.ws.send(
JSON.stringify({
@@ -458,10 +451,6 @@ export function handleShellConnection(
session.ws = null;
session.timeoutId = setTimeout(() => {
if (ptySessionsMap.get(ptySessionKey as string) !== session) {
return;
}
session.pty.kill();
ptySessionsMap.delete(ptySessionKey as string);
}, PTY_SESSION_TIMEOUT);

View File

@@ -592,12 +592,14 @@ class ResponseCollector {
}
}
const inputTokens = totalInput + totalCacheRead + totalCacheCreation;
return {
inputTokens: totalInput,
inputTokens,
outputTokens: totalOutput,
cacheReadTokens: totalCacheRead,
cacheCreationTokens: totalCacheCreation,
totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
totalTokens: inputTokens + totalOutput
};
}
}

View File

@@ -268,16 +268,35 @@ Custom commands can be created in:
tokenUsage.contextWindow ??
0,
) || 0;
const inputTokensRaw =
const normalizedInputValue =
tokenUsage.inputTokens ??
tokenUsage.input ??
tokenUsage.cumulativeInputTokens ??
tokenUsage.breakdown?.input ??
tokenUsage.promptTokens;
const directInputTokens =
Number(
tokenUsage.inputTokens ??
tokenUsage.input ??
normalizedInputValue ??
tokenUsage.input_tokens ??
tokenUsage.cumulativeInputTokens ??
tokenUsage.breakdown?.input ??
tokenUsage.promptTokens ??
0
) || 0;
const cacheReadTokens =
Number(
tokenUsage.cacheReadTokens ??
tokenUsage.cache_read_input_tokens ??
tokenUsage.cacheReadInputTokens ??
0,
) || 0;
const cacheCreationTokens =
Number(
tokenUsage.cacheCreationTokens ??
tokenUsage.cache_creation_input_tokens ??
tokenUsage.cacheCreationInputTokens ??
0,
) || 0;
const inputTokens = normalizedInputValue == null
? directInputTokens + cacheReadTokens + cacheCreationTokens
: directInputTokens;
const outputTokens =
Number(
tokenUsage.outputTokens ??
@@ -288,8 +307,9 @@ Custom commands can be created in:
tokenUsage.completionTokens ??
0,
) || 0;
const hasTokenBreakdown = inputTokensRaw > 0 || outputTokens > 0;
const used = reportedUsed || inputTokensRaw + outputTokens;
const computedUsed = inputTokens + outputTokens;
const hasTokenBreakdown = computedUsed > 0;
const used = Math.max(reportedUsed, computedUsed);
return {
type: "builtin",
@@ -302,7 +322,7 @@ Custom commands can be created in:
...(hasTokenBreakdown
? {
tokenBreakdown: {
input: inputTokensRaw,
input: inputTokens,
output: outputTokens,
},
}

View File

@@ -321,7 +321,6 @@ export default function ProviderSelectionEmptyState({
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground/60">
<Trans
ns="chat"
i18nKey="providerSelection.pressToSearch"
values={{ shortcut: MOD_KEY === "⌘" ? "⌘K" : "Ctrl+K" }}
components={{

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import type { MutableRefObject } from 'react';
import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app';
import { TERMINAL_INIT_DELAY_MS } from '../constants/constants';
import { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket';
@@ -32,8 +31,8 @@ type UseShellConnectionResult = {
isConnected: boolean;
isConnecting: boolean;
closeSocket: () => void;
connectToShell: (options?: { forceRestart?: boolean }) => void;
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
connectToShell: () => void;
disconnectFromShell: () => void;
};
export function useShellConnection({
@@ -55,8 +54,6 @@ export function useShellConnection({
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const connectingRef = useRef(false);
const forceRestartOnInitRef = useRef(false);
const suppressAutoConnectRef = useRef(false);
const handleProcessCompletion = useCallback(
(output: string) => {
@@ -144,8 +141,6 @@ export function useShellConnection({
}
currentFitAddon.fit();
const forceRestart = forceRestartOnInitRef.current;
forceRestartOnInitRef.current = false;
sendSocketMessage(socket, {
type: 'init',
@@ -157,7 +152,6 @@ export function useShellConnection({
rows: currentTerminal.rows,
initialCommand: initialCommandRef.current,
isPlainShell: isPlainShellRef.current,
forceRestart,
});
}, TERMINAL_INIT_DELAY_MS);
};
@@ -183,7 +177,6 @@ export function useShellConnection({
setIsConnected(false);
setIsConnecting(false);
connectingRef.current = false;
forceRestartOnInitRef.current = false;
}
},
[
@@ -202,40 +195,27 @@ export function useShellConnection({
],
);
const connectToShell = useCallback((options?: { forceRestart?: boolean }) => {
const connectToShell = useCallback(() => {
if (!isInitialized || isConnected || isConnecting || connectingRef.current) {
return;
}
forceRestartOnInitRef.current = Boolean(options?.forceRestart);
suppressAutoConnectRef.current = false;
connectingRef.current = true;
setIsConnecting(true);
connectWebSocket(true);
}, [connectWebSocket, isConnected, isConnecting, isInitialized]);
const disconnectFromShell = useCallback((options?: { suppressAutoConnect?: boolean }) => {
if (options?.suppressAutoConnect) {
suppressAutoConnectRef.current = true;
}
const disconnectFromShell = useCallback(() => {
closeSocket();
clearTerminalScreen();
setIsConnected(false);
setIsConnecting(false);
connectingRef.current = false;
forceRestartOnInitRef.current = false;
setAuthUrl('');
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
useEffect(() => {
if (
!autoConnect ||
suppressAutoConnectRef.current ||
!isInitialized ||
isConnecting ||
isConnected
) {
if (!autoConnect || !isInitialized || isConnecting || isConnected) {
return;
}

View File

@@ -1,7 +1,6 @@
import type { MutableRefObject, RefObject } from 'react';
import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app';
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
@@ -16,7 +15,6 @@ export type ShellInitMessage = {
rows: number;
initialCommand: string | null | undefined;
isPlainShell: boolean;
forceRestart?: boolean;
};
export type ShellResizeMessage = {
@@ -71,8 +69,8 @@ export type UseShellRuntimeResult = {
isConnecting: boolean;
authUrl: string;
authUrlVersion: number;
connectToShell: (options?: { forceRestart?: boolean }) => void;
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
connectToShell: () => void;
disconnectFromShell: () => void;
openAuthUrlInBrowser: (url?: string) => boolean;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
};

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import '@xterm/xterm/css/xterm.css';
import type { Project, ProjectSession } from '../../../types/app';
import {
@@ -14,7 +13,6 @@ import {
import { useShellRuntime } from '../hooks/useShellRuntime';
import { sendSocketMessage } from '../utils/socket';
import { getSessionDisplayName } from '../utils/auth';
import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
import ShellEmptyState from './subcomponents/ShellEmptyState';
import ShellHeader from './subcomponents/ShellHeader';
@@ -48,8 +46,6 @@ export default function Shell({
const [isRestarting, setIsRestarting] = useState(false);
const [cliPromptOptions, setCliPromptOptions] = useState<CliPromptOption[] | null>(null);
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const restartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const restartAfterInitRef = useRef(false);
const onOutputRef = useRef<(() => void) | null>(null);
const {
@@ -144,7 +140,6 @@ export default function Shell({
useEffect(() => {
return () => {
if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current);
if (restartTimerRef.current) clearTimeout(restartTimerRef.current);
};
}, []);
@@ -195,42 +190,12 @@ export default function Shell({
);
const handleRestartShell = useCallback(() => {
restartAfterInitRef.current = true;
setIsRestarting(true);
if (restartTimerRef.current) {
clearTimeout(restartTimerRef.current);
}
restartTimerRef.current = setTimeout(() => {
window.setTimeout(() => {
setIsRestarting(false);
restartTimerRef.current = null;
}, SHELL_RESTART_DELAY_MS);
}, []);
const handleDisconnectShell = useCallback(() => {
restartAfterInitRef.current = false;
if (restartTimerRef.current) {
clearTimeout(restartTimerRef.current);
restartTimerRef.current = null;
}
setIsRestarting(false);
disconnectFromShell({ suppressAutoConnect: true });
}, [disconnectFromShell]);
useEffect(() => {
if (
!restartAfterInitRef.current ||
isRestarting ||
!isInitialized ||
isConnected ||
isConnecting
) {
return;
}
restartAfterInitRef.current = false;
connectToShell({ forceRestart: true });
}, [connectToShell, isConnected, isConnecting, isInitialized, isRestarting]);
if (!selectedProject) {
return (
<ShellEmptyState
@@ -289,7 +254,7 @@ export default function Shell({
isRestarting={isRestarting}
hasSession={Boolean(selectedSession)}
sessionDisplayNameShort={sessionDisplayNameShort}
onDisconnect={handleDisconnectShell}
onDisconnect={disconnectFromShell}
onRestart={handleRestartShell}
statusNewSessionText={t('shell.status.newSession')}
statusInitializingText={t('shell.status.initializing')}
@@ -298,7 +263,7 @@ export default function Shell({
disconnectTitle={t('shell.actions.disconnectTitle')}
restartLabel={t('shell.actions.restart')}
restartTitle={t('shell.actions.restartTitle')}
disableRestart={isRestarting || !isInitialized}
disableRestart={isRestarting || isConnected}
/>
<div className="relative flex-1 overflow-hidden p-2">
@@ -316,7 +281,7 @@ export default function Shell({
connectLabel={t('shell.actions.connect')}
connectTitle={t('shell.actions.connectTitle')}
connectingLabel={t('shell.connecting')}
onConnect={handleRestartShell}
onConnect={connectToShell}
/>
)}

View File

@@ -1,5 +1,3 @@
import { Loader2, RotateCcw } from 'lucide-react';
type ShellConnectionOverlayProps = {
mode: 'loading' | 'connect' | 'connecting';
description: string;
@@ -21,42 +19,40 @@ export default function ShellConnectionOverlay({
}: ShellConnectionOverlayProps) {
if (mode === 'loading') {
return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90">
<div className="inline-flex items-center gap-2 text-sm font-medium text-gray-100">
<Loader2 className="h-4 w-4 animate-spin text-blue-300" aria-hidden="true" />
<span>{loadingLabel}</span>
</div>
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-white">{loadingLabel}</div>
</div>
);
}
if (mode === 'connect') {
return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6">
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center">
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="w-full max-w-sm text-center">
<button
type="button"
onClick={onConnect}
className="pointer-events-auto inline-flex min-h-12 w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-md bg-emerald-600 px-5 py-3 text-base font-semibold text-white shadow-lg shadow-emerald-950/30 transition-colors hover:bg-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-300 focus:ring-offset-2 focus:ring-offset-gray-950 active:bg-emerald-700"
className="flex w-full items-center justify-center space-x-2 rounded-lg bg-green-600 px-6 py-3 text-base font-medium text-white transition-colors hover:bg-green-700 sm:w-auto"
title={connectTitle}
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
<span className="min-w-0 truncate">{connectLabel}</span>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>{connectLabel}</span>
</button>
<p className="max-w-md break-words px-2 text-sm leading-6 text-gray-300">{description}</p>
<p className="mt-3 px-2 text-sm text-gray-400">{description}</p>
</div>
</div>
);
}
return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-gray-950/90 p-6">
<div className="flex w-full max-w-md flex-col items-center gap-3 text-center">
<div className="flex items-center justify-center gap-3 text-yellow-300">
<Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="w-full max-w-sm text-center">
<div className="flex items-center justify-center space-x-3 text-yellow-400">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
<span className="text-base font-medium">{connectingLabel}</span>
</div>
<p className="max-w-md break-words px-2 text-sm leading-6 text-gray-300">{description}</p>
<p className="mt-3 px-2 text-sm text-gray-400">{description}</p>
</div>
</div>
);

View File

@@ -1,5 +1,3 @@
import { RotateCcw, X } from 'lucide-react';
type ShellHeaderProps = {
isConnected: boolean;
isInitialized: boolean;
@@ -52,27 +50,34 @@ export default function ShellHeader({
{isRestarting && <span className="text-xs text-blue-400">{statusRestartingText}</span>}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center space-x-3">
{isConnected && (
<button
type="button"
onClick={onDisconnect}
className="inline-flex h-8 items-center gap-1.5 rounded-md bg-red-600 px-3 text-xs font-medium text-white transition-colors hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400/70 focus:ring-offset-2 focus:ring-offset-gray-800"
className="flex items-center space-x-1 rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700"
title={disconnectTitle}
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>{disconnectLabel}</span>
</button>
)}
<button
type="button"
onClick={onRestart}
disabled={disableRestart}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-gray-600/80 bg-gray-700/70 px-3 text-xs font-medium text-gray-100 transition-colors hover:border-blue-400/70 hover:bg-blue-600/80 hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-400/70 focus:ring-offset-2 focus:ring-offset-gray-800 disabled:cursor-not-allowed disabled:border-transparent disabled:bg-transparent disabled:text-gray-500 disabled:opacity-60"
className="flex items-center space-x-1 text-xs text-gray-400 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
title={restartTitle}
>
<RotateCcw className={`h-3.5 w-3.5 ${isRestarting ? 'animate-spin' : ''}`} aria-hidden="true" />
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>{restartLabel}</span>
</button>
</div>

View File

@@ -229,7 +229,7 @@
"disconnect": "Disconnect",
"disconnectTitle": "Disconnect from shell",
"restart": "Restart",
"restartTitle": "Restart Shell",
"restartTitle": "Restart Shell (disconnect first)",
"connect": "Continue in Shell",
"connectTitle": "Connect to shell"
},