diff --git a/.github/workflows/native-windows-startup.yml b/.github/workflows/native-windows-startup.yml new file mode 100644 index 0000000000..4345e2a000 --- /dev/null +++ b/.github/workflows/native-windows-startup.yml @@ -0,0 +1,132 @@ +name: Native Windows startup + +# Runs on PRs that touch start.ps1 (or this workflow). Validates the +# native-Windows launch script catches the bug classes the recent +# Windows-only batch caught manually (#2805 WOW64 ProgramFiles redirect, +# #2806 venv-portability claim, #2807 port-parse + finally-cleanup). +# +# Scope (per nesquena-hermes comment on #2811 — option 1, mock-only): +# hermes-agent is not published to PyPI, so we cannot pip-install it on +# the runner. Instead we stub a hermes_cli/ directory next to a sibling +# hermes-agent/ folder — just enough for start.ps1's existence guard to +# pass. The workflow then runs start.ps1 for a few seconds and asserts +# that none of start.ps1's own Write-Error guards fired. Server-boot +# regressions remain covered by the Linux jobs and docker-smoke.yml. + +on: + pull_request: + paths: + - 'start.ps1' + - '.github/workflows/native-windows-startup.yml' + workflow_dispatch: + +jobs: + native-windows-startup: + name: start.ps1 path discovery (mock hermes-agent) + runs-on: windows-latest + timeout-minutes: 8 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + # Create the WebUI venv. start.ps1 prefers $AgentDir\venv if it + # exists, then falls back to the python on PATH. We create a + # WebUI-local venv to mirror the README's documented native path + # and to give start.ps1 a real python.exe to invoke. + - name: Create venv (README path) + shell: pwsh + run: | + python -m venv venv + if (-not (Test-Path venv\Scripts\python.exe)) { + throw "venv\Scripts\python.exe missing after venv create" + } + + # Mock-only hermes-agent provisioning. We can't pip-install + # hermes-agent (not on PyPI), so we stub the minimum that + # start.ps1's `Test-Path hermes_cli -PathType Container` guard + # needs to pass. server.py would crash on this stub at import + # time — we deliberately do NOT probe /health below. + - name: Stub hermes-agent (mock hermes_cli only) + shell: pwsh + run: | + $agentDir = Join-Path (Split-Path -Parent $PWD) 'hermes-agent' + $cliDir = Join-Path $agentDir 'hermes_cli' + New-Item -ItemType Directory -Force -Path $cliDir | Out-Null + Set-Content -Path (Join-Path $cliDir '__init__.py') -Value '# stub for CI path-discovery test only' + "HERMES_WEBUI_AGENT_DIR=$agentDir" >> $env:GITHUB_ENV + Write-Host "Stub hermes-agent provisioned at $agentDir" + + # Run start.ps1 and verify it passes its own discovery guards + # without erroring out. server.py will exit non-zero on the stub + # (no real CLI code) — that's expected and not asserted against. + # We only fail if start.ps1's own Write-Error guards fire. + - name: Run start.ps1 + verify path discovery + shell: pwsh + run: | + $stdout = Join-Path $env:RUNNER_TEMP 'start-ps1.out' + $stderr = Join-Path $env:RUNNER_TEMP 'start-ps1.err' + $proc = Start-Process -FilePath 'pwsh' ` + -ArgumentList '-NoLogo','-File','.\start.ps1' ` + -WorkingDirectory $PWD ` + -PassThru ` + -RedirectStandardOutput $stdout ` + -RedirectStandardError $stderr + "SERVER_PID=$($proc.Id)" >> $env:GITHUB_ENV + Write-Host "Spawned start.ps1 wrapper PID $($proc.Id)" + + # Path discovery is sub-second; the 8s buffer lets the python + # launch land in the logs (and immediately exit on the stub). + Start-Sleep -Seconds 8 + + Write-Host "===== start.ps1 stdout =====" + $stdoutContent = if (Test-Path $stdout) { Get-Content $stdout -Raw } else { '' } + Write-Host $stdoutContent + Write-Host "===== start.ps1 stderr =====" + $stderrContent = if (Test-Path $stderr) { Get-Content $stderr -Raw } else { '' } + Write-Host $stderrContent + + # Pattern set: every Write-Error message start.ps1 can emit on + # its own discovery path. If any of these appear in stderr, + # path discovery regressed and the job must fail. + $guardErrors = @( + 'Python 3 is required', + 'hermes-agent not found', + 'HERMES_WEBUI_AGENT_DIR is set to', + 'is not a valid integer port', + 'is out of TCP-port range', + 'server.py not found' + ) + foreach ($msg in $guardErrors) { + if ($stderrContent -and $stderrContent -match [regex]::Escape($msg)) { + throw "REGRESSION: start.ps1 errored on guard '$msg' - path discovery failed." + } + } + Write-Host "OK: start.ps1 path discovery - all guards passed." + + # taskkill /T walks the process tree, /F forces. taskkill returns + # 128 ("process not found") if the PID is already gone — that's + # the expected steady state for this mock-only workflow because + # server.py exits immediately on the stub hermes_cli. Reset + # $LASTEXITCODE so the step never fails on the cleanup itself. + - name: Stop background server (tree-kill) + if: always() + shell: pwsh + run: | + if ($env:SERVER_PID) { + & taskkill /PID $env:SERVER_PID /T /F 2>&1 | Out-Host + $global:LASTEXITCODE = 0 + } + # Belt-and-suspenders: kill anything still bound to 8787. + $hanging = Get-NetTCPConnection -LocalPort 8787 -State Listen -ErrorAction SilentlyContinue + if ($hanging) { + foreach ($c in $hanging) { + try { Stop-Process -Id $c.OwningProcess -Force -ErrorAction Stop } catch {} + } + } + exit 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a18913c2..bdf8f056b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,36 @@ ## [Unreleased] +## [v0.51.127] — 2026-05-24 — Release CY (stage-batch9 — 7-PR low-risk batch — brick-class Linux + brick-class update apply + composer wide-screen + Turkish locale + MCP toggle + SSE settlement + Windows CI) + +### Fixed + +- **PR #2854** by @nesquena-hermes — Embedded terminal opens then immediately closes with `[terminal closed]` on every Linux install past `71d8a8fb`. Root cause: `_terminal_shell_preexec_fn` set `PR_SET_PDEATHSIG=SIGTERM` on the PTY shell so orphans would die when WebUI crashed, but `PR_SET_PDEATHSIG` is **per-thread**, not per-process. WebUI uses `ThreadingHTTPServer`, so each HTTP request runs in its own short-lived worker thread; when the request handler returns and the worker thread exits, the kernel sees the pdeathsig-parent thread has died and SIGTERMs the PTY shell within ~10ms. macOS users were unaffected because `libc.prctl` doesn't exist there. Fix: drop the `preexec_fn` entirely; rely on `atexit.register(close_all_terminals)` for graceful shutdown and explicit `close_terminal` for user-driven close. Adds `tests/test_terminal_process_cleanup.py::test_pty_shell_survives_when_spawning_thread_exits` (real PTY shell spawned via worker thread, asserts shell alive after 500ms grace) plus static-check that `preexec_fn` cannot be re-introduced. Closes #2853. + +- **PR #2855** by @nesquena-hermes — "Update Now" loops for every user past the latest tag (#2846). After #2758 the update check correctly fell through to branch comparison when `HEAD` had moved past the latest `v*` tag, but `_select_apply_compare_ref` still returned `tags[0]` — so `git pull --ff-only v2026.5.16` no-op'd, the server bounced, and the banner reappeared unchanged. `apply_force_update` had the same bug except worse (would `git reset --hard v2026.5.16` and rewind the checkout 254 commits). Fix: extract `_head_is_past_latest_tag(path, current_tag)` and have both check and apply paths consult it. Opus pre-release review caught a "case D" parameter-asymmetry drift (HEAD on older tag + commits + newer tag exists → predicate flipped between the two callsites) and patched the apply-side predicate to use `current_tag` + a `behind == 0` gate, exactly mirroring the check-side rule. Adds `test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_exists`. Closes #2846. + +- **PR #2852** by @ai-ag2026 — Chat `stream_end` handler now settles from the persisted session when `done` was not received or replayed, instead of leaving the active pane with live `Thinking` / assistant DOM and inflight state projected indefinitely. Reconnect / journal / replay paths can deliver `stream_end` without preceding `done`; the prior code treated `stream_end` as transport-only close. Duplicate / replayed `done` events are also made idempotent before completion sound / final render side effects. Opus pre-release review added a post-await race guard inside `_restoreSettledSession` to catch the case where a late `done` event runs the finalize path while the settlement is awaiting the `/api/session` roundtrip. Adds 4 new regression tests across `tests/test_1694_terminal_cleanup_ownership.py` covering both `stream_end`-without-`done` and duplicate-`done` paths. + +- **PR #2811** by @Koraji95-coder — Native-Windows startup E2E workflow now self-tests on PR push (closes the post-#2783 gap where Windows-only regressions like the WOW64 ProgramFiles redirect could only be caught after release). Reworked per maintainer feedback to use a stub `hermes_cli/__init__.py` next to a sibling `hermes-agent/` folder rather than `pip install hermes-agent` (which is not on PyPI). Workflow runs `start.ps1` for 8s and asserts none of its `Write-Error` guards fired (no Python, no agent dir, bad port, missing `hermes_cli`, missing `server.py`). PowerShell syntax + path discovery is the testable surface; the server can't actually boot on a stub. `taskkill` exit-128 swallowed when the stub process is already gone. + +### Changed + +- **PR #2812** by @Koraji95-coder — Composer max-width is now responsive on wide displays. Pre-change `.composer-box` had a fixed `max-width: 780px` that pinched footer chips (workspace name, model picker, reasoning chip, context ring) against each other on 1440p+ monitors. Switched to `max-width: clamp(780px, 60vw, 1100px)` — the 780px floor preserves byte-identical layout at 1280px (Aron's laptop reference width); 1440px viewports gain ~84px (864px composer); 1920px viewports gain ~320px (1100px composer cap). Mobile responsive logic untouched. Single-line CSS change in `static/style.css`. + +### Added + +- **PR #2772** by @vaur94 — Complete Turkish (`tr`) locale across `static/i18n.js` (~1,182 keys matching existing locale coverage). Adds Turkish login page strings in `api/routes.py` `_LOGIN_LOCALE`. Settings → Language now offers **Türkçe**; speech recognition uses `tr-TR`. Stage build absorbed a sibling-PR i18n collision with #2776 below (9 missing keys: `mcp_enable_server`, `mcp_disable_server`, `mcp_enabled_toast`, `mcp_disabled_toast`, `mcp_toggle_failed`, `open_in_vscode`, `open_in_vscode_failed`, `settings_label_ignore_agent_updates`, `settings_desc_ignore_agent_updates`) — Turkish translations added in-stage so locale-parity test passes. Closes #2537 as superseded (byzuzayli's earlier Turkish PR with narrower scope). + +- **PR #2776** by @roryford — New `PATCH /api/mcp/servers/{name}` endpoint accepts `{"enabled": bool}`, writes `mcp_servers..enabled` to `config.yaml`, calls `reload_config()`, returns `{"ok": true, "name": "", "enabled": }`. Each MCP server row in the panel now shows a clickable Enabled/Disabled toggle. Also fixes a pre-existing bug: `_handle_mcp_server_delete` and `_handle_mcp_server_update` were defined at line ~11656 but never wired into the HTTP router — DELETE wired into `handle_delete`, PUT wired via new `handle_put` / `do_PUT` in `server.py`. CORS preflight `Access-Control-Allow-Methods` updated to include `PUT` (Opus pre-release review nit). Adds 5 i18n keys to all 11 locales (en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr via in-stage parity fix). 7 new tests covering enable, disable, 404, empty-name, missing-field, response payload, URL-decoded names. + +### Notes + +- Two PRs (#2854, #2855) are brick-class fixes — every Linux install was unable to use the embedded terminal, and every install past the latest agent tag was stuck in an Update Now loop. They land in the same low-risk batch as cosmetic / locale / CI changes because both fixes are mechanical, well-tested, and the brick-class severity made deferring impossible. +- Opus pre-release advisor reviewed all 5 risk areas (PR_SET_PDEATHSIG removal, update apply path symmetry, MCP toggle wiring, composer clamp, stream_end settlement). 1 MUST-FIX + 3 SHOULD-FIX all addressed inline before tag. Net: +69/-9 across 5 files for the Opus fixes. +- Full pytest: 6,424 passed / 6 skipped / 3 xpassed / 8 subtests passed. +- UX evidence for #2812 captured at 1280/1440/1920/mobile (iPhone 14 emulation); Telegram-approved. +- File a follow-up issue for pdeathsig-on-supervisor-thread hardening (#2854 deferred Option B) and French-locale `open_in_vscode` parity gap (predates this batch, Opus advisor flagged). + ## [v0.51.126] — 2026-05-24 — Release CX (stage-batch8 — 2-PR low-risk batch — kanban markdown + live activity timeline) ### Added diff --git a/api/routes.py b/api/routes.py index edef7ed596..d55df8fa98 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2561,6 +2561,15 @@ def submit_pending(session_key: str, approval: dict) -> None: "invalid_pw": "\ube44\ubc00\ubc88\ud638\uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", "conn_failed": "\uc5f0\uacb0 \uc2e4\ud328", }, + "tr": { + "lang": "tr-TR", + "title": "Oturum a\u00e7", + "subtitle": "Devam etmek i\u00e7in \u015fifrenizi girin", + "placeholder": "\u015eifre", + "btn": "Oturum a\u00e7", + "invalid_pw": "Ge\u00e7ersiz \u015fifre", + "conn_failed": "Ba\u011flant\u0131 ba\u015far\u0131s\u0131z", + }, } @@ -6229,6 +6238,9 @@ def handle_patch(handler, parsed) -> bool: if not _check_csrf(handler): return j(handler, {"error": _csrf_rejection_error(handler)}, status=403) body = read_body(handler) + if parsed.path.startswith("/api/mcp/servers/"): + name = parsed.path[len("/api/mcp/servers/"):] + return _handle_mcp_server_toggle(handler, name, body) if parsed.path.startswith("/api/kanban/"): from api.kanban_bridge import handle_kanban_patch @@ -6244,6 +6256,9 @@ def handle_delete(handler, parsed) -> bool: if not _check_csrf(handler): return j(handler, {"error": _csrf_rejection_error(handler)}, status=403) body = read_body(handler) + if parsed.path.startswith("/api/mcp/servers/"): + name = parsed.path[len("/api/mcp/servers/"):] + return _handle_mcp_server_delete(handler, name) if parsed.path.startswith("/api/kanban/"): from api.kanban_bridge import handle_kanban_delete @@ -6253,6 +6268,17 @@ def handle_delete(handler, parsed) -> bool: return True return False + +def handle_put(handler, parsed) -> bool: + """Handle all PUT routes. Returns True if handled, False for 404.""" + if not _check_csrf(handler): + return j(handler, {"error": "Cross-origin request rejected"}, status=403) + body = read_body(handler) + if parsed.path.startswith("/api/mcp/servers/"): + name = parsed.path[len("/api/mcp/servers/"):] + return _handle_mcp_server_update(handler, name, body) + return False + # ── GET route helpers ───────────────────────────────────────────────────────── # MIME types for static file serving. Hoisted to module scope to avoid @@ -11888,7 +11914,7 @@ def _handle_mcp_servers_list(handler): ] return j(handler, { "servers": result, - "toggle_supported": False, + "toggle_supported": True, "reload_required": True, }) @@ -11912,6 +11938,30 @@ def _handle_mcp_server_delete(handler, name): return j(handler, {"ok": True, "deleted": name}) +def _handle_mcp_server_toggle(handler, name, body): + """Toggle enabled state for an MCP server (PATCH /api/mcp/servers/{name}).""" + from urllib.parse import unquote + name = unquote(name) + if not name: + return bad(handler, "name is required") + if "enabled" not in body: + return bad(handler, "enabled field is required") + enabled = bool(body["enabled"]) + cfg = get_config() + servers = cfg.get("mcp_servers", {}) + if not isinstance(servers, dict): + servers = {} + if name not in servers: + return bad(handler, f"MCP server '{name}' not found", 404) + if not isinstance(servers[name], dict): + return bad(handler, f"MCP server '{name}' has invalid config", 400) + servers[name]["enabled"] = enabled + cfg["mcp_servers"] = servers + _save_yaml_config_file(_get_config_path(), cfg) + reload_config() + return j(handler, {"ok": True, "name": name, "enabled": enabled}) + + _MASKED_PLACEHOLDER = "••••••" diff --git a/api/terminal.py b/api/terminal.py index 5ac2c741ed..bb857e16df 100644 --- a/api/terminal.py +++ b/api/terminal.py @@ -70,18 +70,17 @@ def put_output(self, event: str, payload: dict) -> None: _LOCK = threading.RLock() -def _terminal_shell_preexec_fn() -> None: - """Ask Linux to terminate the PTY shell when the WebUI parent dies.""" - try: - import ctypes - - libc = ctypes.CDLL(None) - libc.prctl(1, signal.SIGTERM) # PR_SET_PDEATHSIG=1, SIGTERM=15 - except Exception: - # Non-Linux platforms or restricted runtimes should still be able to - # open an embedded terminal; they just do not get the Linux pdeathsig - # hardening. - pass +# NOTE on parent-death-signal: a previous version of this module set +# PR_SET_PDEATHSIG via a preexec_fn to terminate orphaned PTY shells when the +# WebUI process crashed. That broke every Linux user (#2853): WebUI runs a +# ThreadingHTTPServer, so the Popen call happens on a short-lived per-request +# thread, and PR_SET_PDEATHSIG is per-thread. The PTY shell registered the +# spawning thread as its "parent" and was killed with SIGTERM the instant that +# thread joined — within ~10 ms of opening the terminal — surfacing as the +# `[terminal closed]` banner. The graceful path is covered by +# `atexit.register(close_all_terminals)` and the explicit `close_terminal` +# call sites; hard kills of the WebUI process leak the shell, which is the +# tradeoff for working on Linux at all. def _decode_terminal_output(decoder, data: bytes) -> str: @@ -193,7 +192,6 @@ def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int = stdout=slave_fd, stderr=slave_fd, close_fds=True, - preexec_fn=_terminal_shell_preexec_fn, start_new_session=True, ) os.close(slave_fd) diff --git a/api/updates.py b/api/updates.py index 2df4baf0f1..d7793dc34f 100644 --- a/api/updates.py +++ b/api/updates.py @@ -351,6 +351,21 @@ def _release_gap(tags, current, latest): return 1 +def _head_is_past_latest_tag(path, current_tag): + """Return True when HEAD has moved past the latest reachable release tag. + + `git describe --tags --always` returns the bare tag name (e.g. ``v2026.5.16``) + when HEAD is exactly on the tag, and a ``v2026.5.16-608-g1d22b9c2`` suffix + when HEAD has moved 608 commits past it. Used by both the update check and + the update apply path so they agree on which ref to advance to — see #2653 + (check side) and #2846 (apply side). + """ + if not current_tag: + return False + full_desc, ok = _run_git(['describe', '--tags', '--always'], path) + return bool(ok and full_desc and full_desc != current_tag) + + def _select_apply_compare_ref(path): """Return the same remote ref family that the update check reports. @@ -358,10 +373,31 @@ def _select_apply_compare_ref(path): an update must therefore advance to the latest release tag too; otherwise a checkout on a local/fork tracking branch can report release updates, pull a different branch that is already current, restart, and still remain behind. + + When HEAD is past the latest tag (the agent repo's day-to-day state between + tagged releases), the check side falls through to the branch comparison via + `_check_repo_release` returning None. The apply side must mirror that + decision — otherwise we run `git pull --ff-only ` against a + checkout that's already past the tag, no-op, restart, and the banner + re-appears with the same N commits available. See #2846. """ tags = _release_tags(path) if tags: - return tags[0] + latest_tag = tags[0] + current_tag = _current_release_tag(path) + behind = _release_gap(tags, current_tag, latest_tag) + # Mirror the check side exactly: only fall through when behind == 0 + # AND HEAD has moved past its nearest tag (case A: bench between + # tagged releases). Otherwise the tag is correct — including the + # case where HEAD is on an older release tag with commits on top + # AND a newer tag exists (case D), where `behind > 0` means the + # user is genuinely behind the latest release and should advance + # to it. Pre-#2855 the apply path only consulted `latest_tag` + # without the `behind`/`current_tag` predicate, so case D fell + # through to `origin/` and the pull landed past the + # advertised tag. See #2846 + Opus pre-release review for #2855. + if not (behind == 0 and _head_is_past_latest_tag(path, current_tag)): + return latest_tag upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path) if ok and upstream: @@ -384,14 +420,11 @@ def _check_repo_release(path, name): # If behind == 0 but HEAD has moved past the tag (e.g. the agent repo # keeps committing to master between tagged releases), the release check # would report "Up to date" even though hundreds of commits are missing. - # Detect this by comparing the short describe output (which includes the - # -N-gSHA suffix when HEAD is past a tag) against the bare tag name. - # When HEAD is ahead of the latest tag, fall through to _check_repo_branch - # so the real commit count is reported instead. See #2653. - if behind == 0: - full_desc, ok = _run_git(['describe', '--tags', '--always'], path) - if ok and full_desc and full_desc != current_tag: - return None + # Fall through to _check_repo_branch so the real commit count is reported + # instead. The same predicate is used by _select_apply_compare_ref so the + # check and apply sides cannot drift again. See #2653 (check), #2846 (apply). + if behind == 0 and _head_is_past_latest_tag(path, current_tag): + return None remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path) remote_url = _normalize_remote_url(remote_url) diff --git a/server.py b/server.py index 3888afc581..e663620981 100644 --- a/server.py +++ b/server.py @@ -115,7 +115,7 @@ def _blocked_socket_connect(self, address): from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE from api.helpers import j, get_profile_cookie from api.profiles import set_request_profile, clear_request_profile -from api.routes import handle_delete, handle_get, handle_patch, handle_post +from api.routes import handle_delete, handle_get, handle_patch, handle_post, handle_put from api.startup import auto_install_agent_deps, fix_credential_permissions from api.updates import WEBUI_VERSION @@ -286,6 +286,9 @@ def _handle_write(self, route_func) -> None: def do_POST(self) -> None: self._handle_write(handle_post) + def do_PUT(self) -> None: + self._handle_write(handle_put) + def do_PATCH(self) -> None: self._handle_write(handle_patch) @@ -294,7 +297,7 @@ def do_OPTIONS(self) -> None: self._req_t0 = time.time() self.send_response(200) self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization") self.end_headers() diff --git a/static/i18n.js b/static/i18n.js index 0f1f88673d..c1ba196f8a 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -87,6 +87,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP Tools', mcp_tools_desc: 'Search known tools across active MCP servers.', mcp_tools_search_placeholder: 'Search tools by name, server, or description…', @@ -1325,6 +1330,11 @@ const LOCALES = { mcp_tool_count: '{0} strumenti', mcp_enabled_yes: 'Abilitato', mcp_enabled_no: 'Disabilitato', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'Strumenti MCP', mcp_tools_desc: 'Cerca strumenti noti tra i server MCP attivi.', mcp_tools_search_placeholder: 'Cerca strumenti per nome, server o descrizione…', @@ -2555,6 +2565,11 @@ const LOCALES = { mcp_tool_count: '{0} 個のツール', mcp_enabled_yes: '有効', mcp_enabled_no: '無効', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP ツール', mcp_tools_desc: 'アクティブな MCP サーバー全体から既知のツールを検索します。', mcp_tools_search_placeholder: '名前、サーバー、説明でツールを検索…', @@ -3787,6 +3802,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP Tools', mcp_tools_desc: 'Search known tools across active MCP servers.', mcp_tools_search_placeholder: 'Search tools by name, server, or description…', @@ -4951,6 +4971,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP Tools', mcp_tools_desc: 'Search known tools across active MCP servers.', mcp_tools_search_placeholder: 'Search tools by name, server, or description…', @@ -6118,6 +6143,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP Tools', mcp_tools_desc: 'Search known tools across active MCP servers.', mcp_tools_search_placeholder: 'Search tools by name, server, or description…', @@ -7289,6 +7319,11 @@ const LOCALES = { mcp_tool_count: '{0} 个工具', mcp_enabled_yes: '已启用', mcp_enabled_no: '已禁用', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP 工具', mcp_tools_desc: '搜索活跃 MCP 服务器中的已知工具。', mcp_tools_search_placeholder: '按名称、服务器或描述搜索工具…', @@ -8447,6 +8482,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP Tools', mcp_tools_desc: 'Search known tools across active MCP servers.', mcp_tools_search_placeholder: 'Search tools by name, server, or description…', @@ -10754,6 +10794,11 @@ const LOCALES = { mcp_tool_count: '{0} tools', mcp_enabled_yes: 'Enabled', mcp_enabled_no: 'Disabled', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'MCP Tools', mcp_tools_desc: 'Search known tools across active MCP servers.', mcp_tools_search_placeholder: 'Search tools by name, server, or description…', @@ -11981,6 +12026,11 @@ const LOCALES = { mcp_tool_count: '{0} outils', mcp_enabled_yes: 'Activé', mcp_enabled_no: 'Désactivé', + mcp_enable_server: 'Enable this MCP server', + mcp_disable_server: 'Disable this MCP server', + mcp_enabled_toast: (name) => `MCP server "${name}" enabled.`, + mcp_disabled_toast: (name) => `MCP server "${name}" disabled.`, + mcp_toggle_failed: 'Failed to update MCP server.', mcp_tools_title: 'Outils MCP', mcp_tools_desc: 'Recherchez des outils connus sur les serveurs MCP actifs.', mcp_tools_search_placeholder: 'Outils de recherche par nom, serveur ou description…', @@ -13060,6 +13110,1237 @@ const LOCALES = { usage_total: 'Total de tokens', usage_unknown: 'inconnu', workspace_auto_create_folder: 'Créer le dossier s\'il n\'existe pas', + }, + + tr: { + + + + + offline_title: 'Bağlantı kesildi', + offline_browser_detail: 'Tarayıcınız bu cihazın çevrimdışı olduğunu bildiriyor.', + offline_network_detail: 'Hermes\'e şu anda bu tarayıcıdan ulaşılamıyor.', + offline_autorefresh: 'Hermes\'e tekrar ulaşılabilir olduğunda bu sayfayı otomatik olarak yenileyeceğim.', + offline_check_now: 'Şimdi kontrol et', + offline_checking: 'Kontrol ediliyor\u2026', + offline_stream_waiting: 'Bağlantı kesildi. Yenilenmesi bekleniyor\u2026', + _lang: 'tr', + _label: 'Türkçe', + _speech: 'tr-TR', + // boot.js + cancelling: 'İptal ediliyor\u2026', + cancel_failed: 'İptal başarısız oldu:', + mic_denied: 'Mikrofon erişimi reddedildi. Tarayıcı izinlerini kontrol edin.', + mic_no_speech: 'Konuşma algılanmadı. Tekrar deneyin.', + mic_network: 'Konuşma tanıma kullanılamıyor.', + mic_error: 'Ses girişi hatası:', + voice_dictate: 'Dikte', + voice_dictate_active: 'Dikteyi durdur', + voice_mode_toggle: 'Ses modu', + voice_mode_toggle_active: 'Ses modundan çık', + voice_listening: 'Dinleniyor\u2026', + voice_speaking: 'Konuşuyor\u2026', + voice_thinking: 'Düşünüyor\u2026', + voice_error: 'Ses bu tarayıcıda desteklenmiyor', + voice_mode_active: 'Ses modu açık', + voice_mode_off: 'Ses modu kapalı', + session_imported: 'Oturum içe aktarıldı', + import_failed: 'İçe aktarma başarısız oldu:', + import_invalid_json: 'Geçersiz JSON', + image_pasted: 'Yapıştırılan resim:', + // messages.js + edit_message: 'Mesajı düzenle', + regenerate: 'Yanıtı yeniden oluştur', + copy: 'Kopyala', + copied: 'Kopyalandı!', + copy_failed: 'Kopyalama başarısız oldu', + selected_text_reply: 'Seçimle yanıtla', + selected_text_reply_title: 'Seçilen sohbet metnini alıntılanan bağlam olarak ekle', + selected_text_reply_appended: 'Seçilen metin besteciye eklendi', + + diff_loading: 'Fark yükleniyor', + diff_error: 'Yama dosyası yüklenemedi', + diff_too_large: 'Yama dosyası satır içi görüntülenemeyecek kadar büyük', + tree_view: 'Ağaç', + raw_view: 'Çiğ', + parse_failed_note: 'ayrıştırma başarısız oldu', + you: 'Sen', + mcp_servers_title: 'MCP Sunucuları', + mcp_servers_desc: 'config.yaml dosyasında yapılandırılmış MCP sunucularını görüntüleyin.', + mcp_no_servers: 'Hiçbir MCP sunucusu yapılandırılmadı.', + mcp_add_server: '+ Sunucu Ekle', + mcp_field_name: 'Sunucu Adı', + mcp_transport_label: 'Taşıma Türü', + mcp_field_command: 'Emretmek', + mcp_field_args: 'Bağımsız Değişkenler (virgülle ayrılmış)', + mcp_field_url: 'URL\'si', + mcp_field_timeout: 'Zaman aşımı (saniye)', + mcp_save: 'Kaydetmek', + mcp_cancel: 'İptal etmek', + mcp_name_required: 'Sunucu adı gerekli.', + mcp_url_required: 'HTTP aktarımı için URL gereklidir.', + mcp_command_required: 'Stdio aktarımı için komut gereklidir.', + mcp_saved: 'MCP sunucusu kaydedildi.', + mcp_save_failed: 'MCP sunucusu kaydedilemedi.', + mcp_delete_confirm_title: 'MCP Sunucusunu Sil', + mcp_delete_confirm_message: '"{0}" MCP sunucusu silinsin mi? Bu eylem geri alınamaz.', + mcp_deleted: 'MCP sunucusu silindi.', + mcp_delete_failed: 'MCP sunucusu silinemedi.', + mcp_load_failed: 'MCP sunucuları yüklenemedi.', + mcp_restart_hint: 'Sunucu değişiklikleri şimdilik burada salt okunurdur. Değişikliklerin etkili olması için config.yaml dosyasını düzenleyin ve Hermes\'i yeniden başlatın.', + mcp_enable_server: 'Bu MCP sunucusunu etkinleştir', + mcp_disable_server: 'Bu MCP sunucusunu devre dışı bırak', + mcp_enabled_toast: (name) => `"${name}" MCP sunucusu etkinleştirildi.`, + mcp_disabled_toast: (name) => `"${name}" MCP sunucusu devre dışı bırakıldı.`, + mcp_toggle_failed: 'MCP sunucusu güncellenemedi.', + open_in_vscode: 'VS Code\'da aç', + open_in_vscode_failed: 'VS Code\'da açılamadı: ', + settings_label_ignore_agent_updates: 'Agent güncellemelerini yoksay', + settings_desc_ignore_agent_updates: 'WebUI güncelleme denetimlerini açık tutun, ancak Agent güncelleme bildirimlerini gizleyin ve Agent güncelleme getirme işlemlerini atlayın.', + mcp_toggle_followup: 'Etkinleştirme/devre dışı bırakma kontrolleri, MCP yeniden yükleme semantiği açıkça ortaya çıkana kadar kasıtlı olarak ertelenir.', + mcp_status_active: 'Aktif', + mcp_status_configured: 'Yapılandırılmış', + mcp_status_disabled: 'Engelli', + mcp_status_invalid_config: 'Geçersiz yapılandırma', + mcp_status_unknown: 'Bilinmiyor', + mcp_tool_count: '{0} araç', + mcp_enabled_yes: 'Etkinleştirilmiş', + mcp_enabled_no: 'Engelli', + mcp_tools_title: 'MCP Araçları', + mcp_tools_desc: 'Aktif MCP sunucularında bilinen araçları arayın.', + mcp_tools_search_placeholder: 'Araçlarda ada, sunucuya veya açıklamaya göre arama yapın\u2026', + mcp_tools_no_tools: 'Etkin çalışma zamanı envanterinde hiçbir MCP aracı mevcut değil.', + mcp_tools_no_matches: 'Aramanızla eşleşen MCP aracı yok.', + mcp_tools_load_failed: 'MCP araçları yüklenemedi.', + mcp_tools_schema_empty: 'Şema parametresi yok.', + mcp_tools_runtime_note: 'Araç envanteri yalnızca önceden bilinen etkin MCP çalışma zamanı verilerini kullanır; WebUI sunucuları başlatmaz veya sorgulamaz.', + mcp_tools_summary_no_matches: (query,total) => `“${query}” ile eşleşen MCP aracı yok (toplam ${total} MCP araç).`, + mcp_tools_summary_none: 'Gösterilecek MCP aracı yok.', + mcp_tools_summary_matching: (query) => `"${query}" ile eşleşen`, + mcp_tools_summary_total_note: (total) => `(${total} toplam MCP araç)`, + mcp_tools_summary_showing: (start,end,filtered,searchNote,totalNote,page,pages) => `${start}-${end} / ${filtered} MCPtools${searchNote}${totalNote} gösteriliyor. Sayfa ${page} / ${pages}.`, + mcp_tools_page_size_prefix: 'Göstermek', + mcp_tools_page_size_suffix: 'sayfa başına', + mcp_tools_per_page_aria: 'Sayfa başına MCP araçları', + mcp_tools_inactive_configured_servers: (servers) => `Yapılandırılmış ancak bu WebUI çalışma zamanında etkin değil: ${servers}.`, + mcp_tools_pagination_label: 'MCP araçları sayfalandırması', + mcp_tools_previous_page: '‹ Önceki', + mcp_tools_previous_page_aria: 'Önceki MCP araçları sayfası', + mcp_tools_next_page: 'Sonraki >', + mcp_tools_next_page_aria: 'Sonraki MCP araçları sayfası', + thinking: 'Düşünme', + expand_all: 'Tümünü genişlet', + collapse_all: 'Tümünü daralt', + edit_failed: 'Düzenleme başarısız oldu:', + regen_failed: 'Yeniden oluşturma başarısız oldu:', + reconnect_active: 'Halen bir yanıt oluşturuluyor. Hazır olduğunuzda yeniden yüklensin mi?', + reconnect_finished: 'En son ayrıldığınızda bir yanıt sürüyordu. Mesajlar güncellenmiş olabilir.', + // approval card + approval_heading: 'Onay gerekli', + approval_desc_prefix: 'Tehlikeli komut algılandı', + approval_btn_once: 'Bir kez izin ver', + approval_btn_once_title: 'Bu tek komuta izin ver (Enter)', + approval_btn_session: 'Oturuma izin ver', + approval_btn_session_title: 'Bu konuşma oturumuna izin ver', + approval_btn_always: 'Her zaman izin ver', + approval_btn_always_title: 'Bu komut düzenine her zaman izin ver', + approval_btn_deny: 'Reddetmek', + approval_btn_deny_title: 'Reddet — bu komutu çalıştırma', + approval_responding: 'Yanıt veriliyor\u2026', + clarify_heading: 'Açıklama gerekli', + clarify_hint: 'Bir seçim yapın veya kendi cevabınızı aşağıya yazın.', + clarify_other: 'Diğer', + clarify_send: 'Göndermek', + clarify_input_placeholder: 'Yanıtınızı yazın\u2026', + clarify_responding: 'Yanıt veriliyor\u2026', + untitled: 'İsimsiz', + n_messages: (n) => `${n}개 메시지`, + load_older_messages: '↑ Eski mesajları yüklemek için yukarı kaydırın veya tıklayın', + session_jump_start: 'Başlangıç', + session_jump_start_label: 'Oturumun başlangıcına atla', + session_jump_end: 'Son', + session_jump_end_label: 'Oturumun sonuna atla', + jump_to_question: 'sorgulamak', + jump_to_question_label: 'Bu yanıt için soruya geçin', + queued_label: 'Yanıttan sonra gönderilir', + queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`, + queued_cancel: 'Sıraya alınmış mesajı iptal et', + model_unavailable: '(mevcut değil)', + model_unavailable_title: 'Bu model artık mevcut sağlayıcı listenizde değil', + provider_mismatch_warning: (m,p) => `"${m}" yapılandırılmış sağlayıcınızla (${p}) çalışmayabilir. Yine de gönderin veya geçiş yapmak için terminalinizde \`hermes model\` komutunu çalıştırın.`, + provider_mismatch_label: 'Sağlayıcı uyumsuzluğu', + model_not_found_label: 'Model bulunamadı', + model_custom_label: 'Özel model kimliği', + model_custom_placeholder: 'örneğin openai/gpt-5.4', + model_search_placeholder: 'Modelleri ara\u2026', + session_toolsets: 'Oturum araç setleri', + session_toolsets_desc: 'Bu oturum için kullanılabilir araçları kısıtlayın (boş = genel yapılandırma)', + session_toolsets_global: 'Genel (varsayılan)', + session_toolsets_custom: 'Özel', + session_toolsets_placeholder: 'araç1, araç2, \u2026', + session_toolsets_apply: 'Uygula', + session_toolsets_clear: 'Temizle (geneli kullan)', + session_toolsets_applied: 'Araç setleri güncellendi', + session_toolsets_cleared: 'Araç setleri temizlendi — genel yapılandırma kullanılıyor', + session_toolsets_failed: 'Araç setleri güncellenemedi: ', + model_search_no_results: 'Hiçbir model bulunamadı', + model_group_configured: 'Yapılandırılmış', + ws_search_placeholder: 'Çalışma alanlarını arayın\u2026', + ws_no_results: 'Çalışma alanı bulunamadı', + workspace_new_worktree_conversation: 'Worktree\'de yeni konuşma', + workspace_new_worktree_conversation_meta: 'Bu çalışma alanı için yalıtılmış bir git çalışma ağacı oluşturun.', + workspace_worktree_created: 'Worktree görüşmesi oluşturuldu', + workspace_worktree_failed: 'Çalışma ağacı oluşturma işlemi başarısız oldu:', + session_worktree_badge: 'Çalışma Ağacı', + model_scope_advisory: 'Bir sonraki mesajınızdan itibaren bu görüşmeye uygulanır.', + model_scope_toast: 'Bir sonraki mesajınızdan itibaren bu görüşmeye uygulanır.', + // commands.js + cmd_clear: 'Konuşma mesajlarını temizle', + cmd_compress: 'Konuşma içeriğini manuel olarak sıkıştırın (kullanım: /compress [konuya odaklan])', + ctx_compress_hint: 'Yer açmak için bağlamı sıkıştırın →', + ctx_compress_action: '⚠ Şimdi serbest bağlama sıkıştırın', + cmd_compact_alias: '/compress için eski takma ad', + cmd_model: 'Modeli değiştir (ör. /model gpt-4o)', + cmd_workspace: 'Çalışma alanını ada göre değiştir', + cmd_terminal: 'Çalışma alanı terminalini açın', + cmd_new: 'Yeni bir sohbet oturumu başlatın', + cmd_usage: 'Token kullanımı ekranını aç/kapat', + cmd_theme: 'Görünümü değiştir (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)', + cmd_personality: 'Temsilci kişiliğini değiştir', + cmd_skills: 'Mevcut Hermes becerilerini listele', + available_commands: 'Mevcut komutlar:', + type_slash: 'Komutları görmek için / yazın', + conversation_cleared: 'Görüşme temizlendi', + command_label: 'Komut', + context_compaction_label: 'Bağlam sıkıştırma', + retrieval_context_label: 'Dizine alınmış bağlam', + retrieval_context_preview: 'Daha önceki mesajlar depolanır ve bağlam araçlarıyla alınabilir', + preserved_task_list_label: 'Korunmuş görev listesi', + reference_only_label: 'Yalnızca referans', + model_usage: 'Kullanım: /model ', + no_model_match: 'Eşleşen model yok: "', + switched_to: 'Şuraya geçildi:', + workspace_usage: 'Kullanım: /workspace ', + no_workspace_match: 'Eşleşen çalışma alanı yok: "', + switched_workspace: 'Çalışma alanına geçildi:', + workspace_switch_failed: 'Çalışma alanı anahtarı başarısız oldu:', + new_session: 'Yeni oturum oluşturuldu', + new_session_creating: 'Yeni görüşme oluşturuluyor\u2026', + compressing: 'Bağlam sıkıştırması isteniyor...', + compress_running_label: 'Sıkıştırma', + compress_complete_label: 'Sıkıştırma tamamlandı', + auto_compress_label: 'Otomatik sıkıştırma', + compress_failed_label: 'Sıkıştırma başarısız oldu', + focus_label: 'Odak', + token_usage_on: 'Jeton kullanımı açık', + token_usage_off: 'Jeton kullanımı kapalı', + usage_cache_hit_detail: 'Önbellek: {0}% isabet ({1} okuma / {2} yazma)', + usage_cached_percent: '{0}% önbelleğe alındı', + theme_usage: 'Kullanım: /theme ', + theme_set: 'Tema:', + no_active_session: 'Aktif oturum yok', + cmd_queue: 'Bir sonraki dönüş için bir mesajı sıraya koy', + cmd_goal: 'Kalıcı bir hedef belirleyin veya inceleyin', + goal_evaluating_progress: 'Hedef ilerlemesi değerlendiriliyor\u2026', + goal_working_toward: 'Hedefe doğru çalışmak\u2026', + goal_continuing_toast: 'Hedefe doğru devam\u2026', + goal_status_none: 'Aktif hedef yok. /goal ile bir tane ayarlayın.', + goal_status_active: (turns, max_turns, goal) => `⊙ Hedef (aktif, ${turns}/${max_turns} tur): ${goal}`, + goal_status_paused: (turns, max_turns, reason, goal) => `⏸ Hedef (duraklatıldı, ${turns}/${max_turns}${reason ? `, ${reason}` : ''}): ${goal}`, + goal_status_done: (turns, max_turns, goal) => `✓ Hedef tamamlandı (${turns}/${max_turns}): ${goal}`, + goal_set: (turns, goal) => `⊙ Hedef belirlendi (${turns}-dönüş bütçesi): ${goal}`, + goal_paused: (goal) => `⏸ Hedef duraklatıldı: ${goal}`, + goal_resumed: (goal) => `▶ Hedef devam ettirildi: ${goal}`, + goal_cleared: 'Hedef temizlendi.', + goal_no_goal: 'Aktif hedef yok.', + goal_achieved: (reason) => `✓ Hedefe ulaşıldı: ${reason}`, + goal_paused_budget_exhausted: (turns, max_turns) => `⏸ Hedef duraklatıldı — ${turns}/${max_turns} tur kullanıldı. Devam etmek için /goal devamını, durdurmak için /goal clear komutunu kullanın.`, + goal_continuing: (turns, max_turns, reason) => `↻ Hedefe doğru devam ediyoruz (${turns}/${max_turns}): ${reason}`, + cmd_interrupt: 'Mevcut dönüşü iptal et ve yeni bir mesaj gönder', + cmd_steer: 'Temsilciyi kesintiye uğratmadan dönüş ortası düzeltmesi enjekte edin', + cmd_queue_no_msg: 'Kullanım: /queue ', + cmd_queue_not_busy: 'Etkin görev yok; yalnızca normal şekilde gönderin', + cmd_queue_confirm: 'İleti sıraya alındı', + cmd_interrupt_no_msg: 'Kullanım: /interrupt ', + cmd_interrupt_confirm: 'Kesintiye uğradı — yeni mesaj gönderilir', + cmd_steer_no_msg: 'Kullanım: /steer ', + cmd_steer_fallback: 'Yönlendirme kullanılamıyor - bunun yerine bir sonraki dönüş için sıraya alındı', + cmd_steer_delivered: 'Yönlendirme teslim edildi — temsilci bunu bir sonraki takım sonucunda görecek', + steer_leftover_queued: 'Direksiyon bir sonraki dönüş için kuyruğa alındı', + busy_steer_fallback: 'Yönlendirme kullanılamıyor — bir sonraki dönüş için kuyruğa alındı', + busy_interrupt_confirm: 'Kesintiye uğradı — yeni mesaj gönderilir', + settings_label_busy_input_mode: 'Meşgul giriş modu', + settings_desc_busy_input_mode: 'Aracı çalışırken bir mesaj gönderdiğinizde ne olacağını kontrol eder. Sıra bekler; Interrupt iptal eder ve yeniden başlar; Steer, dönüşün ortasında kesintiye uğramadan bir düzeltme enjekte eder (aracı veya akış mevcut olmadığında kuyruğa geri döner).', + settings_label_fade_text_effect: 'Metin efektini soldur', + settings_desc_fade_text_effect: 'Asistan yanıt verirken yeni aktarılan sözcüklerin geçişini sağlayın. OpenWebUI\'ye benzer; Maksimum performans için varsayılan olarak kapalıdır.', + settings_busy_input_mode_queue: 'Sıra takibi', + settings_busy_input_mode_interrupt: 'Mevcut dönüşü kes', + settings_busy_input_mode_steer: 'Yönlendirme (dönüş ortası düzeltme)', + + slash_skill_badge: 'Yetenek', + slash_skill_desc: 'Bu beceriyi çağır', + cmd_stop: 'Mevcut yanıtı durdur', + cmd_title: 'Oturum başlığını alın veya ayarlayın', + cmd_retry: 'Son mesajı tekrar gönder', + cmd_undo: 'Son değişimi kaldır', + cmd_btw: 'Bir yan soru sorun (geçici)', + cmd_btw_usage: '/btw — oturum bağlamını kullanarak bir yan soru sorun', + cmd_background: 'Arka planda bir istem çalıştır', + cmd_background_usage: '/background — engellemeden paralel olarak çalıştır', + btw_asking: 'Yan soruyu sormak...', + btw_label: 'Yan soru – tarihte değil', + btw_done: 'Yan soru cevaplandı', + btw_no_answer: 'Cevap alınmadı.', + btw_failed: 'Yan soru başarısız oldu:', + bg_running: 'Arka planda çalışıyor...', + bg_complete: 'Arka plan görevi tamamlandı', + bg_label: 'Arka plan sonucu:', + bg_no_answer: '(cevap yok)', + bg_failed: 'Arka plan görevi başarısız oldu:', + undo_exchange: 'Son değişimi geri al', + cmd_status: 'Oturum bilgilerini göster', + cmd_voice: 'Mikrofon girişini değiştir', + stream_stopped: 'Yanıt durduruldu.', + no_active_task: 'Durdurulacak etkin görev yok.', + cancel_unavailable: 'İptal kullanılamıyor.', + retry_failed: 'Yeniden deneme başarısız oldu:', + undo_failed: 'Geri alma başarısız oldu:', + undid_n_messages: 'Kaldırıldı', + undid_messages_suffix: 'mesaj(lar).', + status_heading: 'Oturum Durumu', + status_session_id: 'Oturum Kimliği', + status_title: 'Başlık', + status_model: 'Modeli', + status_provider: 'sağlayıcı', + status_workspace: 'Çalışma alanı', + status_personality: 'Kişilik', + status_messages: 'Mesajlar', + status_agent_running: 'Aracı çalışıyor', + status_profile: 'Profil', + status_hermes_home: 'Hermes\'in evi', + status_started: 'Başlatıldı', + status_tokens: 'Jetonlar', + status_updated: 'Güncellendi', + status_ephemeral: 'Geçici anlık görüntü — transkript geçmişine kaydedilmez.', + status_no_tokens: 'Hiç jeton kullanılmadı', + status_unknown: 'Bilinmiyor', + status_yes: 'Evet', + status_no: 'HAYIR', + status_load_failed: 'Durum yüklenemedi:', + title_current: 'Mevcut başlık', + title_change_hint: 'Yeniden adlandırmak için `/title ` kullanın.', + title_set: 'Başlık şu şekilde ayarlandı:', + cmd_webui_only_session: 'Bu komut CLI\'den içe aktarılan oturumlar için kullanılamaz.', + cmd_voice_use_mic: 'Bestecideki mikrofon düğmesine tıklayın.', + usage_heading: 'Jeton Kullanımı', + usage_default_model: 'varsayılan', + usage_unknown: 'bilinmiyor', + usage_input_tokens: 'Giriş jetonları', + usage_output_tokens: 'Çıkış jetonları', + usage_total: 'Toplam jeton', + usage_estimated_cost: 'Tahmini maliyet', + usage_settings_tip: 'Not: Maliyet tahminleri yaklaşık değerlerdir.', + usage_load_failed: 'Kullanım yüklenemedi:', + usage_personality_none: 'hiçbiri', + no_personalities: 'Hiç kişilik bulunamadı (onları ~/.hermes/personalities/ dosyasına ekleyin)', + available_personalities: 'Mevcut kişilikler:', + personality_switch_hint: 'Geçiş yapmak için `/kişilik ` kullanın veya temizlemek için `/kişilik yok` kullanın.', + personalities_load_failed: 'Kişilikler yüklenemedi', + personality_cleared: 'Kişilik temizlendi', + personality_set: 'Kişilik:', + failed_colon: 'Arızalı:', + // ui.js + no_workspace: 'Çalışma alanı yok', + terminal_open_title: 'Çalışma alanı terminalini aç', + terminal_no_workspace_title: 'Terminali açmak için bir çalışma alanı seçin', + terminal_title: 'terminal', + terminal_clear: 'Temizle', + terminal_copy_output: 'Çıktıyı kopyala', + terminal_restart: 'Tekrar başlat', + terminal_collapse: 'Yıkılmak', + terminal_expand: 'Genişletmek', + terminal_close: 'Kapalı', + terminal_input_placeholder: 'Bir komut çalıştır...', + terminal_start_failed: 'Terminal başlatma başarısız oldu:', + terminal_input_failed: 'Terminal girişi başarısız oldu:', + terminal_copy_failed: 'Kopyalama başarısız oldu:', + terminal_error: 'Terminal hatası', + workspace_empty_no_path: 'Çalışma alanı seçilmedi. Dosyalara göz atmak için Ayarlar \u2192 Çalışma Alanı\'nda bir çalışma alanı ayarlayın.', + workspace_empty_dir: 'Bu çalışma alanı boş.', + workspace_show_hidden_files: 'Gizli dosyaları göster', + workspace_show_hidden_files_desc: 'Dosya ağacına .DS_Store, .git, node_modules ve diğer gizli / sistem dosyalarını ekleyin.', + workspace_hidden_files_visible: 'gizli görünür', + workspace_hidden_files_visible_title: 'Gizli dosyalar görünür — seçenekler için tıklayın', + workspace_options: 'Çalışma alanı seçenekleri', + dialog_confirm_title: 'İşlemi onayla', + dialog_prompt_title: 'Bir değer girin', + dialog_confirm_btn: 'Onaylamak', + // workspace.js + unsaved_confirm: 'Önizlemede kaydedilmemiş değişiklikleriniz var. Atılıp gezinilsin mi?', + discard: 'At', + save: 'Kaydetmek', + edit: 'Düzenlemek', + clear: 'Temizle', + create: 'Oluştur', + remove: 'Kaldır', + save_title: 'Değişiklikleri kaydet', + edit_title: 'Bu dosyayı düzenle', + saved: 'Kaydedildi', + save_failed: 'Kaydetme başarısız oldu:', + image_load_failed: 'Resim yüklenemedi', + file_open_failed: 'Dosya açılamadı', + downloading: (name) => `${name} indiriliyor\u2026`, + double_click_rename: 'Yeniden adlandırmak için çift tıklayın', + renamed_to: 'Yeniden adlandırıldı', + rename_failed: 'Yeniden adlandırma başarısız oldu:', + delete_title: 'Sil', + delete_confirm: (name) => `${name} silinsin mi?`, + delete_dir_confirm: (name) => `"${name}" klasörü ve tüm içeriği silinsin mi?`, + rename_title: 'Yeniden isimlendirmek', + rename_prompt: 'Yeni isim:', + deleted: 'Silindi', + delete_failed: 'Silinemedi:', + reveal_in_finder: 'Dosya Yöneticisinde Göster', + reveal_failed: 'Açıklanamadı:', + copy_file_path: 'Dosya yolunu kopyala', + download_folder: 'İndirme klasörü', + path_copied: 'Dosya yolu panoya kopyalandı', + path_copy_failed: 'Yol kopyalanamadı:', + session_rename: 'Konuşmayı yeniden adlandır', + session_rename_desc: 'Bu görüşmenin başlığını düzenleyin', + new_file_prompt: 'Yeni dosya adı (örn. Notes.md):', + project_name_prompt: 'Proje adı:', + created: 'Oluşturuldu', + create_failed: 'Oluşturma başarısız oldu:', + new_folder_prompt: 'Yeni klasör adı:', + folder_created: 'Oluşturulan klasör', + folder_create_failed: 'Klasör oluşturulamadı:', + workspace_auto_create_folder: 'Mevcut değilse klasör oluşturun', + folder_add_as_space_btn: 'Alan Olarak Ekle', + folder_add_as_space_msg: 'Bu klasör çalışma alanı listenize yeni bir alan olarak eklensin mi?', + archive_extracted: (n, c) => `${c} arşivden ${n} dosya çıkarıldı`, + folder_add_as_space_title: 'Alan olarak eklensin mi?', + remove_title: 'Kaldırmak', + empty_dir: '(boş)', + upload_failed: 'Yükleme başarısız oldu:', + upload_too_large: (maxMb, fileMb) => `Dosya çok büyük (${fileMb} MB). Maksimum yükleme boyutu ${maxMb} MB'tır.`, + all_uploads_failed: (n) => `${n} yüklemenin tümü başarısız oldu`, + session_pin: 'Görüşmeyi sabitle', + session_unpin: 'Görüşmenin sabitlemesini kaldır', + session_pin_desc: 'Bu sohbeti en üstte tut', + session_unpin_desc: 'Sabitlenenlerden kaldır', + session_pin_failed: 'Pin başarısız oldu:', + session_move_project: 'Projeye taşı', + session_move_project_desc_has: 'Bu görüşmenin projesini değiştirin', + session_move_project_desc_none: 'Bu görüşmeye bir proje atayın', + session_archive: 'Konuşmayı arşivle', + session_hide_external: 'Listeden gizle', + session_restore: 'Konuşmayı geri yükle', + session_archive_desc: 'Arşivlendi gösterilene kadar bu konuşmayı gizle', + session_archive_worktree_desc: 'Bu konuşmayı gizle; çalışma ağacını diskte tut', + session_hide_external_desc: 'Kaynak geçmişini silmeden içe aktarılan bu oturumu WebUI\'dan gizleyin.', + session_restore_desc: 'Bu görüşmeyi ana listeye geri getir', + session_archived: 'Oturum arşivlendi', + session_archived_worktree: 'Oturum arşivlendi. Worktree diskte kalır.', + session_hidden: 'Oturum listede gizlendi', + session_restored: 'Oturum geri yüklendi', + session_archive_failed: 'Arşivleme başarısız oldu:', + session_duplicate: 'Yinelenen görüşme', + session_duplicate_desc: 'Aynı çalışma alanı ve modelle bir kopya oluşturun', + session_duplicated: 'Oturum kopyalandı', + session_duplicate_failed: 'Kopyalama başarısız oldu:', + session_stop_response: 'Yanıtı durdur', + session_stop_response_desc: 'Bu görüşme için çalışan yanıtı iptal edin', + session_delete: 'Konuşmayı sil', + session_delete_desc: 'Bu görüşmeyi kalıcı olarak kaldır', + session_delete_confirm: 'Bu görüşme silinsin mi?', + session_delete_worktree_desc: 'Yalnızca WebUI görüşmesini silin; çalışma ağacını diskte tut', + session_delete_worktree_confirm: (path) => `Bu görüşme silinsin mi? ${path} worktree diske kalır.`, + session_deleted: 'Görüşme silindi', + session_deleted_worktree: 'Görüşme silindi. Worktree diskte kalır.', + session_worktree_remove: 'Çalışma ağacını kaldır', + session_worktree_remove_desc: (path) => `${path} git worktree'sini diskten siler`, + session_worktree_remove_confirm: (path) => `Git worktree diskten silinsin mi?\n\nYol: ${path}\n\nTüm worktree dizini silinir. Oturum verileri WebUI'de kalır.`, + session_worktree_remove_not_exists: (path) => `${path} worktree'si artık diskte yok.`, + session_worktree_remove_confirm_label: 'Kaldırmak', + session_worktree_removed: 'Worktree kaldırıldı.', + session_worktree_remove_failed: 'Çalışma ağacı kaldırılamadı:', + session_worktree_remove_status_failed: 'Çalışma ağacı durumu okunamadı:', + session_worktree_remove_locked_by_stream: 'Kaldırılamıyor — etkin bir akış oturumu bu çalışma ağacını kullanıyor.', + session_worktree_remove_locked_by_terminal: 'Kaldırılamıyor — etkin bir terminal oturumu bu çalışma ağacını kullanıyor.', + session_worktree_remove_unsafe_blocked: 'Bu çalışma ağacını kaldırmadan önce yerel değişiklikleri veya gönderilmemiş taahhütleri çözümleyin.', + session_worktree_remove_dirty_warning: 'UYARI: Bu çalışma ağacında kaybolacak kaydedilmemiş değişiklikler var.', + session_worktree_remove_untracked_warning: (count) => `${count} izlenmeyen dosya kalıcı olarak silinecek.`, + session_worktree_remove_ahead_warning: (ahead) => `${ahead} gönderilmemiş commit kaybolacak.`, + session_select_mode: 'Seçme', + session_select_mode_desc: 'Toplu olarak yönetilecek konuşmaları seçin', + session_select_all: 'Tümünü seç', + session_deselect_all: 'Tümünün seçimini kaldır', + session_selected_count: '{0} seçildi', + session_batch_archive: 'Arşiv', + session_batch_delete: 'Silmek', + session_batch_move: 'Projeye taşı', + session_batch_delete_confirm: '{0} görüşme silinsin mi?', + session_batch_archive_confirm: '{0} ileti dizisi arşivlensin mi?', + session_batch_delete_worktree_confirm: '{0} görüşme silinsin mi? {1} çalışma ağacı destekli konuşma, çalışma ağacı dizinlerini diskte bırakacak.', + session_batch_archive_worktree_confirm: '{0} ileti dizisi arşivlensin mi? {1} çalışma ağacı destekli görüşme, çalışma ağacı dizinlerini diskte tutacak.', + session_no_selection: 'Hiçbir görüşme seçilmedi', + // settings panel + settings_heading_title: 'Kontrol Merkezi', + settings_heading_subtitle: 'Tercihler, konuşma araçları ve sistem kontrolleri.', + settings_section_conversation_title: 'Konuşma', + settings_section_appearance_title: 'Dış görünüş', + settings_section_appearance_meta: 'Tema, vurgu renkleri ve görsel stil.', + settings_section_preferences_title: 'Tercihler', + settings_section_preferences_meta: 'Hermes Web Kullanıcı Arayüzü için varsayılanlar ve Kullanıcı Arayüzü davranışı.', + settings_section_system_title: 'Sistem', + settings_section_system_meta: 'Örnek sürümü ve erişim kontrolleri.', + settings_check_now: 'Şimdi kontrol et', + settings_checking: 'Kontrol ediliyor\u2026', + settings_up_to_date: 'Güncel \u2713', + settings_updates_available: '{count} güncelleme mevcut', + settings_updates_disabled: 'Güncelleme kontrolleri devre dışı bırakıldı', + settings_update_check_failed: 'Güncelleme kontrolü başarısız oldu', + settings_label_workspace_panel_open: 'Çalışma alanı panelini varsayılan olarak açık tut', + settings_desc_workspace_panel_open: 'Etkinleştirildiğinde, çalışma alanı / dosya tarayıcı paneli her yeni oturumda otomatik olarak açılır. Yine de istediğiniz zaman manuel olarak kapatabilirsiniz.', + settings_label_session_jump_buttons: 'Oturum atlama düğmelerini göster', + settings_desc_session_jump_buttons: 'Uzun oturum geçmişlerini okurken kayan Başlat ve Bitir düğmelerini gösterin.', + + settings_label_session_endless_scroll: 'Yukarı kaydırırken eski mesajları yükle', + + settings_desc_session_endless_scroll: 'Etkinleştirildiğinde, yukarı doğru kaydırdığınızda eski mesajlar otomatik olarak yüklenir. Devre dışı bırakıldığında eski mesajlar düğmesini kullanın.', + + settings_label_tab_visibility: 'Kenar çubuğu sekmeleri', + settings_desc_tab_visibility: 'Kenar çubuğunda ve rayda hangi sekmelerin görüneceğini seçin. Sohbet ve Ayarlar her zaman görünür durumdadır.', + open_in_browser: 'Tarayıcıda aç', + settings_dropdown_conversation: 'Konuşma', + settings_dropdown_appearance: 'Dış görünüş', + settings_dropdown_preferences: 'Tercihler', + settings_dropdown_providers: 'Sağlayıcılar', + settings_dropdown_system: 'Sistem', + settings_tab_conversation: 'Konuşma', + settings_tab_appearance: 'Dış görünüş', + settings_tab_preferences: 'Tercihler', + settings_tab_plugins: 'Eklentiler', + settings_plugins_title: 'Eklentiler', + settings_plugins_meta: 'Yüklü Hermes eklentilerini ve yaşam döngüsü kancalarını görüntüleyin. Bu panel salt okunurdur.', + settings_plugins_empty: 'Görünür Hermes eklentisi yok. Burada görmek için Hermes CLI/config üzerinden yükleyin veya etkinleştirin.', + plugins_unnamed: 'Adsız eklenti', + plugins_no_description: 'Açıklama yok.', + plugins_no_hooks: 'Kayıtlı yaşam döngüsü kancası yok', + plugins_registered_hooks: 'Kayıtlı kancalar', + plugins_enabled: 'Etkin', + plugins_disabled: 'Devre dışı', + plugins_active_provider: 'Etkin (sağlayıcı)', + plugins_provider_no_hooks: 'Sağlayıcı eklentisi — agent görünürlük kancası yok', + plugins_load_failed: 'Eklentiler yüklenemedi: ', + settings_tab_system: 'Sistem', + settings_title: 'Ayarlar', + settings_save_btn: 'Ayarları Kaydet', + settings_label_model: 'Varsayılan Model', + settings_desc_model: 'Yeni konuşmalar için kullanılır. Mevcut konuşmalar seçilen modellerini korur.', + settings_label_send_key: 'Anahtar Gönder', + settings_label_theme: 'Tema', + settings_label_skin: 'Deri', + settings_label_font_size: 'Yazı tipi boyutu', + font_size_small: 'Küçük', + font_size_default: 'Varsayılan', + font_size_large: 'Büyük', + font_size_xlarge: 'Ekstra Büyük', + settings_autosave_saving: 'Kaydediliyor\u2026', + settings_autosave_saved: 'Kaydedildi', + settings_autosave_failed: 'Kaydetme başarısız oldu', + settings_autosave_retry: 'Yeniden dene', + settings_label_language: 'Dil', + settings_label_quota_chip: 'Bestecide sağlayıcı kota çipini göster', + settings_desc_quota_chip: 'Besteci altbilgisinde bir ortam kalan kota göstergesini (örn. OpenRouter kredi bakiyesi) görüntüler. Varsayılan kapalı. Besteciyi dizüstü bilgisayar ve standart masaüstü genişliklerinde düzenli tutmak için yalnızca etkinleştirildiğinde geniş ekranlarda (≥1400 piksel) görünür.', + settings_label_hide_suggestions: 'Yeni sohbet önerilerini gizle', + settings_desc_hide_suggestions: 'Yanlışlıkla dokunmayı önlemek için boş yeni sohbet ekranındaki üç varsayılan öneri düğmesini gizleyin.', + settings_label_token_usage: 'Jeton kullanımını göster', + settings_label_sidebar_density: 'Kenar çubuğu yoğunluğu', + cmd_reasoning: 'Düşünme görünürlüğünü değiştirin (göster/gizle), çaba düzeyini ayarlayın veya mevcut durumu kontrol edin', + settings_label_external_sessions: 'WebUI olmayan oturumları göster', + settings_label_previous_messaging_sessions: 'Önceki mesajlaşma oturumlarını göster', + settings_label_sync_insights: 'Analizlerle senkronize edin', + settings_label_check_updates: 'Güncellemeleri kontrol edin', + settings_label_whats_new_summary: "Summarize What's New with AI", + settings_label_bot_name: 'Varsayılan asistanın adı', + settings_label_password: 'Erişim Şifresi', + settings_saved: 'Ayarlar kaydedildi', + settings_save_failed: 'Kaydetme başarısız oldu:', + settings_load_failed: 'Ayarlar yüklenemedi:', + settings_saved_pw: 'Ayarlar kaydedildi - şifre koruması etkin ve bu tarayıcıda oturum açık kalıyor', + settings_saved_pw_updated: 'Ayarlar kaydedildi — şifre güncellendi', + // login page (used server-side via /api/i18n/login endpoint) + login_title: 'Oturum aç', + login_subtitle: 'Devam etmek için şifrenizi girin', + login_placeholder: 'Şifre', + login_btn: 'Oturum aç', + login_invalid_pw: 'Geçersiz şifre', + login_conn_failed: 'Bağlantı başarısız oldu', + // Sidebar & Tabs + tab_chat: 'Sohbet', + tab_tasks: 'Görevler', + tab_skills: 'Yetenekler', + tab_memory: 'Hafıza', + tab_workspaces: 'Alanlar', + tab_profiles: 'Agent profilleri', + tab_kanban: 'Kanban', + kanban_board: 'Pano', + kanban_visible_tasks: '{0} görünür görev', + kanban_search_tasks: 'Görevleri ara', + kanban_all_assignees: 'Tüm atananlar', + kanban_all_tenants: 'Tüm kiracılar', + kanban_include_archived: 'Arşivlenenleri dahil et', + kanban_no_matching_tasks: 'Eşleşen görev yok', + kanban_no_data: 'Kanban verisi yok', + kanban_work_queue_hint: 'Bu Hermes Ajanının iş kuyruğudur. Bir görev oluşturun veya önceliklendirin, atayın, Hazır\'a taşıyın ve sevk görevlisinin görevi talep etmesine izin verin.', + kanban_unavailable: 'Kanban kullanılamıyor', + kanban_read_only: 'Salt okunur görünüm', + kanban_empty: 'Boş', + kanban_task: 'Görev', + kanban_no_description: 'Açıklama yok', + kanban_refresh: 'Yenile', + kanban_status_triage: 'Triyaj', + kanban_status_todo: 'Yapılacaklar', + kanban_status_ready: 'Hazır', + kanban_status_running: 'Koşma', + kanban_status_blocked: 'Engellendi', + kanban_status_done: 'Tamamlamak', + kanban_status_original_hint: 'Gerçek durum: {0}. Bu iletişim kutusu yalnızca Triyaj/Yapılacak/Hazır düzenlemelerini destekler.', + kanban_comments_count: 'Yorumlar ({0})', + kanban_events_count: 'Etkinlikler ({0})', + kanban_links: 'Bağlantılar', + kanban_parents: 'Ebeveynler', + kanban_children: 'Çocuklar', + kanban_runs_count: 'Çalıştırmalar ({0})', + kanban_no_comments: 'Yorum yok', + kanban_no_events: 'Etkinlik yok', + kanban_no_runs: 'Koşu yok', + kanban_title: 'Başlık', + kanban_description: 'Tanım', + kanban_description_placeholder: 'İsteğe bağlı - ne olması gerekiyor, kabul kriterleri, bağlantılar', + kanban_status: 'Durum', + kanban_assignee: 'Vekil', + kanban_assignee_placeholder: 'İsteğe bağlı — herhangi bir çalışan için boş bırakın', + kanban_tenant: 'Kiracı', + kanban_tenant_placeholder: 'İsteğe bağlı – proje veya ekip bilgisi', + kanban_priority: 'Öncelik', + kanban_priority_hint: 'Önce yüksek sayılar çalıştırılır. Varsayılan 0.', + kanban_title_required: 'Başlık gerekli.', + kanban_edit_task: 'Görevi düzenle', + kanban_run_dispatcher: 'Dağıtıcıyı çalıştır', + kanban_run_dispatcher_confirm: 'Bu, bu panodaki Hazır görevleri talep edecek ve çalışan alt süreçlerini oluşturacaktır (görev başına bir, tıklama başına en fazla 8). Devam etmek?', + kanban_assignee_profiles_label: 'Hermes profilleri', + kanban_assignee_other_label: 'Diğer (CLI hatları / kaldırılan profiller)', + kanban_assignee_unassigned: '— Atanmadı (otomatik olarak çalıştırılmayacak) —', + kanban_ready_needs_assignee: 'Atanmamış + Hazır\'ı seçtiniz. Gönderici bu görevi atlayacaktır. Onaylamak için tekrar gönderin veya bir profil seçin.', + kanban_dispatch_preview_prefix: 'Önizleme:', + kanban_dispatch_run_prefix: 'Sevk edilen:', + kanban_dispatch_spawned: 'yumurtladı', + kanban_dispatch_promoted: 'terfi ettirildi', + kanban_dispatch_reclaimed: 'geri kazanılmış', + kanban_dispatch_skipped_unassigned: 'atlandı (atanan yok)', + kanban_dispatch_skipped_nonspawnable: 'atlandı (bilinmeyen profil)', + kanban_dispatch_auto_blocked: 'otomatik olarak engellendi', + kanban_dispatch_timed_out: 'zaman aşımına uğradı', + kanban_dispatch_crashed: 'çöktü', + kanban_new_task: 'Yeni görev', + kanban_add_comment: 'Yorum ekle', + kanban_only_mine: 'Sadece benim', + kanban_bulk_action: 'Toplu işlem', + kanban_nudge_dispatcher: 'Göndericiyi önizleyin', + kanban_stats: 'İstatistikler', + kanban_worker_log: 'İşçi günlüğü', + kanban_block: 'Engellemek', + kanban_unblock: 'Engellemeyi kaldır', + kanban_back_to_board: 'Panoya geri dön', + kanban_lanes_by_profile: 'Profile göre şeritler', + kanban_new_board: 'Yeni yönetim kurulu\u2026', + kanban_rename_board: 'Mevcut panoyu yeniden adlandır\u2026', + kanban_archive_board: 'Mevcut panoyu arşivle\u2026', + kanban_archive_board_confirm: 'Arşiv panosu "{name}"? Görevler diskte kalır ve pano kanban/boards/_archived/ adresinden geri yüklenebilir.', + kanban_board_archived: 'Pano arşivlendi', + kanban_board_name: 'İsim', + kanban_board_slug: 'Bilgi notu (küçük harf, kısa çizgi)', + kanban_board_description: 'Açıklama (isteğe bağlı)', + kanban_board_icon: 'Simge (emoji, isteğe bağlı)', + kanban_board_color: 'Renk (isteğe bağlı)', + kanban_board_name_required: 'Ad gerekli', + kanban_board_slug_required: 'Slug gerekli', + kanban_card_complete: 'tamamlamak', + kanban_card_archive: 'arşiv', + kanban_unassigned: 'atanmamış', + kanban_status_archived: 'Arşivlendi', + tab_todos: 'Yapılacaklar', + tab_insights: 'Analizler', + tab_dashboard: 'Hermes Kontrol Paneli', + dashboard_loopback_warning: 'Kontrol Paneli sunucuda yalnızca geri döngüye sahiptir. Ya sunucunun kendisinden göz atın ya da --host 0.0.0.0 (güvenli değil) ile yeniden başlatın.', + tab_logs: 'Günlükler', + tab_settings: 'Ayarlar', + + logs_title: 'Günlükler', + logs_file: 'Dosya', + logs_tail: 'Son satırlar', + logs_auto_refresh: 'Otomatik yenile (5 sn)', + logs_wrap: 'Satırları kaydır', + logs_copy_all: 'Tümünü kopyala', + logs_empty: 'Henüz günlük satırı yok.', + logs_loading: 'Günlükler yükleniyor\u2026', + logs_load_failed: 'Günlükler yüklenemedi', + logs_status_idle: 'Son satırları görmek için bir günlük dosyası seçin.', + logs_no_mtime: 'henüz yazılmadı', + logs_truncated_hint: 'Büyük günlük dosyasının sonu gösteriliyor; bellek sınırı için eski veriler atlandı.', + logs_copied: 'Günlükler kopyalandı', + logs_severity: 'Şiddet', + logs_severity_all: 'Tüm', + logs_severity_errors: 'Hatalar', + logs_severity_warnings: 'Uyarılar+', + logs_filter_active: 'gösteriliyor (filtre etkin)', + new_conversation: 'Yeni görüşme', + filter_conversations: 'Konuşmaları filtrele...', + session_time_unknown: 'Bilinmiyor', + session_time_minutes_ago: (n) => `${n} dk önce`, + session_time_hours_ago: (n) => `${n} sa önce`, + session_time_days_ago: (n) => `${n} gün önce`, + session_time_last_week: 'geçen hafta', + session_time_bucket_today: 'Bugün', + session_time_bucket_yesterday: 'Dün', + session_time_bucket_this_week: 'Bu hafta', + session_time_bucket_last_week: 'Geçen hafta', + session_time_bucket_older: 'daha yaşlı', + scheduled_jobs: 'Planlanmış işler', + new_job: 'Yeni iş', + loading: 'Yükleniyor...', + search_skills: 'Arama becerileri...', + new_skill: 'Yeni beceri', + personal_memory: 'Kişisel hafıza', + current_task_list: 'Mevcut görev listesi', + workspace_desc: 'Oturumlarınız için çalışma alanları ekleyin ve değiştirin.', + session_meta_messages: (n) => `${n} mesaj${n === 1 ? '' : 'S'}`, + session_meta_children: (n) => `${n} çocuk${n === 1 ? '' : 'ren'}`, + session_meta_segments: (n) => `${n} segment${n === 1 ? '' : 'S'}`, + session_lineage_segment_untitled: 'Başlıksız segment', + session_lineage_segment_open: 'Soy segmentini aç', + new_profile: 'Yeni profil', + transcript: 'Deşifre metni', + download_transcript: 'Markdown olarak indir', + import: 'İçe aktarmak', + export_session_json: 'JSON', + export_session_json_tooltip: 'Tüm oturumu JSON olarak dışa aktar', + import_session_json_tooltip: 'JSON\'dan oturumu içe aktar', + clear_conversation_btn_tooltip: 'Bu görüşmedeki tüm mesajları temizle', + // Settings detail + settings_label_rtl: 'Sağdan sola sohbet düzeni', + settings_desc_rtl: 'Arapça veya İbranice gibi diller için sohbet mesajlarının ve besteci girişinin hizalamasını değiştirir. Yalnızca sohbet alanını etkiler; kenar çubuğu ve diğer paneller soldan sağa kalır.', + settings_label_sound: 'Bildirim sesi', + settings_desc_sound: 'Asistan bir yanıtı bitirdiğinde bir ses çalın.', + settings_label_notifications: 'Tarayıcı bildirimleri', + settings_desc_notifications: 'Uygulama arka plandayken bir yanıt tamamlandığında bir sistem bildirimi gösterin.', + settings_desc_token_usage: 'Her Asistan yanıtının altında giriş/çıkış jeton sayılarını gösterir. /usage ile de değiştirilebilir.', + settings_sidebar_density_compact: 'Kompakt', + settings_sidebar_density_detailed: 'Ayrıntılı', + settings_desc_sidebar_density: 'Oturum listesinin sol kenar çubuğunda ne kadar meta veri göstereceğini kontrol eder.', + settings_label_auto_title_refresh: 'Uyarlanabilir başlık yenileme', + settings_auto_title_refresh_off: 'Kapalı', + settings_auto_title_refresh_5: 'Her 5 değişimde bir', + settings_auto_title_refresh_10: 'Her 10 değişimde bir', + settings_auto_title_refresh_20: 'Her 20 değişimde bir', + settings_desc_auto_title_refresh: 'Oturum başlıklarını en son konuşmaya göre otomatik olarak yeniden oluşturarak konuşma ilerledikçe başlıkların alakalı kalmasını sağlar. LLM başlık oluşturma modeli yapılandırması gerektirir.', + settings_desc_external_sessions: 'Oturum listesinde CLI, Telegram, Discord, Slack ve diğer kanallardan gelen konuşmaları gösterin. İçe aktarmak ve devam etmek için tıklayın.', + settings_desc_previous_messaging_sessions: 'Sıfırlama veya sıkıştırmayla değiştirilen eski Discord, Telegram, Slack ve Weixin oturumlarını gösterin.', + settings_desc_sync_insights: 'WebUI belirteci kullanımını state.db\'ye yansıtır, böylece hermes /insights tarayıcı oturum verilerini içerir. Varsayılan olarak kapalıdır.', + settings_desc_check_updates: 'WebUI veya Agent\'ın daha yeni sürümleri mevcut olduğunda bir banner gösterin. Periyodik olarak bir arka plan git getirme işlemi çalıştırır.', + settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", + settings_desc_bot_name: 'Yalnızca varsayılan profil için kullanılır. Diğer profiller kendi profil adlarını kullanır.', + settings_desc_password: 'Ayarlamak veya değiştirmek için yeni bir şifre girin. Geçerli ayarı korumak için boş bırakın.', + password_placeholder: 'Yeni şifreyi girin\u2026', + password_env_var_locked: 'HERMES_WEBUI_PASSWORD ortam değişkeni şu anda ayarlıdır ve önceliklidir. Şifreyi buradan yönetmek için ayarı kaldırın ve sunucuyu yeniden başlatın.', + password_env_var_locked_placeholder: 'Kilitli: HERMES_WEBUI_PASSWORD ortam değişkeni ayarlandı', + disable_auth: 'Kimlik Doğrulamasını Devre Dışı Bırak', + sign_out: 'Oturumu Kapat', + // Providers panel + providers_tab_title: 'Sağlayıcılar', + providers_section_title: 'Sağlayıcılar', + providers_section_meta: 'Yapay zeka sağlayıcıları için API anahtarlarını yönetin. Değişiklikler hemen yürürlüğe girer.', + providers_status_configured: 'API anahtarı yapılandırıldı', + providers_status_not_configured: 'API anahtarı yok', + providers_status_oauth: 'OAuth', + providers_status_api_key: 'API anahtarı', + providers_status_not_configured_label: 'Yapılandırılmadı', + providers_oauth_hint: 'OAuth aracılığıyla kimlik doğrulaması yapıldı. API anahtarına gerek yok.', + providers_oauth_config_yaml_hint: 'Belirteç config.yaml aracılığıyla yapılandırıldı. Güncellemek için config.yaml dosyanızdaki sağlayıcılar bölümünü düzenleyin veya hermes auth\'u çalıştırın.', + providers_oauth_not_configured_hint: 'Kimliği doğrulanmadı. Bu sağlayıcıyı yapılandırmak için terminalde Hermes Auth komutunu çalıştırın.', + providers_save: 'Kaydetmek', + providers_remove: 'Kaldırmak', + providers_saving: 'Kaydediliyor\u2026', + providers_removing: 'Kaldırılıyor\u2026', + providers_enter_key: 'Lütfen bir API anahtarı girin', + providers_empty: 'Yapılandırılabilir sağlayıcı bulunamadı.', + providers_key_updated: 'API anahtarı kaydedildi', + providers_key_removed: 'API anahtarı kaldırıldı', + providers_key_placeholder_new: 'sk-...', + providers_key_placeholder_replace: 'Değiştirilecek yeni anahtarı girin\u2026', + provider_quota_title: 'Etkin sağlayıcı kotası', + provider_quota_active_provider: 'Aktif sağlayıcı', + provider_quota_last_checked_after_refresh: 'Yenilemeden sonra son kontrol edildi', + provider_quota_last_checked: 'Son kontrol edilen {0}', + provider_quota_refresh_usage: 'Kullanımı yenile', + provider_quota_refreshing: 'Yenileniyor...', + provider_quota_refresh_title: 'Sağlayıcı kullanım sınırlarını şimdi yenileyin', + provider_quota_refresh_succeeded: 'Sağlayıcı kullanımı yenilendi', + provider_quota_refresh_failed: 'Sağlayıcı kullanımı yenileme başarısız oldu', + provider_quota_session_limit: '5 saat sınırı', + provider_quota_weekly_limit: 'Haftalık limit', + provider_quota_window_fallback: 'Pencere', + provider_quota_metric_remaining: 'Geriye kalan', + provider_quota_metric_used: 'Kullanılmış', + provider_quota_metric_limit: 'Sınır', + provider_quota_status_available: 'mevcut', + provider_quota_status_exhausted: 'yorgun', + provider_quota_status_unavailable: 'müsait değil', + provider_quota_status_failed: 'arızalı', + provider_quota_status_checked: 'kontrol edildi', + provider_quota_status_no_key: 'anahtar yok', + provider_quota_status_invalid_key: 'geçersiz anahtar', + provider_quota_status_unsupported: 'desteklenmeyen', + provider_quota_used_meta: '{0} kullanıldı', + provider_quota_resets_meta: '{0} sıfırlanır', + provider_quota_credential_pool: 'Kimlik bilgisi havuzu', + provider_quota_credential_label: 'Kimlik bilgisi {0}', + provider_quota_pool_plans: 'Planlar: {0}', + provider_quota_pool_no_windows: 'Hesap sınırı aralığı bildirilmedi.', + provider_quota_account_limits_loaded: 'Hesap limitleri yüklendi.', + provider_quota_unavailable: 'Kota durumu mevcut değil', + provider_quota_retry_after: '{0} sonrasında yeniden deneyin', + provider_quota_pool_summary_available: '{0}/{1} mevcut', + provider_quota_pool_summary_exhausted: '{0} bitkin', + provider_quota_pool_summary_failed: '{0} başarısız oldu', + provider_quota_pool_summary_checked: '{0} işaretlendi', + cancel: 'İptal etmek', + create_job: 'İş oluştur', + save_skill: 'Beceriyi kaydet', + editing: 'Düzenleme', + // Empty state + empty_title: 'Hangi konuda yardımcı olabilirim?', + empty_subtitle: 'İstediğiniz şeyi sorun, komutları çalıştırın, dosyaları keşfedin veya planlanmış görevlerinizi yönetin.', + suggest_files: 'Bu çalışma alanında hangi dosyalar var?', + suggest_schedule: 'Nerelerdesiniz?', + suggest_plan: 'Küçük bir proje planlamama yardım et.', + // onboarding + onboarding_badge: 'İLK ÇALIŞMA', + onboarding_title: 'Hermes Web Kullanıcı Arayüzüne Hoş Geldiniz', + onboarding_lead: 'Hızlı yönlendirmeli kurulum, Hermes\'i doğrulayacak, gerçek bir sağlayıcı yapılandırmasını kaydedecek, bir çalışma alanı ve modeli seçecek ve isteğe bağlı olarak uygulamayı bir parola ile koruyacaktır.', + onboarding_back: 'Geri', + onboarding_continue: 'Devam etmek', + onboarding_skip: 'Kurulumu atla', + onboarding_skipped: 'Kurulum atlandı — mevcut yapılandırma kullanılıyor.', + onboarding_open: 'Hermes\'i aç', + onboarding_step_system_title: 'Sistem kontrolü', + onboarding_step_system_desc: 'Hermes Agent\'ı ve yapılandırma görünürlüğünü doğrulayın.', + onboarding_step_setup_title: 'Sağlayıcı kurulumu', + onboarding_step_setup_desc: 'Minimum Hermes sağlayıcı yapılandırmasını kaydedin.', + onboarding_step_workspace_title: 'Çalışma alanı + modeli', + onboarding_step_workspace_desc: 'Yeni oturumlar ve sohbet için varsayılanları seçin.', + onboarding_step_password_title: 'İsteğe bağlı şifre', + onboarding_step_password_desc: 'Web kullanıcı arayüzünü paylaşmadan önce koruyun.', + onboarding_step_finish_title: 'Sona ermek', + onboarding_step_finish_desc: 'Uygulamayı inceleyin ve girin.', + onboarding_notice_system_ready: 'Hermes Agent\'a Web kullanıcı arayüzünden ulaşılabilir görünüyor.', + onboarding_notice_system_unavailable: 'Hermes Agent henüz tam olarak mevcut değil. Bootstrap bunu kurabilir ancak sağlayıcı kurulumu yine de bir terminal gerektirebilir.', + onboarding_check_agent: 'Hermes Temsilcisi', + onboarding_check_agent_ready: 'Tespit edildi ve içe aktarılabilir', + onboarding_check_agent_missing: 'Eksik veya kısmen içe aktarılabilir', + onboarding_check_password: 'Şifre', + onboarding_check_password_enabled: 'Zaten etkin', + onboarding_check_password_disabled: 'Henüz etkinleştirilmedi', + onboarding_check_provider: 'Sağlayıcı yapılandırması', + onboarding_check_provider_ready: 'Sohbete hazır', + onboarding_check_provider_partial: 'Kaydedildi ancak eksik', + onboarding_check_provider_pending: 'Doğrulama gerekiyor', + onboarding_config_file: 'Yapılandırma dosyası:', + onboarding_env_file: '.env dosyası:', + onboarding_unknown: 'Bilinmiyor', + onboarding_current_provider: 'Mevcut kurulum:', + onboarding_missing_imports: 'Eksik içe aktarmalar:', + onboarding_notice_setup_required: 'Burada basit bir sağlayıcı yolu seçin. Gelişmiş OAuth akışları şimdilik Hermes CLI\'ye ait olmaya devam ediyor.', + onboarding_notice_setup_already_ready: 'Çalışan bir Hermes sağlayıcı kurulumu zaten algılandı. Burada saklayabilir veya değiştirebilirsiniz.', + onboarding_oauth_provider_ready_title: 'Sağlayıcının kimliği zaten doğrulandı', + onboarding_oauth_provider_ready_body: 'Bu örnek, Hermes CLI aracılığıyla ayarlanmış bir OAuth sağlayıcısını ({provider}) kullanacak şekilde yapılandırılmıştır. Burada API anahtarına gerek yoktur; kurulumu tamamlamak için Devam\'a tıklayın.', + onboarding_oauth_provider_not_ready_title: 'OAuth sağlayıcısının kimliği henüz doğrulanmadı', + onboarding_oauth_provider_not_ready_body: 'Bu örnek, API anahtarı yerine OAuth kullanan {provider} sağlayıcısını kullanır. Kimlik doğrulamak için terminalde hermes auth veya hermes model çalıştırın, ardından WebUI\'yi yeniden yükleyin.', + onboarding_oauth_switch_hint: 'Veya API anahtarı kurulumuna geçmek için aşağıdan farklı bir sağlayıcı seçin:', + onboarding_notice_workspace: 'Bu değerler normal uygulamayla aynı ayar API\'lerini yeniden kullanır.', + onboarding_workspace_label: 'Çalışma alanı', + onboarding_workspace_or_path: 'Veya bir çalışma alanı yolu girin', + onboarding_workspace_placeholder: '/ev/siz/çalışma alanı', + onboarding_provider_label: 'Kurulum modu', + onboarding_quick_setup_badge: 'hızlı kurulum', + provider_category_easy_start: 'Kolay başlangıç', + provider_category_self_hosted: 'Açık / kendi kendine barındırılan', + provider_category_specialized: 'Uzmanlaşmış', + onboarding_api_key_label: 'API anahtarı', + oauth_login_codex: 'Codex (ChatGPT) ile giriş yap', + oauth_codex_step1: 'Adım 1: Bu URL\'yi ziyaret edin ve kodu girin', + oauth_codex_step2: 'Adım 2: Bu kodu sayfaya girin', + oauth_codex_polling: 'Yetkilendirme bekleniyor...', + oauth_codex_success: 'Codex OAuth girişi başarılı!', + oauth_codex_error: 'OAuth girişi başarısız', + oauth_codex_expired: 'Kodun süresi doldu, lütfen tekrar deneyin', + onboarding_api_key_placeholder: 'Mevcut kayıtlı anahtarı korumak için boş bırakın', + onboarding_api_key_label_optional: 'API anahtarı (isteğe bağlı)', + onboarding_api_key_placeholder_optional: 'Anahtarsız sunucular için boş bırakın', + onboarding_api_key_help_keyless: 'Çoğu LM Studio / Ollama / vLLM kurulumu anahtarsız çalışır — sunucunuz kimlik doğrulama gerektirmiyorsa boş bırakın. Bağlantıyı test et düğmesini kullanın.', + onboarding_api_key_help_prefix: 'Hermes .env dosyanızda gizli olarak kaydedildi', + onboarding_base_url_label: 'Temel URL', + onboarding_base_url_placeholder: 'https://uç noktanız.example/v1', + onboarding_base_url_help: 'Bunu OpenAI uyumlu yönlendiriciler, şirket içinde barındırılan sunucular, LiteLLM, Ollama, LM Studio, vLLM veya benzer uç noktalar için kullanın.', + onboarding_model_label: 'Varsayılan model', + onboarding_workspace_help: 'Kurulum tamamlandıktan sonra Hermes\'in yeni sohbetler için kullanması gereken modeli seçin.', + onboarding_custom_model_placeholder: 'modelinizin adı', + onboarding_custom_model_help: 'Özel uç noktalar için sunucunuzun beklediği tam model kimliğini girin.', + onboarding_notice_password_enabled: 'Bir parola zaten yapılandırılmıştır. Yalnızca değiştirmek istiyorsanız yeni bir tane girin.', + onboarding_notice_password_recommended: 'İsteğe bağlıdır ancak kullanıcı arayüzünü localhost\'un ötesinde gösterecekseniz önerilir.', + onboarding_password_label: 'Şifre (isteğe bağlı)', + onboarding_password_placeholder: 'Atlamak için boş bırakın', + onboarding_password_help: 'Parolalar, mevcut ayarlar API\'si aracılığıyla ve karma sunucu tarafında saklanır.', + onboarding_notice_finish: 'Bunlardan herhangi birini değiştirmek için Ayarları daha sonra yeniden açabilirsiniz.', + onboarding_not_set: 'Ayarlanmadı', + onboarding_password_will_enable: 'Etkinleştirilecek', + onboarding_password_will_replace: 'Değiştirilecek', + onboarding_password_keep_existing: 'Mevcut şifreyi koru', + onboarding_password_remains_disabled: 'Engelli kalacak', + onboarding_password_skipped: 'Şimdilik atlandı', + onboarding_finish_help: 'Bitirme, ayarlarda onboarding_completed kodunu saklar ve sizi normal uygulamaya bırakır.', + onboarding_error_choose_workspace: 'Devam etmeden önce bir çalışma alanı seçin.', + onboarding_error_choose_model: 'Devam etmeden önce bir model seçin.', + onboarding_error_provider_required: 'Devam etmeden önce bir kurulum modu seçin.', + onboarding_error_base_url_required: 'Özel uç noktalar için temel URL gereklidir.', + onboarding_probe_test_button: 'Bağlantıyı test et', + onboarding_probe_probing: 'Bağlantı test ediliyor\u2026', + onboarding_probe_ok: 'Bağlandı. {n} model mevcut.', + onboarding_probe_error_generic: 'Yapılandırılmış temel URL\'ye ulaşılamadı.', + onboarding_probe_error_invalid_url: 'Temel URL http:// veya https:// ile başlamalı.', + onboarding_probe_error_dns: 'Ana bilgisayar çözülemedi. URL\'yi kontrol edin veya IP adresini kullanın.', + onboarding_probe_error_connect_refused: 'Bağlantı reddedildi — sunucu bu adreste çalışmıyor olabilir. Docker içinden localhost yerine ana bilgisayar IP\'sini deneyin.', + onboarding_probe_error_timeout: 'Uç nokta zamanında yanıt vermedi. Sunucunun çalıştığını ve URL\'nin doğru olduğunu kontrol edin.', + onboarding_probe_error_http_4xx: 'Uç nokta istemci hatası döndürdü. Kimlik doğrulama ve URL yolunu kontrol edin (genelde /v1 ile biter).', + onboarding_probe_error_http_5xx: 'Uç nokta sunucu hatası döndürdü. LM Studio / Ollama günlüklerini kontrol edin.', + onboarding_probe_error_parse: 'Uç nokta beklenen biçimde model listesi döndürmedi. URL\'nin OpenAI uyumlu API köküne işaret ettiğini doğrulayın.', + onboarding_probe_error_unreachable: 'Yapılandırılmış temel URL\'ye ulaşılamadı.', + onboarding_error_probe_failed: 'Yapılandırılmış temel URL doğrulanamadı.', + onboarding_error_workspace_required: 'Çalışma alanı gerekli.', + onboarding_error_model_required: 'Model gerekli.', + onboarding_complete: 'İlk katılım tamamlandı', + + // panel/runtime i18n + error_prefix: 'Hata:', + not_available: 'Yok', + never: 'Asla', + add: 'Eklemek', + add_failed: 'Ekleme başarısız oldu:', + remove_failed: 'Kaldırma başarısız oldu:', + switch_failed: 'Geçiş başarısız oldu:', + name_required: 'Ad gerekli', + content_required: 'İçerik gerekli', + view: 'Görüş', + dismiss: 'Azletmek', + disable: 'Devre dışı bırakmak', + cron_no_jobs: 'Planlanmış iş bulunamadı.', + cron_status_off: 'kapalı', + cron_status_paused: 'duraklatıldı', + cron_status_error: 'hata', + cron_status_active: 'aktif', + cron_status_running: 'koşuyor\u2026', + cron_status_needs_attention: 'dikkat edilmesi gerekiyor', + cron_attention_desc: 'Bu yinelenen işin bir sonraki çalışma süresi yoktur. Zamanlayıcı bir sonraki çalıştırmayı hesaplamada başarısız olmuş olabilir.', + cron_attention_croniter_hint: 'Ağ Geçidi çalışma zamanında croniter paketi eksik olabilir. Ağ Geçidini cron desteğiyle yeniden başlatın, ardından bu işe devam edin.', + cron_attention_resume: 'Devam ettir ve yeniden hesapla', + cron_jobs_project: 'Cron İşleri', + cron_attention_run_once: 'Şimdi bir kez koş', + cron_attention_copy_diagnostics: 'Tanılamayı kopyala', + cron_diagnostics_copied: 'Cron teşhisi kopyalandı', + cron_next: 'Sonraki', + cron_last: 'Son', + cron_run_now: 'Şimdi koş', + cron_pause: 'Duraklat', + cron_resume: 'Sürdürmek', + cron_job_name_placeholder: 'İşin adı', + cron_schedule_placeholder: 'Takvim', + cron_prompt_placeholder: 'Çabuk', + cron_last_output: 'Son çıktı', + cron_expand_prompt: 'İstemi genişlet', + cron_collapse_prompt: 'İstemi daralt', + cron_expand_output: 'Çıktıyı genişlet', + cron_collapse_output: 'Çıktıyı daralt', + cron_all_runs: 'Tüm koşular', + cron_hide_runs: 'Çalıştırmaları gizle', + cron_no_runs_yet: '(henüz koşu yok)', + cron_schedule_required_example: 'Program gereklidir (ör. "0 9 * * *" veya "her 1 saatte bir")', + cron_schedule_required: 'Program gerekli', + cron_prompt_required: 'İstem gerekli', + cron_job_created: 'İş oluşturuldu', + cron_duplicate: 'Kopyalamak', + cron_duplicated: 'İş kopyalandı (duraklatıldı)', + cron_job_triggered: 'İş tetiklendi', + cron_job_paused: 'İş duraklatıldı', + cron_job_resumed: 'İş devam ettirildi', + cron_job_updated: 'İş güncellendi', + cron_delete_confirm_title: 'Cron işini sil', + cron_delete_confirm_message: 'Bu geri alınamaz.', + cron_job_deleted: 'İş silindi', + cron_completion_status: (name, status) => `Cron "${name}" ${status}`, + status_failed: 'arızalı', + status_completed: 'tamamlanmış', + todos_no_active: 'Bu oturumda etkin görev listesi yok.', + clear_conversation_title: 'Görüşmeyi temizle', + clear_conversation_message: 'Tüm mesajlar silinsin mi? Bu geri alınamaz.', + clear_failed: 'Temizleme başarısız oldu:', + skills_no_match: 'Beceri eşleşmesi yok.', + skill_enabled: 'Etkinleştirilmiş', + skill_disabled: 'Engelli', + skill_toggle_failed: 'Beceri değiştirilemedi:', + linked_files: 'Bağlantılı Dosyalar', + skill_load_failed: 'Beceri yüklenemedi:', + skill_file_load_failed: 'Dosya yüklenemedi:', + skill_name_required: 'Beceri adı gerekli', + skill_updated: 'Beceri güncellendi', + skill_created: 'Beceri oluşturuldu', + skill_deleted: 'Beceri silindi', + skill_delete_confirm: '"{0}" becerisi silinsin mi?', + skills_empty_title: 'Bir beceri seçin', + skills_empty_sub: 'İçeriğini görüntülemek için kenar çubuğundan bir beceri seçin veya yeni bir beceri oluşturun.', + skills_edit: 'Düzenlemek', + skills_delete: 'Silmek', + skills_back_to: '{0}\'a geri dön', + tasks_empty_title: 'Planlanmış bir iş seçin', + tasks_empty_sub: 'Ayrıntılarını ve çalışmalarını görüntülemek için kenar çubuğundan bir iş seçin veya yeni bir iş oluşturun.', + workspaces_empty_title: 'Bir alan seçin', + workspaces_empty_sub: 'Dosyalarını ve ayarlarını görüntülemek veya yeni bir tane eklemek için kenar çubuğundan bir alan seçin.', + profiles_empty_title: 'Bir profil seçin', + profiles_empty_sub: 'Ayarlarını görüntülemek ve düzenlemek için kenar çubuğundan bir temsilci profili seçin veya yeni bir tane oluşturun.', + memory_notes_label: 'hafıza (notlar)', + memory_saved: 'Bellek kaydedildi', + my_notes: 'Notlarım', + user_profile: 'Kullanıcı Profili', + no_notes_yet: 'Henüz not yok.', + no_profile_yet: 'Henüz profil yok.', + agent_soul: 'Ajan Ruhu', + no_soul_yet: 'Henüz tanımlanmış bir ruh yok.', + workspace_choose_path: 'Çalışma alanı yolunu seçin', + workspace_choose_path_meta: 'Doğrulanmış bir yol ekleyin ve bu görüşmeyi değiştirin', + workspace_manage: 'Çalışma alanlarını yönet', + workspace_manage_meta: 'Spaces panelini açın', + workspace_use_title: 'Geçerli oturumda kullan', + workspace_use: 'Kullanmak', + workspace_add_path_placeholder: 'Çalışma alanı yolu ekleyin (ör. /home/user/my-project)', + workspace_paths_validated_hint: 'Yollar, kaydedilmeden önce mevcut dizinler olarak doğrulanır.', + + workspace_drag_hint: 'Yeniden sıralamak için sürükleyin', + workspace_reorder_failed: 'Yeniden sıralama başarısız oldu', + workspace_added: 'Çalışma alanı eklendi', + workspace_renamed: 'Çalışma alanı yeniden adlandırıldı', + workspace_remove_confirm_title: 'Çalışma alanını kaldır', + workspace_remove_confirm_message: (path) => `"${path}" kaldırılsın mı?`, + workspace_removed: 'Çalışma alanı kaldırıldı', + workspace_switch_prompt_title: 'Çalışma alanını değiştir', + workspace_switch_prompt_message: 'Bu görüşmeyi eklemek ve bu görüşmeye geçiş yapmak için mutlak bir çalışma alanı yolu girin.', + workspace_switch_prompt_confirm: 'Anahtar', + workspace_switch_prompt_placeholder: '/Kullanıcılar/siz/proje', + workspace_not_added: 'Çalışma alanı eklenmedi', + workspace_already_saved: 'Çalışma alanı zaten kayıtlı; onu listeden seçin', + workspace_busy_switch: 'Aracı çalışırken çalışma alanı değiştirilemiyor', + discard_file_edits_title: 'Dosya düzenlemeleri silinsin mi?', + discard_file_edits_message: 'Çalışma alanlarının değiştirilmesi, önizlemedeki kaydedilmemiş dosya düzenlemelerinin silinmesine neden olur.', + workspace_switched_to: (name) => `${name}'a geçildi`, + profiles_no_profiles: 'Hiçbir profil bulunamadı.', + profile_api_keys_configured: 'API anahtarları yapılandırıldı', + profile_gateway_running: 'Ağ geçidi çalışıyor', + profile_gateway_stopped: 'Ağ geçidi durduruldu', + profile_active: 'AKTİF', + profile_no_configuration: 'Yapılandırma yok', + profile_skill_count: (count) => `${count} beceri${count === 1 ? '' : 'S'}`, + profile_use: 'Kullanmak', + profile_switch_title: 'Bu profile geç', + profile_delete_title: 'Bu profili sil', + profile_default_label: '(varsayılan)', + profile_name_placeholder: 'Profil adı (küçük harf, a-z 0-9 kısa çizgiler)', + profile_clone_label: 'Etkin profilden yapılandırmayı kopyala', + profile_model_label: 'Model / sağlayıcı', + profile_model_hint: 'Bu yeni profil için yapılandırılmış sağlayıcılar ve modeller arasından seçim yapın.', + profile_model_use_default: 'Etkin profil varsayılanını kullan', + profile_base_url_placeholder: 'Temel URL (isteğe bağlı, örneğin http://localhost:11434)', + profile_api_key_placeholder: 'API anahtarı (isteğe bağlı)', + manage_profiles: 'Profilleri yönet', + profiles_load_failed: 'Profiller yüklenemedi', + profile_switched_new_conversation: (name) => `Profile geçildi: ${name} — yeni görüşme başladı`, + profile_switched: (name) => `Profile geçildi: ${name}`, + profile_name_rule: 'Yalnızca küçük harfler, sayılar, kısa çizgiler ve alt çizgiler', + profile_base_url_rule: 'Temel URL http:// veya https:// ile başlamalıdır', + profile_created: (name) => `Profil oluşturuldu: ${name}`, + profile_delete_confirm_title: (name) => `"${name}" profili silinsin mi?`, + profile_delete_confirm_message: 'Bu profilin tüm oturumları, yapılandırmaları, becerileri ve belleği kalıcı olarak silinecek. Bu geri alınamaz.', + profile_deleted: (name) => `Profil silindi: ${name}`, + active_conversation_none: 'Aktif görüşme seçilmedi.', + active_conversation_meta: (title, count) => `${title} · ${count} mesaj${count === 1 ? '' : 'S'}`, + settings_unsaved_changes: 'Kaydedilmemiş değişiklikleriniz var.', + sign_out_failed: 'Oturumu kapatma başarısız oldu:', + disable_auth_confirm_title: 'Şifre korumasını devre dışı bırak', + disable_auth_confirm_message: 'Bu örneğe herkes erişebilecek.', + auth_disabled: 'Kimlik doğrulama devre dışı bırakıldı — şifre koruması kaldırıldı', + disable_auth_failed: 'Kimlik doğrulama devre dışı bırakılamadı:', + bg_error_single: (title) => `"${title}" bir hatayla karşılaştı`, + bg_error_multi: (count) => `${count} oturum bir hatayla karşılaştı`, + // skill form + skill_name: 'İsim', + skill_category: 'Kategori', + skill_category_placeholder: 'İsteğe bağlı, ör. devops', + skill_content: 'SKILL.md içeriği', + skill_content_placeholder: 'YAML ön maddesi + işaretleme gövdesi', + skill_rename_not_supported: 'Bir becerinin yeniden adlandırılması desteklenmiyor. Yeni bir beceri oluşturun ve yeniden adlandırmak için eskisini silin.', + skill_metadata: 'Meta veriler', + // cron form + cron_name_label: 'İsim', + cron_name_placeholder: 'İsteğe bağlı', + cron_schedule_label: 'Takvim', + cron_schedule_hint: "Use 'every 1h' or a cron expression for recurring jobs. Bare durations like '30m' run once.", + cron_schedule_once_warning: "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.", + cron_prompt_label: 'Çabuk', + cron_deliver_label: 'Çıktıyı şuraya ilet:', + cron_deliver_local: 'Yerel (yalnızca çıktıyı kaydet)', + cron_profile_label: 'Profil', + cron_profile_server_default: 'sunucu varsayılanı', + cron_profile_server_default_hint: 'Çalışma zamanında WebUI sunucusunun varsayılan profilini kullanır. Profili olmayan mevcut işler bu eski davranışı sürdürüyor.', + cron_toast_notifications_label: 'Tamamlama kadehleri', + cron_toast_notifications_hint: 'Bu cron bittiğinde kadeh kaldır. Görevler rozeti ve yeni çalıştırma işaretçisi bu kapalıyken de güncellenir.', + cron_toast_notifications_enabled: 'Etkinleştirilmiş', + cron_toast_notifications_disabled: 'Engelli', + cron_skills_label: 'Yetenekler', + cron_skills_placeholder: 'Beceri ekleyin (isteğe bağlı)\u2026', + cron_skills_edit_hint: 'Beceri listesi oluşturulduktan sonra düzenlenemez.', + // workspace form + workspace_name_label: 'İsim', + workspace_name_placeholder: 'İsteğe bağlı kolay ad', + workspace_path_label: 'Yol', + workspace_path_required: 'Yol gerekli', + workspace_path_readonly: 'Yol değiştirilemez. Yalnızca yeniden adlandırın.', + workspace_new_title: 'Yeni alan', + // profile form + profile_name_label: 'İsim', + profile_base_url_label: 'Temel URL', + profile_api_key_label: 'API anahtarı', + cmd_yolo: 'YOLO modunu değiştir (onayları atla)', + cmd_branch: 'Bu konuşmayı yeni bir oturuma aktarın', + cmd_branch_usage: '/branch [isim] - konuşmayı çatallayın (isteğe bağlı olarak bir adla)', + branch_forked: 'Yeni oturuma çatallandı', + branch_failed: 'Çatal başarısız oldu:', + fork_from_here: 'Buradan çatal', + forked_from: 'çatallı', + yolo_no_session: 'Aktif oturum yok', + yolo_enabled: '⚡ YOLO modu AÇIK — onaylar bu oturumu atladı', + yolo_disabled: 'YOLO modu KAPALI', + yolo_pill_label: 'YOLO', + yolo_pill_title_active: 'YOLO modu etkin — devre dışı bırakmak için tıklayın', + approval_skip_all: '⚡ Tüm bu oturumu atla', + approval_skip_all_title: 'Bu oturuma ilişkin tüm onay istemlerini atla', + // composer action tooltips + composer_send: 'Mesaj gönder', + composer_queue: 'Sıra mesajı', + composer_interrupt: 'Kes ve gönder', + composer_steer: 'Akım tepkisini yönlendir', + composer_stop: 'Üretimi durdur', + composer_disabled_clarify: 'Açıklama isteğine yanıt verin', + composer_disabled_compression: 'Sıkıştırmanın bitmesi bekleniyor', + composer_disabled_empty: 'Göndermek için bir mesaj yazın', + composer_mobile_workspace: 'Çalışma alanı', + composer_mobile_model: 'Modeli', + composer_mobile_reasoning: 'muhakeme', + composer_mobile_context: 'Bağlam', + + pdf_loading: 'PDF {0} yükleniyor\u2026', + pdf_too_large: 'PDF satır içi önizleme için çok büyük', + pdf_no_pages: 'PDF\'de sayfa yok', + pdf_error: 'PDF önizlemesi oluşturulamadı', + pdf_download: 'PDF\'yi indir', + html_loading: 'HTML önizlemesi yükleniyor\u2026', + html_too_large: 'HTML satır içi önizleme için çok büyük', + html_error: 'HTML önizlemesi oluşturulamadı', + html_open_full: 'Tam sayfayı aç', + html_sandbox_label: 'HTML Önizlemesi (korumalı alan)', + media_audio_label: 'Ses', + media_svg_label: 'Diyagram', + media_video_label: 'Video', + csv_loading: 'CSV yükleniyor', + csv_too_large: 'CSV dosyası satır içi oluşturma için çok büyük', + csv_no_data: 'CSV dosyasında tablo olarak oluşturmak için yeterli veri yok', + csv_error: 'CSV dosyası yüklenemedi', + csv_header_note: 'Tablo başlığı olarak gösterilen ilk satır', + excalidraw_loading: 'Yükleme diyagramı', + excalidraw_too_large: 'Excalidraw dosyası satır içi oluşturma için çok büyük', + excalidraw_invalid: 'Geçersiz Excalidraw dosya biçimi', + excalidraw_error: 'Excalidraw dosyası yüklenemedi', + excalidraw_label: 'Diyagram', + excalidraw_download: 'İndirmek', + excalidraw_empty: 'Boş diyagram', + excalidraw_render_error: 'Diyagram oluşturulamadı', + excalidraw_simplified: 'Basitleştirilmiş SVG önizlemesi — Excalidraw tuvaliyle piksel açısından aynı değil', + // TTS (#499) + tts_listen: 'Dinlemek', + tts_not_supported: 'Konuşma sentezi bu tarayıcıda desteklenmiyor.', + settings_label_tts: 'Yanıtlar için Metinden Konuşmaya', + settings_desc_tts: 'Yardım mesajlarında hoparlör düğmesini göster', + settings_label_tts_auto_read: 'Yanıtları sesli olarak otomatik oku', + settings_desc_tts_auto_read: 'Tamamlandığında her yeni asistanın yanıtını otomatik olarak söyleyin. Yazmaya başladığınızda duraklar.', + // Composer voice-mode pref (#1488) + settings_label_voice_mode: 'Eller serbest ses modu düğmesi', + settings_desc_voice_mode: 'Dikte mikrofonunun yanında ses modu düğmesini gösterir. Duraklamadan sonra Hermes otomatik gönderir ve yanıtları sesli okur. Konuşma tanıma ve TTS destekleyen tarayıcı gerektirir.', + settings_label_tts_voice: 'Ses', + settings_desc_tts_voice: 'Ses sentezi sesini seçin', + settings_label_tts_rate: 'Konuşma hızı', + settings_label_tts_pitch: 'Konuşma perdesi', + checkpoint_date: 'Tarih', + checkpoint_diff_files_changed: (n) => `${n} dosya değişti`, + checkpoint_diff_no_changes: 'Bu kontrol noktası ile mevcut çalışma alanı arasında fark bulunamadı.', + checkpoint_diff_title: 'Kontrol noktasındaki değişiklikler', + checkpoint_empty: 'Bu çalışma alanı için kontrol noktası bulunamadı.', + checkpoint_error: 'Kontrol noktaları yüklenemedi', + checkpoint_files: 'Dosyalar', + checkpoint_loading: 'Kontrol noktaları yükleniyor\u2026', + checkpoint_message: 'Mesaj', + checkpoint_restore: 'Geri yükle', + checkpoint_restore_confirm_message: (ckpt) => `Çalışma alanı "${ckpt}" kontrol noktasına geri yüklensin mi? Kayıtlı sürümlerle dosyaların üzerine yazılır. Sonradan eklenen dosyalar silinmez.`, + checkpoint_restore_confirm_title: 'Kontrol noktası geri yüklensin mi?', + checkpoint_restored: 'Kontrol noktası geri yüklendi', + checkpoint_title: 'Kontrol noktaları', + checkpoint_view_diff: 'Farkı görüntüle', + insights_activity_by_day: 'Güne göre etkinlik', + insights_activity_by_hour: 'Saate göre etkinlik', + insights_cost: 'Tahmini maliyet', + insights_daily_tokens: 'Günlük Jetonlar', + insights_model_name: 'Modeli', + insights_model_sessions: 'Oturumlar', + insights_model_tokens: 'Jetonlar', + insights_model_cost: 'Maliyet', + insights_model_share: 'Paylaşmak', + insights_no_usage_data: 'Henüz kullanım verisi yok', + insights_footer: 'Son {days} günün verileri gösteriliyor', + insights_input_tokens: 'Giriş', + insights_messages: 'Mesajlar', + insights_models: 'Modeller', + insights_no_cost: 'Yok', + insights_output_tokens: 'Çıkış', + insights_peak_hour: 'Zirve: {hour}', + insights_sessions: 'Oturumlar', + insights_title: 'Kullanım analitiği', + insights_token_breakdown: 'Jeton dağılımı', + insights_tokens: 'Jetonlar', + insights_total: 'Toplam', + settings_desc_api_redact: 'Kendi sunucunuzda şeffaflık için devre dışı bırakılabilir (paylaşımlı örneklerde önerilmez).', + settings_label_api_redact: 'API yanıtlarında hassas verileri gizle', + subagent_children: 'Alt agent oturumları', + + + } }; diff --git a/static/messages.js b/static/messages.js index 4bccc85ba1..bfebb94d75 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1682,6 +1682,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }); source.addEventListener('done',e=>{ + if(_streamFinalized) return; _terminalStateReached=true; if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;} const _doneData=JSON.parse(e.data); @@ -1859,12 +1860,31 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _finishDone(); }); - source.addEventListener('stream_end',e=>{ + source.addEventListener('stream_end',async e=>{ + if(_streamFinalized){ + source.close(); + return; + } _terminalStateReached=true; try{ const d=JSON.parse(e.data||'{}'); if((d.session_id||activeSid)!==activeSid) return; }catch(_){} + // Some replay/journal paths can deliver stream_end without a preceding + // done event. In that case closing the EventSource is not enough: the + // live DOM/inflight state remains projected and can duplicate Thinking or + // assistant content until a later session switch. Settle from the persisted + // session before closing so the pane converges on canonical state. + if(await _restoreSettledSession()){ + source.close(); + return; + } + if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;} + _streamFinalized=true; + _cancelAnimationFramePendingStreamRender(); + _streamFadeCleanupReduceMotionListener(); + _smdEndParser(); + if(typeof finalizeThinkingCard==='function') finalizeThinkingCard(); source.close(); }); @@ -2132,9 +2152,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ async function _restoreSettledSession(){ try{ const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`); + // Opus #2852 race-fix: if a late `done` event ran the finalize path while + // we were awaiting the network roundtrip, bail out — done already settled. + if(_streamFinalized) return true; const session=data&&data.session; if(!session) return false; if(session.active_stream_id||session.pending_user_message) return false; + if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;} + _streamFinalized=true; + _cancelAnimationFramePendingStreamRender(); + _streamFadeCleanupReduceMotionListener(); + _smdEndParser(); + if(typeof finalizeThinkingCard==='function') finalizeThinkingCard(); _clearOwnerInflightState(); _closeSource(); _clearApprovalForOwner(); diff --git a/static/panels.js b/static/panels.js index fdeeb5100f..89ad0d20c6 100644 --- a/static/panels.js +++ b/static/panels.js @@ -7120,6 +7120,16 @@ function _mcpStatusLabel(status){ }[status]||'mcp_status_unknown'; return t(key); } +function toggleMcpServer(name, enabled){ + api('/api/mcp/servers/'+encodeURIComponent(name),{ + method:'PATCH', + body:JSON.stringify({enabled:enabled}), + }).then(r=>{ + if(r&&r.ok) showToast(t(enabled?'mcp_enabled_toast':'mcp_disabled_toast',name)); + else showToast(t('mcp_toggle_failed'),'error'); + loadMcpServers(); + }).catch(()=>{showToast(t('mcp_toggle_failed'),'error');loadMcpServers();}); +} function loadMcpServers(){ const list=$('mcpServerList'); if(!list) return; @@ -7130,7 +7140,6 @@ function loadMcpServers(){ list.innerHTML=`
${esc(t('mcp_no_servers'))}
`; return; } - const toggleNote=r.toggle_supported?'':'
'+esc(t('mcp_toggle_followup'))+'
'; list.innerHTML=r.servers.map(s=>{ const transportLabel=s.transport==='http'?'HTTP':s.transport==='stdio'?'stdio':(''+(s.transport||'unknown')); const transportClass=s.transport==='http'?'mcp-http':s.transport==='stdio'?'mcp-stdio':'mcp-unknown'; @@ -7144,6 +7153,11 @@ function loadMcpServers(){ const envInfo=s.env?Object.entries(s.env).map(([k,v])=>`${k}=${v}`).join(', '):''; const headersInfo=s.headers?Object.entries(s.headers).map(([k,v])=>`${k}=${v}`).join(', '):''; const secretInfo=[envInfo,headersInfo].filter(Boolean).join(' | '); + const isEnabled=s.enabled!==false; + const encodedName=encodeURIComponent(s.name).replace(/'/g,"\\'"); + const toggleBtn=r.toggle_supported + ?`` + :`${esc(t(isEnabled?'mcp_enabled_yes':'mcp_enabled_no'))}`; return `
${esc(s.name)} @@ -7151,9 +7165,9 @@ function loadMcpServers(){ ${statusBadge}
${esc(detail)}${secretInfo?' | '+esc(secretInfo):''}
-
${esc(t('mcp_tool_count',toolCount))}${esc(t(s.enabled===false?'mcp_enabled_no':'mcp_enabled_yes'))}
+
${esc(t('mcp_tool_count',toolCount))}${toggleBtn}
`; - }).join('')+toggleNote; + }).join(''); }).catch(()=>{list.innerHTML=`
${esc(t('mcp_load_failed'))}
`}); } let _mcpToolsCache=[]; diff --git a/static/style.css b/static/style.css index 429500caee..5566545a83 100644 --- a/static/style.css +++ b/static/style.css @@ -1352,7 +1352,7 @@ .suggestion:hover{background:var(--accent-bg);color:var(--text);border-color:var(--accent-bg);transform:translateX(2px);} /* ── Composer ── */ .composer-wrap{padding:12px 20px 16px;background:var(--bg);flex-shrink:0;} - .composer-box{max-width:780px;margin:0 auto;background:linear-gradient(var(--input-bg),var(--input-bg)),var(--bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;z-index:2;} + .composer-box{max-width:clamp(780px,60vw,1100px);margin:0 auto;background:linear-gradient(var(--input-bg),var(--input-bg)),var(--bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;z-index:2;} .composer-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);} .composer-wrap.drag-over .composer-box{border-color:var(--accent-text);background:var(--accent-bg);} .drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:var(--accent-bg);border:2px dashed var(--accent);border-radius:14px;font-size:14px;color:var(--accent-text);pointer-events:none;z-index:10;flex-direction:column;gap:8px;} @@ -2956,6 +2956,10 @@ main.main.showing-logs > #mainLogs{display:flex;} .mcp-status-disabled{background:rgba(161,161,170,.12);color:#a1a1aa;} .mcp-status-invalid_config,.mcp-status-unknown{background:rgba(239,68,68,.12);color:#f87171;} .mcp-tool-count{color:var(--text);} +.mcp-toggle-btn{font-size:10px;font-weight:600;padding:2px 8px;border-radius:999px;border:1px solid transparent;cursor:pointer;transition:opacity .15s;} +.mcp-toggle-btn:hover{opacity:.8;} +.mcp-toggle-enabled{background:rgba(34,197,94,.15);color:#4ade80;border-color:rgba(34,197,94,.3);} +.mcp-toggle-disabled{background:rgba(161,161,170,.12);color:#a1a1aa;border-color:rgba(161,161,170,.25);} .mcp-readonly-note,.mcp-restart-hint{margin-top:8px;color:var(--muted);font-size:11px;line-height:1.45;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;padding:8px 10px;} .mcp-tool-search{width:100%;margin:0 0 8px 0;padding:8px 10px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:8px;font-size:12px;outline:none;} .mcp-tool-search:focus{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-bg-soft);} diff --git a/tests/test_1560_password_env_var_no_op.py b/tests/test_1560_password_env_var_no_op.py index bebc25c888..1e8723cb77 100644 --- a/tests/test_1560_password_env_var_no_op.py +++ b/tests/test_1560_password_env_var_no_op.py @@ -333,7 +333,7 @@ def test_panels_js_uses_locked_placeholder_i18n_key(): # (en/es/de/zh/zh-Hant/ru/ja/fr/pt). The repo currently ships 9 locales but # substitutes 'ko' for 'fr' — we test what the repo actually has, not what the # issue body lists, so a future addition of fr won't fail the suite either. -EXPECTED_LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko") +EXPECTED_LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr") def _locale_block(locale_key: str) -> str: diff --git a/tests/test_1694_terminal_cleanup_ownership.py b/tests/test_1694_terminal_cleanup_ownership.py index 57fea1d18e..f2a12c5329 100644 --- a/tests/test_1694_terminal_cleanup_ownership.py +++ b/tests/test_1694_terminal_cleanup_ownership.py @@ -91,3 +91,33 @@ def test_reconnect_settled_and_error_paths_keep_cleanup_session_scoped(): assert "stopApprovalPolling();stopClarifyPolling();" not in combined assert "renderSessionList();setBusy(false)" not in combined assert "_setActivePaneIdleIfOwner" in combined + +def test_stream_end_without_done_restores_settled_session_before_closing(): + """If a journal/replay emits stream_end without done, the UI must settle from /api/session. + + A close-only stream_end handler leaves live Thinking/inflight DOM around and + never replaces the pane with the persisted transcript when done is missing. + """ + body = _event_body("stream_end") + restore_idx = body.find("_restoreSettledSession()") + close_idx = body.rfind("source.close()") + finalized_idx = body.find("_streamFinalized=true") + assert restore_idx != -1, "stream_end handler must restore settled session when done is absent" + assert close_idx != -1, "stream_end handler must still close the EventSource" + assert restore_idx < close_idx, "restore must be attempted before closing the stream" + assert finalized_idx != -1, "stream_end terminal path must suppress trailing rAF/render work" + + +def test_done_handler_is_idempotent_for_replay_or_duplicate_done_events(): + """Duplicate/replayed done events must not replay completion sound or duplicate render.""" + body = _event_body("done") + first_stmt = body.strip().splitlines()[0].strip() + assert "_streamFinalized" in first_stmt and "return" in first_stmt, ( + "done handler must return early when the stream was already finalized" + ) + guard_idx = body.find("if(_streamFinalized) return;") + sound_idx = body.find("playNotificationSound();") + assert sound_idx != -1, "done handler should still play completion sound once" + assert guard_idx != -1 and guard_idx < sound_idx, ( + "completion sound must be behind the duplicate-done finalization guard" + ) diff --git a/tests/test_2735_open_in_vscode.py b/tests/test_2735_open_in_vscode.py index dbf2fb8c7d..207d358da1 100644 --- a/tests/test_2735_open_in_vscode.py +++ b/tests/test_2735_open_in_vscode.py @@ -226,19 +226,19 @@ class TestOpenInVsCodeI18n: ] def test_open_in_vscode_key_count(self): - """open_in_vscode key must appear exactly once per locale (10 total).""" + """open_in_vscode key must appear exactly once per locale (11 total).""" src = I18N.read_text(encoding="utf-8") count = src.count("open_in_vscode:") - assert count == 10, ( - f"Expected 10 open_in_vscode: entries (one per locale), found {count}" + assert count == 11, ( + f"Expected 11 open_in_vscode: entries (one per locale), found {count}" ) def test_open_in_vscode_failed_key_count(self): - """open_in_vscode_failed key must appear exactly once per locale (10 total).""" + """open_in_vscode_failed key must appear exactly once per locale (11 total).""" src = I18N.read_text(encoding="utf-8") count = src.count("open_in_vscode_failed:") - assert count == 10, ( - f"Expected 10 open_in_vscode_failed: entries (one per locale), found {count}" + assert count == 11, ( + f"Expected 11 open_in_vscode_failed: entries (one per locale), found {count}" ) def test_english_translation_not_a_placeholder(self): diff --git a/tests/test_issue1488_composer_voice_buttons.py b/tests/test_issue1488_composer_voice_buttons.py index f1e1a78ea3..72870e1966 100644 --- a/tests/test_issue1488_composer_voice_buttons.py +++ b/tests/test_issue1488_composer_voice_buttons.py @@ -123,7 +123,7 @@ class TestComposerVoiceButtonI18n: "voice_mode_toggle_active", ) - LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko") + LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr") def test_legacy_voice_toggle_key_removed(self): """The old key whose string was 'Voice input' caused the duplicate- @@ -171,7 +171,7 @@ def test_english_voice_mode_label_is_voice_mode(self): class TestVoiceModePreferenceGate: """boot.js must hide btnVoiceMode by default, surface it via Preferences.""" - LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko") + LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr") def test_voice_mode_pref_is_localstorage_backed(self): """The pref reads from localStorage key 'hermes-voice-mode-button'.""" diff --git a/tests/test_issue2419_cache_usage_display.py b/tests/test_issue2419_cache_usage_display.py index e6cf94e258..e5cd884887 100644 --- a/tests/test_issue2419_cache_usage_display.py +++ b/tests/test_issue2419_cache_usage_display.py @@ -63,8 +63,8 @@ def test_context_indicator_surfaces_cache_hit_rate(): def test_cache_usage_labels_are_localized(): src = (ROOT / "static" / "i18n.js").read_text() - assert src.count("usage_cache_hit_detail:") == 11 - assert src.count("usage_cached_percent:") == 11 + assert src.count("usage_cache_hit_detail:") == 12 + assert src.count("usage_cached_percent:") == 12 assert "usage_cache_hit_detail: 'Cache: {0}% hit ({1} read / {2} write)'" in src assert "usage_cached_percent: '{0}% cached'" in src diff --git a/tests/test_issue2462_theme_i18n.py b/tests/test_issue2462_theme_i18n.py index 07e77302e8..0b83b3fb3d 100644 --- a/tests/test_issue2462_theme_i18n.py +++ b/tests/test_issue2462_theme_i18n.py @@ -31,7 +31,7 @@ def test_theme_command_help_mentions_current_theme_and_skin_values(): "system/dark/light", "default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast", ) - for locale in ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "fr"): + for locale in ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "fr", "tr"): value = _literal_value(_locale_block(locale), "cmd_theme") for fragment in required_fragments: assert fragment in value, f"{locale} cmd_theme missing {fragment!r}: {value!r}" diff --git a/tests/test_issue2679_hide_suggestions.py b/tests/test_issue2679_hide_suggestions.py index b00b047b83..1b4f24e8a9 100644 --- a/tests/test_issue2679_hide_suggestions.py +++ b/tests/test_issue2679_hide_suggestions.py @@ -50,8 +50,8 @@ def test_panels_round_trip_and_hot_apply_hide_suggestions(): def test_hide_suggestions_i18n_all_locales_and_changelog(): js = I18N.read_text(encoding="utf-8") - assert js.count("settings_label_hide_suggestions:") == 11 - assert js.count("settings_desc_hide_suggestions:") == 11 + assert js.count("settings_label_hide_suggestions:") == 12 + assert js.count("settings_desc_hide_suggestions:") == 12 changelog = CHANGELOG.read_text(encoding="utf-8") assert "#2679" in changelog assert "hide_empty_state_suggestions" in changelog diff --git a/tests/test_issue538_mcp_management.py b/tests/test_issue538_mcp_management.py index 758eff1a80..77c3ea7209 100644 --- a/tests/test_issue538_mcp_management.py +++ b/tests/test_issue538_mcp_management.py @@ -5,6 +5,7 @@ _handle_mcp_servers_list, _handle_mcp_server_update, _handle_mcp_server_delete, + _handle_mcp_server_toggle, _mask_secrets, _parse_mcp_enabled, _server_summary, @@ -60,7 +61,7 @@ def test_empty_config(self, mock_cfg): assert status == 200 payload = _json_payload(h) assert payload['servers'] == [] - assert payload['toggle_supported'] is False + assert payload['toggle_supported'] is True assert payload['reload_required'] is True @patch('api.routes._mcp_runtime_status_by_name') @@ -307,3 +308,83 @@ def test_non_dict_passthrough(self): def test_empty_dicts(self): assert _strip_masked_values({}, {}) == {} assert _strip_masked_values({"k": "v"}, {}) == {"k": "v"} + + +class TestMcpToggle: + """PATCH /api/mcp/servers/ — enable/disable.""" + + @patch('api.routes.reload_config') + @patch('api.routes._save_yaml_config_file') + @patch('api.routes._get_config_path', return_value='/tmp/test.yaml') + @patch('api.routes.get_config') + def test_disable_server(self, mock_cfg, mock_path, mock_save, mock_reload): + mock_cfg.return_value = {'mcp_servers': {'myserver': {'command': 'run'}}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'myserver', {'enabled': False}) + assert mock_save.called + saved = mock_save.call_args[0][1] + assert saved['mcp_servers']['myserver']['enabled'] is False + assert mock_reload.called + + @patch('api.routes.reload_config') + @patch('api.routes._save_yaml_config_file') + @patch('api.routes._get_config_path', return_value='/tmp/test.yaml') + @patch('api.routes.get_config') + def test_enable_server(self, mock_cfg, mock_path, mock_save, mock_reload): + mock_cfg.return_value = {'mcp_servers': {'myserver': {'command': 'run', 'enabled': False}}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'myserver', {'enabled': True}) + saved = mock_save.call_args[0][1] + assert saved['mcp_servers']['myserver']['enabled'] is True + + @patch('api.routes.get_config') + def test_nonexistent_server_returns_404(self, mock_cfg): + mock_cfg.return_value = {'mcp_servers': {}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'ghost', {'enabled': True}) + status = h.send_response.call_args[0][0] + assert status == 404 + + def test_empty_name_rejected(self): + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, '', {'enabled': True}) + status = h.send_response.call_args[0][0] + assert status == 400 + + def test_missing_enabled_field_rejected(self): + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'myserver', {}) + status = h.send_response.call_args[0][0] + assert status == 400 + + @patch('api.routes.reload_config') + @patch('api.routes._save_yaml_config_file') + @patch('api.routes._get_config_path', return_value='/tmp/test.yaml') + @patch('api.routes.get_config') + def test_response_payload(self, mock_cfg, mock_path, mock_save, mock_reload): + mock_cfg.return_value = {'mcp_servers': {'srv': {'url': 'http://localhost'}}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'srv', {'enabled': False}) + body = h.wfile.write.call_args[0][0] + payload = json.loads(body.decode('utf-8')) + assert payload == {'ok': True, 'name': 'srv', 'enabled': False} + + @patch('api.routes.reload_config') + @patch('api.routes._save_yaml_config_file') + @patch('api.routes._get_config_path', return_value='/tmp/test.yaml') + @patch('api.routes.get_config') + def test_url_encoded_name(self, mock_cfg, mock_path, mock_save, mock_reload): + """Names with special characters must be URL-decoded.""" + mock_cfg.return_value = {'mcp_servers': {'my server': {'command': 'x'}}} + h = _make_handler() + h.command = 'PATCH' + _handle_mcp_server_toggle(h, 'my%20server', {'enabled': False}) + saved = mock_save.call_args[0][1] + assert 'my server' in saved['mcp_servers'] + assert saved['mcp_servers']['my server']['enabled'] is False diff --git a/tests/test_issue696_mcp_visibility_panel.py b/tests/test_issue696_mcp_visibility_panel.py index 999192e5be..3fa1cf4332 100644 --- a/tests/test_issue696_mcp_visibility_panel.py +++ b/tests/test_issue696_mcp_visibility_panel.py @@ -24,7 +24,8 @@ def test_mcp_panel_renders_status_badges_tool_counts_and_empty_error_states(): assert "mcp-tool-count" in js assert "mcp-empty-state" in js assert "mcp-error-state" in js - assert "mcp_toggle_followup" in js + assert "toggleMcpServer" in js + assert "mcp-toggle-btn" in js assert "api('/api/mcp/servers')" in js assert "mcp-delete-btn" not in js assert "showMcpAddForm" not in js diff --git a/tests/test_pr1721_rtl_salvage.py b/tests/test_pr1721_rtl_salvage.py index f7cd1d7af0..bc7c84459d 100644 --- a/tests/test_pr1721_rtl_salvage.py +++ b/tests/test_pr1721_rtl_salvage.py @@ -112,5 +112,5 @@ def test_rtl_in_config_defaults_and_writable_keys(): def test_rtl_localized_in_all_locales(): js = I18N.read_text(encoding="utf-8") # Count occurrences — should match the 11 locale blocks - assert js.count("settings_label_rtl:") == 11 - assert js.count("settings_desc_rtl:") == 11 + assert js.count("settings_label_rtl:") == 12 + assert js.count("settings_desc_rtl:") == 12 diff --git a/tests/test_quota_chip_settings_toggle.py b/tests/test_quota_chip_settings_toggle.py index d5cda58e4e..91c90de77b 100644 --- a/tests/test_quota_chip_settings_toggle.py +++ b/tests/test_quota_chip_settings_toggle.py @@ -88,5 +88,5 @@ def test_quota_chip_panels_round_trip(): def test_quota_chip_localized_in_all_locales(): js = I18N.read_text(encoding="utf-8") - assert js.count("settings_label_quota_chip:") == 11, "11 locales expected" - assert js.count("settings_desc_quota_chip:") == 11, "11 locales expected" + assert js.count("settings_label_quota_chip:") == 12, "12 locales expected" + assert js.count("settings_desc_quota_chip:") == 12, "12 locales expected" diff --git a/tests/test_sidebar_tab_visibility.py b/tests/test_sidebar_tab_visibility.py index 43ce2f66fa..2d80187b15 100644 --- a/tests/test_sidebar_tab_visibility.py +++ b/tests/test_sidebar_tab_visibility.py @@ -99,8 +99,8 @@ def test_i18n_coverage(): """Label and description keys must exist in all locales with matching counts.""" label_count = I18N_JS.count("settings_label_tab_visibility") desc_count = I18N_JS.count("settings_desc_tab_visibility") - assert label_count >= 11, f"Expected ≥11 locales, found {label_count}" - assert desc_count >= 11, f"Expected ≥11 locales, found {desc_count}" + assert label_count >= 12, f"Expected ≥12 locales, found {label_count}" + assert desc_count >= 12, f"Expected ≥12 locales, found {desc_count}" assert label_count == desc_count, \ f"Label ({label_count}) and desc ({desc_count}) counts must match" diff --git a/tests/test_terminal_process_cleanup.py b/tests/test_terminal_process_cleanup.py index 5160793583..a537108191 100644 --- a/tests/test_terminal_process_cleanup.py +++ b/tests/test_terminal_process_cleanup.py @@ -1,4 +1,9 @@ +import os import subprocess +import threading +import time + +import pytest import api.terminal as terminal @@ -27,7 +32,19 @@ def wait(self, timeout=None): return 0 -def test_terminal_shell_uses_parent_death_signal_preexec(monkeypatch, tmp_path): +def test_terminal_shell_does_not_use_pdeathsig_preexec(monkeypatch, tmp_path): + """Regression for #2853. + + The previous implementation passed a ``preexec_fn`` that called + ``prctl(PR_SET_PDEATHSIG, SIGTERM)``. Because that signal is *per-thread* + and WebUI's ``ThreadingHTTPServer`` spawns a new thread for every HTTP + request, the PTY shell registered the request-handler thread as its + parent and was killed within ~10 ms of being created on Linux. + + The fix is to spawn the shell without ``preexec_fn`` at all. Graceful + shutdown remains covered by ``atexit.register(close_all_terminals)`` and + the explicit ``close_terminal`` paths. + """ captured = {} proc = _FakeProc() @@ -40,15 +57,59 @@ def fake_popen(*args, **kwargs): monkeypatch.setattr(terminal.threading, "Thread", _DummyThread) monkeypatch.setattr(terminal, "_set_size", lambda *args, **kwargs: None) - term = terminal.start_terminal("term-preexec", tmp_path) + term = terminal.start_terminal("term-no-preexec", tmp_path) try: assert term.proc is proc - assert captured["kwargs"]["preexec_fn"] is terminal._terminal_shell_preexec_fn + assert "preexec_fn" not in captured["kwargs"], ( + "preexec_fn must not be set — the PR_SET_PDEATHSIG implementation " + "killed every Linux user's terminal (#2853). See module-level note." + ) assert captured["kwargs"]["start_new_session"] is True assert captured["kwargs"]["stdin"] == captured["kwargs"]["stdout"] == captured["kwargs"]["stderr"] finally: - terminal.close_terminal("term-preexec") + terminal.close_terminal("term-no-preexec") + + +@pytest.mark.skipif( + not hasattr(os, "openpty") or os.name != "posix", + reason="PTY-spawn test requires a POSIX host", +) +def test_pty_shell_survives_when_spawning_thread_exits(tmp_path): + """End-to-end regression for #2853. + + Spawn a real PTY shell via ``start_terminal`` from inside a worker thread + that then exits. The shell must remain alive after the spawning thread + joins, otherwise we've regressed back to the PR_SET_PDEATHSIG behaviour + that killed every Linux user's embedded terminal. + """ + sid = "term-thread-survival" + holder: dict = {} + + def worker(): + try: + holder["term"] = terminal.start_terminal(sid, tmp_path) + except Exception as exc: # pragma: no cover - surface in assertion + holder["error"] = exc + + t = threading.Thread(target=worker) + t.start() + t.join(timeout=5) + assert not t.is_alive(), "spawn worker thread should have exited" + assert "error" not in holder, holder.get("error") + term = holder["term"] + + try: + # Give the kernel a beat — if PR_SET_PDEATHSIG were re-introduced the + # shell would receive SIGTERM right about now. + time.sleep(0.5) + assert term.proc.poll() is None, ( + "PTY shell exited after the spawning thread joined — likely a " + "PR_SET_PDEATHSIG regression (#2853). " + f"exit_code={term.proc.poll()!r}" + ) + finally: + terminal.close_terminal(sid) def test_close_terminal_waits_again_after_sigkill(monkeypatch): @@ -96,8 +157,11 @@ def fake_close(session_id): def test_terminal_module_registers_graceful_shutdown_reaper(): + """atexit is still the reap path; pdeathsig must NOT be re-introduced.""" src = terminal.Path(terminal.__file__).read_text() assert "atexit.register(close_all_terminals)" in src - assert "preexec_fn=_terminal_shell_preexec_fn" in src - assert "libc.prctl(1, signal.SIGTERM)" in src + # The PR_SET_PDEATHSIG implementation broke every Linux user (#2853); + # guard against accidentally bringing it back. + assert "preexec_fn=_terminal_shell_preexec_fn" not in src + assert "libc.prctl(1, signal.SIGTERM)" not in src diff --git a/tests/test_turkish_locale.py b/tests/test_turkish_locale.py new file mode 100644 index 0000000000..5fa0d78983 --- /dev/null +++ b/tests/test_turkish_locale.py @@ -0,0 +1,155 @@ +from collections import Counter +from pathlib import Path +import re + + +REPO = Path(__file__).resolve().parent.parent + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def extract_locale_block(src: str, locale_key: str) -> str: + start_match = re.search(rf"\b{re.escape(locale_key)}\s*:\s*\{{", src) + assert start_match, f"{locale_key} locale block not found" + + start = start_match.end() - 1 + depth = 0 + in_single = False + in_double = False + in_backtick = False + escape = False + + for i in range(start, len(src)): + ch = src[i] + + if escape: + escape = False + continue + + if in_single: + if ch == "\\": + escape = True + elif ch == "'": + in_single = False + continue + + if in_double: + if ch == "\\": + escape = True + elif ch == '"': + in_double = False + continue + + if in_backtick: + if ch == "\\": + escape = True + elif ch == "`": + in_backtick = False + continue + + if ch == "'": + in_single = True + continue + if ch == '"': + in_double = True + continue + if ch == "`": + in_backtick = True + continue + + if ch == "{": + depth += 1 + continue + if ch == "}": + depth -= 1 + if depth == 0: + return src[start + 1 : i] + + raise AssertionError(f"{locale_key} locale block braces are not balanced") + + +def locale_keys(src: str, locale_key: str) -> list[str]: + key_pattern = re.compile(r"^\s*([a-zA-Z0-9_]+)\s*:", re.MULTILINE) + return key_pattern.findall(extract_locale_block(src, locale_key)) + + +def test_turkish_locale_block_exists(): + src = read(REPO / "static" / "i18n.js") + tr_block = extract_locale_block(src, "tr") + assert tr_block + assert "_lang: 'tr'" in tr_block + assert "_label: 'Türkçe'" in tr_block + assert "_speech: 'tr-TR'" in tr_block + + +def test_turkish_locale_includes_representative_translations(): + src = read(REPO / "static" / "i18n.js") + tr_block = extract_locale_block(src, "tr") + expected = [ + "settings_title: 'Ayarlar'", + "settings_label_language: 'Dil'", + "login_title: 'Oturum aç'", + "approval_heading: 'Onay gerekli'", + "tab_chat: 'Sohbet'", + "tab_tasks: 'Görevler'", + "tab_profiles: 'Agent profilleri'", + "empty_title: 'Hangi konuda yardımcı olabilirim?'", + "onboarding_title: 'Hermes Web Kullanıcı Arayüzüne Hoş Geldiniz'", + ] + for entry in expected: + assert entry in tr_block + + +def test_turkish_settings_detail_descriptions_are_translated(): + src = read(REPO / "static" / "i18n.js") + tr_block = extract_locale_block(src, "tr") + expected = [ + "settings_desc_workspace_panel_open: 'Etkinleştirildiğinde, çalışma alanı / dosya tarayıcı paneli her yeni oturumda otomatik olarak açılır. Yine de istediğiniz zaman manuel olarak kapatabilirsiniz.'", + "settings_desc_notifications: 'Uygulama arka plandayken bir yanıt tamamlandığında bir sistem bildirimi gösterin.'", + "settings_desc_token_usage: 'Her Asistan yanıtının altında giriş/çıkış jeton sayılarını gösterir. /usage ile de değiştirilebilir.'", + "settings_desc_sidebar_density: 'Oturum listesinin sol kenar çubuğunda ne kadar meta veri göstereceğini kontrol eder.'", + "settings_desc_auto_title_refresh: 'Oturum başlıklarını en son konuşmaya göre otomatik olarak yeniden oluşturarak konuşma ilerledikçe başlıkların alakalı kalmasını sağlar. LLM başlık oluşturma modeli yapılandırması gerektirir.'", + "settings_desc_external_sessions: 'Oturum listesinde CLI, Telegram, Discord, Slack ve diğer kanallardan gelen konuşmaları gösterin. İçe aktarmak ve devam etmek için tıklayın.'", + "settings_desc_sync_insights: 'WebUI belirteci kullanımını state.db\\'ye yansıtır, böylece hermes /insights tarayıcı oturum verilerini içerir. Varsayılan olarak kapalıdır.'", + "settings_desc_check_updates: 'WebUI veya Agent\\'ın daha yeni sürümleri mevcut olduğunda bir banner gösterin. Periyodik olarak bir arka plan git getirme işlemi çalıştırır.'", + "settings_desc_bot_name: 'Yalnızca varsayılan profil için kullanılır. Diğer profiller kendi profil adlarını kullanır.'", + "settings_desc_password: 'Ayarlamak veya değiştirmek için yeni bir şifre girin. Geçerli ayarı korumak için boş bırakın.'", + ] + for entry in expected: + assert entry in tr_block + + +def test_turkish_locale_matches_english_key_coverage(): + src = read(REPO / "static" / "i18n.js") + en_keys = set(locale_keys(src, "en")) + tr_keys = set(locale_keys(src, "tr")) + assert sorted(en_keys - tr_keys) == [] + assert sorted(tr_keys - en_keys) == [] + + +def test_turkish_locale_has_no_duplicate_keys(): + src = read(REPO / "static" / "i18n.js") + keys = locale_keys(src, "tr") + duplicates = sorted(k for k, count in Counter(keys).items() if count > 1) + assert not duplicates, f"Turkish locale has duplicate keys: {duplicates}" + + +def test_turkish_locale_keys_use_standard_indentation(): + src = read(REPO / "static" / "i18n.js") + tr_block = extract_locale_block(src, "tr") + badly_indented = [ + line.strip() + for line in tr_block.splitlines() + if re.match(r"^\s{1,3}[a-zA-Z0-9_]+\s*:", line) + ] + assert badly_indented == [] + + +def test_turkish_locale_has_no_double_escaped_unicode_sequences(): + """JSON-style double escapes (\\\\u2026) render literal backslash-u in the UI.""" + src = read(REPO / "static" / "i18n.js") + tr_block = extract_locale_block(src, "tr") + for bad in ("\\\\u2026", "\\\\u2192", "\\\\u2713"): + assert bad not in tr_block, f"Turkish locale must not contain {bad!r}" diff --git a/tests/test_updates.py b/tests/test_updates.py index 94c29e34ab..71792c4438 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -419,3 +419,177 @@ def fake_git(args, cwd, timeout=10): assert info.get('release_based') is not True, ( 'post-tag HEAD should use branch check, not release-based check' ) + + +# --------------------------------------------------------------------------- +# Regression tests for #2846: _select_apply_compare_ref must mirror the +# check-side decision about whether to advance to the latest tag or to the +# upstream branch. Pre-fix, the check correctly fell through to the branch +# count when HEAD was past the latest tag, but apply still aimed at the tag — +# so clicking "Update Now" no-op'd, restarted the server, and the banner +# re-appeared with the same N commits. +# --------------------------------------------------------------------------- + + +def test_select_apply_compare_ref_uses_tag_when_head_is_on_tag(tmp_path): + """HEAD == latest tag → apply path advances to the tag (unchanged).""" + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return 'v2026.5.16\nv2026.5.10', True + if args == ['describe', '--tags', '--abbrev=0']: + return 'v2026.5.16', True + if args == ['describe', '--tags', '--always']: + return 'v2026.5.16', True + raise AssertionError(f'unexpected git args: {args!r}') + + with patch.object(updates, '_run_git', side_effect=fake_git): + ref = updates._select_apply_compare_ref(tmp_path) + + assert ref == 'v2026.5.16' + + +def test_select_apply_compare_ref_falls_through_when_head_is_past_tag(tmp_path): + """HEAD past latest tag → apply path advances to origin/, not the tag. + + Mirrors the issue #2846 repro: hermes-agent has tag v2026.5.16, master is + 608 commits ahead, the banner correctly reports 608 commits available + (post-#2758), but pre-fix apply ran `git pull --ff-only v2026.5.16` — a + no-op — and the banner reappeared after restart. + """ + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return 'v2026.5.16', True + if args == ['describe', '--tags', '--abbrev=0']: + # HEAD's nearest tag is v2026.5.16; HEAD is 608 commits past it. + return 'v2026.5.16', True + if args == ['describe', '--tags', '--always']: + return 'v2026.5.16-608-g1d22b9c2d', True + if args == ['rev-parse', '--abbrev-ref', '@{upstream}']: + return 'origin/main', True + raise AssertionError(f'unexpected git args: {args!r}') + + with patch.object(updates, '_run_git', side_effect=fake_git): + ref = updates._select_apply_compare_ref(tmp_path) + + assert ref == 'origin/main', ( + 'apply path must advance to the upstream branch when HEAD is past the ' + 'latest tag, otherwise Update Now no-ops and the banner loops (#2846)' + ) + + +def test_select_apply_compare_ref_no_tags_uses_upstream(tmp_path): + """No `v*` tags → apply path uses the configured upstream (unchanged).""" + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return '', True + if args == ['rev-parse', '--abbrev-ref', '@{upstream}']: + return 'origin/feat/foo', True + raise AssertionError(f'unexpected git args: {args!r}') + + with patch.object(updates, '_run_git', side_effect=fake_git): + ref = updates._select_apply_compare_ref(tmp_path) + + assert ref == 'origin/feat/foo' + + +def test_select_apply_compare_ref_no_tags_no_upstream_uses_default_branch(tmp_path): + """No tags and no upstream → fall back to origin/.""" + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return '', True + if args == ['rev-parse', '--abbrev-ref', '@{upstream}']: + return '', False + if args == ['symbolic-ref', 'refs/remotes/origin/HEAD']: + return 'refs/remotes/origin/main', True + raise AssertionError(f'unexpected git args: {args!r}') + + with patch.object(updates, '_run_git', side_effect=fake_git): + ref = updates._select_apply_compare_ref(tmp_path) + + assert ref == 'origin/main' + + +def test_check_and_apply_paths_agree_when_head_is_past_tag(tmp_path): + """Check and apply paths must agree: both fall through to origin/. + + The bug class in #2846 (and #2653 before it) was the two paths drifting + apart — check said "you're 608 behind origin/main", apply said "advance + to v2026.5.16". This test pins the symmetry so they can't drift again. + """ + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return 'v2026.5.16', True + if args == ['describe', '--tags', '--abbrev=0']: + return 'v2026.5.16', True + if args == ['describe', '--tags', '--always']: + return 'v2026.5.16-608-g1d22b9c2d', True + if args == ['rev-parse', '--abbrev-ref', '@{upstream}']: + return 'origin/main', True + return '', True + + with patch.object(updates, '_run_git', side_effect=fake_git): + check_result = updates._check_repo_release(tmp_path, 'agent') + apply_ref = updates._select_apply_compare_ref(tmp_path) + + # Check side falls through (release check returns None → branch check runs) + assert check_result is None, ( + '_check_repo_release should fall through when HEAD is past the latest ' + 'tag (#2653)' + ) + # Apply side picks the same branch the check would have reported against + assert apply_ref == 'origin/main', ( + '_select_apply_compare_ref must mirror the check-side fall-through ' + 'when HEAD is past the latest tag (#2846)' + ) + + +def test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_exists(tmp_path): + """Case D — HEAD on older tag + commits + newer tag exists → advance to newer tag. + + Pre-Opus-#2855-fix: the check side correctly reported "behind by N" and + suggested `latest_tag`, but the apply side's predicate consulted + `_head_is_past_latest_tag(path, latest_tag)` which returned True (because + `git describe --tags --always` returns `v.older-N-g...` ≠ `latest_tag`). + So the apply side fell through to `origin/` and the pull landed + PAST the advertised tag — silent drift between check ("advance to + v2026.5.16") and apply ("pulled to whatever origin/main is now"). + + Fix: the apply-side predicate now uses `current_tag` (HEAD's nearest tag) + AND requires `behind == 0`, exactly mirroring the check-side rule. + """ + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return 'v2026.5.16\nv2026.5.10', True + if args == ['describe', '--tags', '--abbrev=0']: + # HEAD's nearest reachable tag (older one) + return 'v2026.5.10', True + if args == ['describe', '--tags', '--always']: + # HEAD has 3 commits past v2026.5.10 + return 'v2026.5.10-3-gabcdef12', True + if args == ['rev-parse', '--abbrev-ref', '@{upstream}']: + return 'origin/main', True + return '', True + + with patch.object(updates, '_run_git', side_effect=fake_git): + apply_ref = updates._select_apply_compare_ref(tmp_path) + + # User is genuinely behind v2026.5.16 (the newer published tag) — apply + # MUST advance to the tag, NOT fall through to origin/. + assert apply_ref == 'v2026.5.16', ( + 'case D: HEAD on older tag with commits + newer tag exists. Apply ' + 'should advance to the newer tag, not silently fall through to ' + 'origin/. Regression for Opus-flagged drift in #2855.' + ) +