Compare commits

..

2 Commits

Author SHA1 Message Date
Simos Mikelatos
3a14aa08d8 Merge branch 'main' into fix/restart-and-disconnect-buttons-on-shell 2026-06-05 13:56:54 +02:00
Haileyesus
7704fde228 fix(shell): disconnect and restart buttons 2026-06-05 10:49:00 +03:00
28 changed files with 864 additions and 1207 deletions

View File

@@ -3,35 +3,6 @@
All notable changes to CloudCLI UI will be documented in this file.
## [1.33.1](https://github.com/siteboon/claudecodeui/compare/v1.33.0...v1.33.1) (2026-06-05)
### New Features
* **chat:** auto-detect text direction for RTL languages ([#729](https://github.com/siteboon/claudecodeui/issues/729)) ([fa9eaf5](https://github.com/siteboon/claudecodeui/commit/fa9eaf5573a6f870a19fb62ab430ffd87c466582))
### Bug Fixes
* file tree concurrency ([#828](https://github.com/siteboon/claudecodeui/issues/828)) ([ebb0e59](https://github.com/siteboon/claudecodeui/commit/ebb0e59e8023c0a8040d168a5adffb7102e80561))
* load claude models directly from provider ([cdcac18](https://github.com/siteboon/claudecodeui/commit/cdcac182d458a24908777568979c8e756f94428c))
* plugin svg icon sanitization ([#817](https://github.com/siteboon/claudecodeui/issues/817)) ([d9e9df1](https://github.com/siteboon/claudecodeui/commit/d9e9df183f462c88c3b60975eb8254faa9168717))
* recognize claude auth token env ([#818](https://github.com/siteboon/claudecodeui/issues/818)) ([43c33d5](https://github.com/siteboon/claudecodeui/commit/43c33d5cb1b41835dfe3bccd450c5a9c2441509b))
* redact websocket auth token in logs ([#827](https://github.com/siteboon/claudecodeui/issues/827)) ([14ddbc7](https://github.com/siteboon/claudecodeui/commit/14ddbc7c57a01da9fb65fd87d8588532b11833fa))
* remove thinking mode ([#835](https://github.com/siteboon/claudecodeui/issues/835)) ([2149b87](https://github.com/siteboon/claudecodeui/commit/2149b8776b7ebfec0eace413f4fc527ccb2324c0))
* **shell:** disconnect and restart buttons ([#831](https://github.com/siteboon/claudecodeui/issues/831)) ([ef2fd48](https://github.com/siteboon/claudecodeui/commit/ef2fd48b46452d4b9e2bf1f5e3c30fafe19f27f2))
* show Claude tool result errors ([bb8db58](https://github.com/siteboon/claudecodeui/commit/bb8db5815c2d20ee4fbfa02d14c886a56ef352e0))
* **vite:** proxy /plugin-ws WebSocket requests to the backend in dev ([#757](https://github.com/siteboon/claudecodeui/issues/757)) ([96b16b4](https://github.com/siteboon/claudecodeui/commit/96b16b42e4f807d04ec743a5a4117a37a3f5e0d9))
* **websocket:** add 30s server-side heartbeat to prevent proxy idle disconnects ([#770](https://github.com/siteboon/claudecodeui/issues/770)) ([2edfef2](https://github.com/siteboon/claudecodeui/commit/2edfef2e3f4271c29ae8670df9dd382a9eef7c3c)), closes [#769](https://github.com/siteboon/claudecodeui/issues/769)
* **websocket:** reset unmountedRef on each effect re-run so token refresh reconnects ([#721](https://github.com/siteboon/claudecodeui/issues/721)) ([f082cdc](https://github.com/siteboon/claudecodeui/commit/f082cdc63bd0de90f8b3da1df6071e91ab545831))
### Documentation
* add nginx subpath deployment template ([#820](https://github.com/siteboon/claudecodeui/issues/820)) ([3ec76b5](https://github.com/siteboon/claudecodeui/commit/3ec76b5bb15a13cec41056f4c9b9c425195022fa))
### Maintenance
* update Claude fallback models ([94785bf](https://github.com/siteboon/claudecodeui/commit/94785bfa579d1f39a2bee0f9dd0f09fd0243bc79))
* update package-lock.json ([c90b341](https://github.com/siteboon/claudecodeui/commit/c90b34108e86a3effdb5c6979ea7b1692d2b9da0))
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
### New Features

View File

@@ -1,218 +0,0 @@
# CloudCLI UI Nginx subpath deployment template.
#
# Purpose:
# Serve CloudCLI UI from a path prefix such as:
# http://localhost/ai/
# https://example.com/ai/
#
# CloudCLI itself still runs at the root of its own HTTP server, for example:
# http://127.0.0.1:3001/
#
# Nginx receives public requests under /ai, strips that prefix, and forwards the
# remaining path to CloudCLI. For example:
# /ai/ -> /
# /ai/session/abc -> /session/abc
# /ai/assets/index.js -> /assets/index.js
#
# Important Nginx limitation:
# Nginx does not allow variables in `location` matchers or `rewrite` regexes.
# The configurable variables below are still useful for proxy/filter values,
# but if you change /ai to a different subpath, also update every line marked:
# [SUBPATH LITERAL]
#
# To use a different subpath, replace these literal matchers:
# location = /ai
# location ^~ /ai/
# rewrite ^/ai(?<cloudcli_path>/.*)$ ...
#
# Recommended deployment shape:
# CloudCLI is the only app using /ai, while root paths /api, /ws, and /shell
# are also proxied because the current frontend still calls those endpoints
# with root-relative URLs.
worker_processes 1;
events {
# Maximum simultaneous connections handled by each worker process.
# The default is enough for local testing and small self-hosted deployments.
worker_connections 1024;
}
http {
# WebSocket requests include an Upgrade header. Normal HTTP requests do not.
# This map gives us the right Connection header for both cases:
# Upgrade present -> "upgrade"
# Upgrade absent -> "close"
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
# For HTTPS deployments, replace this with `listen 443 ssl http2;` and
# add ssl_certificate / ssl_certificate_key lines.
listen 80 default_server;
# Use your real hostname in production, for example:
# server_name cloudcli.example.com;
server_name localhost 127.0.0.1;
# ---- User settings -------------------------------------------------
#
# Public path prefix where users access CloudCLI.
# Do not add a trailing slash.
#
# This variable can be used in redirects and response rewrites. It
# cannot be used in `location` matchers, so update the [SUBPATH LITERAL]
# lines too if you change it.
set $cloudcli_subpath /ai;
# Private upstream URL where the CloudCLI server is listening.
# For a default local server this is usually http://127.0.0.1:3001.
set $cloudcli_upstream http://127.0.0.1:3001;
# Allow larger file uploads through the code editor/project file APIs.
client_max_body_size 100m;
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
location = /ai {
return 301 $cloudcli_subpath/;
}
# Main prefixed CloudCLI UI route.
#
# [SUBPATH LITERAL] Change `/ai/` and the `^/ai` rewrite if you change
# $cloudcli_subpath.
location ^~ /ai/ {
# Strip the public subpath before proxying. CloudCLI expects to see
# root paths such as /, /session/:id, /assets/..., /manifest.json.
rewrite ^/ai(?<cloudcli_path>/.*)$ $cloudcli_path break;
# Forward the rewritten request to the private CloudCLI server.
proxy_pass $cloudcli_upstream;
# Use HTTP/1.1 so WebSocket upgrade requests can pass through if a
# browser reaches a socket endpoint under the subpath.
proxy_http_version 1.1;
# Preserve useful request metadata for logs and future app support.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
# WebSocket upgrade headers. Harmless for normal HTTP requests.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Long-running agent and terminal sessions can stay open for a long
# time, so avoid closing idle proxied connections too aggressively.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Disable gzip from the upstream response so sub_filter can inspect
# and rewrite HTML/JSON/JS response bodies.
proxy_set_header Accept-Encoding "";
# Rewrite browser-visible root-relative URLs so the runtime can
# discover that the app is mounted under the subpath.
#
# Examples:
# href="/manifest.json" -> href="/ai/manifest.json"
# src="/assets/app.js" -> src="/ai/assets/app.js"
#
# These rewrites are important for React Router basename detection.
sub_filter_once off;
sub_filter_types
application/json
application/manifest+json
application/javascript
text/javascript;
sub_filter 'href="/' 'href="$cloudcli_subpath/';
sub_filter 'src="/' 'src="$cloudcli_subpath/';
# The production HTML and JS register the service worker at /sw.js.
# Rewrite that registration so the worker is served from /ai/sw.js.
sub_filter "register('/sw.js')" "register('$cloudcli_subpath/sw.js')";
sub_filter 'register("/sw.js")' 'register("$cloudcli_subpath/sw.js")';
# The manifest and service worker contain root-relative paths too.
# Rewriting them keeps PWA metadata and cached manifest requests
# under the same public subpath.
sub_filter '"start_url": "/"' '"start_url": "$cloudcli_subpath/"';
sub_filter '"scope": "/"' '"scope": "$cloudcli_subpath/"';
sub_filter '"src": "/' '"src": "$cloudcli_subpath/';
sub_filter "'/manifest.json'" "'$cloudcli_subpath/manifest.json'";
sub_filter '"/manifest.json"' '"$cloudcli_subpath/manifest.json"';
}
# Root API proxy.
#
# The current CloudCLI frontend calls APIs with root-relative URLs such
# as /api/auth/login. Keep this location unless the frontend becomes
# fully prefix-aware for API requests.
location ^~ /api/ {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Main app WebSocket proxy.
#
# The frontend opens /ws for realtime chat/session/task updates.
location /ws {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Shell WebSocket proxy.
#
# The browser terminal uses /shell. It requires the same WebSocket
# upgrade handling as /ws.
location /shell {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Optional health endpoint proxy used by the frontend version checker.
location = /health {
proxy_pass $cloudcli_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
}
}
}

787
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.33.1",
"version": "1.33.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "dist-server/server/index.js",

View File

@@ -11,8 +11,7 @@ export const CLAUDE_MODELS = {
{
value: "default",
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.8 (1M context)) · $5/$25 per Mtok",
},
{
value: "sonnet",
@@ -24,12 +23,6 @@ export const CLAUDE_MODELS = {
label: "Sonnet (1M context)",
description: "Sonnet 4.6 for long sessions · $3/$15 per Mtok",
},
{
value: "opus[1m]",
label: "Opus 4.8 (1M context)",
description:
"Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok",
},
{
value: "haiku",
label: "Haiku",

View File

@@ -1483,133 +1483,74 @@ function permToRwx(perm) {
return r + w + x;
}
// Directories that are almost never interesting for a project tree but can
// contain tens of thousands of files. Skipping them before recursion keeps
// traversal time bounded on large monorepos and high-latency filesystems
// (NFS / SMB).
const IGNORED_DIRS = new Set([
// JS / TS toolchains
'node_modules', 'dist', 'build', '.next', '.nuxt', '.cache', '.parcel-cache',
// VCS
'.git', '.svn', '.hg',
// Python
'__pycache__', '.pytest_cache', '.mypy_cache', '.tox', 'venv', '.venv',
// Rust / Go / Java / Ruby
'target', 'vendor',
// Build output / IDE
'.gradle', '.idea', 'coverage', '.nyc_output'
]);
const DEFAULT_FS_CONCURRENCY = 64;
const parsedFsConcurrency = Number.parseInt(process.env.FS_CONCURRENCY || '', 10);
const FS_CONCURRENCY = Number.isFinite(parsedFsConcurrency) && parsedFsConcurrency > 0
? parsedFsConcurrency
: DEFAULT_FS_CONCURRENCY;
let activeFsOperations = 0;
const pendingFsOperations = [];
async function acquire() {
if (activeFsOperations < FS_CONCURRENCY) {
activeFsOperations += 1;
return;
}
await new Promise((resolve) => {
pendingFsOperations.push(resolve);
});
}
function release() {
const next = pendingFsOperations.shift();
if (next) {
next();
return;
}
activeFsOperations = Math.max(0, activeFsOperations - 1);
}
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
// Using fsPromises from import
let entries;
const items = [];
try {
await acquire();
try {
entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
} finally {
release();
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
// Debug: log all entries including hidden files
// Skip heavy build directories and VCS directories
if (entry.name === 'node_modules' ||
entry.name === 'dist' ||
entry.name === 'build' ||
entry.name === '.git' ||
entry.name === '.svn' ||
entry.name === '.hg') continue;
const itemPath = path.join(dirPath, entry.name);
const item = {
name: entry.name,
path: itemPath,
type: entry.isDirectory() ? 'directory' : 'file'
};
// Get file stats for additional metadata
try {
const stats = await fsPromises.stat(itemPath);
item.size = stats.size;
item.modified = stats.mtime.toISOString();
// Convert permissions to rwx format
const mode = stats.mode;
const ownerPerm = (mode >> 6) & 7;
const groupPerm = (mode >> 3) & 7;
const otherPerm = mode & 7;
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
} catch (statError) {
// If stat fails, provide default values
item.size = 0;
item.modified = null;
item.permissions = '000';
item.permissionsRwx = '---------';
}
if (entry.isDirectory() && currentDepth < maxDepth) {
// Recursively get subdirectories but limit depth
try {
// Check if we can access the directory before trying to read it
await fsPromises.access(item.path, fs.constants.R_OK);
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
} catch (e) {
// Silently skip directories we can't access (permission denied, etc.)
item.children = [];
}
}
items.push(item);
}
} catch (error) {
// Only log non-permission errors to avoid spam
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
console.error('Error reading directory:', error);
}
return [];
}
const filteredEntries = entries.filter((entry) => !(entry.isDirectory() && IGNORED_DIRS.has(entry.name)));
// Process every entry in parallel. On high-latency filesystems (NFS/SMB)
// serial stat() was the real bottleneck — issuing them concurrently lets
// the kernel pipeline the round-trips and the recursive calls overlap too.
const items = await Promise.all(filteredEntries.map(async (entry) => {
const itemPath = path.join(dirPath, entry.name);
const item = {
name: entry.name,
path: itemPath,
type: entry.isDirectory() ? 'directory' : 'file'
};
// Get file stats for additional metadata
try {
await acquire();
try {
const stats = await fsPromises.lstat(itemPath);
item.size = stats.size;
item.modified = stats.mtime.toISOString();
// Mark symlinks so UI can distinguish them
if (stats.isSymbolicLink()) {
item.isSymlink = true;
}
// Convert permissions to rwx format
const mode = stats.mode;
const ownerPerm = (mode >> 6) & 7;
const groupPerm = (mode >> 3) & 7;
const otherPerm = mode & 7;
item.permissions =
((mode >> 6) & 7).toString() +
((mode >> 3) & 7).toString() +
(mode & 7).toString();
item.permissionsRwx =
permToRwx(ownerPerm) +
permToRwx(groupPerm) +
permToRwx(otherPerm);
} finally {
release();
}
} catch (statError) {
// If stat fails, provide default values
item.size = 0;
item.modified = null;
item.permissions = '000';
item.permissionsRwx = '---------';
}
if (entry.isDirectory() && currentDepth < maxDepth) {
// Recurse. Let readdir's own EACCES bubble up through the catch in
// the recursive call rather than doing a separate access() probe
// (which doubled the round-trip count on SMB without adding info).
// The recursive call starts with a bounded readdir; holding a permit
// for the whole subtree can deadlock when sibling directories are
// waiting on their own children.
item.children = await getFileTree(itemPath, maxDepth, currentDepth + 1, showHidden);
}
return item;
}));
return items.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;

View File

@@ -18,23 +18,18 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
{
value: 'default',
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",
label: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
value: 'sonnet',
label: 'Sonnet',
description: 'Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok',
},
{
value: 'sonnet[1m]',
label: 'Sonnet (1M context)',
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
},
{
value: 'opus[1m]',
label: 'Opus 4.8 (1M context)',
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
},
{
value: 'haiku',
label: 'Haiku',

View File

@@ -17,7 +17,6 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
const PROVIDER_MODELS_CACHE_VERSION = 1;
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
type ProviderModelsServiceDependencies = {
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
@@ -233,42 +232,10 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
return request;
};
const loadDirectModels = (
provider: LLMProvider,
): Promise<ProviderModelsResult> => {
const request = resolveProvider(provider).models.getSupportedModels()
.then((models) => {
const currentTime = now();
return {
models,
cache: {
updatedAt: new Date(currentTime).toISOString(),
expiresAt: new Date(currentTime).toISOString(),
source: 'fresh' as const,
},
};
})
.finally(() => {
pendingRequests.delete(provider);
});
pendingRequests.set(provider, request);
return request;
};
const getProviderModels = async (
provider: LLMProvider,
options: ProviderModelsOptions = {},
): Promise<ProviderModelsResult> => {
if (UNCACHED_PROVIDERS.has(provider)) {
const pendingRequest = pendingRequests.get(provider);
if (pendingRequest) {
return pendingRequest;
}
return loadDirectModels(provider);
}
if (options.bypassCache) {
const pendingRequest = pendingRequests.get(provider);
if (pendingRequest) {

View File

@@ -130,37 +130,6 @@ test('provider models are cached for the three-day ttl', async () => {
}
});
test('claude provider models are always loaded directly from the provider', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-claude-direct-'));
let loadCount = 0;
try {
const service = createProviderModelsService({
cachePath: path.join(tempRoot, 'models-cache.json'),
resolveProvider: (provider) => ({
models: {
getSupportedModels: async () => {
loadCount += 1;
return createModels(`${provider}-${loadCount}`);
},
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
},
}),
});
const first = await service.getProviderModels('claude');
const second = await service.getProviderModels('claude');
assert.equal(loadCount, 2);
assert.equal(first.models.DEFAULT, 'claude-1');
assert.equal(second.models.DEFAULT, 'claude-2');
assert.equal(second.cache.source, 'fresh');
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
test('provider model cache is persisted across service instances', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
const cachePath = path.join(tempRoot, 'models-cache.json');

View File

@@ -20,13 +20,7 @@ export function verifyWebSocketClient(
dependencies: WebSocketAuthDependencies
): boolean {
const request = info.req as AuthenticatedWebSocketRequest;
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
const loggedUrl = new URL(upgradeUrl);
if (loggedUrl.searchParams.has('token')) {
loggedUrl.searchParams.set('token', 'REDACTED');
}
console.log('WebSocket connection attempt to:', `${loggedUrl.pathname}${loggedUrl.search}`);
console.log('WebSocket connection attempt to:', request.url);
// Platform mode: use the first DB user and skip token checks.
if (dependencies.isPlatform) {
@@ -42,6 +36,7 @@ export function verifyWebSocketClient(
}
// OSS mode: read JWT from query string first, then Authorization header.
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
const token =
upgradeUrl.searchParams.get('token') ??
request.headers.authorization?.split(' ')[1] ??

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 { authenticatedFetch } from '../../../utils/api';
import { thinkingModes } from '../constants/thinkingModes';
import { grantClaudeToolPermission } from '../utils/chatPermissions';
import { safeLocalStorage } from '../utils/chatStorage';
import type {
@@ -203,6 +204,7 @@ export function useChatComposerState({
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
const [thinkingMode, setThinkingMode] = useState('none');
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -311,7 +313,7 @@ export function useChatComposerState({
}, [addMessage]);
const executeCommand = useCallback(
async (command: SlashCommand, rawInput?: string, options?: { preserveInput?: boolean }) => {
async (command: SlashCommand, rawInput?: string) => {
if (!command || !selectedProject) {
return;
}
@@ -368,10 +370,8 @@ export function useChatComposerState({
const result = (await response.json()) as CommandExecutionResult;
if (result.type === 'builtin') {
handleBuiltInCommand(result);
if (!options?.preserveInput) {
setInput('');
inputValueRef.current = '';
}
setInput('');
inputValueRef.current = '';
} else if (result.type === 'custom') {
await handleCustomCommand(result);
}
@@ -402,19 +402,6 @@ export function useChatComposerState({
],
);
const showCostModal = useCallback(() => {
executeCommand(
{
name: '/cost',
description: 'Display token usage information',
namespace: 'builtin',
metadata: { type: 'builtin' },
} as SlashCommand,
'/cost',
{ preserveInput: true },
);
}, [executeCommand]);
const {
slashCommands,
slashCommandsCount,
@@ -575,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[] = [];
if (attachedImages.length > 0) {
@@ -758,6 +749,7 @@ export function useChatComposerState({
setUploadingImages(new Map());
setImageErrors(new Map());
setIsTextareaExpanded(false);
setThinkingMode('none');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
@@ -791,6 +783,7 @@ export function useChatComposerState({
setIsLoading,
setIsUserScrolledUp,
slashCommands,
thinkingMode,
],
);
@@ -1027,6 +1020,8 @@ export function useChatComposerState({
textareaRef,
inputHighlightRef,
isTextareaExpanded,
thinkingMode,
setThinkingMode,
slashCommandsCount,
filteredCommands,
frequentCommands,
@@ -1064,6 +1059,5 @@ export function useChatComposerState({
isInputFocused,
commandModalPayload,
closeCommandModal,
showCostModal,
};
}

View File

@@ -7,12 +7,6 @@ import type { NormalizedMessage } from '../../../stores/useSessionStore';
import type { ChatMessage, SubagentChildTool } from '../types/types';
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
function formatToolResultContent(content: unknown): string {
const text = typeof content === 'string' ? content : JSON.stringify(content);
const toolUseErrorMatch = /^<tool_use_error>([\s\S]*)<\/tool_use_error>$/.exec(text.trim());
return toolUseErrorMatch ? toolUseErrorMatch[1] : text;
}
/**
* Convert NormalizedMessage[] from the session store into ChatMessage[]
* that the existing UI components expect.
@@ -26,12 +20,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
// First pass: collect tool results for attachment
const toolResultMap = new Map<string, NormalizedMessage>();
const toolUseIds = new Set<string>();
for (const msg of messages) {
if (msg.kind === 'tool_use' && msg.toolId) {
toolUseIds.add(msg.toolId);
}
if (msg.kind === 'tool_result' && msg.toolId) {
toolResultMap.set(msg.toolId, msg);
}
@@ -108,7 +97,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
const toolResult = tr
? {
content: formatToolResultContent(tr.content),
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
isError: Boolean(tr.isError),
toolUseResult: (tr as any).toolUseResult,
}
@@ -202,25 +191,8 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
break;
// tool_result is handled via attachment to tool_use above
case 'tool_result': {
if (msg.toolId && toolUseIds.has(msg.toolId)) {
break;
}
const content = formatToolResultContent(msg.content || '');
if (!content.trim()) {
break;
}
converted.push({
type: msg.isError ? 'error' : 'assistant',
content,
timestamp: msg.timestamp,
toolId: msg.toolId,
...sharedMetadata,
});
case 'tool_result':
break;
}
default:
break;

View File

@@ -564,15 +564,11 @@ export function shouldHideToolResult(toolName: string, toolResult: any): boolean
if (!config.result) return false;
// Hidden/success-only configs suppress noisy successful output, but errors
// still need to be visible so failed tool calls are diagnosable.
if (toolResult?.isError) return false;
// Always hidden
if (config.result.hidden) return true;
// Hide on success only
if (config.result.hideOnSuccess && toolResult) {
if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
return true;
}

View File

@@ -141,6 +141,8 @@ function ChatInterface({
textareaRef,
inputHighlightRef,
isTextareaExpanded,
thinkingMode,
setThinkingMode,
slashCommandsCount,
filteredCommands,
frequentCommands,
@@ -178,7 +180,6 @@ function ChatInterface({
isInputFocused: _isInputFocused,
commandModalPayload,
closeCommandModal,
showCostModal,
} = useChatComposerState({
selectedProject,
selectedSession,
@@ -368,8 +369,9 @@ function ChatInterface({
provider={provider}
permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode}
thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget}
onShowTokenUsage={showCostModal}
slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={handleToggleCommandMenu}
hasInput={Boolean(input.trim())}

View File

@@ -2,16 +2,23 @@ import { useTranslation } from 'react-i18next';
import type {
ChangeEvent,
ClipboardEvent,
Dispatch,
FormEvent,
KeyboardEvent,
MouseEvent,
ReactNode,
RefObject,
SetStateAction,
TouchEvent,
} from 'react';
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsageSummary from './TokenUsageSummary';
import {
PromptInput,
PromptInputHeader,
@@ -23,12 +30,6 @@ import {
PromptInputSubmit,
} 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 {
name: string;
path: string;
@@ -57,8 +58,9 @@ interface ChatComposerProps {
provider: Provider | string;
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
thinkingMode: string;
setThinkingMode: Dispatch<SetStateAction<string>>;
tokenBudget: Record<string, unknown> | null;
onShowTokenUsage: () => void;
slashCommandsCount: number;
onToggleCommandMenu: () => void;
hasInput: boolean;
@@ -111,8 +113,9 @@ export default function ChatComposer({
provider,
permissionMode,
onModeSwitch,
thinkingMode,
setThinkingMode,
tokenBudget,
onShowTokenUsage,
slashCommandsCount,
onToggleCommandMenu,
hasInput,
@@ -355,7 +358,11 @@ export default function ChatComposer({
</div>
</button>
<TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
{provider === 'claude' && (
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)}
<TokenUsageSummary usage={tokenBudget} />
<PromptInputButton
tooltip={{ content: t('input.showAllCommands') }}
@@ -376,7 +383,7 @@ export default function ChatComposer({
<PromptInputButton
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
onClick={onClearInput}
className="hidden sm:flex"
className="hidden sm:No-flex"
>
<XIcon />
</PromptInputButton>
@@ -393,8 +400,7 @@ export default function ChatComposer({
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
</div>
<PromptInputSubmit
onClick={isLoading ? onAbortSession : undefined}
disabled={!isLoading && !input.trim()}
disabled={!input.trim() || isLoading}
className="h-10 w-10 sm:h-10 sm:w-10"
/>
</div>

View File

@@ -1,6 +1,5 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type {
ChatMessage,
@@ -9,10 +8,10 @@ import type {
Provider,
} from '../../types/types';
import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl';
@@ -42,9 +41,10 @@ type InteractiveOption = {
isSelected: boolean;
};
type PermissionGrantState = 'idle' | 'granted' | 'error';
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') ||
@@ -53,6 +53,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
(prevMessage.type === 'error'));
const messageRef = useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
const userCopyContent = String(message.content || '');
const formattedMessageContent = useMemo(
() => formatUsageLimitText(String(message.content || '')),
@@ -71,6 +73,10 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
!message.isThinking;
useEffect(() => {
setPermissionGrantState('idle');
}, [permissionSuggestion?.entry, message.toolId]);
useEffect(() => {
const node = messageRef.current;
if (!autoExpandTools || !node || !message.isToolUse) return;
@@ -235,6 +241,55 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
{String(message.toolResult.content || '')}
</Markdown>
{permissionSuggestion && (
<div className="mt-4 border-t border-red-200/60 pt-3 dark:border-red-800/60">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
if (!onGrantToolPermission) return;
const result = onGrantToolPermission(permissionSuggestion);
if (result?.success) {
setPermissionGrantState('granted');
} else {
setPermissionGrantState('error');
}
}}
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'cursor-default border-green-300/70 bg-green-100 text-green-800 dark:border-green-800/60 dark:bg-green-900/30 dark:text-green-200'
: 'border-red-300/70 bg-white/80 text-red-700 hover:bg-white dark:border-red-800/60 dark:bg-gray-900/40 dark:text-red-200 dark:hover:bg-gray-900/70'
}`}
>
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? t('permissions.added')
: t('permissions.grant', { tool: permissionSuggestion.toolName })}
</button>
{onShowSettings && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
className="text-xs text-red-700 underline hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
>
{t('permissions.openSettings')}
</button>
)}
</div>
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
{t('permissions.addTo', { entry: permissionSuggestion.entry })}
</div>
{permissionGrantState === 'error' && (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
{t('permissions.error')}
</div>
)}
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
{t('permissions.retry')}
</div>
)}
</div>
)}
</div>
</div>
) : (

View File

@@ -277,15 +277,11 @@ export default function ProviderSelectionEmptyState({
>
<div className="min-w-0 flex-1">
<div className="truncate">{model.label}</div>
{/*
// * Temporarly commented out because the description of models from claude
// * was a bit inconsistent. Will return it back when it becomes more consistent.
*/}
{/* {model.description && (
{model.description && (
<div className="truncate text-xs text-muted-foreground">
{model.description}
</div>
)} */}
)}
</div>
{isSelected && (
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />

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

@@ -2,7 +2,6 @@ import { ActivityIcon } from 'lucide-react';
type TokenUsageSummaryProps = {
usage: Record<string, unknown> | null;
onClick?: () => void;
};
const formatTokenCount = (value: number) => {
@@ -30,7 +29,7 @@ const readUsageNumber = (value: unknown) => {
return Number.isFinite(parsed) ? parsed : 0;
};
export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryProps) {
export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
const breakdown =
usage?.breakdown && typeof usage.breakdown === 'object'
? usage.breakdown as Record<string, unknown>
@@ -40,18 +39,15 @@ export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryP
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
return (
<button
type="button"
onClick={onClick}
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5"
<div
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground sm:gap-2 sm:px-2.5"
title={`${usedTokens.toLocaleString()} tokens used`}
aria-label="Show token usage"
>
<span className="grid h-5 w-5 place-items-center rounded-md bg-primary/10 text-primary">
<ActivityIcon className="h-3.5 w-3.5" />
</span>
<span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span>
<span className="hidden text-muted-foreground/70 sm:inline">tokens</span>
</button>
</div>
);
}

View File

@@ -138,6 +138,42 @@
"clearInput": "Eingabe leeren",
"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": {
"title": "KI-Assistent wählen",
"description": "Anbieter auswählen, um eine neue Unterhaltung zu starten",

View File

@@ -139,6 +139,42 @@
"clearInput": "Clear input",
"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": {
"title": "Choose Your AI Assistant",
"description": "Select a provider to start a new conversation",

View File

@@ -138,6 +138,42 @@
"clearInput": "Cancella input",
"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": {
"title": "Scegli il tuo assistente AI",
"description": "Seleziona un provider per iniziare una nuova conversazione",

View File

@@ -117,6 +117,42 @@
"clickToChangeMode": "クリックで権限モードを変更または入力欄でTab",
"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": {
"title": "AIアシスタントを選択",
"description": "新しい会話を始めるプロバイダーを選択してください",

View File

@@ -120,6 +120,42 @@
"clearInput": "입력 지우기",
"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": {
"title": "AI 어시스턴트 선택",
"description": "새 대화를 시작할 프로바이더를 선택하세요",

View File

@@ -138,6 +138,42 @@
"clearInput": "Очистить ввод",
"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": {
"title": "Выберите вашего AI-ассистента",
"description": "Выберите провайдера для начала нового разговора",

View File

@@ -138,6 +138,42 @@
"clearInput": "Girdiyi temizle",
"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": {
"title": "AI Asistanını Seç",
"description": "Yeni bir konuşma başlatmak için bir sağlayıcı seç",

View File

@@ -120,6 +120,42 @@
"clearInput": "清空输入",
"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": {
"title": "选择您的 AI 助手",
"description": "选择一个供应商以开始新对话",