diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36a914b..561f0dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.4.0] - 2026-06-13
+
+### Added
+
+- 🔌 **Tool servers.** Connect external tools via MCP or OpenAPI. Add servers from the new Tool Servers admin tab, verify the connection, and the AI can use them immediately. Supports bearer auth and custom headers.
+- 🤖 **Sub-agents.** The AI can now spin up sub-agents to work on tasks in parallel. Each sub-agent gets full tool access and runs as a real chat you can inspect afterwards. Configure concurrency and limits from the new Subagents admin tab.
+- ⚡ **Parallel tool execution.** When the AI makes multiple tool calls in one response, they now run concurrently instead of one at a time.
+- 🔐 **Signup toggle.** Enable or disable user registration directly from the Users admin tab.
+- 🧠 **Context compaction threshold in UI.** The token threshold for automatic context compaction can now be set from the Models admin tab instead of editing `config.toml`.
+
+### Changed
+
+- 🏗️ **Admin panel restructure.** The old Settings and Browser panels have been split into focused tabs: **Web** (search providers + browser backends), **Tool Servers**, and **Subagents**.
+- 🌍 **Comprehensive i18n.** 200+ new translation keys across all 10 locales. Almost every remaining hardcoded English string in the UI is now translatable.
+- 🏷️ **External tool labels.** Tool calls from external servers show the tool name and server in the chat UI. Sub-agent tasks show as `Sub-agent: "task…"`.
+
+### Fixed
+
+- 🐛 **Plan mode with external tools.** Plan mode now correctly sees tools from connected external servers, not just built-in ones.
+
## [0.3.5] - 2026-06-13
### Added
diff --git a/cptr/frontend/package-lock.json b/cptr/frontend/package-lock.json
index e421e4e..1f05b5e 100644
--- a/cptr/frontend/package-lock.json
+++ b/cptr/frontend/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "frontend",
- "version": "0.3.4",
+ "version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
diff --git a/cptr/frontend/package.json b/cptr/frontend/package.json
index d56009c..43b48b5 100644
--- a/cptr/frontend/package.json
+++ b/cptr/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.3.5",
+ "version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite dev",
diff --git a/cptr/frontend/src/lib/apis/admin.ts b/cptr/frontend/src/lib/apis/admin.ts
index c6243fb..f3c71b8 100644
--- a/cptr/frontend/src/lib/apis/admin.ts
+++ b/cptr/frontend/src/lib/apis/admin.ts
@@ -128,3 +128,41 @@ export const updateModelConfig = (
...jsonBody(update),
method: 'PUT'
});
+
+// ── Tool Servers ────────────────────────────────────────────
+
+export interface ToolServer {
+ id: string;
+ type: 'openapi' | 'mcp';
+ url: string;
+ path: string;
+ auth_type: string;
+ key: string;
+ name: string;
+ description: string;
+ headers: Record | null;
+ enabled: boolean;
+}
+
+export const listToolServers = async (): Promise => {
+ const data = await fetchJSON<{ servers: ToolServer[] }>('/api/admin/tools/servers');
+ return data.servers;
+};
+
+export const createToolServer = (server: Omit) =>
+ fetchJSON('/api/admin/tools/servers', jsonBody(server));
+
+export const updateToolServer = (id: string, updates: Partial>) =>
+ fetchJSON(`/api/admin/tools/servers/${id}`, {
+ ...jsonBody(updates),
+ method: 'PUT'
+ });
+
+export const deleteToolServer = (id: string) =>
+ fetchJSON(`/api/admin/tools/servers/${id}`, { method: 'DELETE' });
+
+export const verifyToolServer = (id: string) =>
+ fetchJSON<{ ok: boolean; tools?: { name: string; description: string }[]; message?: string }>(
+ `/api/admin/tools/servers/${id}/verify`,
+ { method: 'POST' }
+ );
diff --git a/cptr/frontend/src/lib/components/Admin/AudioSettings.svelte b/cptr/frontend/src/lib/components/Admin/AudioSettings.svelte
index 8ae2435..32b6c71 100644
--- a/cptr/frontend/src/lib/components/Admin/AudioSettings.svelte
+++ b/cptr/frontend/src/lib/components/Admin/AudioSettings.svelte
@@ -52,7 +52,7 @@
toast.success($t('settings.saved'));
refreshAudioState();
} catch {
- toast.error('Failed to save audio settings');
+ toast.error($t('admin.audio.saveFailed'));
} finally {
saving = false;
}
@@ -60,53 +60,53 @@
-
Audio
+
{$t('admin.audio.title')}
{#if loading}
{:else}
-
Voice Memos
+
{$t('admin.audio.voiceMemos')}
- Enable Voice Memos
+ {$t('admin.audio.enableVoiceMemos')}
{ voiceMemosEnabled = v; }} />
- Record voice memos from the "+" menu.
+ {$t('admin.audio.voiceMemosHint')}
- Auto-transcribe
+ {$t('admin.audio.autoTranscribe')}
{ transcribeEnabled = v; }} />
- {transcribeEnabled ? 'Recordings are transcribed to markdown via STT.' : 'Recordings are saved as audio only.'}
+ {transcribeEnabled ? $t('admin.audio.transcribeOnHint') : $t('admin.audio.transcribeOffHint')}
- Recording quality
+ {$t('admin.audio.recordingQuality')}
- High (128kbps)
- Medium (64kbps)
- Low (32kbps)
+ {$t('admin.audio.qualityHigh')}
+ {$t('admin.audio.qualityMedium')}
+ {$t('admin.audio.qualityLow')}
- {quality === 'high' ? 'Best quality, larger files.' : quality === 'medium' ? 'Balanced quality and size.' : 'Smallest files, optimized for speech.'}
+ {quality === 'high' ? $t('admin.audio.qualityHintHigh') : quality === 'medium' ? $t('admin.audio.qualityHintMedium') : $t('admin.audio.qualityHintLow')}
-
Speech-to-Text
+
{$t('admin.audio.stt')}
diff --git a/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte b/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte
index 31d8dbf..209c07a 100644
--- a/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte
+++ b/cptr/frontend/src/lib/components/Admin/CreateBotModal.svelte
@@ -54,16 +54,16 @@
);
let selectedWsName = $derived(
- $workspaceList.find((w) => w.path === workspace)?.name || workspace.split('/').pop() || 'Select workspace'
+ $workspaceList.find((w) => w.path === workspace)?.name || workspace.split('/').pop() || $t('automationModal.selectWorkspace')
);
- const platformHints: Record
= {
- telegram: 'Create a bot via @BotFather',
- discord: 'Create a bot in the Developer Portal',
- slack: 'Bot Token | App Token (pipe-separated)',
- whatsapp: 'Access Token | Phone Number ID (pipe-separated)',
- signal: 'signal-cli URL | Phone Number (pipe-separated)'
- };
+ const platformHints: Record = $derived({
+ telegram: $t('messaging.hint.telegram'),
+ discord: $t('messaging.hint.discord'),
+ slack: $t('messaging.hint.slack'),
+ whatsapp: $t('messaging.hint.whatsapp'),
+ signal: $t('messaging.hint.signal')
+ });
async function handleVerify() {
if (!token.trim()) return;
@@ -106,7 +106,7 @@
onsave();
onclose();
} catch (e: any) {
- toast.error(e.message || 'Failed to save');
+ toast.error(e.message || $t('messaging.failedToSave'));
} finally {
saving = false;
}
@@ -122,14 +122,14 @@
}}
>
- {bot ? 'Edit Bot' : 'Add Bot'}
+ {bot ? $t('messaging.editBot') : $t('messaging.addBot')}
{#if !bot}
- Name
+ {$t('messaging.name')}
- Platform
+ {$t('messaging.platform')}
{ verifyResult = null; }}
@@ -157,7 +157,7 @@
{:else}
-
Name
+
{$t('messaging.name')}
@@ -191,7 +191,7 @@
{#if verifying}
{:else}
- Verify
+ {$t('messaging.verify')}
{/if}
{/if}
@@ -208,8 +208,8 @@
{/if}
-
Allowed senders
-
Comma-separated user IDs. Leave empty to allow all.
+
{$t('messaging.allowedSenders')}
+
{$t('messaging.allowedSendersHint')}
{:else}
- {bot ? 'Save' : 'Add'} →
+ {bot ? $t('messaging.save') : $t('messaging.add')}
{/if}
diff --git a/cptr/frontend/src/lib/components/Admin/Gateway.svelte b/cptr/frontend/src/lib/components/Admin/Gateway.svelte
index 868127c..bd2c26e 100644
--- a/cptr/frontend/src/lib/components/Admin/Gateway.svelte
+++ b/cptr/frontend/src/lib/components/Admin/Gateway.svelte
@@ -167,7 +167,7 @@
class="shrink-0 text-[11px] text-gray-500 hover:text-gray-900 dark:text-gray-500 dark:hover:text-white transition-colors"
onclick={copyKey}
>
- Copy
+ {$t('admin.gateway.copy')}
@@ -183,7 +183,7 @@
{/if}
- Keys
+ {$t('admin.gateway.keys')}
- Base URL:
+ {$t('admin.gateway.baseUrl')}
{`${typeof window !== 'undefined' ? window.location.origin : ''}/v1`}
- API Key:
+ {$t('admin.gateway.apiKey')}
sk-cptr-...
- Headers:
+ {$t('admin.gateway.headers')}
- Copy
+ {$t('admin.gateway.copy')}
No messaging bots configured.
+ {$t('messaging.noBots')}
{/if}
{/if}
diff --git a/cptr/frontend/src/lib/components/Admin/Models.svelte b/cptr/frontend/src/lib/components/Admin/Models.svelte
index 49229c1..234a8d8 100644
--- a/cptr/frontend/src/lib/components/Admin/Models.svelte
+++ b/cptr/frontend/src/lib/components/Admin/Models.svelte
@@ -4,6 +4,8 @@
import {
getModelConfig,
updateModelConfig,
+ getAdminConfig,
+ updateConfig,
type ModelConfigEntry
} from '$lib/apis/admin';
import { t } from '$lib/i18n';
@@ -32,6 +34,10 @@
let globalExpanded = $state(false);
let showVariables = $state(false);
+ // Context compaction
+ let compactTokenThreshold = $state(80000);
+ let compactDirty = $state(false);
+
const TEMPLATE_VARIABLES = [
{ name: 'WORKSPACE_NAME', desc: 'Workspace folder name' },
{ name: 'WORKSPACE_PATH', desc: 'Full workspace path' },
@@ -52,7 +58,7 @@ Workspace: {{WORKSPACE_NAME}}
Files:
{{FILE_TREE}}`;
- let hasDirty = $derived(globalDirty || models.some((m) => m.dirty));
+ let hasDirty = $derived(globalDirty || compactDirty || models.some((m) => m.dirty));
function parseRows(config: ModelConfigEntry | undefined): ParamRow[] {
const rp = config?.params?.request_params;
@@ -98,6 +104,12 @@ Files:
} finally {
loading = false;
}
+
+ // Load context compaction threshold from admin config
+ try {
+ const adminCfg = await getAdminConfig();
+ compactTokenThreshold = Number(adminCfg['chat.compact_token_threshold']) || 80000;
+ } catch {}
});
async function toggleModel(e: Event, model: ModelEntry) {
@@ -145,6 +157,13 @@ Files:
await Promise.all(promises);
globalDirty = false;
models.forEach((m) => (m.dirty = false));
+
+ // Save context compaction threshold
+ if (compactDirty) {
+ await updateConfig({ 'chat.compact_token_threshold': compactTokenThreshold });
+ compactDirty = false;
+ }
+
toast.success($t('settings.saved'));
} catch {
toast.error($t('models.failedToSave'));
@@ -256,6 +275,24 @@ Files:
{$t('admin.models')}
+
+
+
{$t('admin.contextCompaction')}
+
+
+
+
{$t('admin.compactTokenThreshold')}
+
+ (compactDirty = true)}
+ min="10000" max="1000000" step="10000"
+ class="w-24 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-blue-400 dark:focus:border-blue-500 transition-colors" />
+ {$t('admin.compactTokenThresholdUnit')}
+
+
{$t('admin.compactTokenThresholdHint')}
+
+
+
- import { toast } from 'svelte-sonner';
- import { onMount } from 'svelte';
- import { getAdminConfig, updateConfig as apiUpdateConfig } from '$lib/apis/admin';
- import { t } from '$lib/i18n';
- import ToggleSwitch from '$lib/components/common/ToggleSwitch.svelte';
- import Spinner from '$lib/components/common/Spinner.svelte';
-
- let config = $state>({});
- let loading = $state(true);
- let saving = $state(false);
-
- // Local state for key inputs (save on blur, not every keystroke)
- let exaKey = $state('');
- let tavilyKey = $state('');
- let braveKey = $state('');
- let perplexityKey = $state('');
- let ccKey = $state('');
- let ccBaseUrl = $state('');
- let ccModel = $state('');
-
- async function loadConfig() {
- try {
- config = await getAdminConfig();
- exaKey = config['web.exa_api_key'] || '';
- tavilyKey = config['web.tavily_api_key'] || '';
- braveKey = config['web.brave_api_key'] || '';
- perplexityKey = config['web.perplexity_api_key'] || '';
- ccKey = config['web.chat_completions_api_key'] || '';
- ccBaseUrl = config['web.chat_completions_base_url'] || '';
- ccModel = config['web.chat_completions_model'] || '';
- } catch {
- toast.error($t('admin.failedToLoadConfig'));
- } finally {
- loading = false;
- }
- }
-
- async function updateConfig(key: string, value: any) {
- saving = true;
- try {
- await apiUpdateConfig({ [key]: value });
- config[key] = value;
- toast.success($t('settings.saved'));
- } catch {
- toast.error($t('admin.failedToSave'));
- } finally {
- saving = false;
- }
- }
-
- async function saveKey(key: string, value: string) {
- if (value !== (config[key] || '')) {
- await updateConfig(key, value);
- }
- }
-
- onMount(loadConfig);
-
-
-
-
{$t('admin.settings')}
-
- {#if loading}
-
-
-
- {:else}
-
-
{$t('admin.authentication')}
-
-
- {$t('admin.allowSignUp')}
- updateConfig('auth.signup_enabled', v)}
- disabled={saving}
- />
-
-
- {config['auth.signup_enabled'] ? $t('admin.signUpEnabled') : $t('admin.signUpDisabled')}
-
-
-
-
{$t('admin.web')}
-
-
-
- {$t('admin.webEnabled')}
- updateConfig('web.enabled', config['web.enabled'] === false)}
- disabled={saving}
- />
-
-
- {config['web.enabled'] !== false ? $t('admin.webEnabledHint') : $t('admin.webDisabledHint')}
-
-
- {#if config['web.enabled'] !== false}
-
-
- {$t('admin.webSearchProvider')}
-
- updateConfig('web.search_provider', (e.target as HTMLSelectElement).value)}
- disabled={saving}
- >
- {$t('admin.webProviderAuto')}
- Exa
- Tavily
- Brave
- Perplexity
- DuckDuckGo
- {$t('admin.webChatCompletions')}
-
-
-
-
- {@const provider = config['web.search_provider'] || 'auto'}
-
- {#if provider === 'auto'}
-
{$t('admin.webAutoHint')}
- {:else if provider === 'exa'}
-
-
{$t('admin.webExaKey')}
-
saveKey('web.exa_api_key', exaKey)}
- disabled={saving}
- />
-
- {$t('admin.webExaHint')}
-
-
- {:else if provider === 'tavily'}
-
-
{$t('admin.webTavilyKey')}
-
saveKey('web.tavily_api_key', tavilyKey)}
- disabled={saving}
- />
-
- {$t('admin.webTavilyHint')}
-
-
- {:else if provider === 'brave'}
-
-
{$t('admin.webBraveKey')}
-
saveKey('web.brave_api_key', braveKey)}
- disabled={saving}
- />
-
- {$t('admin.webBraveHint')}
-
-
- {:else if provider === 'duckduckgo'}
-
- {$t('admin.webDuckDuckGoNote')}
-
- {:else if provider === 'perplexity'}
-
-
{$t('admin.webPerplexityKey')}
-
saveKey('web.perplexity_api_key', perplexityKey)}
- disabled={saving}
- />
-
- {$t('admin.webPerplexityHint')}
-
-
- {:else if provider === 'chat_completions'}
-
-
- {$t('admin.webCcBaseUrl')}
- saveKey('web.chat_completions_base_url', ccBaseUrl)}
- disabled={saving}
- />
-
-
- {$t('admin.webCcKey')}
- saveKey('web.chat_completions_api_key', ccKey)}
- disabled={saving}
- />
-
-
- {$t('admin.webCcModel')}
- saveKey('web.chat_completions_model', ccModel)}
- disabled={saving}
- />
-
-
- {$t('admin.webCcHint')}
-
-
- {/if}
- {/if}
-
-
- toast.success($t('settings.saved'))}>{$t('settings.save')}
-
- {/if}
-
diff --git a/cptr/frontend/src/lib/components/Admin/Subagents.svelte b/cptr/frontend/src/lib/components/Admin/Subagents.svelte
new file mode 100644
index 0000000..a6c0dd1
--- /dev/null
+++ b/cptr/frontend/src/lib/components/Admin/Subagents.svelte
@@ -0,0 +1,113 @@
+
+
+
+
{$t('admin.subagents')}
+
+ {#if loading}
+
+ {:else}
+
+
+ {$t('admin.subagentsEnabled')}
+ { enabled = v; }} />
+
+
+ {$t('admin.subagentsHint')}
+
+
+ {#if enabled}
+
+
{$t('admin.subagentsMaxConcurrent')}
+
+
+ {$t('admin.subagentsMaxConcurrentHint')}
+
+
+
+
+
{$t('admin.subagentsMaxIterations')}
+
+
+ {$t('admin.subagentsMaxIterationsHint')}
+
+
+
+
+
{$t('admin.subagentsMaxOutput')}
+
+
+ chars
+
+
+
+
+
{$t('admin.subagentsSystemPrompt')}
+
+
{$t('admin.subagentsSystemPromptHint')}
+
+ {/if}
+
+
+
+
+ save()}
+ disabled={saving}
+ >{$t('settings.save')}
+
+ {/if}
+
diff --git a/cptr/frontend/src/lib/components/Admin/ToolServers.svelte b/cptr/frontend/src/lib/components/Admin/ToolServers.svelte
new file mode 100644
index 0000000..4b4e6b4
--- /dev/null
+++ b/cptr/frontend/src/lib/components/Admin/ToolServers.svelte
@@ -0,0 +1,444 @@
+
+
+
+
{$t('toolServers.title')}
+ openCreate()}
+ >
+
+
+
+
+{#if loading}
+
+
+
+{:else}
+
+ {#each servers as s}
+
openEdit(s)}
+ >
+
+ {s.type === 'mcp' ? 'MCP' : 'API'}
+
+
+ {s.name || s.url}
+
+
+ toggleEnabled(e, s)}
+ onkeydown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ toggleEnabled(e, s);
+ }
+ }}
+ >
+
+
+
+ {/each}
+
+ {#if servers.length === 0}
+
+ {$t('toolServers.empty')}
+
+ {/if}
+
+{/if}
+
+{#if showModal}
+ (showModal = false)} class="w-full max-w-md mx-4">
+
+ {
+ if (e.key === 'Enter' && formUrl.trim()) handleSubmit();
+ }}
+ >
+
+ {editServer ? $t('toolServers.edit') : $t('toolServers.add')}
+
+
+
+
+
+ {$t('toolServers.id')}
+
+
+
+ {$t('toolServers.type')}
+
+ OpenAPI
+ MCP
+
+
+
+
+
+
{$t('toolServers.name')}
+
+
+
+
{$t('toolServers.url')}
+
+
+
+ {#if formType === 'openapi'}
+
{$t('toolServers.specPath')}
+
+ {/if}
+
+
+
+
+ {$t('toolServers.auth')}
+
+ {$t('toolServers.authNone')}
+ {$t('toolServers.authBearer')}
+
+
+ {#if formAuthType === 'bearer'}
+
+ {$t('toolServers.apiKey')}
+
+
+ {/if}
+
+
+
+
{$t('toolServers.description')}
+
+
+
+
{$t('toolServers.headers')}
+
{$t('toolServers.headersHint')}
+
+
+
+
+ {#if verifyResult?.ok && verifyResult.tools}
+
+ {verifyResult.tools.length}
+ {$t('toolServers.toolsFound')}:
+ {verifyResult.tools.map((t) => t.name).join(', ')}
+
+ {/if}
+
+
+
+
+ {#if editServer}
+ {$t('toolServers.delete')}
+
+ {#if verifying}
+
+ {:else}
+ {$t('toolServers.verify')}
+ {/if}
+
+ {/if}
+
+
+ {#if saving}
+
+ {:else if editServer}
+ {$t('settings.save')} →
+ {:else}
+ {$t('toolServers.add')} →
+ {/if}
+
+
+
+
+{/if}
diff --git a/cptr/frontend/src/lib/components/Admin/Users.svelte b/cptr/frontend/src/lib/components/Admin/Users.svelte
index e9c7030..91de2d8 100644
--- a/cptr/frontend/src/lib/components/Admin/Users.svelte
+++ b/cptr/frontend/src/lib/components/Admin/Users.svelte
@@ -4,10 +4,11 @@
import CreateUserModal from './CreateUserModal.svelte';
import EditUserModal from './EditUserModal.svelte';
import { onMount } from 'svelte';
- import { listUsers } from '$lib/apis/admin';
+ import { listUsers, getAdminConfig, updateConfig } from '$lib/apis/admin';
import { session } from '$lib/session';
import { t } from '$lib/i18n';
import Spinner from '$lib/components/common/Spinner.svelte';
+ import ToggleSwitch from '$lib/components/common/ToggleSwitch.svelte';
interface User {
user_id: string;
@@ -24,6 +25,10 @@
let loading = $state(true);
let page = $state(0);
+ // Auth config
+ let signupEnabled = $state(false);
+ let savingConfig = $state(false);
+
let showCreate = $state(false);
let editUser = $state(null);
@@ -41,6 +46,26 @@
}
}
+ async function loadAuthConfig() {
+ try {
+ const config = await getAdminConfig();
+ signupEnabled = config['auth.signup_enabled'] === true;
+ } catch {}
+ }
+
+ async function toggleSignup(value: boolean) {
+ savingConfig = true;
+ try {
+ await updateConfig({ 'auth.signup_enabled': value });
+ signupEnabled = value;
+ toast.success($t('settings.saved'));
+ } catch {
+ toast.error($t('admin.failedToSave'));
+ } finally {
+ savingConfig = false;
+ }
+ }
+
function handleCreated() {
showCreate = false;
loadUsers();
@@ -52,7 +77,10 @@
if (page >= totalPages) page = Math.max(0, totalPages - 1);
}
- onMount(loadUsers);
+ onMount(() => {
+ loadUsers();
+ loadAuthConfig();
+ });
@@ -65,6 +93,21 @@
+
+
+
+ {$t('admin.allowSignUp')}
+ toggleSignup(v)}
+ disabled={savingConfig}
+ />
+
+
+ {signupEnabled ? $t('admin.signUpEnabled') : $t('admin.signUpDisabled')}
+
+
+
{#if loading}
diff --git a/cptr/frontend/src/lib/components/Admin/Web.svelte b/cptr/frontend/src/lib/components/Admin/Web.svelte
new file mode 100644
index 0000000..40a12a5
--- /dev/null
+++ b/cptr/frontend/src/lib/components/Admin/Web.svelte
@@ -0,0 +1,314 @@
+
+
+
+
{$t('admin.web')}
+
+ {#if loading}
+
+ {:else}
+
+
Search
+
+
+
+ {$t('admin.webEnabled')}
+ { webEnabled = v; }} />
+
+
+ {webEnabled ? $t('admin.webEnabledHint') : $t('admin.webDisabledHint')}
+
+
+ {#if webEnabled}
+
+ {$t('admin.webSearchProvider')}
+
+ {$t('admin.webProviderAuto')}
+ Exa
+ Tavily
+ Brave
+ Perplexity
+ DuckDuckGo
+ {$t('admin.webChatCompletions')}
+
+
+
+ {#if searchProvider === 'auto'}
+ {$t('admin.webAutoHint')}
+ {:else if searchProvider === 'duckduckgo'}
+ {$t('admin.webDuckDuckGoNote')}
+ {/if}
+
+
+ {#if searchProvider === 'exa'}
+
+
{$t('admin.webExaKey')}
+
+
{$t('admin.webExaHint')}
+
+ {:else if searchProvider === 'tavily'}
+
+
{$t('admin.webTavilyKey')}
+
+
{$t('admin.webTavilyHint')}
+
+ {:else if searchProvider === 'brave'}
+
+
{$t('admin.webBraveKey')}
+
+
{$t('admin.webBraveHint')}
+
+ {:else if searchProvider === 'perplexity'}
+
+
{$t('admin.webPerplexityKey')}
+
+
{$t('admin.webPerplexityHint')}
+
+ {:else if searchProvider === 'chat_completions'}
+
+ {$t('admin.webCcBaseUrl')}
+
+
+
+ {$t('admin.webCcKey')}
+
+
+
+ {$t('admin.webCcModel')}
+
+
+
{$t('admin.webCcHint')}
+ {/if}
+ {/if}
+
+
+
+
Browser
+
+
+
+ Browser tools
+ { browserEnabled = v; }} />
+
+
+ Give the AI access to a web browser for navigating pages, clicking elements, and taking screenshots.
+
+
+ {#if browserEnabled}
+
+ Provider
+
+ Local CDP
+ Firecrawl
+ Browser-Use
+
+
+
+ {#if browserProvider === 'local'}
+ Connects to Chrome via DevTools Protocol. Full interactive browsing with clicking, typing, and screenshots.
+ {:else if browserProvider === 'firecrawl'}
+ Cloud API that converts web pages to markdown. Fast extraction, no interactive browsing.
+ {:else}
+ Cloud API for LLM-driven browser tasks. Describe what you need in natural language.
+ {/if}
+
+
+ {#if browserProvider === 'local'}
+
+
+
Auto-launch Chrome
+
Start a headless Chrome if none is running
+
+ { autoLaunch = v; }} />
+
+
+
+
CDP URL
+
+
+ testConnection()}
+ disabled={testing}
+ >{testing ? '...' : 'Test'}
+
+ {#if testResult}
+
+ {testResult.message}
+
+ {/if}
+
+
+
+
Session timeout
+
+
+ minutes
+
+
+ {:else if browserProvider === 'firecrawl'}
+
+ API Key
+
+
+
+
Base URL
+
+
Change for self-hosted Firecrawl instances
+
+ {:else if browserProvider === 'browser_use'}
+
+ API Key
+
+
+
+ Base URL
+
+
+ {/if}
+ {/if}
+
+
+
+
+ save()}
+ disabled={saving}
+ >{$t('settings.save')}
+
+ {/if}
+
diff --git a/cptr/frontend/src/lib/components/GitView.svelte b/cptr/frontend/src/lib/components/GitView.svelte
index 2084745..d782100 100644
--- a/cptr/frontend/src/lib/components/GitView.svelte
+++ b/cptr/frontend/src/lib/components/GitView.svelte
@@ -4,6 +4,7 @@
import { gitStatusStore, type GitStatus, type GitFile } from '$lib/stores/gitStatus.svelte';
import { tooltip } from '$lib/tooltip';
+ import { t } from '$lib/i18n';
import Icon from './Icon.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
@@ -309,8 +310,8 @@
class="flex h-6 w-6 items-center justify-center rounded-md text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-white/6 dark:hover:text-gray-300"
onclick={() => refreshReview(false)}
disabled={refreshing}
- aria-label="Refresh changes"
- use:tooltip={'Refresh changes'}
+ aria-label={$t('a11y.refreshChanges')}
+ use:tooltip={$t('a11y.refreshChanges')}
>
diff --git a/cptr/frontend/src/lib/components/GroupTabBar.svelte b/cptr/frontend/src/lib/components/GroupTabBar.svelte
index cf4e553..583648c 100644
--- a/cptr/frontend/src/lib/components/GroupTabBar.svelte
+++ b/cptr/frontend/src/lib/components/GroupTabBar.svelte
@@ -165,7 +165,7 @@
...($voiceMemosEnabled
? [
{
- label: 'Voice Memo',
+ label: $t('bar.voiceMemo'),
icon: 'microphone',
shortcut: formatChord($keybindings.voiceMemo),
onclick: () => {
@@ -183,12 +183,12 @@
if (isWideScreen && tab.type === 'file' && tab.filePath && !$splitActive) {
items.push({
- label: 'Split Right',
+ label: $t('bar.splitRight'),
icon: 'split-horizontal',
onclick: () => openInSplit(tab.filePath!, 'horizontal')
});
items.push({
- label: 'Split Down',
+ label: $t('bar.splitDown'),
icon: 'split-vertical',
onclick: () => openInSplit(tab.filePath!, 'vertical')
});
@@ -210,7 +210,7 @@
const direction = $activeWorkspace?.splitDirection ?? 'horizontal';
return [
{
- label: 'Split Right',
+ label: $t('bar.splitRight'),
icon: 'split-horizontal',
active: direction === 'horizontal',
onclick: () => {
@@ -219,7 +219,7 @@
}
},
{
- label: 'Split Down',
+ label: $t('bar.splitDown'),
icon: 'split-vertical',
active: direction === 'vertical',
onclick: () => {
@@ -309,7 +309,7 @@
{/if}
- {tab.type === 'files' ? ($activeWorkspace?.name ?? 'Files') : tab.label}
+ {tab.type === 'files' ? ($activeWorkspace?.name ?? $t('bar.files')) : tab.label}
{#if tab.unsaved}
{/if}
{#if !tab.permanent}
@@ -354,8 +354,8 @@
? 'bg-gray-200/50 text-gray-900 dark:bg-white/8 dark:text-white'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}"
onclick={() => (showSplitMenu = !showSplitMenu)}
- aria-label="Split Editor"
- use:tooltip={'Split Editor'}
+ aria-label={$t('a11y.splitEditor')}
+ use:tooltip={$t('a11y.splitEditor')}
>
closeGroup(group.id)}
- aria-label="Close pane"
- use:tooltip={'Close pane'}
+ aria-label={$t('a11y.closePane')}
+ use:tooltip={$t('a11y.closePane')}
>
diff --git a/cptr/frontend/src/lib/components/Icon.svelte b/cptr/frontend/src/lib/components/Icon.svelte
index c9261a6..3137450 100644
--- a/cptr/frontend/src/lib/components/Icon.svelte
+++ b/cptr/frontend/src/lib/components/Icon.svelte
@@ -360,6 +360,13 @@
+ {:else if name === 'globe'}
+
+
+
+
+
+
{:else if name === 'microphone'}
import { onMount } from 'svelte';
+ import { t } from '$lib/i18n';
interface Props {
onClick?: () => void;
@@ -72,7 +73,7 @@
{ e.stopPropagation(); onclose(); }}
>
diff --git a/cptr/frontend/src/lib/components/PortPreview.svelte b/cptr/frontend/src/lib/components/PortPreview.svelte
index 02ed5a7..24c2730 100644
--- a/cptr/frontend/src/lib/components/PortPreview.svelte
+++ b/cptr/frontend/src/lib/components/PortPreview.svelte
@@ -1,6 +1,7 @@
-
-
-
Browser
-
- {#if loading}
-
- {:else}
-
-
Enable
-
-
-
- Browser tools
- { enabled = v; }} />
-
-
- Give the AI access to a web browser for navigating pages, clicking elements, and taking screenshots.
-
-
-
- {#if enabled}
-
-
Provider
-
-
- {#each [
- { value: 'local' as const, label: 'Local CDP' },
- { value: 'firecrawl' as const, label: 'Firecrawl' },
- { value: 'browser_use' as const, label: 'Browser-Use' }
- ] as opt}
- { provider = opt.value; }}
- >
- {opt.label}
-
- {/each}
-
-
- {#if provider === 'local'}
- Connects to Chrome via DevTools Protocol. Full interactive browsing with clicking, typing, and screenshots.
- {:else if provider === 'firecrawl'}
- Cloud API that converts web pages to markdown. Fast extraction, no interactive browsing.
- {:else}
- Cloud API for LLM-driven browser tasks. Describe what you need in natural language.
- {/if}
-
-
-
- {#if provider === 'local'}
-
Connection
-
-
-
-
-
Auto-launch Chrome
-
Start a headless Chrome if none is running
-
- { autoLaunch = v; }} />
-
-
-
-
CDP URL
-
-
- testConnection()}
- disabled={testing}
- >
- {testing ? '...' : 'Test'}
-
-
- {#if testResult}
-
- {testResult.message}
-
- {/if}
-
-
-
-
Session timeout
-
-
- minutes
-
-
-
- {/if}
-
-
- {#if provider === 'firecrawl'}
-
Firecrawl
-
-
-
- API Key
-
-
-
-
Base URL
-
-
Change for self-hosted Firecrawl instances
-
-
- {/if}
-
-
- {#if provider === 'browser_use'}
-
Browser-Use
-
-
- {/if}
- {/if}
-
-
-
- save()}
- disabled={saving}
- >{$t('settings.save')}
-
- {/if}
-
diff --git a/cptr/frontend/src/lib/components/Settings/Keyboard.svelte b/cptr/frontend/src/lib/components/Settings/Keyboard.svelte
index d376088..6aed5ad 100644
--- a/cptr/frontend/src/lib/components/Settings/Keyboard.svelte
+++ b/cptr/frontend/src/lib/components/Settings/Keyboard.svelte
@@ -2,7 +2,6 @@
import {
keybindings,
ACTION_IDS,
- ACTION_LABELS,
DEFAULT_KEYBINDINGS,
formatChord,
eventToChord,
@@ -26,6 +25,22 @@
toggleSidebar: $t('keyboard.toggleSidebar')
});
+ /** Translated action labels for display. */
+ const ACTION_LABELS: Record = $derived({
+ newFile: $t('keyboard.action.newFile'),
+ newTerminal: $t('keyboard.action.newTerminal'),
+ newChat: $t('keyboard.action.newChat'),
+ closeTab: $t('keyboard.action.closeTab'),
+ nextTab: $t('keyboard.action.nextTab'),
+ prevTab: $t('keyboard.action.prevTab'),
+ quickOpen: $t('keyboard.action.quickOpen'),
+ searchAll: $t('keyboard.action.searchAll'),
+ openSettings: $t('keyboard.action.openSettings'),
+ toggleSplit: $t('keyboard.action.toggleSplit'),
+ toggleSidebar: $t('keyboard.action.toggleSidebar'),
+ voiceMemo: $t('keyboard.action.voiceMemo')
+ });
+
let recordingAction = $state(null);
function startRecording(actionId: ActionId) {
@@ -116,7 +131,7 @@
startRecording(actionId)}
- title="Click to rebind"
+ title={$t('keyboard.clickToRebind')}
>
@@ -130,7 +145,7 @@
{#if conflict}
! !
{/if}
diff --git a/cptr/frontend/src/lib/components/SettingsModal.svelte b/cptr/frontend/src/lib/components/SettingsModal.svelte
index 936a54b..03b9b9c 100644
--- a/cptr/frontend/src/lib/components/SettingsModal.svelte
+++ b/cptr/frontend/src/lib/components/SettingsModal.svelte
@@ -4,7 +4,6 @@
import General from './Settings/General.svelte';
import Account from './Settings/Account.svelte';
import Keyboard from './Settings/Keyboard.svelte';
- import Browser from './Settings/Browser.svelte';
import About from './Settings/About.svelte';
import Users from './Admin/Users.svelte';
import Connections from './Admin/Connections.svelte';
@@ -12,14 +11,15 @@
import Messaging from './Admin/Messaging.svelte';
import Gateway from './Admin/Gateway.svelte';
import AudioSettings from './Admin/AudioSettings.svelte';
- import AdminSettings from './Admin/Settings.svelte';
+ import AdminWeb from './Admin/Web.svelte';
+ import ToolServers from './Admin/ToolServers.svelte';
+ import Subagents from './Admin/Subagents.svelte';
import { session } from '$lib/session';
import { t } from '$lib/i18n';
type Tab =
| 'general'
| 'keyboard'
- | 'browser'
| 'account'
| 'about'
| 'users'
@@ -28,7 +28,9 @@
| 'messaging'
| 'gateway'
| 'audio'
- | 'admin_settings';
+ | 'web'
+ | 'toolservers'
+ | 'subagents';
interface Props {
onclose: () => void;
@@ -54,9 +56,10 @@
{ id: 'models', label: $t('admin.models'), icon: 'cube' },
{ id: 'messaging', label: $t('admin.messaging'), icon: 'chat-bubble' },
{ id: 'gateway', label: $t('admin.gateway.tab'), icon: 'gateway' },
- { id: 'audio', label: 'Audio', icon: 'microphone' },
- { id: 'browser', label: 'Browser', icon: 'browser' },
- { id: 'admin_settings', label: $t('settings.configuration'), icon: 'shield' }
+ { id: 'audio', label: $t('admin.audio.title'), icon: 'microphone' },
+ { id: 'web', label: $t('admin.web'), icon: 'globe' },
+ { id: 'toolservers', label: $t('admin.toolServers'), icon: 'plug' },
+ { id: 'subagents', label: $t('admin.subagents'), icon: 'user' }
]);
@@ -117,8 +120,6 @@
{:else if activeTab === 'keyboard'}
- {:else if activeTab === 'browser'}
-
{:else if activeTab === 'account'}
{:else if activeTab === 'about'}
@@ -135,8 +136,12 @@
{:else if activeTab === 'audio'}
- {:else if activeTab === 'admin_settings'}
-
+ {:else if activeTab === 'web'}
+
+ {:else if activeTab === 'toolservers'}
+
+ {:else if activeTab === 'subagents'}
+
{/if}
diff --git a/cptr/frontend/src/lib/components/Sidebar.svelte b/cptr/frontend/src/lib/components/Sidebar.svelte
index 74f8a44..f20f902 100644
--- a/cptr/frontend/src/lib/components/Sidebar.svelte
+++ b/cptr/frontend/src/lib/components/Sidebar.svelte
@@ -343,7 +343,7 @@
}}
>
-
Automations
+
{$t('automations.title')}
{/if}
@@ -388,7 +388,7 @@
e.preventDefault();
toggleWorkspaceExpand(ws.path);
}}
- aria-label={isExpanded ? 'Collapse' : 'Expand'}
+ aria-label={isExpanded ? $t('sidebar.collapse') : $t('sidebar.addWorkspace')}
>
@@ -417,8 +417,8 @@
role="button"
tabindex="-1"
onclick={() => handleNewChat(ws.path)}
- aria-label="New Chat"
- use:tooltip={'New Chat'}
+ aria-label={$t('bar.newChat')}
+ use:tooltip={$t('bar.newChat')}
>
diff --git a/cptr/frontend/src/lib/components/VoiceMemoModal.svelte b/cptr/frontend/src/lib/components/VoiceMemoModal.svelte
index 5be2c0c..ceb31be 100644
--- a/cptr/frontend/src/lib/components/VoiceMemoModal.svelte
+++ b/cptr/frontend/src/lib/components/VoiceMemoModal.svelte
@@ -7,6 +7,7 @@
import { writeFile } from '$lib/apis/files';
import { fetchJSON } from '$lib/apis';
import { transcribeEnabled, recordingQuality, QUALITY_BITRATES } from '$lib/stores/audio';
+ import { t } from '$lib/i18n';
interface Props {
workspace: string;
@@ -126,7 +127,7 @@
elapsed = 0;
timerInterval = window.setInterval(() => elapsed++, 1000);
} catch {
- toast.error('Could not access microphone');
+ toast.error($t('voiceMemo.microphoneError'));
onclose();
}
}
@@ -164,7 +165,7 @@
await fetchJSON('/api/workspace/files/upload', { method: 'POST', body: form });
await clearFromIDB(id).catch(() => {});
} catch {
- toast.error('Audio saved locally. Server upload failed.');
+ toast.error($t('voiceMemo.uploadFailed'));
}
// 3. Transcribe (if enabled)
@@ -181,7 +182,7 @@
transcript = '';
const msg = err?.message || '';
if (msg.includes('not configured')) {
- toast.error('STT not configured. Set up in Settings → Audio.');
+ toast.error($t('voiceMemo.sttNotConfigured'));
}
}
}
@@ -214,7 +215,7 @@
phase = 'done';
setTimeout(onclose, 500);
} catch {
- toast.error('Failed to write transcript. Audio is safe.');
+ toast.error($t('voiceMemo.writeFailed'));
}
}
@@ -258,17 +259,17 @@
Cancel {$t('common.cancel')}
Done → {$t('voiceMemo.done')}
{:else if phase === 'processing'}
- Processing…
+ {$t('voiceMemo.processing')}
{:else if phase === 'naming'}
{#if transcript}
@@ -277,12 +278,12 @@
{/if}
- Filename
+ {$t('voiceMemo.filename')}
Save →{$t('common.save')} →
{:else if phase === 'done'}
- Saved ✓
+ {$t('voiceMemo.saved')}
{/if}
diff --git a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte
index 298a846..08ce235 100644
--- a/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte
+++ b/cptr/frontend/src/lib/components/chat/AssistantMessage.svelte
@@ -6,6 +6,7 @@
import MarkdownRenderer from '$lib/components/markdown/MarkdownRenderer.svelte';
import OutputEditView from './OutputEditView.svelte';
import { currentWorkspace, openFileTab } from '$lib/stores';
+ import { t } from '$lib/i18n';
interface Props {
content: string;
@@ -139,42 +140,58 @@
/** Human-readable label for a tool call */
function toolLabel(name: string, args: any): string {
+ const _t = $t;
switch (name) {
case 'read_file': {
- const range = args.start_line ? ` L${args.start_line}–${args.end_line || 'end'}` : '';
- return `Read ${shortPath(args.path)}${range}`;
+ const p = shortPath(args.path);
+ if (args.start_line) return _t('chat.tool.readFileRange', { path: p, range: `${args.start_line}–${args.end_line || 'end'}` });
+ return _t('chat.tool.readFile', { path: p });
}
case 'edit_file':
- return `Edit ${shortPath(args.path)}`;
+ return _t('chat.tool.editFile', { path: shortPath(args.path) });
case 'multi_edit_file':
- return `Multi-edit ${shortPath(args.path)}`;
+ return _t('chat.tool.multiEditFile', { path: shortPath(args.path) });
case 'create_file':
- return `Create ${shortPath(args.path)}`;
+ return _t('chat.tool.createFile', { path: shortPath(args.path) });
case 'write_file':
- return `Write ${shortPath(args.path)}`;
+ return _t('chat.tool.writeFile', { path: shortPath(args.path) });
case 'list_directory':
- return `List ${shortPath(args.path)}${args.recursive ? ' (recursive)' : ''}`;
+ return args.recursive
+ ? _t('chat.tool.listDirectoryRecursive', { path: shortPath(args.path) })
+ : _t('chat.tool.listDirectory', { path: shortPath(args.path) });
case 'search_files': {
- const scope = args.include ? ` in ${args.include}` : '';
- return `Search "${args.query || '?'}"${scope}`;
+ const scope = args.include ? _t('chat.tool.searchFilesScope', { include: args.include }) : '';
+ return _t('chat.tool.searchFiles', { query: args.query || '?', scope });
}
case 'run_command':
- return args.background ? `Background: ${args.command || '?'}` : args.command || '?';
+ return args.background ? _t('chat.tool.backgroundCommand', { command: args.command || '?' }) : args.command || '?';
case 'check_task':
- return `Check task ${args.task_id || '?'}`;
+ return _t('chat.tool.checkTask', { id: args.task_id || '?' });
case 'kill_task':
- return `Kill task ${args.task_id || '?'}`;
+ return _t('chat.tool.killTask', { id: args.task_id || '?' });
case 'web_search':
- return `Search web: "${args.query || '?'}"`;
+ return _t('chat.tool.webSearch', { query: args.query || '?' });
case 'read_url': {
try {
- return `Fetch ${new URL(args.url).hostname}`;
+ return _t('chat.tool.fetchUrl', { hostname: new URL(args.url).hostname });
} catch {
- return `Fetch URL`;
+ return _t('chat.tool.fetchUrlFallback');
}
}
- default:
+ case 'delegate_task': {
+ const t = args.task || '?';
+ return `Sub-agent: "${t.length > 60 ? t.slice(0, 60) + '…' : t}"`;
+ }
+ default: {
+ // External tool: {server_id}_{tool_name} → "tool_name (server_id)"
+ const idx = name.indexOf('_');
+ if (idx > 0) {
+ const serverId = name.slice(0, idx);
+ const toolName = name.slice(idx + 1);
+ return `${toolName} (${serverId})`;
+ }
return name;
+ }
}
}
@@ -329,16 +346,16 @@
Save As {$t('chat.saveAs')}
Cancel {$t('common.cancel')}
Save {$t('common.save')}
@@ -395,7 +412,7 @@
/>
{artifact.title || 'Artifact'} {artifact.title || $t('chat.artifact')}
{#if preview}
@@ -419,7 +436,7 @@
toggleGroupExpanded(groupIdx)}
>
@@ -504,7 +521,7 @@
@@ -513,7 +530,7 @@
{hasPending ? 'Exploring' : 'Explored'} {hasPending ? $t('chat.exploring') : $t('chat.explored')}
{#if groupSummaryText(calls)}
@@ -673,7 +690,7 @@
hover:bg-gray-200 dark:hover:bg-white/12
transition-colors duration-100"
onclick={() => onapprove(messageId, item.call_id, true)}
- >Allow {$t('chat.allow')}
onapprove(messageId, item.call_id, false)}
- >Deny {$t('chat.deny')}
{:else}
@@ -720,7 +737,7 @@
- Input
+ {$t('chat.toolInput')}
{#if toolName === 'edit_file' && args.target}
@@ -752,7 +769,7 @@
- Lines {args.start_line}–{args.end_line || 'end'}
+ {$t('chat.toolLines', { start: args.start_line, end: args.end_line || 'end' })}
{/if}
{:else if toolName === 'run_command'}
@@ -786,7 +803,7 @@
- Output
+ {$t('chat.toolOutput')}
- {pairedOutput.output.length.toLocaleString()} total characters
+ {$t('chat.totalChars', { count: pairedOutput.output.length.toLocaleString() })}
{/if}
@@ -829,14 +846,14 @@
bg-gray-100 dark:bg-white/8
hover:bg-gray-200 dark:hover:bg-white/12
transition-colors duration-100"
- onclick={() => onapprove(messageId, item.call_id, true)}>Allow onapprove(messageId, item.call_id, true)}>{$t('chat.allow')}
onapprove(messageId, item.call_id, false)}>Deny onapprove(messageId, item.call_id, false)}>{$t('chat.deny')}
@@ -867,7 +884,7 @@
class="p-0.5 rounded text-gray-400 dark:text-gray-600 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:cursor-default transition-colors duration-100"
disabled={siblingIndex === 0}
onclick={() => onnavigate?.(-1)}
- aria-label="Previous response"
+ aria-label={$t('chat.prevResponse')}
>
onnavigate?.(1)}
- aria-label="Next response"
+ aria-label={$t('chat.nextResponse')}
>
{#if copied}
(showUsageTooltip = !showUsageTooltip)}
onmouseenter={() => (showUsageTooltip = true)}
onmouseleave={() => (showUsageTooltip = false)}
- aria-label="Usage info"
+ aria-label={$t('chat.usageInfo')}
>
import type { ChatInfo } from '$lib/apis/chat';
+ import { t } from '$lib/i18n';
import ChatItem from '../common/ChatItem.svelte';
import DropdownMenu from '../DropdownMenu.svelte';
import Pagination from '../common/Pagination.svelte';
@@ -48,12 +49,12 @@
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffM = Math.floor(diffMs / 60000);
- if (diffM < 1) return 'Just now';
- if (diffM < 60) return `${diffM}m ago`;
+ if (diffM < 1) return $t('chat.history.justNow');
+ if (diffM < 60) return $t('chat.history.minutesAgo', { count: diffM });
const diffH = Math.floor(diffM / 60);
- if (diffH < 24) return `${diffH}h ago`;
+ if (diffH < 24) return $t('chat.history.hoursAgo', { count: diffH });
const diffD = Math.floor(diffH / 24);
- if (diffD < 7) return `${diffD}d ago`;
+ if (diffD < 7) return $t('chat.history.daysAgo', { count: diffD });
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
@@ -100,7 +101,7 @@
class="flex-1 flex items-center gap-1 hover:text-gray-600 dark:hover:text-gray-400 transition-colors"
onclick={() => onsort?.('title')}
>
- Title
+ {$t('chat.history.title')}
{#if sortBy === 'title'}
{#if sortDir === 'asc'}
{@render chevronUp()}
@@ -115,7 +116,7 @@
class="shrink-0 flex items-center gap-1 hover:text-gray-600 dark:hover:text-gray-400 transition-colors"
onclick={() => onsort?.('updated_at')}
>
- Updated
+ {$t('chat.history.updated')}
{#if sortBy === 'updated_at'}
{#if sortDir === 'asc'}
{@render chevronUp()}
@@ -148,7 +149,7 @@
align="end"
items={[
{
- label: 'Delete',
+ label: $t('chat.history.delete'),
icon: 'trash',
onclick: () => {
if (menuChatId) ondelete(menuChatId);
diff --git a/cptr/frontend/src/lib/components/chat/ChatInput.svelte b/cptr/frontend/src/lib/components/chat/ChatInput.svelte
index 7a60586..d7cb37d 100644
--- a/cptr/frontend/src/lib/components/chat/ChatInput.svelte
+++ b/cptr/frontend/src/lib/components/chat/ChatInput.svelte
@@ -23,6 +23,7 @@
import Icon from '../Icon.svelte';
import { planMode } from '$lib/stores';
import Spinner from '$lib/components/common/Spinner.svelte';
+ import { t } from '$lib/i18n';
interface Props {
inputText: string;
@@ -586,7 +587,7 @@
class="bg-white text-black border border-white rounded-full group-hover:visible invisible transition outline-none shadow-sm flex items-center justify-center"
type="button"
onclick={() => removeUpload(upload.id)}
- aria-label="Remove upload"
+ aria-label={$t('chat.removeUpload')}
>
diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte
index e0be5c1..6c0c7fb 100644
--- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte
+++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte
@@ -39,6 +39,7 @@
import ChatHistory from './ChatHistory.svelte';
import Spinner from '../common/Spinner.svelte';
import { toast } from 'svelte-sonner';
+ import { t } from '$lib/i18n';
interface Props {
workspace: string;
@@ -290,7 +291,7 @@
async function openChat(id: string) {
await loadChat(id);
const chat = previousChats.find((c) => c.id === id);
- if (tabId) updateTab(tabId, id, chat?.title || 'Chat');
+ if (tabId) updateTab(tabId, id, chat?.title || $t('chat.fallbackTitle'));
}
async function deleteChat(id: string) {
@@ -612,7 +613,7 @@
// Update tab label instantly for new chats
if (isNew && tabId) {
- updateTab(tabId, `pending-${tempId}`, text.slice(0, 40) || 'Chat');
+ updateTab(tabId, `pending-${tempId}`, text.slice(0, 40) || $t('chat.fallbackTitle'));
}
try {
@@ -639,7 +640,7 @@
currentMessageId = result.message_id;
if (isNew && tabId) {
- updateTab(tabId, result.chat_id, text.slice(0, 40) || 'Chat');
+ updateTab(tabId, result.chat_id, text.slice(0, 40) || $t('chat.fallbackTitle'));
}
} catch (e) {
console.error('[chat] send error', e);
@@ -876,7 +877,7 @@
- What can I help you with?
+ {$t('chat.greeting')}
@@ -886,7 +887,7 @@
bind:selectedModel
{sending}
{workspace}
- placeholder="Ask anything about {workspaceName}..."
+ placeholder={$t('chat.placeholder', { name: workspaceName })}
onsend={send}
{queuedMessages}
onqueuesendnow={handleQueueSendNow}
@@ -963,7 +964,7 @@
autoScroll = true;
scrollToBottom();
}}
- aria-label="Scroll to bottom"
+ aria-label={$t('chat.scrollToBottom')}
>
+ import { t } from '$lib/i18n';
interface Props {
ontext: (text: string) => void;
}
@@ -15,7 +16,7 @@
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
- alert('Speech recognition not supported in this browser.');
+ alert($t('chat.dictate.unsupported'));
return;
}
recognition = new SpeechRecognition();
diff --git a/cptr/frontend/src/lib/components/chat/OutputEditView.svelte b/cptr/frontend/src/lib/components/chat/OutputEditView.svelte
index 8b6af5b..0d1793e 100644
--- a/cptr/frontend/src/lib/components/chat/OutputEditView.svelte
+++ b/cptr/frontend/src/lib/components/chat/OutputEditView.svelte
@@ -1,5 +1,6 @@
@@ -252,7 +253,7 @@
updateMessageText(di.indices[0], (e.target as HTMLTextAreaElement).value);
autoResize(e);
}}
- placeholder="Message text..."
+ placeholder={$t('chat.editMessagePlaceholder')}
rows="1"
>
{:else if di.type === 'reasoning'}
@@ -264,7 +265,7 @@
updateReasoningText(di.indices[0], (e.target as HTMLTextAreaElement).value);
autoResize(e);
}}
- placeholder="Reasoning text..."
+ placeholder={$t('chat.editReasoningPlaceholder')}
rows="1"
>
{:else if di.type === 'function_call'}
diff --git a/cptr/frontend/src/lib/components/chat/PlusMenu.svelte b/cptr/frontend/src/lib/components/chat/PlusMenu.svelte
index cb5e221..dcb671d 100644
--- a/cptr/frontend/src/lib/components/chat/PlusMenu.svelte
+++ b/cptr/frontend/src/lib/components/chat/PlusMenu.svelte
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { toolApprovalMode, planMode, requestParams, type ToolApprovalMode } from '$lib/stores';
import { tooltip } from '$lib/tooltip';
+ import { t } from '$lib/i18n';
import Icon from '../Icon.svelte';
import ToggleSwitch from '../common/ToggleSwitch.svelte';
@@ -20,14 +21,14 @@
let pos = $state<{ x: number; bottom: number }>({ x: -9999, bottom: -9999 });
let ready = $state(false);
- const modes: { value: ToolApprovalMode; label: string; desc: string }[] = [
- { value: 'ask', label: 'Ask for approval', desc: 'Confirm each tool call before it runs' },
- { value: 'auto', label: 'Auto-approve', desc: 'Approve safe actions, ask for risky ones' },
- { value: 'full', label: 'Full access', desc: 'All tool calls run without confirmation' }
- ];
+ const modes: { value: ToolApprovalMode; label: string; desc: string }[] = $derived([
+ { value: 'ask', label: $t('plusMenu.askApproval'), desc: $t('plusMenu.askApprovalDesc') },
+ { value: 'auto', label: $t('plusMenu.autoApprove'), desc: $t('plusMenu.autoApproveDesc') },
+ { value: 'full', label: $t('plusMenu.fullAccess'), desc: $t('plusMenu.fullAccessDesc') }
+ ]);
const currentModeLabel = $derived(
- modes.find((m) => m.value === $toolApprovalMode)?.label ?? 'Tool permissions'
+ modes.find((m) => m.value === $toolApprovalMode)?.label ?? $t('plusMenu.toolPermissions')
);
// ── Request params state ────────────────────────
@@ -254,7 +255,7 @@
d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"
/>
- Attach files
+ {$t('plusMenu.attachFiles')}
- Capture
+ {$t('plusMenu.capture')}
@@ -297,7 +298,7 @@
- Plan mode
+ {$t('plusMenu.planMode')}
planMode.set(v)} />
@@ -308,7 +309,7 @@
onclick={() => (tab = 'tools')}
>
- Tool permissions
+ {$t('plusMenu.toolPermissions')}
{currentModeLabel}
@@ -333,7 +334,7 @@
- Parameters
+ {$t('plusMenu.parameters')}
{#if paramCount > 0}
{paramCount}
{/if}
@@ -348,7 +349,7 @@
onclick={() => (tab = '')}
>
- Tool permissions
+ {$t('plusMenu.toolPermissions')}
@@ -387,20 +388,20 @@
onclick={() => (tab = '')}
>
- Parameters
+ {$t('plusMenu.parameters')}
{#if paramRows.length === 0}
- No parameters configured
+ {$t('plusMenu.noParams')}
{:else}
{#each paramRows as row, i}
removeParamRow(i)}
class="shrink-0 text-gray-300 dark:text-gray-700 opacity-0 group-hover/row:opacity-100 hover:text-gray-500 dark:hover:text-gray-400 transition-colors duration-75"
- aria-label="Remove parameter"
+ aria-label={$t('plusMenu.removeParam')}
>
@@ -435,7 +436,7 @@
disabled={!canAddParam}
>
- Add parameter
+ {$t('plusMenu.addParam')}
{/if}
diff --git a/cptr/frontend/src/lib/components/chat/QueuedMessageItem.svelte b/cptr/frontend/src/lib/components/chat/QueuedMessageItem.svelte
index 5e2c6a3..afb86cb 100644
--- a/cptr/frontend/src/lib/components/chat/QueuedMessageItem.svelte
+++ b/cptr/frontend/src/lib/components/chat/QueuedMessageItem.svelte
@@ -7,6 +7,8 @@
ondelete: (id: string) => void;
}
let { id, content, onsendnow, onedit, ondelete }: Props = $props();
+
+ import { t } from '$lib/i18n';
@@ -35,7 +37,7 @@
onsendnow(id)}
>
onedit(id)}
>
ondelete(id)}
>
Save {$t('common.save')}
Cancel {$t('common.cancel')}
Send {$t('chat.send')}
@@ -186,7 +187,7 @@
class="p-0.5 rounded text-gray-400 dark:text-gray-600 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30 disabled:cursor-default transition-colors duration-100"
disabled={siblingIndex === 0}
onclick={() => onnavigate?.(-1)}
- aria-label="Previous message"
+ aria-label={$t('chat.prevMessage')}
>
onnavigate?.(1)}
- aria-label="Next message"
+ aria-label={$t('chat.nextMessage')}
>
{#if copied}
{ e.stopPropagation(); onmenu?.(e); }}
- aria-label="Chat options"
+ aria-label={$t('a11y.chatOptions')}
>
diff --git a/cptr/frontend/src/lib/components/common/Pagination.svelte b/cptr/frontend/src/lib/components/common/Pagination.svelte
index 80c5a71..48dc6ae 100644
--- a/cptr/frontend/src/lib/components/common/Pagination.svelte
+++ b/cptr/frontend/src/lib/components/common/Pagination.svelte
@@ -5,6 +5,7 @@
onpagechange: (page: number) => void;
}
let { page, totalPages, onpagechange }: Props = $props();
+ import { t } from '$lib/i18n';
// Build page numbers with ellipsis for large page counts
const pages = $derived.by((): (number | 'ellipsis')[] => {
@@ -28,7 +29,7 @@
class="pagination-btn"
disabled={page <= 1}
onclick={() => onpagechange(page - 1)}
- aria-label="Previous page"
+ aria-label={$t('a11y.prevPage')}
>