Compare commits

..

16 Commits

Author SHA1 Message Date
Haileyesus
d638a8982c fix: do not show model description in chat view 2026-06-05 21:28:08 +03:00
Simos Mikelatos
f238050b85 feat(chat): open cost modal from token usage 2026-06-05 17:33:22 +00:00
viper151
beaa2d2533 chore(release): v1.33.1 2026-06-05 16:56:27 +00:00
Simos Mikelatos
c90b34108e chore: update package-lock.json 2026-06-05 16:54:22 +00:00
Simos Mikelatos
323357384e Merge pull request #837 from siteboon/fix/tool-result-error-rendering 2026-06-05 17:40:54 +02:00
Simos Mikelatos
d509aa635b Merge pull request #834 from siteboon/chore/update-claude-fallback-models 2026-06-05 17:36:22 +02:00
Haile
2149b8776b fix: remove thinking mode (#835) 2026-06-05 17:35:39 +02:00
Haile
2b416f2dcb Merge branch 'main' into fix/tool-result-error-rendering 2026-06-05 16:32:51 +03:00
Haileyesus
bb8db5815c fix: show Claude tool result errors
Claude stores some tool failures as errored tool_result rows. The UI either
attached those rows to hidden tool output or dropped them when no matching tool
call was rendered, which made validation failures disappear from chat history.

Render unattached errored tool results, unwrap Claude tool_use_error content,
and keep tool errors visible even for tools whose successful output is hidden.
Also remove the permission-grant recovery controls from rendered error history
so denied tool use stays a plain error message.
2026-06-05 16:16:34 +03:00
Haile
b3d0f9037d Merge branch 'main' into chore/update-claude-fallback-models 2026-06-05 15:58:05 +03:00
Haile
3ec76b5bb1 docs: add nginx subpath deployment template (#820)
Users deploying behind a reverse proxy need a config they can adapt.

The template documents each proxy block and centralizes upstream/subpath values.

It also notes that Nginx location matchers still require literal subpath edits.
2026-06-05 14:24:26 +02:00
Haile
14ddbc7c57 fix: redact websocket auth token in logs (#827) 2026-06-05 14:23:27 +02:00
Haile
ebb0e59e80 fix: file tree concurrency (#828)
* perf(file-tree): parallelize directory traversal and widen default ignore list

The project file-tree endpoint walked children sequentially with
`await fsPromises.stat()` inside a for-loop plus a separate
`fsPromises.access()` probe before recursing. On high-latency
filesystems (NFS/SMB) every one of those round-trips was serialized,
so a 120k-file SMB-mounted project took ~2 minutes to load.

This change:
* Runs stat() and recursive getFileTree() calls in parallel via
  `Promise.all` — pipelines round-trips and lets subtree traversals
  overlap.
* Drops the redundant access() probe; any EACCES now surfaces from
  readdir's own try/catch in the recursive call, saving one RTT per
  directory.
* Extracts the hardcoded skip list into an IGNORED_DIRS Set and
  extends it to cover common Python / Rust / JVM / IDE build
  artefacts (.next, __pycache__, .pytest_cache, .tox, .venv,
  target, .gradle, .idea, coverage, etc).

No API shape change; existing consumers get the same tree structure,
only much faster on large or remote-mounted projects.

* fix(file-tree): bound filesystem traversal concurrency

Prevent large file-tree scans from launching unbounded stat and readdir work.

Keep the parallel traversal benefit on high-latency mounts with a bounded queue.

Ignore skipped names only for directories so same-named files stay visible.

* fix(file-tree): inspect entries with lstat

Use lstat for file-tree metadata so symlink entries are identified without following targets.

---------

Co-authored-by: leonkong via Claude <leonkong.claude@users.noreply.github.com>
2026-06-05 14:21:30 +02:00
Haile
957f53fb99 Merge branch 'main' into chore/update-claude-fallback-models 2026-06-05 15:19:13 +03:00
Haileyesus
cdcac182d4 fix: load claude models directly from provider
Claude's model catalog changes quickly enough that a shared three-day cache can
leave users selecting stale defaults or missing newly available model aliases.
Route Claude model lookups through the provider every time so the UI and slash
commands reflect the current provider result instead of an old disk snapshot.

Keep the static fallback catalog aligned with the latest Claude defaults so the
provider still has a sensible response when live discovery is unavailable.
2026-06-05 15:14:32 +03:00
Haileyesus
94785bfa57 chore: update Claude fallback models 2026-06-05 15:02:25 +03:00
27 changed files with 1091 additions and 807 deletions

View File

@@ -3,6 +3,35 @@
All notable changes to CloudCLI UI will be documented in this file. 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) ## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
### New Features ### New Features

View File

@@ -0,0 +1,218 @@
# 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", "name": "@cloudcli-ai/cloudcli",
"version": "1.33.0", "version": "1.33.1",
"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",

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000; export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
const PROVIDER_MODELS_CACHE_VERSION = 1; const PROVIDER_MODELS_CACHE_VERSION = 1;
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
type ProviderModelsServiceDependencies = { type ProviderModelsServiceDependencies = {
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>; resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
@@ -232,10 +233,42 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
return request; 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 ( const getProviderModels = async (
provider: LLMProvider, provider: LLMProvider,
options: ProviderModelsOptions = {}, options: ProviderModelsOptions = {},
): Promise<ProviderModelsResult> => { ): Promise<ProviderModelsResult> => {
if (UNCACHED_PROVIDERS.has(provider)) {
const pendingRequest = pendingRequests.get(provider);
if (pendingRequest) {
return pendingRequest;
}
return loadDirectModels(provider);
}
if (options.bypassCache) { if (options.bypassCache) {
const pendingRequest = pendingRequests.get(provider); const pendingRequest = pendingRequests.get(provider);
if (pendingRequest) { if (pendingRequest) {

View File

@@ -130,6 +130,37 @@ 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 () => { test('provider model cache is persisted across service instances', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-')); const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
const cachePath = path.join(tempRoot, 'models-cache.json'); const cachePath = path.join(tempRoot, 'models-cache.json');

View File

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

View File

@@ -1,44 +0,0 @@
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,7 +12,6 @@ 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 {
@@ -204,7 +203,6 @@ 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);
@@ -313,7 +311,7 @@ export function useChatComposerState({
}, [addMessage]); }, [addMessage]);
const executeCommand = useCallback( const executeCommand = useCallback(
async (command: SlashCommand, rawInput?: string) => { async (command: SlashCommand, rawInput?: string, options?: { preserveInput?: boolean }) => {
if (!command || !selectedProject) { if (!command || !selectedProject) {
return; return;
} }
@@ -370,8 +368,10 @@ export function useChatComposerState({
const result = (await response.json()) as CommandExecutionResult; const result = (await response.json()) as CommandExecutionResult;
if (result.type === 'builtin') { if (result.type === 'builtin') {
handleBuiltInCommand(result); handleBuiltInCommand(result);
setInput(''); if (!options?.preserveInput) {
inputValueRef.current = ''; setInput('');
inputValueRef.current = '';
}
} else if (result.type === 'custom') { } else if (result.type === 'custom') {
await handleCustomCommand(result); await handleCustomCommand(result);
} }
@@ -402,6 +402,19 @@ 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 { const {
slashCommands, slashCommands,
slashCommandsCount, slashCommandsCount,
@@ -562,11 +575,7 @@ export function useChatComposerState({
} }
} }
let messageContent = currentInput; const 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) {
@@ -749,7 +758,6 @@ 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';
@@ -783,7 +791,6 @@ export function useChatComposerState({
setIsLoading, setIsLoading,
setIsUserScrolledUp, setIsUserScrolledUp,
slashCommands, slashCommands,
thinkingMode,
], ],
); );
@@ -1020,8 +1027,6 @@ export function useChatComposerState({
textareaRef, textareaRef,
inputHighlightRef, inputHighlightRef,
isTextareaExpanded, isTextareaExpanded,
thinkingMode,
setThinkingMode,
slashCommandsCount, slashCommandsCount,
filteredCommands, filteredCommands,
frequentCommands, frequentCommands,
@@ -1059,5 +1064,6 @@ export function useChatComposerState({
isInputFocused, isInputFocused,
commandModalPayload, commandModalPayload,
closeCommandModal, closeCommandModal,
showCostModal,
}; };
} }

View File

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

View File

@@ -564,11 +564,15 @@ export function shouldHideToolResult(toolName: string, toolResult: any): boolean
if (!config.result) return false; 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 // Always hidden
if (config.result.hidden) return true; if (config.result.hidden) return true;
// Hide on success only // Hide on success only
if (config.result.hideOnSuccess && toolResult && !toolResult.isError) { if (config.result.hideOnSuccess && toolResult) {
return true; return true;
} }

View File

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

View File

@@ -2,23 +2,16 @@ 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,
@@ -30,6 +23,12 @@ 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;
@@ -58,9 +57,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;
onShowTokenUsage: () => void;
slashCommandsCount: number; slashCommandsCount: number;
onToggleCommandMenu: () => void; onToggleCommandMenu: () => void;
hasInput: boolean; hasInput: boolean;
@@ -113,9 +111,8 @@ export default function ChatComposer({
provider, provider,
permissionMode, permissionMode,
onModeSwitch, onModeSwitch,
thinkingMode,
setThinkingMode,
tokenBudget, tokenBudget,
onShowTokenUsage,
slashCommandsCount, slashCommandsCount,
onToggleCommandMenu, onToggleCommandMenu,
hasInput, hasInput,
@@ -358,11 +355,7 @@ export default function ChatComposer({
</div> </div>
</button> </button>
{provider === 'claude' && ( <TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)}
<TokenUsageSummary usage={tokenBudget} />
<PromptInputButton <PromptInputButton
tooltip={{ content: t('input.showAllCommands') }} tooltip={{ content: t('input.showAllCommands') }}
@@ -383,7 +376,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:No-flex" className="hidden sm:flex"
> >
<XIcon /> <XIcon />
</PromptInputButton> </PromptInputButton>
@@ -400,7 +393,8 @@ 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
disabled={!input.trim() || isLoading} onClick={isLoading ? onAbortSession : undefined}
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"
/> />
</div> </div>

View File

@@ -1,5 +1,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { import type {
ChatMessage, ChatMessage,
@@ -8,10 +9,10 @@ import type {
Provider, Provider,
} from '../../types/types'; } from '../../types/types';
import { formatUsageLimitText } from '../../utils/chatFormatting'; import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import type { Project } from '../../../../types/app'; import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools'; import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui'; import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl'; import MessageCopyControl from './MessageCopyControl';
@@ -41,10 +42,9 @@ type InteractiveOption = {
isSelected: boolean; isSelected: boolean;
}; };
type PermissionGrantState = 'idle' | 'granted' | 'error';
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']); const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => { const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type && const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') || ((prevMessage.type === 'assistant') ||
@@ -53,8 +53,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
(prevMessage.type === 'error')); (prevMessage.type === 'error'));
const messageRef = useRef<HTMLDivElement | null>(null); const messageRef = useRef<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
const userCopyContent = String(message.content || ''); const userCopyContent = String(message.content || '');
const formattedMessageContent = useMemo( const formattedMessageContent = useMemo(
() => formatUsageLimitText(String(message.content || '')), () => formatUsageLimitText(String(message.content || '')),
@@ -73,10 +71,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
!message.isThinking; !message.isThinking;
useEffect(() => {
setPermissionGrantState('idle');
}, [permissionSuggestion?.entry, message.toolId]);
useEffect(() => { useEffect(() => {
const node = messageRef.current; const node = messageRef.current;
if (!autoExpandTools || !node || !message.isToolUse) return; if (!autoExpandTools || !node || !message.isToolUse) return;
@@ -241,55 +235,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert"> <Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
{String(message.toolResult.content || '')} {String(message.toolResult.content || '')}
</Markdown> </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>
</div> </div>
) : ( ) : (

View File

@@ -277,11 +277,15 @@ export default function ProviderSelectionEmptyState({
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate">{model.label}</div> <div className="truncate">{model.label}</div>
{model.description && ( {/*
// * 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 && (
<div className="truncate text-xs text-muted-foreground"> <div className="truncate text-xs text-muted-foreground">
{model.description} {model.description}
</div> </div>
)} )} */}
</div> </div>
{isSelected && ( {isSelected && (
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" /> <Check className="ml-auto h-4 w-4 shrink-0 text-primary" />

View File

@@ -1,244 +0,0 @@
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,6 +2,7 @@ import { ActivityIcon } from 'lucide-react';
type TokenUsageSummaryProps = { type TokenUsageSummaryProps = {
usage: Record<string, unknown> | null; usage: Record<string, unknown> | null;
onClick?: () => void;
}; };
const formatTokenCount = (value: number) => { const formatTokenCount = (value: number) => {
@@ -29,7 +30,7 @@ const readUsageNumber = (value: unknown) => {
return Number.isFinite(parsed) ? parsed : 0; return Number.isFinite(parsed) ? parsed : 0;
}; };
export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) { export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryProps) {
const breakdown = const breakdown =
usage?.breakdown && typeof usage.breakdown === 'object' usage?.breakdown && typeof usage.breakdown === 'object'
? usage.breakdown as Record<string, unknown> ? usage.breakdown as Record<string, unknown>
@@ -39,15 +40,18 @@ export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens; const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
return ( return (
<div <button
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" 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"
title={`${usedTokens.toLocaleString()} tokens used`} 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"> <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" /> <ActivityIcon className="h-3.5 w-3.5" />
</span> </span>
<span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span> <span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span>
<span className="hidden text-muted-foreground/70 sm:inline">tokens</span> <span className="hidden text-muted-foreground/70 sm:inline">tokens</span>
</div> </button>
); );
} }

View File

@@ -138,42 +138,6 @@
"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

@@ -139,42 +139,6 @@
"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

@@ -138,42 +138,6 @@
"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

@@ -117,42 +117,6 @@
"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

@@ -120,42 +120,6 @@
"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

@@ -138,42 +138,6 @@
"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

@@ -138,42 +138,6 @@
"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

@@ -120,42 +120,6 @@
"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": "选择一个供应商以开始新对话",