Compare commits

..

2 Commits

Author SHA1 Message Date
Haileyesus
04edf7d9c4 fix: harden token usage reporting
Token usage is shown as concrete counts, so malformed provider payloads must not leak NaN.

Gemini can emit token fields as strings or invalid values, so non-finite values now fall back to 0.

OpenCode token reads happen while the CLI is shutting down, when the DB may be missing or locked.

Those failures now return null instead of interrupting session completion.

/cost no longer invents an input breakdown from an aggregate total.

When a provider only supplies total usage, the UI now says the breakdown is unavailable.

This keeps the display honest instead of presenting made-up input and output rows.

Verification: npm run typecheck; targeted eslint.
2026-05-29 17:43:22 +03:00
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
39 changed files with 853 additions and 627 deletions

View File

@@ -3,25 +3,6 @@
All notable changes to CloudCLI UI will be documented in this file. All notable changes to CloudCLI UI will be documented in this file.
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
### New Features
* add opencode support ([#762](https://github.com/siteboon/claudecodeui/issues/762)) ([374e9de](https://github.com/siteboon/claudecodeui/commit/374e9de71934c41ce2c19c796e35a19234b240ec))
* **sidebar:** tooltip for the active-session indicator dot ([#782](https://github.com/siteboon/claudecodeui/issues/782)) ([27e509a](https://github.com/siteboon/claudecodeui/commit/27e509a9b8bb25c35ae0abbda44c536e15c332c8))
### Bug Fixes
* **chat:** prevent double send on mobile by removing redundant submit handlers ([#719](https://github.com/siteboon/claudecodeui/issues/719)) ([dbc41dc](https://github.com/siteboon/claudecodeui/commit/dbc41dc91dbf1fb54f92f5536d64646b4e924f31))
* preserve WebSocket frame type in plugin proxy ([#594](https://github.com/siteboon/claudecodeui/issues/594)) ([36b860e](https://github.com/siteboon/claudecodeui/commit/36b860e322454df62ebf5309018590b596e6b913)), closes [CoderLuii/HolyClaude#11](https://github.com/CoderLuii/HolyClaude/issues/11)
* refine token usage reporting ([#807](https://github.com/siteboon/claudecodeui/issues/807)) ([38bf21d](https://github.com/siteboon/claudecodeui/commit/38bf21ddf554ed28676d86b5221c25adf6f07afd))
* refresh Claude auth status after login flow ([#617](https://github.com/siteboon/claudecodeui/issues/617)) ([1e125f3](https://github.com/siteboon/claudecodeui/commit/1e125f3db5248399cd50dc3d40b1f8f44cf7ccb6))
* **sidebar:** keep session rename input visible while editing ([#781](https://github.com/siteboon/claudecodeui/issues/781)) ([951f587](https://github.com/siteboon/claudecodeui/commit/951f58751c152fbbb3f8b3ce3c814c06c061de18))
### Styling
* fix project star button location by replacing folder icon ([#793](https://github.com/siteboon/claudecodeui/issues/793)) ([295bad9](https://github.com/siteboon/claudecodeui/commit/295bad9c006b669878cbf52940794f29f7370178))
## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13) ## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13)
### Bug Fixes ### Bug Fixes

21
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.33.0", "version": "1.32.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.33.0", "version": "1.32.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
@@ -39,7 +39,6 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"dompurify": "^3.4.7",
"express": "^4.18.2", "express": "^4.18.2",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
@@ -4581,13 +4580,6 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -7493,15 +7485,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dot-prop": { "node_modules/dot-prop": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.33.0", "version": "1.32.0",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "dist-server/server/index.js", "main": "dist-server/server/index.js",
@@ -96,7 +96,6 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"dompurify": "^3.4.7",
"express": "^4.18.2", "express": "^4.18.2",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",

View File

@@ -11,7 +11,7 @@ export const CLAUDE_MODELS = {
{ {
value: "default", value: "default",
label: "Default (recommended)", label: "Default (recommended)",
description: "Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok", description: "Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok",
}, },
{ {
value: "sonnet", value: "sonnet",

View File

@@ -16,10 +16,6 @@ type ClaudeCredentialsStatus = {
error?: string; error?: string;
}; };
const hasErrorCode = (error: unknown, code: string): boolean => (
error instanceof Error && 'code' in error && error.code === code
);
export class ClaudeProviderAuth implements IProviderAuth { export class ClaudeProviderAuth implements IProviderAuth {
/** /**
* Checks whether the Claude Code CLI is available on this host. * Checks whether the Claude Code CLI is available on this host.
@@ -81,12 +77,6 @@ export class ClaudeProviderAuth implements IProviderAuth {
* Checks Claude credentials in the same priority order used by Claude Code. * Checks Claude credentials in the same priority order used by Claude Code.
*/ */
private async checkCredentials(): Promise<ClaudeCredentialsStatus> { private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
const missingCredentialsError = 'Claude CLI is not authenticated. Run claude /login or configure ANTHROPIC_API_KEY.';
if (process.env.ANTHROPIC_AUTH_TOKEN?.trim()) {
return { authenticated: true, email: 'Auth Token', method: 'api_key' };
}
if (process.env.ANTHROPIC_API_KEY?.trim()) { if (process.env.ANTHROPIC_API_KEY?.trim()) {
return { authenticated: true, email: 'API Key Auth', method: 'api_key' }; return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
} }
@@ -120,33 +110,15 @@ export class ClaudeProviderAuth implements IProviderAuth {
return { return {
authenticated: false, authenticated: false,
email: null, email,
method: null, method: 'credentials_file',
error: 'Claude login has expired. Run claude /login again.', error: 'OAuth token has expired. Please re-authenticate with claude login',
}; };
} }
return { return { authenticated: false, email: null, method: null };
authenticated: false, } catch {
email: null, return { authenticated: false, email: null, method: null };
method: null,
error: missingCredentialsError,
};
} catch (error) {
let errorMessage = 'Unable to read Claude credentials. Run claude /login again.';
if (hasErrorCode(error, 'ENOENT')) {
errorMessage = missingCredentialsError;
} else if (error instanceof SyntaxError) {
errorMessage = 'Claude credentials are unreadable. Run claude /login again.';
}
return {
authenticated: false,
email: null,
method: null,
error: errorMessage,
};
} }
} }
} }

View File

@@ -26,15 +26,15 @@ export function handlePluginWsProxy(
console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`); console.log(`[Plugins] WS proxy connected to "${pluginName}" on port ${port}`);
}); });
upstream.on('message', (data, isBinary) => { upstream.on('message', (data) => {
if (clientWs.readyState === WebSocket.OPEN) { if (clientWs.readyState === WebSocket.OPEN) {
clientWs.send(data, { binary: isBinary }); clientWs.send(data);
} }
}); });
clientWs.on('message', (data, isBinary) => { clientWs.on('message', (data) => {
if (upstream.readyState === WebSocket.OPEN) { if (upstream.readyState === WebSocket.OPEN) {
upstream.send(data, { binary: isBinary }); upstream.send(data);
} }
}); });

View File

@@ -31,24 +31,6 @@ export function createWebSocketServer(
}); });
wss.on('connection', (ws, request) => { wss.on('connection', (ws, request) => {
// Keep WebSocket alive across reverse-proxy idle timeouts (Cloudflare ~100s,
// AWS ALB 60s, nginx 60s, etc.). Without app-level pings these connections
// are silently torn down even when the UI is active, causing repeated
// reconnect cycles. ws library heartbeat is opt-in.
const HEARTBEAT_INTERVAL_MS = 30_000;
const heartbeat = setInterval(() => {
if (ws.readyState === ws.OPEN) {
try {
ws.ping();
} catch {
// socket may have been closed concurrently — interval will be cleared below
}
}
}, HEARTBEAT_INTERVAL_MS);
const stopHeartbeat = () => clearInterval(heartbeat);
ws.on('close', stopHeartbeat);
ws.on('error', stopHeartbeat);
const incomingRequest = request as AuthenticatedWebSocketRequest; const incomingRequest = request as AuthenticatedWebSocketRequest;
const url = incomingRequest.url ?? '/'; const url = incomingRequest.url ?? '/';
const pathname = new URL(url, 'http://localhost').pathname; const pathname = new URL(url, 'http://localhost').pathname;

View File

@@ -1,6 +1,5 @@
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider, ProtectedRoute } from './components/auth'; import { AuthProvider, ProtectedRoute } from './components/auth';
import { TaskMasterProvider } from './contexts/TaskMasterContext'; import { TaskMasterProvider } from './contexts/TaskMasterContext';
@@ -10,99 +9,7 @@ import { PluginsProvider } from './contexts/PluginsContext';
import AppContent from './components/app/AppContent'; import AppContent from './components/app/AppContent';
import i18n from './i18n/config.js'; import i18n from './i18n/config.js';
const DEPLOYMENT_ASSET_DIRECTORIES = new Set(['assets', 'static', 'icons', 'images']);
/**
* Detect the router basename from explicit runtime config or deployment hints.
*
* CloudCLI can be served from a path prefix by a reverse proxy, for example:
* /ai/manifest.json
* /ai/assets/index-abc123.js
* /ai/icons/icon-192x192.png
*
* React Router needs that prefix as its basename, but the packaged app should
* also keep working when served directly from the domain root. The direct-root
* case is easy to misread because asset URLs such as /icons/icon-192x192.png
* contain a directory even though there is no application basename.
*/
function detectRouterBasename() {
const explicitBasename = typeof window !== 'undefined' ? window.__ROUTER_BASENAME__ || '' : '';
if (explicitBasename) {
// Keep the deployment escape hatch authoritative. A trailing slash is
// harmless for humans but React Router expects a normalized basename.
return explicitBasename.replace(/\/+$/, '');
}
if (typeof window === 'undefined' || typeof document === 'undefined') {
return '';
}
const candidatePaths = [
{ kind: 'manifest' as const, value: document.querySelector('link[rel="manifest"]')?.getAttribute('href') },
{ kind: 'script' as const, value: document.querySelector('script[type="module"][src]')?.getAttribute('src') },
...Array.from(
document.querySelectorAll(
'link[rel~="icon"][href], link[rel="apple-touch-icon"][href], link[rel="apple-touch-icon-precomposed"][href], link[rel="mask-icon"][href]'
)
).map((node) => ({
kind: 'icon' as const,
value: node.getAttribute('href'),
})),
].filter((candidate): candidate is { kind: 'manifest' | 'script' | 'icon'; value: string } => Boolean(candidate.value));
let detectedBasename = '';
for (const candidate of candidatePaths) {
try {
const candidateUrl = new URL(candidate.value, document.baseURI || window.location.href);
if (candidateUrl.origin !== window.location.origin) {
continue;
}
const pathname = candidateUrl.pathname;
const normalizedPathname = pathname.replace(/\/+$/, '');
let normalized = '';
if (candidate.kind === 'script') {
const match = normalizedPathname.match(/^(.*)\/assets\//);
normalized = match?.[1] ? match[1].replace(/\/+$/, '') : '';
} else {
const manifestMatch = normalizedPathname.match(/^(.*)\/(?:manifest\.json|site\.webmanifest)$/);
const iconMatch = normalizedPathname.match(
/^(.*)\/(?:favicon(?:\.[^/]+)?|apple-touch-icon(?:-[^/]+)?(?:\.[^/]+)?|mask-icon(?:\.[^/]+)?|[^/]*icon[^/]*)$/
);
const match = candidate.kind === 'manifest' ? manifestMatch : iconMatch;
if (match?.[1]) {
const segments = match[1].split('/').filter(Boolean);
// Strip directories that describe where static files live, not where
// the app is mounted. This must also run for a single segment:
// /icons/icon-192x192.png -> ''
// /ai/icons/icon-192x192.png -> '/ai'
// The previous implementation only stripped while more than one
// segment remained, which incorrectly turned root deployments into a
// Router basename of /icons and caused a blank page after login.
while (segments.length > 0 && DEPLOYMENT_ASSET_DIRECTORIES.has(segments[segments.length - 1])) {
segments.pop();
}
normalized = segments.length > 0 ? `/${segments.join('/')}` : '';
}
}
if (normalized.length > detectedBasename.length) {
detectedBasename = normalized;
}
} catch {
// Ignore invalid candidate URLs and continue checking other hints.
}
}
return detectedBasename;
}
export default function App() { export default function App() {
const routerBasename = detectRouterBasename();
return ( return (
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<ThemeProvider> <ThemeProvider>
@@ -112,7 +19,7 @@ export default function App() {
<TasksSettingsProvider> <TasksSettingsProvider>
<TaskMasterProvider> <TaskMasterProvider>
<ProtectedRoute> <ProtectedRoute>
<Router basename={routerBasename}> <Router basename={window.__ROUTER_BASENAME__ || ''}>
<Routes> <Routes>
<Route path="/" element={<AppContent />} /> <Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} /> <Route path="/session/:sessionId" element={<AppContent />} />

View File

@@ -0,0 +1,44 @@
import { Brain, Zap, Sparkles, Atom } from 'lucide-react';
export const thinkingModes = [
{
id: 'none',
name: 'Standard',
description: 'Regular Claude response',
icon: null,
prefix: '',
color: 'text-gray-600'
},
{
id: 'think',
name: 'Think',
description: 'Basic extended thinking',
icon: Brain,
prefix: 'think',
color: 'text-blue-600'
},
{
id: 'think-hard',
name: 'Think Hard',
description: 'More thorough evaluation',
icon: Zap,
prefix: 'think hard',
color: 'text-purple-600'
},
{
id: 'think-harder',
name: 'Think Harder',
description: 'Deep analysis with alternatives',
icon: Sparkles,
prefix: 'think harder',
color: 'text-indigo-600'
},
{
id: 'ultrathink',
name: 'Ultrathink',
description: 'Maximum thinking budget',
icon: Atom,
prefix: 'ultrathink',
color: 'text-red-600'
}
];

View File

@@ -12,6 +12,7 @@ import type {
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { thinkingModes } from '../constants/thinkingModes';
import { grantClaudeToolPermission } from '../utils/chatPermissions'; import { grantClaudeToolPermission } from '../utils/chatPermissions';
import { safeLocalStorage } from '../utils/chatStorage'; import { safeLocalStorage } from '../utils/chatStorage';
import type { import type {
@@ -203,6 +204,7 @@ export function useChatComposerState({
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map()); const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map()); const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
const [thinkingMode, setThinkingMode] = useState('none');
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null); const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -560,7 +562,11 @@ export function useChatComposerState({
} }
} }
const messageContent = currentInput; let messageContent = currentInput;
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
if (selectedThinkingMode && selectedThinkingMode.prefix) {
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
}
let uploadedImages: unknown[] = []; let uploadedImages: unknown[] = [];
if (attachedImages.length > 0) { if (attachedImages.length > 0) {
@@ -743,6 +749,7 @@ export function useChatComposerState({
setUploadingImages(new Map()); setUploadingImages(new Map());
setImageErrors(new Map()); setImageErrors(new Map());
setIsTextareaExpanded(false); setIsTextareaExpanded(false);
setThinkingMode('none');
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = 'auto';
@@ -776,6 +783,7 @@ export function useChatComposerState({
setIsLoading, setIsLoading,
setIsUserScrolledUp, setIsUserScrolledUp,
slashCommands, slashCommands,
thinkingMode,
], ],
); );
@@ -1012,6 +1020,8 @@ export function useChatComposerState({
textareaRef, textareaRef,
inputHighlightRef, inputHighlightRef,
isTextareaExpanded, isTextareaExpanded,
thinkingMode,
setThinkingMode,
slashCommandsCount, slashCommandsCount,
filteredCommands, filteredCommands,
frequentCommands, frequentCommands,

View File

@@ -141,6 +141,8 @@ function ChatInterface({
textareaRef, textareaRef,
inputHighlightRef, inputHighlightRef,
isTextareaExpanded, isTextareaExpanded,
thinkingMode,
setThinkingMode,
slashCommandsCount, slashCommandsCount,
filteredCommands, filteredCommands,
frequentCommands, frequentCommands,
@@ -367,6 +369,8 @@ function ChatInterface({
provider={provider} provider={provider}
permissionMode={permissionMode} permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode} onModeSwitch={cyclePermissionMode}
thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget} tokenBudget={tokenBudget}
slashCommandsCount={slashCommandsCount} slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={handleToggleCommandMenu} onToggleCommandMenu={handleToggleCommandMenu}

View File

@@ -2,16 +2,23 @@ import { useTranslation } from 'react-i18next';
import type { import type {
ChangeEvent, ChangeEvent,
ClipboardEvent, ClipboardEvent,
Dispatch,
FormEvent, FormEvent,
KeyboardEvent, KeyboardEvent,
MouseEvent, MouseEvent,
ReactNode, ReactNode,
RefObject, RefObject,
SetStateAction,
TouchEvent, TouchEvent,
} from 'react'; } from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react'; import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types'; 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 { import {
PromptInput, PromptInput,
PromptInputHeader, PromptInputHeader,
@@ -23,12 +30,6 @@ import {
PromptInputSubmit, PromptInputSubmit,
} from '../../../../shared/view/ui'; } from '../../../../shared/view/ui';
import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import TokenUsageSummary from './TokenUsageSummary';
interface MentionableFile { interface MentionableFile {
name: string; name: string;
path: string; path: string;
@@ -57,6 +58,8 @@ interface ChatComposerProps {
provider: Provider | string; provider: Provider | string;
permissionMode: PermissionMode | string; permissionMode: PermissionMode | string;
onModeSwitch: () => void; onModeSwitch: () => void;
thinkingMode: string;
setThinkingMode: Dispatch<SetStateAction<string>>;
tokenBudget: Record<string, unknown> | null; tokenBudget: Record<string, unknown> | null;
slashCommandsCount: number; slashCommandsCount: number;
onToggleCommandMenu: () => void; onToggleCommandMenu: () => void;
@@ -110,6 +113,8 @@ export default function ChatComposer({
provider, provider,
permissionMode, permissionMode,
onModeSwitch, onModeSwitch,
thinkingMode,
setThinkingMode,
tokenBudget, tokenBudget,
slashCommandsCount, slashCommandsCount,
onToggleCommandMenu, onToggleCommandMenu,
@@ -290,7 +295,6 @@ export default function ChatComposer({
<PromptInputTextarea <PromptInputTextarea
ref={textareaRef} ref={textareaRef}
dir="auto"
value={input} value={input}
onChange={onInputChange} onChange={onInputChange}
onClick={onTextareaClick} onClick={onTextareaClick}
@@ -353,6 +357,10 @@ export default function ChatComposer({
</div> </div>
</button> </button>
{provider === 'claude' && (
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)}
<TokenUsageSummary usage={tokenBudget} /> <TokenUsageSummary usage={tokenBudget} />
<PromptInputButton <PromptInputButton
@@ -374,7 +382,7 @@ export default function ChatComposer({
<PromptInputButton <PromptInputButton
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }} tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
onClick={onClearInput} onClick={onClearInput}
className="hidden sm:flex" className="hidden sm:No-flex"
> >
<XIcon /> <XIcon />
</PromptInputButton> </PromptInputButton>
@@ -391,9 +399,16 @@ export default function ChatComposer({
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')} {sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
</div> </div>
<PromptInputSubmit <PromptInputSubmit
onClick={isLoading ? onAbortSession : undefined} disabled={!input.trim() || isLoading}
disabled={!isLoading && !input.trim()}
className="h-10 w-10 sm:h-10 sm:w-10" 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> </div>
</PromptInputFooter> </PromptInputFooter>

View File

@@ -120,7 +120,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
/* User message bubble on the right */ /* User message bubble on the right */
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl"> <div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4"> <div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
<div dir="auto" className="whitespace-pre-wrap break-words text-sm"> <div className="whitespace-pre-wrap break-words text-sm">
{message.content} {message.content}
</div> </div>
{message.images && message.images.length > 0 && ( {message.images && message.images.length > 0 && (
@@ -405,7 +405,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
</ReasoningContent> </ReasoningContent>
</Reasoning> </Reasoning>
) : ( ) : (
<div dir="auto" className="text-sm text-gray-700 dark:text-gray-300"> <div className="text-sm text-gray-700 dark:text-gray-300">
{/* Reasoning accordion */} {/* Reasoning accordion */}
{showThinking && message.reasoning && ( {showThinking && message.reasoning && (
<Reasoning className="mb-3" defaultOpen={false}> <Reasoning className="mb-3" defaultOpen={false}>

View File

@@ -0,0 +1,244 @@
import { useState, useRef, useEffect, useCallback, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { Brain, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { thinkingModes } from '../../constants/thinkingModes';
type ThinkingModeSelectorProps = {
selectedMode: string;
onModeChange: (modeId: string) => void;
onClose?: () => void;
className?: string;
};
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
const { t } = useTranslation('chat');
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
// Mapping from mode ID to translation key
const modeKeyMap: Record<string, string> = {
'think-hard': 'thinkHard',
'think-harder': 'thinkHarder'
};
// Create translated modes for display
const translatedModes = thinkingModes.map(mode => {
const modeKey = modeKeyMap[mode.id] || mode.id;
return {
...mode,
name: t(`thinkingMode.modes.${modeKey}.name`),
description: t(`thinkingMode.modes.${modeKey}.description`),
prefix: t(`thinkingMode.modes.${modeKey}.prefix`)
};
});
const closeDropdown = useCallback(() => {
setIsOpen(false);
onClose?.();
}, [onClose]);
const updateDropdownPosition = useCallback(() => {
const trigger = triggerRef.current;
const dropdown = dropdownRef.current;
if (!trigger || !dropdown || typeof window === 'undefined') {
return;
}
const triggerRect = trigger.getBoundingClientRect();
const viewportPadding = window.innerWidth < 640 ? 12 : 16;
const spacing = 8;
const width = Math.min(window.innerWidth - viewportPadding * 2, window.innerWidth < 640 ? 320 : 256);
let left = triggerRect.left + triggerRect.width / 2 - width / 2;
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - width - viewportPadding));
const measuredHeight = dropdown.offsetHeight || 0;
const spaceBelow = window.innerHeight - triggerRect.bottom - spacing - viewportPadding;
const spaceAbove = triggerRect.top - spacing - viewportPadding;
const openBelow = spaceBelow >= Math.min(measuredHeight || 320, 320) || spaceBelow >= spaceAbove;
const availableHeight = Math.min(
window.innerHeight - viewportPadding * 2,
Math.max(180, openBelow ? spaceBelow : spaceAbove),
);
const panelHeight = Math.min(measuredHeight || availableHeight, availableHeight);
const top = openBelow
? Math.min(triggerRect.bottom + spacing, window.innerHeight - viewportPadding - panelHeight)
: Math.max(viewportPadding, triggerRect.top - spacing - panelHeight);
setDropdownStyle({
position: 'fixed',
top,
left,
width,
maxHeight: availableHeight,
zIndex: 80,
});
}, []);
useEffect(() => {
if (!isOpen) {
setDropdownStyle(null);
return;
}
const rafId = window.requestAnimationFrame(updateDropdownPosition);
const handleViewportChange = () => updateDropdownPosition();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
};
}, [isOpen, updateDropdownPosition]);
useEffect(() => {
if (!isOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) {
return;
}
closeDropdown();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeDropdown();
}
};
document.addEventListener('pointerdown', handlePointerDown, true);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, closeDropdown]);
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
const IconComponent = currentMode.icon || Brain;
return (
<div className={`relative ${className}`} ref={containerRef}>
<button
ref={triggerRef}
type="button"
onClick={() => {
if (isOpen) {
closeDropdown();
return;
}
setIsOpen(true);
}}
className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
}`}
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
aria-haspopup="dialog"
aria-expanded={isOpen}
>
<IconComponent className={`h-5 w-5 ${currentMode.color}`} />
</button>
{isOpen && typeof document !== 'undefined' && createPortal(
<div
ref={dropdownRef}
style={dropdownStyle || { position: 'fixed', top: 0, left: 0, visibility: 'hidden' }}
className="flex flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
role="dialog"
aria-modal="false"
>
<div className="border-b border-gray-200 p-3 dark:border-gray-700">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('thinkingMode.selector.title')}
</h3>
<button
type="button"
onClick={closeDropdown}
className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="h-4 w-4 text-gray-500" />
</button>
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('thinkingMode.selector.description')}
</p>
</div>
<div className="min-h-0 overflow-y-auto py-1">
{translatedModes.map((mode) => {
const ModeIcon = mode.icon;
const isSelected = mode.id === selectedMode;
return (
<button
key={mode.id}
type="button"
onClick={() => {
onModeChange(mode.id);
closeDropdown();
}}
className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
}`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 ${mode.icon ? mode.color : 'text-gray-400'}`}>
{ModeIcon ? <ModeIcon className="h-5 w-5" /> : <div className="h-5 w-5" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${isSelected ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'
}`}>
{mode.name}
</span>
{isSelected && (
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{t('thinkingMode.selector.active')}
</span>
)}
</div>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{mode.description}
</p>
{mode.prefix && (
<code className="mt-1 inline-block rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-700">
{mode.prefix}
</code>
)}
</div>
</div>
</button>
);
})}
</div>
<div className="border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900">
<p className="text-xs text-gray-600 dark:text-gray-400">
<strong>Tip:</strong> {t('thinkingMode.selector.tip')}
</p>
</div>
</div>,
document.body
)}
</div>
);
}
export default ThinkingModeSelector;

View File

@@ -1,6 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import DOMPurify from 'dompurify';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
type Props = { type Props = {
@@ -12,48 +10,6 @@ type Props = {
// Module-level cache so repeated renders don't re-fetch // Module-level cache so repeated renders don't re-fetch
const svgCache = new Map<string, string>(); const svgCache = new Map<string, string>();
const FORBIDDEN_SVG_TAGS = [
'script',
'foreignObject',
'iframe',
'object',
'embed',
'link',
'meta',
'style',
'animate',
'set',
'animateTransform',
'animateMotion',
];
const FORBIDDEN_SVG_ATTRS = [
'href',
'xlink:href',
'src',
'style',
];
function sanitizeSvg(svgText: string): string | null {
const sanitized = DOMPurify.sanitize(svgText, {
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: FORBIDDEN_SVG_TAGS,
FORBID_ATTR: FORBIDDEN_SVG_ATTRS,
});
if (!sanitized) return null;
try {
const doc = new DOMParser().parseFromString(sanitized, 'image/svg+xml');
const root = doc.documentElement;
if (!root || root.nodeName.toLowerCase() !== 'svg') return null;
if (doc.querySelector('parsererror')) return null;
return sanitized;
} catch {
return null;
}
}
export default function PluginIcon({ pluginName, iconFile, className }: Props) { export default function PluginIcon({ pluginName, iconFile, className }: Props) {
const url = iconFile const url = iconFile
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}` ? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
@@ -68,11 +24,9 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
return r.text(); return r.text();
}) })
.then((text) => { .then((text) => {
if (!text) return; if (text && text.trimStart().startsWith('<svg')) {
const sanitized = sanitizeSvg(text); svgCache.set(url, text);
if (sanitized) { setSvg(text);
svgCache.set(url, sanitized);
setSvg(sanitized);
} }
}) })
.catch(() => {}); .catch(() => {});
@@ -81,6 +35,10 @@ export default function PluginIcon({ pluginName, iconFile, className }: Props) {
if (!svg) return <span className={className} />; if (!svg) return <span className={className} />;
return ( return (
<span className={className} dangerouslySetInnerHTML={{ __html: svg }} /> <span
className={className}
// SVG is fetched from the user's own installed plugin — same trust level as the plugin code itself
dangerouslySetInnerHTML={{ __html: svg }}
/>
); );
} }

View File

@@ -1,93 +1,12 @@
import { useState, type ReactNode } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
Activity,
BarChart3,
BookOpen,
Clock,
Download,
ExternalLink,
GitBranch,
Loader2,
RefreshCw,
ServerCrash,
ShieldAlert,
Terminal,
Trash2,
type LucideIcon,
} from 'lucide-react';
import { usePlugins } from '../../../contexts/PluginsContext'; import { usePlugins } from '../../../contexts/PluginsContext';
import type { Plugin } from '../../../contexts/PluginsContext'; import type { Plugin } from '../../../contexts/PluginsContext';
import PluginIcon from './PluginIcon'; import PluginIcon from './PluginIcon';
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter'; const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal'; const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
type PluginRecommendation = {
id: string;
translationKey: string;
repoUrl: string;
installedNames: string[];
icon: LucideIcon;
source: 'official' | 'unofficial';
};
const OFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
{
id: 'project-stats',
translationKey: 'starterPlugin',
repoUrl: STARTER_PLUGIN_URL,
installedNames: ['project-stats'],
icon: BarChart3,
source: 'official',
},
{
id: 'web-terminal',
translationKey: 'terminalPlugin',
repoUrl: TERMINAL_PLUGIN_URL,
installedNames: ['web-terminal'],
icon: Terminal,
source: 'official',
},
];
const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
{
id: 'cloudcli-claude-watch',
translationKey: 'claudeWatchPlugin',
repoUrl: CLAUDE_WATCH_PLUGIN_URL,
installedNames: ['cloudcli-claude-watch'],
icon: Activity,
source: 'unofficial',
},
{
id: 'workspace-scheduled-prompts',
translationKey: 'scheduledPromptPlugin',
repoUrl: SCHEDULED_PROMPT_PLUGIN_URL,
installedNames: ['workspace-scheduled-prompts'],
icon: Clock,
source: 'unofficial',
},
];
function repoSlug(repoUrl: string) {
return repoUrl.replace(/^https?:\/\/(www\.)?github\.com\//, '');
}
function normalizeRepoUrl(repoUrl: string | null) {
return repoUrl?.replace(/\.git$/, '').replace(/\/$/, '').toLowerCase() ?? null;
}
function pluginMatchesRecommendation(plugin: Plugin, recommendation: PluginRecommendation) {
return (
recommendation.installedNames.includes(plugin.name)
|| normalizeRepoUrl(plugin.repoUrl) === normalizeRepoUrl(recommendation.repoUrl)
);
}
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */ /* ─── Toggle Switch ─────────────────────────────────────────────────────── */
function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) { function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {
@@ -289,95 +208,117 @@ function PluginCard({
); );
} }
/* ─── Recommendation Section ────────────────────────────────────────────── */ /* ─── Starter Plugin Card ───────────────────────────────────────────────── */
function RecommendationSection({ function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
title,
description,
children,
}: {
title: string;
description: string;
children: ReactNode;
}) {
return (
<section className="space-y-2">
<div>
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{title}
</h4>
<p className="mt-0.5 text-xs text-muted-foreground/70">
{description}
</p>
</div>
<div className="space-y-2">
{children}
</div>
</section>
);
}
/* ─── Plugin Recommendation Card ────────────────────────────────────────── */
function PluginRecommendationCard({
recommendation,
onInstall,
disabled,
installing,
}: {
recommendation: PluginRecommendation;
onInstall: () => void;
disabled: boolean;
installing: boolean;
}) {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const Icon = recommendation.icon;
const isOfficial = recommendation.source === 'official';
const accentClass = isOfficial ? 'bg-blue-500/30' : 'bg-amber-500/40';
const hoverClass = isOfficial ? 'hover:border-blue-400 dark:hover:border-blue-500' : 'hover:border-amber-400 dark:hover:border-amber-500';
const iconClass = isOfficial ? 'text-blue-500' : 'text-amber-500';
return ( return (
<div className={`relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 ${hoverClass}`}> <div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
<div className={`w-[3px] flex-shrink-0 ${accentClass}`} /> <div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
<div className="min-w-0 flex-1 p-4"> <div className="min-w-0 flex-1 p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5"> <div className="flex min-w-0 items-center gap-2.5">
<div className={`h-5 w-5 flex-shrink-0 ${iconClass}`}> <div className="h-5 w-5 flex-shrink-0 text-blue-500">
<Icon className="h-5 w-5" /> <BarChart3 className="h-5 w-5" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold leading-none text-foreground"> <span className="text-sm font-semibold leading-none text-foreground">
{t(`pluginSettings.${recommendation.translationKey}.name`)} {t('pluginSettings.starterPlugin.name')}
</span>
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
{t('pluginSettings.starterPlugin.badge')}
</span> </span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"> <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{t('pluginSettings.tab')} {t('pluginSettings.tab')}
</span> </span>
</div> </div>
<p className="mt-1 text-sm leading-snug text-muted-foreground"> <p className="mt-1 text-sm leading-snug text-muted-foreground">
{t(`pluginSettings.${recommendation.translationKey}.description`)} {t('pluginSettings.starterPlugin.description')}
</p> </p>
<a <a
href={recommendation.repoUrl} href={STARTER_PLUGIN_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground" className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
> >
<GitBranch className="h-3 w-3" /> <GitBranch className="h-3 w-3" />
{repoSlug(recommendation.repoUrl)} cloudcli-ai/cloudcli-plugin-starter
</a> </a>
</div> </div>
</div> </div>
<button <button
onClick={onInstall} onClick={onInstall}
disabled={disabled} disabled={installing}
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-50" className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
> >
{installing ? ( {installing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
) : ( ) : (
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
)} )}
{installing ? t('pluginSettings.installing') : t(`pluginSettings.${recommendation.translationKey}.install`)} {installing ? t('pluginSettings.installing') : t('pluginSettings.starterPlugin.install')}
</button>
</div>
</div>
</div>
);
}
/* ─── Terminal Plugin Card ──────────────────────────────────────────────── */
function TerminalPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
const { t } = useTranslation('settings');
return (
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
<div className="min-w-0 flex-1 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M7 8l4 4-4 4"/>
<line x1="13" y1="16" x2="17" y2="16"/>
</svg>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold leading-none text-foreground">
{t('pluginSettings.terminalPlugin.name')}
</span>
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
{t('pluginSettings.terminalPlugin.badge')}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{t('pluginSettings.tab')}
</span>
</div>
<p className="mt-1 text-sm leading-snug text-muted-foreground">
{t('pluginSettings.terminalPlugin.description')}
</p>
<a
href={TERMINAL_PLUGIN_URL}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
>
<GitBranch className="h-3 w-3" />
cloudcli-ai/cloudcli-plugin-terminal
</a>
</div>
</div>
<button
onClick={onInstall}
disabled={installing}
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{installing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{installing ? t('pluginSettings.installing') : t('pluginSettings.terminalPlugin.install')}
</button> </button>
</div> </div>
</div> </div>
@@ -393,7 +334,8 @@ export default function PluginSettingsTab() {
const [gitUrl, setGitUrl] = useState(''); const [gitUrl, setGitUrl] = useState('');
const [installing, setInstalling] = useState(false); const [installing, setInstalling] = useState(false);
const [installingRecommendation, setInstallingRecommendation] = useState<string | null>(null); const [installingStarter, setInstallingStarter] = useState(false);
const [installingTerminal, setInstallingTerminal] = useState(false);
const [installError, setInstallError] = useState<string | null>(null); const [installError, setInstallError] = useState<string | null>(null);
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null); const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set()); const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
@@ -422,18 +364,24 @@ export default function PluginSettingsTab() {
setInstalling(false); setInstalling(false);
}; };
const handleInstallRecommendation = async (recommendation: PluginRecommendation) => { const handleInstallStarter = async () => {
if (installingRecommendation) return; setInstallingStarter(true);
setInstallingRecommendation(recommendation.id);
setInstallError(null); setInstallError(null);
try { const result = await installPlugin(STARTER_PLUGIN_URL);
const result = await installPlugin(recommendation.repoUrl);
if (!result.success) { if (!result.success) {
setInstallError(result.error || t('pluginSettings.installFailed')); setInstallError(result.error || t('pluginSettings.installFailed'));
} }
} finally { setInstallingStarter(false);
setInstallingRecommendation(null); };
const handleInstallTerminal = async () => {
setInstallingTerminal(true);
setInstallError(null);
const result = await installPlugin(TERMINAL_PLUGIN_URL);
if (!result.success) {
setInstallError(result.error || t('pluginSettings.installFailed'));
} }
setInstallingTerminal(false);
}; };
const handleUninstall = async (name: string) => { const handleUninstall = async (name: string) => {
@@ -450,50 +398,8 @@ export default function PluginSettingsTab() {
} }
}; };
const isRecommendationInstalled = (recommendation: PluginRecommendation) => { const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
return plugins.some((plugin) => pluginMatchesRecommendation(plugin, recommendation)); const hasTerminalInstalled = plugins.some((p) => p.name === 'web-terminal');
};
const isOfficialPlugin = (plugin: Plugin) => {
return OFFICIAL_PLUGIN_RECOMMENDATIONS.some((recommendation) => (
pluginMatchesRecommendation(plugin, recommendation)
));
};
const officialPlugins = plugins.filter(isOfficialPlugin);
const otherPlugins = plugins.filter((plugin) => !isOfficialPlugin(plugin));
const officialRecommendations = OFFICIAL_PLUGIN_RECOMMENDATIONS.filter(
(recommendation) => !isRecommendationInstalled(recommendation),
);
const unofficialRecommendations = UNOFFICIAL_PLUGIN_RECOMMENDATIONS.filter(
(recommendation) => !isRecommendationInstalled(recommendation),
);
const hasOfficialSection = officialPlugins.length > 0 || officialRecommendations.length > 0;
const hasOtherSection = otherPlugins.length > 0 || unofficialRecommendations.length > 0;
const renderPluginCard = (plugin: Plugin, index: number) => {
const handleToggle = async (enabled: boolean) => {
const r = await togglePlugin(plugin.name, enabled);
if (!r.success) {
setInstallError(r.error || t('pluginSettings.toggleFailed'));
}
};
return (
<PluginCard
key={plugin.name}
plugin={plugin}
index={index}
onToggle={(enabled) => void handleToggle(enabled)}
onUpdate={() => void handleUpdate(plugin.name)}
onUninstall={() => void handleUninstall(plugin.name)}
updating={updatingPlugins.has(plugin.name)}
confirmingUninstall={confirmUninstall === plugin.name}
onCancelUninstall={() => setConfirmUninstall(null)}
updateError={updateErrors[plugin.name] ?? null}
/>
);
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -550,49 +456,51 @@ export default function PluginSettingsTab() {
</span> </span>
</p> </p>
{/* Plugin sections */} {/* Official plugin suggestions — above the list */}
{!loading && (!hasStarterInstalled || !hasTerminalInstalled) && (
<div className="space-y-2">
{!hasStarterInstalled && (
<StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} />
)}
{!hasTerminalInstalled && (
<TerminalPluginCard onInstall={handleInstallTerminal} installing={installingTerminal} />
)}
</div>
)}
{/* Plugin List */}
{loading ? ( {loading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
{t('pluginSettings.scanningPlugins')} {t('pluginSettings.scanningPlugins')}
</div> </div>
) : plugins.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t('pluginSettings.noPluginsInstalled')}</p>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-2">
{hasOfficialSection && ( {plugins.map((plugin, index) => {
<RecommendationSection const handleToggle = async (enabled: boolean) => {
title={t('pluginSettings.sections.officialTitle')} const r = await togglePlugin(plugin.name, enabled);
description={t('pluginSettings.sections.officialDescription')} if (!r.success) {
> setInstallError(r.error || t('pluginSettings.toggleFailed'));
{officialPlugins.map((plugin, index) => renderPluginCard(plugin, index))} }
{officialRecommendations.map((recommendation) => ( };
<PluginRecommendationCard
key={recommendation.id}
recommendation={recommendation}
onInstall={() => void handleInstallRecommendation(recommendation)}
disabled={!!installingRecommendation}
installing={installingRecommendation === recommendation.id}
/>
))}
</RecommendationSection>
)}
{hasOtherSection && ( return (
<RecommendationSection <PluginCard
title={t('pluginSettings.sections.unofficialTitle')} key={plugin.name}
description={t('pluginSettings.sections.unofficialDescription')} plugin={plugin}
> index={index}
{otherPlugins.map((plugin, index) => renderPluginCard(plugin, officialPlugins.length + index))} onToggle={(enabled) => void handleToggle(enabled)}
{unofficialRecommendations.map((recommendation) => ( onUpdate={() => void handleUpdate(plugin.name)}
<PluginRecommendationCard onUninstall={() => void handleUninstall(plugin.name)}
key={recommendation.id} updating={updatingPlugins.has(plugin.name)}
recommendation={recommendation} confirmingUninstall={confirmUninstall === plugin.name}
onInstall={() => void handleInstallRecommendation(recommendation)} onCancelUninstall={() => setConfirmUninstall(null)}
disabled={!!installingRecommendation} updateError={updateErrors[plugin.name] ?? null}
installing={installingRecommendation === recommendation.id}
/> />
))} );
</RecommendationSection> })}
)}
</div> </div>
)} )}

View File

@@ -70,39 +70,34 @@ export function useProviderAuthStatus(
})); }));
}, []); }, []);
const checkProviderAuthStatus = useCallback(async (provider: LLMProvider): Promise<ProviderAuthStatus> => { const checkProviderAuthStatus = useCallback(async (provider: LLMProvider) => {
setProviderLoading(provider); setProviderLoading(provider);
try { try {
const response = await authenticatedFetch(PROVIDER_AUTH_STATUS_ENDPOINTS[provider]); const response = await authenticatedFetch(PROVIDER_AUTH_STATUS_ENDPOINTS[provider]);
if (!response.ok) { if (!response.ok) {
const status: ProviderAuthStatus = { setProviderStatus(provider, {
authenticated: false, authenticated: false,
email: null, email: null,
method: null, method: null,
loading: false, loading: false,
error: FALLBACK_STATUS_ERROR, error: FALLBACK_STATUS_ERROR,
}; });
setProviderStatus(provider, status); return;
return status;
} }
const payload = (await response.json()) as ProviderAuthStatusApiResponse; const payload = (await response.json()) as ProviderAuthStatusApiResponse;
const status = toProviderAuthStatus(payload.data); setProviderStatus(provider, toProviderAuthStatus(payload.data));
setProviderStatus(provider, status);
return status;
} catch (caughtError) { } catch (caughtError) {
console.error(`Error checking ${provider} auth status:`, caughtError); console.error(`Error checking ${provider} auth status:`, caughtError);
const status: ProviderAuthStatus = { setProviderStatus(provider, {
authenticated: false, authenticated: false,
email: null, email: null,
method: null, method: null,
loading: false, loading: false,
error: toErrorMessage(caughtError), error: toErrorMessage(caughtError),
}; });
setProviderStatus(provider, status);
return status;
} }
}, [setProviderLoading, setProviderStatus]); }, [setProviderLoading, setProviderStatus]);

View File

@@ -213,19 +213,12 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
}, []); }, []);
const handleLoginComplete = useCallback((exitCode: number) => { const handleLoginComplete = useCallback((exitCode: number) => {
if (!loginProvider) { if (exitCode !== 0 || !loginProvider) {
return; return;
} }
void (async () => { setSaveStatus('success');
const authStatus = await checkProviderAuthStatus(loginProvider); void checkProviderAuthStatus(loginProvider);
if (exitCode !== 0) {
console.warn(`Login process exited with code ${exitCode}; refreshing auth status before setting save status.`);
}
setSaveStatus(authStatus.authenticated ? 'success' : 'error');
})();
}, [checkProviderAuthStatus, loginProvider]); }, [checkProviderAuthStatus, loginProvider]);
const saveSettings = useCallback(async () => { const saveSettings = useCallback(async () => {

View File

@@ -1,4 +1,4 @@
import { Check, ChevronDown, ChevronRight, Edit3, Star, Trash2, X } from 'lucide-react'; import { Check, ChevronDown, ChevronRight, Edit3, Folder, FolderOpen, Star, Trash2, X } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { Button } from '../../../../shared/view/ui'; import { Button } from '../../../../shared/view/ui';
@@ -131,28 +131,18 @@ export default function SidebarProjectItem({
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3">
<button <div
className={cn( className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center active:scale-90 transition-all duration-150 border', 'w-8 h-8 rounded-lg flex items-center justify-center transition-colors',
isStarred isExpanded ? 'bg-primary/10' : 'bg-muted',
? 'bg-yellow-500/10 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800'
: 'bg-gray-500/10 dark:bg-gray-900/30 border-gray-200 dark:border-gray-800',
)} )}
onClick={(event) => {
event.stopPropagation();
toggleStarProject();
}}
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
> >
<Star {isExpanded ? (
className={cn( <FolderOpen className="h-4 w-4 text-primary" />
'w-4 h-4 transition-colors', ) : (
isStarred <Folder className="h-4 w-4 text-muted-foreground" />
? 'text-yellow-600 dark:text-yellow-400 fill-current'
: 'text-gray-600 dark:text-gray-400',
)} )}
/> </div>
</button>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{isEditing ? ( {isEditing ? (
@@ -222,6 +212,29 @@ export default function SidebarProjectItem({
</> </>
) : ( ) : (
<> <>
<button
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center active:scale-90 transition-all duration-150 border',
isStarred
? 'bg-yellow-500/10 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800'
: 'bg-gray-500/10 dark:bg-gray-900/30 border-gray-200 dark:border-gray-800',
)}
onClick={(event) => {
event.stopPropagation();
toggleStarProject();
}}
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
>
<Star
className={cn(
'w-4 h-4 transition-colors',
isStarred
? 'text-yellow-600 dark:text-yellow-400 fill-current'
: 'text-gray-600 dark:text-gray-400',
)}
/>
</button>
<button <button
className="flex h-8 w-8 items-center justify-center rounded-lg border border-red-200 bg-red-500/10 active:scale-90 dark:border-red-800 dark:bg-red-900/30" className="flex h-8 w-8 items-center justify-center rounded-lg border border-red-200 bg-red-500/10 active:scale-90 dark:border-red-800 dark:bg-red-900/30"
onClick={(event) => { onClick={(event) => {
@@ -268,28 +281,11 @@ export default function SidebarProjectItem({
onClick={selectAndToggleProject} onClick={selectAndToggleProject}
> >
<div className="flex min-w-0 flex-1 items-center gap-3"> <div className="flex min-w-0 flex-1 items-center gap-3">
<div {isExpanded ? (
className={cn( <FolderOpen className="h-4 w-4 flex-shrink-0 text-primary" />
'w-6 h-6 flex items-center justify-center rounded cursor-pointer transition-all duration-200', ) : (
isStarred <Folder className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
? 'hover:bg-yellow-50 dark:hover:bg-yellow-900/20'
: 'opacity-40 hover:opacity-100 hover:bg-accent',
)} )}
onClick={(event) => {
event.stopPropagation();
toggleStarProject();
}}
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
>
<Star
className={cn(
'w-3 h-3 transition-colors',
isStarred
? 'text-yellow-600 dark:text-yellow-400 fill-current'
: 'text-muted-foreground',
)}
/>
</div>
<div className="min-w-0 flex-1 text-left"> <div className="min-w-0 flex-1 text-left">
{isEditing ? ( {isEditing ? (
<div className="space-y-1"> <div className="space-y-1">
@@ -356,6 +352,26 @@ export default function SidebarProjectItem({
</> </>
) : ( ) : (
<> <>
<div
className={cn(
'w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 flex items-center justify-center rounded cursor-pointer touch:opacity-100',
isStarred ? 'hover:bg-yellow-50 dark:hover:bg-yellow-900/20 opacity-100' : 'hover:bg-accent',
)}
onClick={(event) => {
event.stopPropagation();
toggleStarProject();
}}
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
>
<Star
className={cn(
'w-3 h-3 transition-colors',
isStarred
? 'text-yellow-600 dark:text-yellow-400 fill-current'
: 'text-muted-foreground',
)}
/>
</div>
<div <div
className="touch:opacity-100 flex h-6 w-6 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-accent group-hover:opacity-100" className="touch:opacity-100 flex h-6 w-6 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-accent group-hover:opacity-100"
onClick={(event) => { onClick={(event) => {

View File

@@ -1,8 +1,7 @@
import { useEffect, useRef } from 'react';
import { Check, Edit2, Trash2, X } from 'lucide-react'; import { Check, Edit2, Trash2, X } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { Badge, Button, Tooltip } from '../../../../shared/view/ui'; import { Badge, Button } from '../../../../shared/view/ui';
import { cn } from '../../../../lib/utils'; import { cn } from '../../../../lib/utils';
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app'; import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
import type { SessionWithProvider } from '../../types/types'; import type { SessionWithProvider } from '../../types/types';
@@ -77,28 +76,7 @@ export default function SidebarSessionItem({
}: SidebarSessionItemProps) { }: SidebarSessionItemProps) {
const sessionView = createSessionViewModel(session, currentTime, t); const sessionView = createSessionViewModel(session, currentTime, t);
const isSelected = selectedSession?.id === session.id; const isSelected = selectedSession?.id === session.id;
const isEditing = editingSession === session.id;
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime); const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
const editingContainerRef = useRef<HTMLDivElement>(null);
// The rename panel sits inside a group-hover opacity wrapper, so leaving the row
// would visually hide it. While editing, dismiss only when the user clicks outside
// the panel (matches Escape / cancel-button behaviour).
useEffect(() => {
if (!isEditing) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
const container = editingContainerRef.current;
if (container && !container.contains(event.target as Node)) {
onCancelEditingSession();
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [isEditing, onCancelEditingSession]);
// Sessions are owned by a project identified by `projectId` (DB primary key) // Sessions are owned by a project identified by `projectId` (DB primary key)
// after the projectName → projectId migration. // after the projectName → projectId migration.
@@ -119,13 +97,7 @@ export default function SidebarSessionItem({
<div className="group relative"> <div className="group relative">
{sessionView.isActive && ( {sessionView.isActive && (
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform"> <div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
<Tooltip content={t('tooltips.activeSessionIndicator')} position="right"> <div className="h-2 w-2 animate-pulse rounded-full bg-green-500" />
<div
role="status"
aria-label={t('tooltips.activeSessionIndicator')}
className="h-2 w-2 animate-pulse rounded-full bg-green-500"
/>
</Tooltip>
</div> </div>
)} )}
@@ -196,12 +168,7 @@ export default function SidebarSessionItem({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div> <div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
{compactSessionAge && ( {compactSessionAge && (
<span <span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200 group-hover:opacity-0">
className={cn(
'ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200',
isEditing ? 'opacity-0' : 'group-hover:opacity-0',
)}
>
{compactSessionAge} {compactSessionAge}
</span> </span>
)} )}
@@ -213,14 +180,8 @@ export default function SidebarSessionItem({
</div> </div>
</Button> </Button>
<div <div className="absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center gap-1 opacity-0 transition-all duration-200 group-hover:opacity-100">
ref={editingContainerRef} {editingSession === session.id ? (
className={cn(
'absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center gap-1 transition-all duration-200',
isEditing ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
)}
>
{isEditing ? (
<> <>
<input <input
type="text" type="text"

View File

@@ -36,10 +36,6 @@ const useWebSocketProviderState = (): WebSocketContextType => {
const { token } = useAuth(); const { token } = useAuth();
useEffect(() => { useEffect(() => {
// The cleanup below sets unmountedRef = true. Without this reset, every
// re-run of the effect (e.g. on token refresh) would short-circuit connect()
// at its unmounted guard and leave the socket permanently disconnected.
unmountedRef.current = false;
connect(); connect();
return () => { return () => {

View File

@@ -138,6 +138,42 @@
"clearInput": "Eingabe leeren", "clearInput": "Eingabe leeren",
"scrollToBottom": "Nach unten scrollen" "scrollToBottom": "Nach unten scrollen"
}, },
"thinkingMode": {
"selector": {
"title": "Denkmodus",
"description": "Erweitertes Denken gibt Claude mehr Zeit, Alternativen zu evaluieren",
"active": "Aktiv",
"tip": "Höhere Denkmodi brauchen mehr Zeit, liefern aber eine gründlichere Analyse"
},
"modes": {
"none": {
"name": "Standard",
"description": "Reguläre Claude-Antwort",
"prefix": ""
},
"think": {
"name": "Denken",
"description": "Grundlegendes erweitertes Denken",
"prefix": "think"
},
"thinkHard": {
"name": "Intensiv denken",
"description": "Gründlichere Auswertung",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Sehr intensiv denken",
"description": "Tiefgehende Analyse mit Alternativen",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultradenken",
"description": "Maximales Denkbudget",
"prefix": "ultrathink"
}
},
"buttonTitle": "Denkmodus: {{mode}}"
},
"providerSelection": { "providerSelection": {
"title": "KI-Assistent wählen", "title": "KI-Assistent wählen",
"description": "Anbieter auswählen, um eine neue Unterhaltung zu starten", "description": "Anbieter auswählen, um eine neue Unterhaltung zu starten",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Aus Favoriten entfernen", "removeFromFavorites": "Aus Favoriten entfernen",
"editSessionName": "Sitzungsname manuell bearbeiten", "editSessionName": "Sitzungsname manuell bearbeiten",
"deleteSession": "Diese Sitzung dauerhaft löschen", "deleteSession": "Diese Sitzung dauerhaft löschen",
"activeSessionIndicator": "Kürzlich aktive Sitzung (letzte 10 Minuten)",
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"clearSearch": "Suche leeren", "clearSearch": "Suche leeren",

View File

@@ -139,6 +139,42 @@
"clearInput": "Clear input", "clearInput": "Clear input",
"scrollToBottom": "Scroll to bottom" "scrollToBottom": "Scroll to bottom"
}, },
"thinkingMode": {
"selector": {
"title": "Thinking Mode",
"description": "Extended thinking gives Claude more time to evaluate alternatives",
"active": "Active",
"tip": "Higher thinking modes take more time but provide more thorough analysis"
},
"modes": {
"none": {
"name": "Standard",
"description": "Regular Claude response",
"prefix": ""
},
"think": {
"name": "Think",
"description": "Basic extended thinking",
"prefix": "think"
},
"thinkHard": {
"name": "Think Hard",
"description": "More thorough evaluation",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Think Harder",
"description": "Deep analysis with alternatives",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultrathink",
"description": "Maximum thinking budget",
"prefix": "ultrathink"
}
},
"buttonTitle": "Thinking mode: {{mode}}"
},
"providerSelection": { "providerSelection": {
"title": "Choose Your AI Assistant", "title": "Choose Your AI Assistant",
"description": "Select a provider to start a new conversation", "description": "Select a provider to start a new conversation",

View File

@@ -472,12 +472,6 @@
"starterPluginLabel": "Starter Plugin", "starterPluginLabel": "Starter Plugin",
"starter": "Starter", "starter": "Starter",
"docs": "Docs", "docs": "Docs",
"sections": {
"officialTitle": "Official Plugins",
"officialDescription": "Maintained by the CloudCLI team and ready for direct install.",
"unofficialTitle": "Other Plugins",
"unofficialDescription": "Unofficial plugins and integrations from other users. Review the source before installing."
},
"starterPlugin": { "starterPlugin": {
"name": "Project Stats", "name": "Project Stats",
"badge": "starter", "badge": "starter",
@@ -490,18 +484,6 @@
"description": "Integrated terminal with full shell access directly within the interface.", "description": "Integrated terminal with full shell access directly within the interface.",
"install": "Install" "install": "Install"
}, },
"scheduledPromptPlugin": {
"name": "Scheduled Prompts",
"badge": "unofficial",
"description": "Schedule workspace prompts, review run history, and manage recurring local tasks.",
"install": "Install"
},
"claudeWatchPlugin": {
"name": "Claude Watch",
"badge": "unofficial",
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
"install": "Install"
},
"morePlugins": "More", "morePlugins": "More",
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Remove from favorites", "removeFromFavorites": "Remove from favorites",
"editSessionName": "Manually edit session name", "editSessionName": "Manually edit session name",
"deleteSession": "Delete this session permanently", "deleteSession": "Delete this session permanently",
"activeSessionIndicator": "Recently active session (last 10 minutes)",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"clearSearch": "Clear search", "clearSearch": "Clear search",

View File

@@ -138,6 +138,42 @@
"clearInput": "Cancella input", "clearInput": "Cancella input",
"scrollToBottom": "Scorri in basso" "scrollToBottom": "Scorri in basso"
}, },
"thinkingMode": {
"selector": {
"title": "Modalità ragionamento",
"description": "Il ragionamento esteso dà a Claude più tempo per valutare le alternative",
"active": "Attivo",
"tip": "Modalità di ragionamento più elevate richiedono più tempo ma forniscono un'analisi più approfondita"
},
"modes": {
"none": {
"name": "Standard",
"description": "Risposta Claude normale",
"prefix": ""
},
"think": {
"name": "Pensa",
"description": "Ragionamento esteso base",
"prefix": "think"
},
"thinkHard": {
"name": "Pensa di più",
"description": "Valutazione più approfondita",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Pensa ancora",
"description": "Analisi profonda con alternative",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultrapensiero",
"description": "Budget massimo di ragionamento",
"prefix": "ultrathink"
}
},
"buttonTitle": "Modalità ragionamento: {{mode}}"
},
"providerSelection": { "providerSelection": {
"title": "Scegli il tuo assistente AI", "title": "Scegli il tuo assistente AI",
"description": "Seleziona un provider per iniziare una nuova conversazione", "description": "Seleziona un provider per iniziare una nuova conversazione",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Rimuovi dai preferiti", "removeFromFavorites": "Rimuovi dai preferiti",
"editSessionName": "Modifica manualmente il nome della sessione", "editSessionName": "Modifica manualmente il nome della sessione",
"deleteSession": "Elimina questa sessione permanentemente", "deleteSession": "Elimina questa sessione permanentemente",
"activeSessionIndicator": "Sessione attiva di recente (ultimi 10 minuti)",
"save": "Salva", "save": "Salva",
"cancel": "Annulla", "cancel": "Annulla",
"clearSearch": "Cancella ricerca", "clearSearch": "Cancella ricerca",

View File

@@ -117,6 +117,42 @@
"clickToChangeMode": "クリックで権限モードを変更または入力欄でTab", "clickToChangeMode": "クリックで権限モードを変更または入力欄でTab",
"showAllCommands": "すべてのコマンドを表示" "showAllCommands": "すべてのコマンドを表示"
}, },
"thinkingMode": {
"selector": {
"title": "思考モード",
"description": "拡張思考によりClaudeがより多くの選択肢を検討できます",
"active": "有効",
"tip": "高い思考モードは時間がかかりますが、より深い分析が得られます"
},
"modes": {
"none": {
"name": "標準",
"description": "通常のClaudeの応答",
"prefix": ""
},
"think": {
"name": "Think",
"description": "基本的な拡張思考",
"prefix": "think"
},
"thinkHard": {
"name": "Think Hard",
"description": "より深い検討",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Think Harder",
"description": "代替案を含む深い分析",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultrathink",
"description": "最大限の思考予算",
"prefix": "ultrathink"
}
},
"buttonTitle": "思考モード: {{mode}}"
},
"providerSelection": { "providerSelection": {
"title": "AIアシスタントを選択", "title": "AIアシスタントを選択",
"description": "新しい会話を始めるプロバイダーを選択してください", "description": "新しい会話を始めるプロバイダーを選択してください",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "お気に入りから削除", "removeFromFavorites": "お気に入りから削除",
"editSessionName": "セッション名を手動で編集", "editSessionName": "セッション名を手動で編集",
"deleteSession": "このセッションを完全に削除", "deleteSession": "このセッションを完全に削除",
"activeSessionIndicator": "最近アクティブなセッション過去10分以内",
"save": "保存", "save": "保存",
"cancel": "キャンセル", "cancel": "キャンセル",
"openCommandPalette": "コマンドパレットを開く" "openCommandPalette": "コマンドパレットを開く"

View File

@@ -120,6 +120,42 @@
"clearInput": "입력 지우기", "clearInput": "입력 지우기",
"scrollToBottom": "맨 아래로 스크롤" "scrollToBottom": "맨 아래로 스크롤"
}, },
"thinkingMode": {
"selector": {
"title": "Thinking 모드",
"description": "확장된 thinking은 Claude에게 대안을 평가할 시간을 더 줍니다",
"active": "활성",
"tip": "높은 thinking 모드는 시간이 더 걸리지만 더 철저한 분석을 제공합니다"
},
"modes": {
"none": {
"name": "Standard",
"description": "일반 Claude 응답",
"prefix": ""
},
"think": {
"name": "Think",
"description": "기본 확장 thinking",
"prefix": "think"
},
"thinkHard": {
"name": "Think Hard",
"description": "더 철저한 평가",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Think Harder",
"description": "대안을 포함한 심층 분석",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultrathink",
"description": "최대 thinking 예산",
"prefix": "ultrathink"
}
},
"buttonTitle": "Thinking 모드: {{mode}}"
},
"providerSelection": { "providerSelection": {
"title": "AI 어시스턴트 선택", "title": "AI 어시스턴트 선택",
"description": "새 대화를 시작할 프로바이더를 선택하세요", "description": "새 대화를 시작할 프로바이더를 선택하세요",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "즐겨찾기에서 제거", "removeFromFavorites": "즐겨찾기에서 제거",
"editSessionName": "세션 이름 직접 편집", "editSessionName": "세션 이름 직접 편집",
"deleteSession": "이 세션 영구 삭제", "deleteSession": "이 세션 영구 삭제",
"activeSessionIndicator": "최근 활성 세션 (지난 10분)",
"save": "저장", "save": "저장",
"cancel": "취소", "cancel": "취소",
"openCommandPalette": "명령 팔레트 열기" "openCommandPalette": "명령 팔레트 열기"

View File

@@ -138,6 +138,42 @@
"clearInput": "Очистить ввод", "clearInput": "Очистить ввод",
"scrollToBottom": "Прокрутить вниз" "scrollToBottom": "Прокрутить вниз"
}, },
"thinkingMode": {
"selector": {
"title": "Режим размышления",
"description": "Расширенное размышление дает Claude больше времени для оценки альтернатив",
"active": "Активен",
"tip": "Более высокие режимы размышления занимают больше времени, но обеспечивают более тщательный анализ"
},
"modes": {
"none": {
"name": "Стандартный",
"description": "Обычный ответ Claude",
"prefix": ""
},
"think": {
"name": "Думать",
"description": "Базовое расширенное размышление",
"prefix": "думать"
},
"thinkHard": {
"name": "Думать усердно",
"description": "Более тщательная оценка",
"prefix": "думать усердно"
},
"thinkHarder": {
"name": "Думать еще усерднее",
"description": "Глубокий анализ с альтернативами",
"prefix": "думать еще усерднее"
},
"ultrathink": {
"name": "Ультра-размышление",
"description": "Максимальный бюджет размышления",
"prefix": "ультра-размышление"
}
},
"buttonTitle": "Режим размышления: {{mode}}"
},
"providerSelection": { "providerSelection": {
"title": "Выберите вашего AI-ассистента", "title": "Выберите вашего AI-ассистента",
"description": "Выберите провайдера для начала нового разговора", "description": "Выберите провайдера для начала нового разговора",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Удалить из избранного", "removeFromFavorites": "Удалить из избранного",
"editSessionName": "Вручную редактировать имя сеанса", "editSessionName": "Вручную редактировать имя сеанса",
"deleteSession": "Удалить этот сеанс навсегда", "deleteSession": "Удалить этот сеанс навсегда",
"activeSessionIndicator": "Недавно активный сеанс (последние 10 минут)",
"save": "Сохранить", "save": "Сохранить",
"cancel": "Отмена", "cancel": "Отмена",
"clearSearch": "Очистить поиск", "clearSearch": "Очистить поиск",

View File

@@ -138,6 +138,42 @@
"clearInput": "Girdiyi temizle", "clearInput": "Girdiyi temizle",
"scrollToBottom": "En alta git" "scrollToBottom": "En alta git"
}, },
"thinkingMode": {
"selector": {
"title": "Düşünme Modu",
"description": "Uzatılmış düşünme, Claude'a alternatifleri değerlendirmek için daha fazla zaman verir",
"active": "Aktif",
"tip": "Daha yüksek düşünme modları daha fazla zaman alır ama daha kapsamlı analiz sağlar"
},
"modes": {
"none": {
"name": "Standart",
"description": "Normal Claude yanıtı",
"prefix": ""
},
"think": {
"name": "Düşün",
"description": "Temel uzatılmış düşünme",
"prefix": "think"
},
"thinkHard": {
"name": "Daha Fazla Düşün",
"description": "Daha kapsamlı değerlendirme",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Derin Düşün",
"description": "Alternatiflerle derin analiz",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultra Düşün",
"description": "Maksimum düşünme bütçesi",
"prefix": "ultrathink"
}
},
"buttonTitle": "Düşünme modu: {{mode}}"
},
"providerSelection": { "providerSelection": {
"title": "AI Asistanını Seç", "title": "AI Asistanını Seç",
"description": "Yeni bir konuşma başlatmak için bir sağlayıcı seç", "description": "Yeni bir konuşma başlatmak için bir sağlayıcı seç",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "Favorilerden çıkar", "removeFromFavorites": "Favorilerden çıkar",
"editSessionName": "Oturum adını elle düzenle", "editSessionName": "Oturum adını elle düzenle",
"deleteSession": "Bu oturumu kalıcı olarak sil", "deleteSession": "Bu oturumu kalıcı olarak sil",
"activeSessionIndicator": "Yakın zamanda etkin oturum (son 10 dakika)",
"save": "Kaydet", "save": "Kaydet",
"cancel": "İptal", "cancel": "İptal",
"clearSearch": "Aramayı temizle", "clearSearch": "Aramayı temizle",

View File

@@ -120,6 +120,42 @@
"clearInput": "清空输入", "clearInput": "清空输入",
"scrollToBottom": "滚动到底部" "scrollToBottom": "滚动到底部"
}, },
"thinkingMode": {
"selector": {
"title": "思考模式",
"description": "扩展思考给 Claude 更多时间来评估替代方案",
"active": "激活",
"tip": "更高的思考模式需要更多时间,但提供更彻底的分析"
},
"modes": {
"none": {
"name": "标准",
"description": "常规 Claude 响应",
"prefix": ""
},
"think": {
"name": "思考",
"description": "基本扩展思考",
"prefix": "思考"
},
"thinkHard": {
"name": "深入思考",
"description": "更彻底的评估",
"prefix": "深入思考"
},
"thinkHarder": {
"name": "更深入思考",
"description": "考虑替代方案的深度分析",
"prefix": "更深入思考"
},
"ultrathink": {
"name": "超级思考",
"description": "最大思考预算",
"prefix": "超级思考"
}
},
"buttonTitle": "思考模式:{{mode}}"
},
"providerSelection": { "providerSelection": {
"title": "选择您的 AI 助手", "title": "选择您的 AI 助手",
"description": "选择一个供应商以开始新对话", "description": "选择一个供应商以开始新对话",

View File

@@ -45,7 +45,6 @@
"removeFromFavorites": "从收藏移除", "removeFromFavorites": "从收藏移除",
"editSessionName": "手动编辑会话名称", "editSessionName": "手动编辑会话名称",
"deleteSession": "永久删除此会话", "deleteSession": "永久删除此会话",
"activeSessionIndicator": "最近活跃的会话(最近 10 分钟)",
"save": "保存", "save": "保存",
"cancel": "取消", "cancel": "取消",
"clearSearch": "清除搜索", "clearSearch": "清除搜索",

View File

@@ -37,10 +37,6 @@ export default defineConfig(({ mode }) => {
'/shell': { '/shell': {
target: `ws://${proxyHost}:${serverPort}`, target: `ws://${proxyHost}:${serverPort}`,
ws: true ws: true
},
'/plugin-ws': {
target: `ws://${proxyHost}:${serverPort}`,
ws: true
} }
} }
}, },