diff --git a/examples/console/README.md b/examples/console/README.md
new file mode 100644
index 00000000..4d4f96d5
--- /dev/null
+++ b/examples/console/README.md
@@ -0,0 +1,57 @@
+# OpenViking Console
+
+This is a standalone console service.
+It is not wired into release packaging or CLI commands.
+
+## What it provides
+
+- File system browsing (`ls/read/stat`)
+- Find query
+- Add resource (`/api/v1/resources`)
+- Tenant/account management UI
+- System/observer status panels
+
+## Quick start
+
+1. Start OpenViking server (default: `http://127.0.0.1:1933`)
+2. Start this console example:
+
+```bash
+python examples/console/run_console.py \
+ --host 127.0.0.1 \
+ --port 8020 \
+ --openviking-url http://127.0.0.1:1933
+```
+
+3. Open:
+
+```text
+http://127.0.0.1:8020/
+```
+
+4. In **Settings**, paste your OpenViking `X-API-Key` and click **Save** (or press Enter).
+`X-API-Key` is configured in the web UI Settings panel and stored in browser session storage.
+
+## Startup parameters
+
+- `--openviking-url` (default `http://127.0.0.1:1933`)
+- `--host` (default `127.0.0.1`)
+- `--port` (default `8020`)
+- `--write-enabled` (default `false`)
+- `--request-timeout-sec` (default `30`)
+- `--cors-origins` (default `*`, comma-separated)
+
+Without `--write-enabled`, write operations are blocked by backend guardrails.
+If you need **Add Resource** or **multi-tenant management** (create/delete account, add/delete user, role/key changes),
+start with `--write-enabled`.
+
+Example:
+
+```bash
+python examples/console/run_console.py \
+ --host 127.0.0.1 \
+ --port 8020 \
+ --openviking-url http://127.0.0.1:1933 \
+ --write-enabled
+```
+
diff --git a/examples/console/__init__.py b/examples/console/__init__.py
new file mode 100644
index 00000000..e9f42e1d
--- /dev/null
+++ b/examples/console/__init__.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
+# SPDX-License-Identifier: Apache-2.0
+"""OpenViking console example package."""
+
+from .app import create_console_app
+from .config import ConsoleConfig, load_console_config
+
+__all__ = ["create_console_app", "ConsoleConfig", "load_console_config"]
diff --git a/examples/console/app.py b/examples/console/app.py
new file mode 100644
index 00000000..6b060076
--- /dev/null
+++ b/examples/console/app.py
@@ -0,0 +1,309 @@
+# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
+# SPDX-License-Identifier: Apache-2.0
+"""FastAPI app for the standalone OpenViking console service."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Optional
+
+import httpx
+from fastapi import APIRouter, FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse, JSONResponse, Response
+
+from .config import (
+ ConsoleConfig,
+ as_runtime_capabilities,
+ load_console_config,
+)
+
+PROXY_PREFIX = "/console/api/v1"
+
+_ALLOWED_FORWARD_HEADERS = {
+ "x-api-key",
+ "authorization",
+ "x-openviking-account",
+ "x-openviking-user",
+ "x-openviking-agent",
+ "content-type",
+}
+
+
+def _error_response(status_code: int, code: str, message: str, details: Optional[dict] = None):
+ return JSONResponse(
+ status_code=status_code,
+ content={
+ "status": "error",
+ "error": {
+ "code": code,
+ "message": message,
+ "details": details or {},
+ },
+ },
+ )
+
+
+def _copy_forward_headers(request: Request) -> dict[str, str]:
+ headers: dict[str, str] = {}
+ for key, value in request.headers.items():
+ if key.lower() in _ALLOWED_FORWARD_HEADERS:
+ headers[key] = value
+ return headers
+
+
+async def _forward_request(request: Request, upstream_path: str) -> Response:
+ """Forward the incoming request to OpenViking upstream."""
+ client: httpx.AsyncClient = request.app.state.upstream_client
+ body = await request.body()
+ try:
+ upstream_response = await client.request(
+ method=request.method,
+ url=upstream_path,
+ params=request.query_params,
+ content=body,
+ headers=_copy_forward_headers(request),
+ )
+ except httpx.RequestError as exc:
+ return _error_response(
+ status_code=502,
+ code="UPSTREAM_UNAVAILABLE",
+ message=f"Failed to reach OpenViking upstream: {exc}",
+ )
+
+ content_type = upstream_response.headers.get("content-type", "application/json")
+ return Response(
+ content=upstream_response.content,
+ status_code=upstream_response.status_code,
+ media_type=content_type,
+ )
+
+
+def _ensure_write_enabled(request: Request) -> Optional[JSONResponse]:
+ config: ConsoleConfig = request.app.state.console_config
+ if config.write_enabled:
+ return None
+ return _error_response(
+ status_code=403,
+ code="WRITE_DISABLED",
+ message=(
+ "Console write mode is disabled. Start service with --write-enabled "
+ "and restart the service to allow write operations."
+ ),
+ )
+
+
+def _create_proxy_router() -> APIRouter:
+ router = APIRouter(prefix=PROXY_PREFIX, tags=["console"])
+
+ @router.get("/runtime/capabilities")
+ async def runtime_capabilities(request: Request):
+ config: ConsoleConfig = request.app.state.console_config
+ return {"status": "ok", "result": as_runtime_capabilities(config)}
+
+ # ---- Read routes ----
+
+ @router.get("/ov/fs/ls")
+ async def fs_ls(request: Request):
+ return await _forward_request(request, "/api/v1/fs/ls")
+
+ @router.get("/ov/fs/tree")
+ async def fs_tree(request: Request):
+ return await _forward_request(request, "/api/v1/fs/tree")
+
+ @router.get("/ov/fs/stat")
+ async def fs_stat(request: Request):
+ return await _forward_request(request, "/api/v1/fs/stat")
+
+ @router.post("/ov/search/find")
+ async def search_find(request: Request):
+ return await _forward_request(request, "/api/v1/search/find")
+
+ @router.get("/ov/content/read")
+ async def content_read(request: Request):
+ return await _forward_request(request, "/api/v1/content/read")
+
+ @router.get("/ov/admin/accounts")
+ async def admin_accounts(request: Request):
+ return await _forward_request(request, "/api/v1/admin/accounts")
+
+ @router.get("/ov/admin/accounts/{account_id}/users")
+ async def admin_users(request: Request, account_id: str):
+ return await _forward_request(request, f"/api/v1/admin/accounts/{account_id}/users")
+
+ @router.get("/ov/system/status")
+ async def system_status(request: Request):
+ return await _forward_request(request, "/api/v1/system/status")
+
+ @router.get("/ov/observer/{component}")
+ async def observer_component(request: Request, component: str):
+ return await _forward_request(request, f"/api/v1/observer/{component}")
+
+ # ---- Write routes ----
+
+ @router.post("/ov/fs/mkdir")
+ async def fs_mkdir(request: Request):
+ blocked = _ensure_write_enabled(request)
+ if blocked:
+ return blocked
+ return await _forward_request(request, "/api/v1/fs/mkdir")
+
+ @router.post("/ov/resources")
+ async def add_resource(request: Request):
+ blocked = _ensure_write_enabled(request)
+ if blocked:
+ return blocked
+ return await _forward_request(request, "/api/v1/resources")
+
+ @router.post("/ov/resources/temp_upload")
+ async def add_resource_temp_upload(request: Request):
+ blocked = _ensure_write_enabled(request)
+ if blocked:
+ return blocked
+ return await _forward_request(request, "/api/v1/resources/temp_upload")
+
+ @router.post("/ov/fs/mv")
+ async def fs_mv(request: Request):
+ blocked = _ensure_write_enabled(request)
+ if blocked:
+ return blocked
+ return await _forward_request(request, "/api/v1/fs/mv")
+
+ @router.delete("/ov/fs")
+ async def fs_rm(request: Request):
+ blocked = _ensure_write_enabled(request)
+ if blocked:
+ return blocked
+ return await _forward_request(request, "/api/v1/fs")
+
+ @router.post("/ov/admin/accounts")
+ async def create_account(request: Request):
+ blocked = _ensure_write_enabled(request)
+ if blocked:
+ return blocked
+ return await _forward_request(request, "/api/v1/admin/accounts")
+
+ @router.delete("/ov/admin/accounts/{account_id}")
+ async def delete_account(request: Request, account_id: str):
+ blocked = _ensure_write_enabled(request)
+ if blocked:
+ return blocked
+ return await _forward_request(request, f"/api/v1/admin/accounts/{account_id}")
+
+ @router.post("/ov/admin/accounts/{account_id}/users")
+ async def create_user(request: Request, account_id: str):
+ blocked = _ensure_write_enabled(request)
+ if blocked:
+ return blocked
+ return await _forward_request(request, f"/api/v1/admin/accounts/{account_id}/users")
+
+ @router.delete("/ov/admin/accounts/{account_id}/users/{user_id}")
+ async def delete_user(request: Request, account_id: str, user_id: str):
+ blocked = _ensure_write_enabled(request)
+ if blocked:
+ return blocked
+ return await _forward_request(
+ request, f"/api/v1/admin/accounts/{account_id}/users/{user_id}"
+ )
+
+ @router.put("/ov/admin/accounts/{account_id}/users/{user_id}/role")
+ async def set_user_role(request: Request, account_id: str, user_id: str):
+ blocked = _ensure_write_enabled(request)
+ if blocked:
+ return blocked
+ return await _forward_request(
+ request,
+ f"/api/v1/admin/accounts/{account_id}/users/{user_id}/role",
+ )
+
+ @router.post("/ov/admin/accounts/{account_id}/users/{user_id}/key")
+ async def regenerate_key(request: Request, account_id: str, user_id: str):
+ blocked = _ensure_write_enabled(request)
+ if blocked:
+ return blocked
+ return await _forward_request(
+ request,
+ f"/api/v1/admin/accounts/{account_id}/users/{user_id}/key",
+ )
+
+ @router.post("/ov/sessions")
+ async def create_session(request: Request):
+ blocked = _ensure_write_enabled(request)
+ if blocked:
+ return blocked
+ return await _forward_request(request, "/api/v1/sessions")
+
+ @router.post("/ov/sessions/{session_id}/messages")
+ async def add_session_message(request: Request, session_id: str):
+ blocked = _ensure_write_enabled(request)
+ if blocked:
+ return blocked
+ return await _forward_request(request, f"/api/v1/sessions/{session_id}/messages")
+
+ @router.post("/ov/sessions/{session_id}/commit")
+ async def commit_session(request: Request, session_id: str):
+ blocked = _ensure_write_enabled(request)
+ if blocked:
+ return blocked
+ return await _forward_request(request, f"/api/v1/sessions/{session_id}/commit")
+
+ return router
+
+
+def create_console_app(
+ config: Optional[ConsoleConfig] = None,
+ upstream_transport: Optional[httpx.AsyncBaseTransport] = None,
+) -> FastAPI:
+ """Create console app instance."""
+ if config is None:
+ config = load_console_config()
+
+ static_dir = Path(__file__).resolve().parent / "static"
+ index_file = static_dir / "index.html"
+
+ app = FastAPI(
+ title="OpenViking Console",
+ description="Standalone console for OpenViking HTTP APIs",
+ version="0.1.0",
+ )
+
+ app.state.console_config = config
+ app.state.upstream_client = httpx.AsyncClient(
+ base_url=config.normalized_base_url(),
+ timeout=config.request_timeout_sec,
+ transport=upstream_transport,
+ )
+
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=config.cors_origins,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ allow_credentials=True,
+ )
+
+ app.include_router(_create_proxy_router())
+
+ @app.get("/health", include_in_schema=False)
+ async def healthz():
+ return {"status": "ok", "service": "openviking-console"}
+
+ @app.get("/", include_in_schema=False)
+ async def index_root():
+ return FileResponse(index_file)
+
+ @app.get("/console", include_in_schema=False)
+ async def index_console():
+ return FileResponse(index_file)
+
+ @app.get("/console/{path:path}", include_in_schema=False)
+ async def console_assets(path: str):
+ if path.startswith("api/"):
+ return _error_response(status_code=404, code="NOT_FOUND", message="Not found")
+
+ requested_file = static_dir / path
+ if requested_file.exists() and requested_file.is_file():
+ return FileResponse(requested_file)
+ return FileResponse(index_file)
+
+ return app
diff --git a/examples/console/bootstrap.py b/examples/console/bootstrap.py
new file mode 100644
index 00000000..9d3685eb
--- /dev/null
+++ b/examples/console/bootstrap.py
@@ -0,0 +1,73 @@
+# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
+# SPDX-License-Identifier: Apache-2.0
+"""Bootstrap entrypoint for OpenViking console service."""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+import uvicorn
+
+if __package__ in {None, ""}:
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+
+from examples.console.app import create_console_app
+from examples.console.config import load_console_config
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="OpenViking Console",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+ parser.add_argument("--host", type=str, default="127.0.0.1", help="Host to bind to")
+ parser.add_argument("--port", type=int, default=8020, help="Port to bind to")
+ parser.add_argument(
+ "--openviking-url",
+ type=str,
+ default="http://127.0.0.1:1933",
+ help="Base URL for OpenViking HTTP service",
+ )
+ parser.add_argument(
+ "--write-enabled",
+ action="store_true",
+ help="Enable write operations in console proxy",
+ )
+ parser.add_argument(
+ "--request-timeout-sec",
+ type=float,
+ default=30.0,
+ help="Upstream request timeout in seconds",
+ )
+ parser.add_argument(
+ "--cors-origins",
+ type=str,
+ default="*",
+ help="Comma-separated CORS origins",
+ )
+ return parser
+
+
+def main() -> None:
+ """Run console service."""
+ parser = _build_parser()
+ args = parser.parse_args()
+
+ config = load_console_config(
+ host=args.host,
+ port=args.port,
+ openviking_base_url=args.openviking_url,
+ write_enabled=args.write_enabled,
+ request_timeout_sec=args.request_timeout_sec,
+ cors_origins=args.cors_origins,
+ )
+
+ app = create_console_app(config=config)
+ print(f"OpenViking Console is running on {config.host}:{config.port}")
+ uvicorn.run(app, host=config.host, port=config.port)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/console/config.py b/examples/console/config.py
new file mode 100644
index 00000000..ede3aec5
--- /dev/null
+++ b/examples/console/config.py
@@ -0,0 +1,84 @@
+# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
+# SPDX-License-Identifier: Apache-2.0
+"""Configuration for the standalone OpenViking console service."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Iterable, List
+
+
+def _parse_cors_origins(raw_value: str | None) -> List[str]:
+ if not raw_value:
+ return ["*"]
+ return [item.strip() for item in raw_value.split(",") if item.strip()]
+
+
+@dataclass(slots=True)
+class ConsoleConfig:
+ """Runtime settings for console BFF + static frontend."""
+
+ host: str = "127.0.0.1"
+ port: int = 8020
+ openviking_base_url: str = "http://127.0.0.1:1933"
+ write_enabled: bool = False
+ request_timeout_sec: float = 30.0
+ cors_origins: List[str] = field(default_factory=lambda: ["*"])
+
+ def normalized_base_url(self) -> str:
+ """Return upstream base URL without trailing slash."""
+ return self.openviking_base_url.rstrip("/")
+
+
+def load_console_config(
+ *,
+ host: str = "127.0.0.1",
+ port: int = 8020,
+ openviking_base_url: str = "http://127.0.0.1:1933",
+ write_enabled: bool = False,
+ request_timeout_sec: float = 30.0,
+ cors_origins: str | List[str] | None = None,
+) -> ConsoleConfig:
+ """Load console config from startup parameters."""
+ resolved_cors_origins = (
+ _parse_cors_origins(cors_origins)
+ if isinstance(cors_origins, str) or cors_origins is None
+ else list(cors_origins)
+ )
+ return ConsoleConfig(
+ host=host,
+ port=port,
+ openviking_base_url=openviking_base_url,
+ write_enabled=write_enabled,
+ request_timeout_sec=request_timeout_sec,
+ cors_origins=resolved_cors_origins,
+ )
+
+
+def as_runtime_capabilities(config: ConsoleConfig) -> dict:
+ """Expose runtime behavior switches for UI gating."""
+ allowed_modules: Iterable[str] = [
+ "fs.read",
+ "search.find",
+ "admin.read",
+ "monitor.read",
+ ]
+ if config.write_enabled:
+ allowed_modules = [*allowed_modules, "fs.write", "admin.write", "resources.write"]
+
+ return {
+ "write_enabled": config.write_enabled,
+ "allowed_modules": list(allowed_modules),
+ "dangerous_actions": [
+ "fs.mkdir",
+ "fs.mv",
+ "fs.rm",
+ "admin.create_account",
+ "admin.delete_account",
+ "admin.create_user",
+ "admin.delete_user",
+ "admin.set_role",
+ "admin.regenerate_key",
+ "resources.add_resource",
+ ],
+ }
diff --git a/examples/console/run_console.py b/examples/console/run_console.py
new file mode 100644
index 00000000..3548d733
--- /dev/null
+++ b/examples/console/run_console.py
@@ -0,0 +1,16 @@
+# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
+# SPDX-License-Identifier: Apache-2.0
+"""Run the standalone console example service."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+if __package__ in {None, ""}:
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+
+from examples.console.bootstrap import main
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/console/static/app.js b/examples/console/static/app.js
new file mode 100644
index 00000000..451d5912
--- /dev/null
+++ b/examples/console/static/app.js
@@ -0,0 +1,2405 @@
+const API_BASE = "/console/api/v1";
+const SESSION_KEY = "ov_console_api_key";
+const THEME_MODE_KEY = "ov_console_theme_mode";
+const NAV_COLLAPSED_KEY = "ov_console_nav_collapsed";
+const RESULT_COLLAPSED_KEY = "ov_console_result_collapsed";
+
+const PANEL_META = {
+ filesystem: {
+ title: "FileSystem",
+ subtitle: "Browse viking:// paths.",
+ },
+ find: {
+ title: "Find",
+ subtitle: "Run semantic retrieval and inspect matched records.",
+ },
+ "add-resource": {
+ title: "Add Resource",
+ subtitle: "Submit path or upload data into viking:// resources.",
+ },
+ "add-memory": {
+ title: "Add Memory",
+ subtitle: "Store text or conversations as persistent memories.",
+ },
+ tenants: {
+ title: "Tenants",
+ subtitle: "Manage accounts and users with explicit write confirmation.",
+ },
+ monitor: {
+ title: "Monitor",
+ subtitle: "Inspect runtime and observer status snapshots.",
+ },
+ settings: {
+ title: "Settings",
+ subtitle: "Configure session credentials and connection behavior.",
+ },
+};
+
+const state = {
+ activePanel: "filesystem",
+ writeEnabled: false,
+ fsCurrentUri: "viking://",
+ fsHistory: [],
+ fsSortField: "uri",
+ fsSortDirection: "asc",
+ fsViewMode: "list",
+ fsTreeData: {},
+ fsTreeExpanded: new Set(),
+ findRows: [],
+ findSortField: "",
+ findSortDirection: "asc",
+ tenantAccounts: [],
+ tenantFilteredAccounts: [],
+ tenantUsers: [],
+ tenantSelectedAccountId: "",
+ tenantAccountsLoaded: false,
+ tenantAccountSortField: "account_id",
+ tenantAccountSortDirection: "asc",
+ tenantUserSortField: "user_id",
+ tenantUserSortDirection: "asc",
+ tenantConfirmRequest: null,
+ themeMode: "dark",
+ navCollapsed: false,
+ resultCollapsed: false,
+};
+
+const elements = {
+ workspace: document.querySelector(".workspace"),
+ shell: document.querySelector(".shell"),
+ content: document.querySelector(".content"),
+ tabsTop: document.querySelector(".tabs-top"),
+ panelStack: document.querySelector(".panel-stack"),
+ sidebar: document.querySelector(".sidebar"),
+ resultCard: document.querySelector(".result-card"),
+ sidebarResizer: document.getElementById("sidebarResizer"),
+ outputResizer: document.getElementById("outputResizer"),
+ apiKeyInput: document.getElementById("apiKeyInput"),
+ saveKeyBtn: document.getElementById("saveKeyBtn"),
+ clearKeyBtn: document.getElementById("clearKeyBtn"),
+ connectionHint: document.getElementById("connectionHint"),
+ writeBadge: document.getElementById("writeBadge"),
+ output: document.getElementById("output"),
+ tabs: document.querySelectorAll(".tab"),
+ panels: document.querySelectorAll(".panel"),
+ fsBackBtn: document.getElementById("fsBackBtn"),
+ fsUpBtn: document.getElementById("fsUpBtn"),
+ fsRefreshBtn: document.getElementById("fsRefreshBtn"),
+ fsModeListBtn: document.getElementById("fsModeListBtn"),
+ fsModeTreeBtn: document.getElementById("fsModeTreeBtn"),
+ fsGoBtn: document.getElementById("fsGoBtn"),
+ fsCurrentUri: document.getElementById("fsCurrentUri"),
+ fsEntries: document.getElementById("fsEntries"),
+ fsSortHeaders: document.querySelectorAll(".fs-sort-btn"),
+ fsTable: document.querySelector(".fs-table"),
+ fsTableWrap: document.querySelector(".fs-table-wrap"),
+ fsTree: document.getElementById("fsTree"),
+ findQuery: document.getElementById("findQuery"),
+ findTarget: document.getElementById("findTarget"),
+ findBtn: document.getElementById("findBtn"),
+ findResultsHead: document.getElementById("findResultsHead"),
+ findResultsBody: document.getElementById("findResultsBody"),
+ addResourcePath: document.getElementById("addResourcePath"),
+ addResourceFile: document.getElementById("addResourceFile"),
+ addResourceTarget: document.getElementById("addResourceTarget"),
+ addResourceWait: document.getElementById("addResourceWait"),
+ addResourceStrict: document.getElementById("addResourceStrict"),
+ addResourceUploadMedia: document.getElementById("addResourceUploadMedia"),
+ addResourceTimeout: document.getElementById("addResourceTimeout"),
+ addResourceIgnoreDirs: document.getElementById("addResourceIgnoreDirs"),
+ addResourceInclude: document.getElementById("addResourceInclude"),
+ addResourceExclude: document.getElementById("addResourceExclude"),
+ addResourceReason: document.getElementById("addResourceReason"),
+ addResourceInstruction: document.getElementById("addResourceInstruction"),
+ addResourceBtn: document.getElementById("addResourceBtn"),
+ addResourceUploadBtn: document.getElementById("addResourceUploadBtn"),
+ addMemoryInput: document.getElementById("addMemoryInput"),
+ addMemoryBtn: document.getElementById("addMemoryBtn"),
+ tenantAccountSearch: document.getElementById("tenantAccountSearch"),
+ tenantRefreshAccountsBtn: document.getElementById("tenantRefreshAccountsBtn"),
+ tenantCreateAccountBtn: document.getElementById("tenantCreateAccountBtn"),
+ tenantCreateAccountId: document.getElementById("tenantCreateAccountId"),
+ tenantCreateAdminUserId: document.getElementById("tenantCreateAdminUserId"),
+ tenantAccountsBody: document.getElementById("tenantAccountsBody"),
+ tenantCurrentAccount: document.getElementById("tenantCurrentAccount"),
+ tenantAddUserBtn: document.getElementById("tenantAddUserBtn"),
+ tenantAddUserId: document.getElementById("tenantAddUserId"),
+ tenantAddUserRole: document.getElementById("tenantAddUserRole"),
+ tenantUsersBody: document.getElementById("tenantUsersBody"),
+ tenantAccountSortBtns: document.querySelectorAll("[data-tenant-account-sort]"),
+ tenantUserSortBtns: document.querySelectorAll("[data-tenant-user-sort]"),
+ tenantConfirmModal: document.getElementById("tenantConfirmModal"),
+ tenantConfirmTitle: document.getElementById("tenantConfirmTitle"),
+ tenantConfirmMessage: document.getElementById("tenantConfirmMessage"),
+ tenantConfirmLabel: document.getElementById("tenantConfirmLabel"),
+ tenantConfirmInput: document.getElementById("tenantConfirmInput"),
+ tenantConfirmError: document.getElementById("tenantConfirmError"),
+ tenantConfirmActionBtn: document.getElementById("tenantConfirmActionBtn"),
+ tenantConfirmCancelBtn: document.getElementById("tenantConfirmCancelBtn"),
+ systemBtn: document.getElementById("systemBtn"),
+ observerBtn: document.getElementById("observerBtn"),
+ monitorResults: document.getElementById("monitorResults"),
+ navToggleBtn: document.getElementById("navToggleBtn"),
+ resultToggleBtn: document.getElementById("resultToggleBtn"),
+ clearOutputBtn: document.getElementById("clearOutputBtn"),
+ themeButtons: document.querySelectorAll("[data-theme-mode]"),
+ contentTitle: document.querySelector(".title-block h1"),
+ contentSubtitle: document.querySelector(".title-block .subtitle"),
+};
+
+const layoutLimits = {
+ minSidebar: 200,
+ maxSidebar: 560,
+ minPanel: 0,
+ minResult: 48,
+};
+
+function readLocalStorage(key) {
+ try {
+ return window.localStorage.getItem(key);
+ } catch (_error) {
+ return null;
+ }
+}
+
+function writeLocalStorage(key, value) {
+ try {
+ window.localStorage.setItem(key, value);
+ } catch (_error) {
+ // Ignore storage failures in private mode or restricted browsers.
+ }
+}
+
+function prefersDarkTheme() {
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
+}
+
+function resolveThemeMode(mode) {
+ if (mode === "light") {
+ return "light";
+ }
+ if (mode === "system") {
+ return prefersDarkTheme() ? "dark" : "light";
+ }
+ return "dark";
+}
+
+function updateThemeButtons() {
+ for (const button of elements.themeButtons) {
+ const selected = button.dataset.themeMode === state.themeMode;
+ button.classList.toggle("active", selected);
+ button.setAttribute("aria-pressed", selected ? "true" : "false");
+ }
+}
+
+function applyThemeMode(mode, { persist = true } = {}) {
+ const normalized = mode === "light" || mode === "system" ? mode : "dark";
+ state.themeMode = normalized;
+ const resolved = resolveThemeMode(normalized);
+ document.documentElement.setAttribute("data-theme", resolved);
+ updateThemeButtons();
+ if (persist) {
+ writeLocalStorage(THEME_MODE_KEY, normalized);
+ }
+}
+
+function applyShellStateClasses() {
+ if (!elements.shell) {
+ return;
+ }
+ elements.shell.classList.toggle("shell--nav-collapsed", state.navCollapsed);
+ elements.shell.classList.toggle("shell--result-collapsed", state.resultCollapsed);
+}
+
+function setNavCollapsed(collapsed, { persist = true } = {}) {
+ state.navCollapsed = Boolean(collapsed);
+ applyShellStateClasses();
+ if (persist) {
+ writeLocalStorage(NAV_COLLAPSED_KEY, state.navCollapsed ? "1" : "0");
+ }
+}
+
+function setResultCollapsed(collapsed, { persist = true } = {}) {
+ state.resultCollapsed = Boolean(collapsed);
+ applyShellStateClasses();
+ if (elements.resultToggleBtn) {
+ elements.resultToggleBtn.textContent = state.resultCollapsed ? "Show Result" : "Hide Result";
+ }
+ if (persist) {
+ writeLocalStorage(RESULT_COLLAPSED_KEY, state.resultCollapsed ? "1" : "0");
+ }
+}
+
+function setOutput(value) {
+ const content = typeof value === "string" ? value : JSON.stringify(value, null, 2);
+ elements.output.textContent = content;
+}
+
+function setActivePanel(panel) {
+ state.activePanel = panel;
+ for (const tab of elements.tabs) {
+ tab.classList.toggle("active", tab.dataset.panel === panel);
+ }
+ for (const panelNode of elements.panels) {
+ panelNode.classList.toggle("active", panelNode.id === `panel-${panel}`);
+ }
+
+ const meta = PANEL_META[panel];
+ if (meta && elements.contentTitle && elements.contentSubtitle) {
+ elements.contentTitle.textContent = `OpenViking ${meta.title}`;
+ elements.contentSubtitle.textContent = meta.subtitle;
+ }
+
+ if (window.matchMedia("(max-width: 900px)").matches) {
+ setNavCollapsed(true);
+ }
+
+ // If a confirmation dialog was left open, never carry it across panel switches.
+ if (elements.tenantConfirmModal && !elements.tenantConfirmModal.hidden) {
+ closeTenantConfirmModal();
+ }
+
+ if (panel === "tenants") {
+ ensureTenantsLoaded().catch((error) => {
+ setOutput(error.message);
+ });
+ }
+}
+
+function getApiKey() {
+ return window.sessionStorage.getItem(SESSION_KEY) || "";
+}
+
+function updateConnectionHint() {
+ const key = getApiKey();
+ elements.connectionHint.textContent = key
+ ? `API key loaded in session (${key.length} chars).`
+ : "No API key in session.";
+}
+
+async function callConsole(path, options = {}) {
+ const headers = {
+ ...(options.headers || {}),
+ };
+
+ if (!(options.body instanceof FormData)) {
+ headers["Content-Type"] = headers["Content-Type"] || "application/json";
+ }
+
+ const apiKey = getApiKey();
+ if (apiKey) {
+ headers["X-API-Key"] = apiKey;
+ }
+
+ const response = await fetch(`${API_BASE}${path}`, {
+ ...options,
+ headers,
+ });
+
+ const payload = await response.json().catch(() => ({
+ status: "error",
+ error: {
+ code: "BAD_RESPONSE",
+ message: "Invalid JSON response from console",
+ },
+ }));
+
+ if (!response.ok) {
+ const code = payload.error?.code || "ERROR";
+ const message = payload.error?.message || `Request failed with status ${response.status}`;
+ const missingApiKey =
+ code === "UNAUTHENTICATED" && message.toLowerCase().includes("missing api key");
+ const hint = missingApiKey ? " Please go to Settings and set X-API-Key." : "";
+ throw new Error(`${code}: ${message}${hint}`);
+ }
+
+ return payload;
+}
+
+function normalizeDirUri(uri) {
+ const value = (uri || "").trim();
+ if (!value) {
+ return "viking://";
+ }
+ if (value === "viking://") {
+ return value;
+ }
+ return value.endsWith("/") ? value : `${value}/`;
+}
+
+function parentUri(uri) {
+ const normalized = normalizeDirUri(uri);
+ if (normalized === "viking://") {
+ return normalized;
+ }
+
+ const scheme = "viking://";
+ if (!normalized.startsWith(scheme)) {
+ return scheme;
+ }
+
+ const withoutTrailingSlash = normalized.slice(0, -1);
+ const body = withoutTrailingSlash.slice(scheme.length);
+ if (!body.includes("/")) {
+ return scheme;
+ }
+
+ const prefix = body.slice(0, body.lastIndexOf("/") + 1);
+ return `${scheme}${prefix}`;
+}
+
+function joinUri(baseUri, child) {
+ const raw = String(child || "").trim();
+ if (!raw) {
+ return normalizeDirUri(baseUri);
+ }
+ if (raw.startsWith("viking://")) {
+ return raw;
+ }
+
+ const normalizedBase = normalizeDirUri(baseUri);
+ const cleanedChild = raw.replace(/^\//, "");
+ return `${normalizedBase}${cleanedChild}`;
+}
+
+function pickFirstNonEmpty(candidates) {
+ for (const candidate of candidates) {
+ if (candidate !== undefined && candidate !== null && String(candidate).trim() !== "") {
+ return candidate;
+ }
+ }
+ return null;
+}
+
+function normalizeFsEntries(result, currentUri) {
+ const toEntry = (item) => {
+ if (typeof item === "string") {
+ const rawName = item.trim();
+ const isDir = rawName.endsWith("/");
+ const resolvedUri = joinUri(currentUri, rawName);
+ return {
+ uri: isDir ? normalizeDirUri(resolvedUri) : resolvedUri,
+ size: null,
+ isDir,
+ modTime: null,
+ abstract: "",
+ };
+ }
+
+ if (item && typeof item === "object") {
+ const baseLabel =
+ item.name || item.path || item.relative_path || item.uri || item.id || JSON.stringify(item);
+ const isDir =
+ Boolean(item.is_dir) ||
+ Boolean(item.isDir) ||
+ item.type === "dir" ||
+ item.type === "directory" ||
+ item.kind === "dir" ||
+ String(baseLabel).endsWith("/");
+ const rawUri = item.uri || item.path || item.relative_path || baseLabel;
+ const resolvedUri = joinUri(currentUri, rawUri);
+ const size = pickFirstNonEmpty([
+ item.size,
+ item.size_bytes,
+ item.content_length,
+ item.contentLength,
+ item.bytes,
+ ]);
+ const modTime = pickFirstNonEmpty([
+ item.modTime,
+ item.mod_time,
+ item.mtime,
+ item.modified_at,
+ item.modifiedAt,
+ item.updated_at,
+ item.updatedAt,
+ item.last_modified,
+ item.lastModified,
+ item.timestamp,
+ item.time,
+ ]);
+ const abstract = pickFirstNonEmpty([
+ item.abstract,
+ item.summary,
+ item.description,
+ item.desc,
+ ]);
+
+ return {
+ uri: isDir ? normalizeDirUri(resolvedUri) : resolvedUri,
+ size,
+ isDir,
+ modTime,
+ abstract: abstract === null ? "" : String(abstract),
+ };
+ }
+
+ return {
+ uri: joinUri(currentUri, String(item)),
+ size: null,
+ isDir: false,
+ modTime: null,
+ abstract: "",
+ };
+ };
+
+ if (Array.isArray(result)) {
+ return result.map(toEntry);
+ }
+
+ if (result && typeof result === "object") {
+ const candidates = [result.entries, result.items, result.children, result.results];
+ for (const candidate of candidates) {
+ if (Array.isArray(candidate)) {
+ return candidate.map(toEntry);
+ }
+ }
+ }
+
+ if (typeof result === "string") {
+ return result
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map(toEntry);
+ }
+
+ return [];
+}
+
+function normalizeSortString(value) {
+ if (value === null || value === undefined) {
+ return "";
+ }
+ return String(value).toLowerCase();
+}
+
+function toSortableNumber(value) {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === "string") {
+ const parsed = Number.parseFloat(value);
+ if (Number.isFinite(parsed)) {
+ return parsed;
+ }
+ }
+ return null;
+}
+
+function toSortableTime(value) {
+ if (!value) {
+ return null;
+ }
+
+ const date = new Date(value);
+ if (!Number.isNaN(date.getTime())) {
+ return date.getTime();
+ }
+ return toSortableNumber(value);
+}
+
+function compareNullable(left, right, compareFn) {
+ const leftMissing = left === null || left === undefined || left === "";
+ const rightMissing = right === null || right === undefined || right === "";
+ if (leftMissing && rightMissing) {
+ return 0;
+ }
+ if (leftMissing) {
+ return 1;
+ }
+ if (rightMissing) {
+ return -1;
+ }
+ return compareFn(left, right);
+}
+
+function compareFsEntries(left, right, field) {
+ switch (field) {
+ case "size":
+ return compareNullable(left.size, right.size, (a, b) => {
+ const leftNum = toSortableNumber(a);
+ const rightNum = toSortableNumber(b);
+ if (leftNum !== null && rightNum !== null) {
+ return leftNum - rightNum;
+ }
+ return normalizeSortString(a).localeCompare(normalizeSortString(b));
+ });
+ case "isDir":
+ return Number(left.isDir) - Number(right.isDir);
+ case "modTime":
+ return compareNullable(left.modTime, right.modTime, (a, b) => {
+ const leftTime = toSortableTime(a);
+ const rightTime = toSortableTime(b);
+ if (leftTime !== null && rightTime !== null) {
+ return leftTime - rightTime;
+ }
+ return normalizeSortString(a).localeCompare(normalizeSortString(b));
+ });
+ case "abstract":
+ return compareNullable(left.abstract, right.abstract, (a, b) =>
+ normalizeSortString(a).localeCompare(normalizeSortString(b))
+ );
+ case "uri":
+ default:
+ return normalizeSortString(left.uri).localeCompare(normalizeSortString(right.uri));
+ }
+}
+
+function sortFilesystemEntries(entries) {
+ const sorted = [...entries].sort((left, right) =>
+ compareFsEntries(left, right, state.fsSortField)
+ );
+ if (state.fsSortDirection === "desc") {
+ sorted.reverse();
+ }
+ return sorted;
+}
+
+function updateFilesystemSortHeaders() {
+ for (const button of elements.fsSortHeaders) {
+ const field = button.dataset.fsSort || "";
+ const isActive = field === state.fsSortField;
+ button.classList.toggle("active", isActive);
+ button.setAttribute(
+ "aria-sort",
+ isActive ? (state.fsSortDirection === "asc" ? "ascending" : "descending") : "none"
+ );
+ const suffix = !isActive ? "" : state.fsSortDirection === "asc" ? " ↑" : " ↓";
+ button.textContent = `${field}${suffix}`;
+ }
+}
+
+function bindFilesystemSort() {
+ for (const button of elements.fsSortHeaders) {
+ button.addEventListener("click", async () => {
+ const field = button.dataset.fsSort;
+ if (!field) {
+ return;
+ }
+
+ if (state.fsSortField === field) {
+ state.fsSortDirection = state.fsSortDirection === "asc" ? "desc" : "asc";
+ } else {
+ state.fsSortField = field;
+ state.fsSortDirection = "asc";
+ }
+
+ updateFilesystemSortHeaders();
+
+ try {
+ await loadFilesystem(state.fsCurrentUri);
+ } catch (error) {
+ setOutput(error.message);
+ }
+ });
+ }
+}
+
+function initFsColumnResize() {
+ if (!elements.fsTable) {
+ return;
+ }
+
+ const headers = elements.fsTable.querySelectorAll("thead th");
+ for (const header of headers) {
+ if (header.dataset.resizable === "false") {
+ continue;
+ }
+ if (header.querySelector(".fs-col-resizer")) {
+ continue;
+ }
+
+ const handle = document.createElement("div");
+ handle.className = "fs-col-resizer";
+ handle.setAttribute("role", "separator");
+ handle.setAttribute("aria-orientation", "vertical");
+ handle.setAttribute("aria-label", "Resize column");
+ header.appendChild(handle);
+
+ handle.addEventListener("pointerdown", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ document.body.classList.add("dragging-fs-column");
+
+ const startX = event.clientX;
+ const startWidth = header.getBoundingClientRect().width;
+ const minWidth = Number.parseFloat(header.dataset.minWidth || "90");
+
+ handle.setPointerCapture(event.pointerId);
+
+ const onMove = (moveEvent) => {
+ const nextWidth = clamp(startWidth + (moveEvent.clientX - startX), minWidth, 1200);
+ header.style.width = `${nextWidth}px`;
+ header.style.minWidth = `${nextWidth}px`;
+ };
+
+ const onUp = () => {
+ handle.removeEventListener("pointermove", onMove);
+ handle.removeEventListener("pointerup", onUp);
+ handle.removeEventListener("pointercancel", onUp);
+ document.body.classList.remove("dragging-fs-column");
+ handle.releasePointerCapture(event.pointerId);
+ };
+
+ handle.addEventListener("pointermove", onMove);
+ handle.addEventListener("pointerup", onUp);
+ handle.addEventListener("pointercancel", onUp);
+ });
+ }
+}
+
+function normalizeReadContent(result) {
+ if (typeof result === "string") {
+ return result;
+ }
+ if (Array.isArray(result)) {
+ return result.map((item) => String(item)).join("\n");
+ }
+ if (result && typeof result === "object") {
+ const content = pickFirstNonEmpty([
+ result.content,
+ result.text,
+ result.body,
+ result.value,
+ result.data,
+ ]);
+ if (content !== null) {
+ return typeof content === "string" ? content : JSON.stringify(content, null, 2);
+ }
+ }
+ return JSON.stringify(result, null, 2);
+}
+
+async function readFilesystemFile(entry) {
+ const uri = String(entry?.uri || "").replace(/\/$/, "");
+ if (!uri) {
+ throw new Error("Invalid file uri.");
+ }
+
+ setOutput(`Reading ${uri} ...`);
+ const payload = await callConsole(
+ `/ov/content/read?uri=${encodeURIComponent(uri)}&offset=0&limit=-1`,
+ { method: "GET" }
+ );
+ const content = normalizeReadContent(payload.result);
+ setOutput(content && content.trim() ? content : "(empty file)");
+}
+
+async function statFilesystemResource(entry) {
+ let uri = String(entry?.uri || "").trim();
+ if (!uri) {
+ throw new Error("Invalid resource uri.");
+ }
+ if (uri !== "viking://") {
+ uri = uri.replace(/\/$/, "");
+ }
+
+ const payload = await callConsole(`/ov/fs/stat?uri=${encodeURIComponent(uri)}`, { method: "GET" });
+ setOutput(payload);
+}
+
+function renderFilesystemEntries(target, rows, onOpen, onOpenContent) {
+ target.innerHTML = "";
+
+ if (!rows.length) {
+ const tr = document.createElement("tr");
+ const td = document.createElement("td");
+ td.colSpan = 6;
+ td.className = "fs-empty";
+ td.textContent = "No data";
+ tr.appendChild(td);
+ target.appendChild(tr);
+ return;
+ }
+
+ for (const row of rows) {
+ const tr = document.createElement("tr");
+
+ const actionCell = document.createElement("td");
+ actionCell.className = "fs-col-action";
+ const openBtn = document.createElement("button");
+ openBtn.type = "button";
+ openBtn.className = "fs-open-btn";
+ openBtn.title = "Show stat info";
+ openBtn.setAttribute("aria-label", `Show stat info for ${row.uri}`);
+ openBtn.textContent = "ⓘ";
+ openBtn.addEventListener("click", async (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ try {
+ await onOpenContent(row);
+ } catch (error) {
+ setOutput(error.message);
+ }
+ });
+ actionCell.appendChild(openBtn);
+ tr.appendChild(actionCell);
+
+ const uriCell = document.createElement("td");
+ uriCell.className = "fs-col-uri";
+ const uriBtn = document.createElement("button");
+ uriBtn.type = "button";
+ uriBtn.className = "fs-uri-btn";
+ uriBtn.textContent = row.uri || "-";
+ uriBtn.addEventListener("click", () => onOpen(row));
+ uriCell.appendChild(uriBtn);
+ tr.appendChild(uriCell);
+
+ const sizeCell = document.createElement("td");
+ sizeCell.className = "fs-col-size";
+ sizeCell.textContent = row.size === null || row.size === undefined || row.size === "" ? "-" : String(row.size);
+ tr.appendChild(sizeCell);
+
+ const dirCell = document.createElement("td");
+ dirCell.className = "fs-col-dir";
+ dirCell.textContent = row.isDir ? "true" : "false";
+ tr.appendChild(dirCell);
+
+ const modTimeCell = document.createElement("td");
+ modTimeCell.className = "fs-col-mod-time";
+ modTimeCell.textContent =
+ row.modTime === null || row.modTime === undefined || row.modTime === ""
+ ? "-"
+ : String(row.modTime);
+ tr.appendChild(modTimeCell);
+
+ const abstractCell = document.createElement("td");
+ abstractCell.className = "fs-col-abstract";
+ abstractCell.textContent = row.abstract || "-";
+ tr.appendChild(abstractCell);
+
+ target.appendChild(tr);
+ }
+}
+
+function isRecord(value) {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function extractDeepestObjectArray(value) {
+ const best = { depth: -1, rows: null };
+
+ const visit = (current, depth) => {
+ if (Array.isArray(current)) {
+ if (current.length > 0 && current.every((item) => isRecord(item))) {
+ if (depth > best.depth) {
+ best.depth = depth;
+ best.rows = current;
+ }
+ }
+
+ for (const item of current) {
+ visit(item, depth + 1);
+ }
+ return;
+ }
+
+ if (!isRecord(current)) {
+ return;
+ }
+
+ for (const nested of Object.values(current)) {
+ visit(nested, depth + 1);
+ }
+ };
+
+ visit(value, 0);
+ return best.rows;
+}
+
+function normalizeFindRows(result) {
+ if (Array.isArray(result)) {
+ return result.map((item) => (isRecord(item) ? item : { value: item }));
+ }
+
+ if (isRecord(result)) {
+ const typedBucketKeys = ["memories", "resources", "skills"];
+ const hasTypedBuckets = typedBucketKeys.some((key) => Array.isArray(result[key]));
+ if (hasTypedBuckets) {
+ const typedRows = [];
+ for (const key of typedBucketKeys) {
+ const rows = Array.isArray(result[key]) ? result[key] : [];
+ for (const row of rows) {
+ const normalized = isRecord(row) ? row : { value: row };
+ typedRows.push({
+ ...normalized,
+ context_type:
+ normalized.context_type || (key === "memories" ? "memory" : key.slice(0, -1)),
+ });
+ }
+ }
+ return typedRows;
+ }
+
+ const topLevelArrays = [
+ result.results,
+ result.items,
+ result.matches,
+ result.hits,
+ result.rows,
+ result.entries,
+ result.data,
+ ];
+ for (const rows of topLevelArrays) {
+ if (Array.isArray(rows)) {
+ return rows.map((item) => (isRecord(item) ? item : { value: item }));
+ }
+ }
+
+ const deepestRows = extractDeepestObjectArray(result);
+ if (deepestRows) {
+ return deepestRows;
+ }
+
+ return [result];
+ }
+
+ if (result === null || result === undefined) {
+ return [];
+ }
+
+ return [{ value: result }];
+}
+
+function collectFindColumns(rows) {
+ const columns = [];
+ const seen = new Set();
+
+ for (const row of rows) {
+ if (!isRecord(row)) {
+ continue;
+ }
+
+ for (const key of Object.keys(row)) {
+ if (!seen.has(key)) {
+ seen.add(key);
+ columns.push(key);
+ }
+ }
+ }
+
+ return columns;
+}
+
+function formatFindCellValue(value) {
+ if (value === null || value === undefined || value === "") {
+ return "-";
+ }
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
+ return String(value);
+ }
+ return JSON.stringify(value);
+}
+
+function toFindComparable(value) {
+ if (value === null || value === undefined || value === "") {
+ return { missing: true, type: "missing", value: "" };
+ }
+
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return { missing: false, type: "number", value };
+ }
+
+ if (typeof value === "boolean") {
+ return { missing: false, type: "number", value: Number(value) };
+ }
+
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ const asNumber = Number.parseFloat(trimmed);
+ if (trimmed !== "" && Number.isFinite(asNumber)) {
+ return { missing: false, type: "number", value: asNumber };
+ }
+
+ const asDate = new Date(trimmed);
+ if (!Number.isNaN(asDate.getTime())) {
+ return { missing: false, type: "date", value: asDate.getTime() };
+ }
+
+ return { missing: false, type: "string", value: trimmed.toLowerCase() };
+ }
+
+ return { missing: false, type: "string", value: JSON.stringify(value).toLowerCase() };
+}
+
+function compareFindValues(left, right) {
+ const leftValue = toFindComparable(left);
+ const rightValue = toFindComparable(right);
+
+ if (leftValue.missing && rightValue.missing) {
+ return 0;
+ }
+ if (leftValue.missing) {
+ return 1;
+ }
+ if (rightValue.missing) {
+ return -1;
+ }
+
+ if (leftValue.type === rightValue.type && (leftValue.type === "number" || leftValue.type === "date")) {
+ return leftValue.value - rightValue.value;
+ }
+
+ return String(leftValue.value).localeCompare(String(rightValue.value));
+}
+
+function sortFindRows(rows, column, direction) {
+ const sorted = [...rows].sort((left, right) => {
+ const leftCell = isRecord(left) ? left[column] : undefined;
+ const rightCell = isRecord(right) ? right[column] : undefined;
+ return compareFindValues(leftCell, rightCell);
+ });
+
+ if (direction === "desc") {
+ sorted.reverse();
+ }
+ return sorted;
+}
+
+function renderFindTable(rows) {
+ state.findRows = rows;
+ elements.findResultsHead.innerHTML = "";
+ elements.findResultsBody.innerHTML = "";
+
+ const columns = collectFindColumns(rows);
+ if (!columns.length) {
+ columns.push("value");
+ }
+
+ if (!state.findSortField || !columns.includes(state.findSortField)) {
+ state.findSortField = columns[0];
+ state.findSortDirection = "asc";
+ }
+
+ const headerRow = document.createElement("tr");
+ for (const column of columns) {
+ const th = document.createElement("th");
+ th.scope = "col";
+
+ const sortBtn = document.createElement("button");
+ sortBtn.type = "button";
+ sortBtn.className = "find-sort-btn";
+ sortBtn.dataset.findSort = column;
+
+ const isActive = state.findSortField === column;
+ const sortLabel = isActive ? (state.findSortDirection === "asc" ? " ↑" : " ↓") : "";
+ sortBtn.textContent = `${column}${sortLabel}`;
+ sortBtn.setAttribute(
+ "aria-sort",
+ isActive ? (state.findSortDirection === "asc" ? "ascending" : "descending") : "none"
+ );
+
+ sortBtn.addEventListener("click", () => {
+ if (state.findSortField === column) {
+ state.findSortDirection = state.findSortDirection === "asc" ? "desc" : "asc";
+ } else {
+ state.findSortField = column;
+ state.findSortDirection = "asc";
+ }
+ renderFindTable(state.findRows);
+ });
+
+ th.appendChild(sortBtn);
+ headerRow.appendChild(th);
+ }
+ elements.findResultsHead.appendChild(headerRow);
+
+ if (!rows.length) {
+ const emptyRow = document.createElement("tr");
+ const emptyCell = document.createElement("td");
+ emptyCell.colSpan = columns.length;
+ emptyCell.className = "find-empty";
+ emptyCell.textContent = "No data";
+ emptyRow.appendChild(emptyCell);
+ elements.findResultsBody.appendChild(emptyRow);
+ return;
+ }
+
+ const sortedRows = sortFindRows(rows, state.findSortField, state.findSortDirection);
+ for (const row of sortedRows) {
+ const tr = document.createElement("tr");
+ for (const column of columns) {
+ const td = document.createElement("td");
+ const cellValue = isRecord(row) ? row[column] : undefined;
+ td.textContent = formatFindCellValue(cellValue);
+ tr.appendChild(td);
+ }
+ elements.findResultsBody.appendChild(tr);
+ }
+}
+
+function renderList(target, rows, onClick) {
+ target.innerHTML = "";
+ if (!rows.length) {
+ const empty = document.createElement("li");
+ empty.innerHTML = '
No data
';
+ target.appendChild(empty);
+ return;
+ }
+
+ for (const row of rows) {
+ const li = document.createElement("li");
+ if (onClick) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.textContent = row.label;
+ button.addEventListener("click", () => onClick(row));
+ li.appendChild(button);
+ } else {
+ const div = document.createElement("div");
+ div.className = "row-item";
+ div.textContent = row.label;
+ li.appendChild(div);
+ }
+ target.appendChild(li);
+ }
+}
+
+function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+}
+
+function syncWriteControls() {
+ const writeButtons = document.querySelectorAll("[data-tenant-write]");
+ for (const button of writeButtons) {
+ button.disabled = !state.writeEnabled;
+ }
+}
+
+function initResizablePanes() {
+ const rootStyle = document.documentElement.style;
+
+ if (elements.sidebarResizer && elements.sidebar) {
+ elements.sidebarResizer.addEventListener("pointerdown", (event) => {
+ if (window.matchMedia("(max-width: 900px)").matches) {
+ return;
+ }
+ event.preventDefault();
+ document.body.classList.add("dragging-sidebar");
+ elements.sidebarResizer.setPointerCapture(event.pointerId);
+ const startX = event.clientX;
+ const startWidth = elements.sidebar.getBoundingClientRect().width;
+
+ const onMove = (moveEvent) => {
+ const nextWidth = clamp(
+ startWidth + (moveEvent.clientX - startX),
+ layoutLimits.minSidebar,
+ layoutLimits.maxSidebar
+ );
+ rootStyle.setProperty("--sidebar-width", `${nextWidth}px`);
+ };
+
+ const onUp = () => {
+ elements.sidebarResizer.removeEventListener("pointermove", onMove);
+ elements.sidebarResizer.removeEventListener("pointerup", onUp);
+ elements.sidebarResizer.removeEventListener("pointercancel", onUp);
+ document.body.classList.remove("dragging-sidebar");
+ elements.sidebarResizer.releasePointerCapture(event.pointerId);
+ };
+
+ elements.sidebarResizer.addEventListener("pointermove", onMove);
+ elements.sidebarResizer.addEventListener("pointerup", onUp);
+ elements.sidebarResizer.addEventListener("pointercancel", onUp);
+ });
+ }
+
+ if (elements.outputResizer && elements.resultCard) {
+ elements.outputResizer.addEventListener("pointerdown", (event) => {
+ if (window.matchMedia("(max-width: 900px)").matches) {
+ return;
+ }
+ event.preventDefault();
+ document.body.classList.add("dragging-output");
+ elements.outputResizer.setPointerCapture(event.pointerId);
+ const startY = event.clientY;
+ const startHeight =
+ elements.panelStack?.getBoundingClientRect().height || layoutLimits.minPanel;
+
+ const onMove = (moveEvent) => {
+ const contentHeight = elements.content?.getBoundingClientRect().height || window.innerHeight;
+ const tabsHeight = elements.tabsTop?.getBoundingClientRect().height || 0;
+ const resizerHeight = elements.outputResizer.getBoundingClientRect().height || 8;
+ const rowGap = Number.parseFloat(
+ window.getComputedStyle(elements.content || document.body).rowGap || "0"
+ );
+ const totalGap = Number.isFinite(rowGap) ? rowGap * 3 : 0;
+ const maxPanel = Math.max(
+ layoutLimits.minPanel,
+ contentHeight - tabsHeight - resizerHeight - layoutLimits.minResult - totalGap
+ );
+ const nextHeight = clamp(
+ startHeight + (moveEvent.clientY - startY),
+ layoutLimits.minPanel,
+ maxPanel
+ );
+ rootStyle.setProperty("--panel-height", `${nextHeight}px`);
+ };
+
+ const onUp = () => {
+ elements.outputResizer.removeEventListener("pointermove", onMove);
+ elements.outputResizer.removeEventListener("pointerup", onUp);
+ elements.outputResizer.removeEventListener("pointercancel", onUp);
+ document.body.classList.remove("dragging-output");
+ elements.outputResizer.releasePointerCapture(event.pointerId);
+ };
+
+ elements.outputResizer.addEventListener("pointermove", onMove);
+ elements.outputResizer.addEventListener("pointerup", onUp);
+ elements.outputResizer.addEventListener("pointercancel", onUp);
+ });
+ }
+}
+
+function buildFsTreeItem(entry, depth) {
+ const uriStr = entry.uri || "";
+ const trimmed = uriStr.replace(/\/$/, "");
+ const lastSlash = trimmed.lastIndexOf("/");
+ const displayName = lastSlash >= 0 ? trimmed.slice(lastSlash + 1) || trimmed : trimmed;
+
+ const item = document.createElement("div");
+ item.className = `fs-tree-item${entry.isDir ? " fs-tree-item--dir" : ""}`;
+ item.style.paddingLeft = `${10 + depth * 16}px`;
+
+ // ⓘ button — leftmost, matches list view action column
+ const infoBtn = document.createElement("button");
+ infoBtn.type = "button";
+ infoBtn.className = "fs-tree-info-btn";
+ infoBtn.textContent = "ⓘ";
+ infoBtn.title = "Show stat info";
+ infoBtn.setAttribute("aria-label", `Show stat info for ${uriStr}`);
+ infoBtn.addEventListener("click", async (event) => {
+ event.stopPropagation();
+ try {
+ await statFilesystemResource(entry);
+ } catch (error) {
+ setOutput(error.message);
+ }
+ });
+ item.appendChild(infoBtn);
+
+ // collapse/expand arrow (dirs only; files get a fixed-width placeholder)
+ const toggle = document.createElement("span");
+ toggle.className = "fs-tree-toggle";
+ toggle.setAttribute("aria-hidden", "true");
+ toggle.textContent = entry.isDir ? (state.fsTreeExpanded.has(entry.uri) ? "▼" : "▶") : "";
+ item.appendChild(toggle);
+
+ const name = document.createElement("span");
+ name.className = "fs-tree-name";
+ name.textContent = displayName;
+ name.title = uriStr;
+ item.appendChild(name);
+
+ item.addEventListener("click", async () => {
+ if (entry.isDir) {
+ if (state.fsTreeExpanded.has(entry.uri)) {
+ state.fsTreeExpanded.delete(entry.uri);
+ await renderFsTree();
+ } else if (state.fsTreeData[entry.uri]) {
+ state.fsTreeExpanded.add(entry.uri);
+ await renderFsTree();
+ } else {
+ try {
+ const payload = await callConsole(
+ `/ov/fs/ls?uri=${encodeURIComponent(entry.uri)}&show_all_hidden=true`,
+ { method: "GET" }
+ );
+ const children = normalizeFsEntries(payload.result, entry.uri);
+ children.sort((a, b) => {
+ if (a.isDir !== b.isDir) {
+ return a.isDir ? -1 : 1;
+ }
+ return (a.uri || "").localeCompare(b.uri || "");
+ });
+ state.fsTreeData[entry.uri] = children;
+ state.fsTreeExpanded.add(entry.uri);
+ await renderFsTree();
+ } catch (error) {
+ setOutput(error.message);
+ }
+ }
+ } else {
+ try {
+ await readFilesystemFile(entry);
+ } catch (error) {
+ setOutput(error.message);
+ }
+ }
+ });
+
+ return item;
+}
+
+async function renderFsTreeLevel(container, uri, depth) {
+ const entries = state.fsTreeData[uri] || [];
+ for (const entry of entries) {
+ const item = buildFsTreeItem(entry, depth);
+ container.appendChild(item);
+ if (entry.isDir && state.fsTreeExpanded.has(entry.uri)) {
+ const childContainer = document.createElement("div");
+ childContainer.className = "fs-tree-children";
+ container.appendChild(childContainer);
+ await renderFsTreeLevel(childContainer, entry.uri, depth + 1);
+ }
+ }
+}
+
+async function renderFsTree() {
+ elements.fsTree.innerHTML = "";
+ await renderFsTreeLevel(elements.fsTree, state.fsCurrentUri, 0);
+}
+
+function setFsViewMode(mode) {
+ state.fsViewMode = mode;
+ elements.fsModeListBtn.classList.toggle("active", mode === "list");
+ elements.fsModeTreeBtn.classList.toggle("active", mode === "tree");
+ elements.fsModeListBtn.setAttribute("aria-pressed", String(mode === "list"));
+ elements.fsModeTreeBtn.setAttribute("aria-pressed", String(mode === "tree"));
+ elements.fsTableWrap.hidden = mode === "tree";
+ elements.fsTree.hidden = mode === "list";
+}
+
+async function loadFilesystem(uri, { pushHistory = false } = {}) {
+ const targetUri = normalizeDirUri(uri);
+ const payload = await callConsole(
+ `/ov/fs/ls?uri=${encodeURIComponent(targetUri)}&show_all_hidden=true`,
+ { method: "GET" }
+ );
+
+ if (pushHistory && state.fsCurrentUri !== targetUri) {
+ state.fsHistory.push(state.fsCurrentUri);
+ }
+
+ state.fsCurrentUri = targetUri;
+ elements.fsCurrentUri.value = targetUri;
+
+ const rawEntries = normalizeFsEntries(payload.result, targetUri);
+
+ if (state.fsViewMode === "list") {
+ const entries = sortFilesystemEntries(rawEntries);
+ renderFilesystemEntries(
+ elements.fsEntries,
+ entries,
+ async (entry) => {
+ if (entry.isDir) {
+ try {
+ await loadFilesystem(entry.uri, { pushHistory: true });
+ } catch (error) {
+ setOutput(error.message);
+ }
+ return;
+ }
+ try {
+ await readFilesystemFile(entry);
+ } catch (error) {
+ setOutput(error.message);
+ }
+ },
+ async (entry) => {
+ await statFilesystemResource(entry);
+ }
+ );
+ } else {
+ rawEntries.sort((a, b) => {
+ if (a.isDir !== b.isDir) {
+ return a.isDir ? -1 : 1;
+ }
+ return (a.uri || "").localeCompare(b.uri || "");
+ });
+ state.fsTreeData[targetUri] = rawEntries;
+ await renderFsTree();
+ }
+
+ setOutput(payload);
+}
+
+async function refreshCapabilities() {
+ try {
+ const payload = await callConsole("/runtime/capabilities", { method: "GET" });
+ state.writeEnabled = Boolean(payload.result?.write_enabled);
+ elements.writeBadge.textContent = state.writeEnabled ? "Write Enabled" : "Readonly";
+ elements.writeBadge.classList.toggle("write", state.writeEnabled);
+ elements.addResourceBtn.disabled = !state.writeEnabled;
+ elements.addResourceUploadBtn.disabled = !state.writeEnabled;
+ syncWriteControls();
+ renderAccountsTable();
+ renderUsersTable();
+ } catch (error) {
+ setOutput(`Failed to load capabilities: ${error.message}`);
+ }
+}
+
+function bindShellControls() {
+ const preferDark = window.matchMedia("(prefers-color-scheme: dark)");
+
+ if (elements.navToggleBtn) {
+ elements.navToggleBtn.addEventListener("click", () => {
+ setNavCollapsed(!state.navCollapsed);
+ });
+ }
+
+ if (elements.resultToggleBtn) {
+ elements.resultToggleBtn.addEventListener("click", () => {
+ setResultCollapsed(!state.resultCollapsed);
+ });
+ }
+
+ if (elements.clearOutputBtn) {
+ elements.clearOutputBtn.addEventListener("click", () => {
+ setOutput("Ready.");
+ });
+ }
+
+ for (const button of elements.themeButtons) {
+ button.addEventListener("click", () => {
+ applyThemeMode(button.dataset.themeMode || "dark");
+ });
+ }
+
+ if (elements.content) {
+ elements.content.addEventListener("click", () => {
+ if (window.matchMedia("(max-width: 900px)").matches && !state.navCollapsed) {
+ setNavCollapsed(true);
+ }
+ });
+ }
+
+ const onThemeChange = () => {
+ if (state.themeMode === "system") {
+ applyThemeMode("system", { persist: false });
+ }
+ };
+ if (typeof preferDark.addEventListener === "function") {
+ preferDark.addEventListener("change", onThemeChange);
+ } else if (typeof preferDark.addListener === "function") {
+ preferDark.addListener(onThemeChange);
+ }
+}
+
+function initShellState() {
+ const storedTheme = readLocalStorage(THEME_MODE_KEY);
+ const themeMode = storedTheme === "light" || storedTheme === "system" ? storedTheme : "dark";
+ applyThemeMode(themeMode, { persist: false });
+
+ const storedNav = readLocalStorage(NAV_COLLAPSED_KEY);
+ const defaultNavCollapsed =
+ storedNav === "1" || (storedNav === null && window.matchMedia("(max-width: 900px)").matches);
+ setNavCollapsed(defaultNavCollapsed, { persist: false });
+
+ const storedResult = readLocalStorage(RESULT_COLLAPSED_KEY);
+ setResultCollapsed(storedResult === "1", { persist: false });
+}
+
+function bindTabs() {
+ for (const tab of elements.tabs) {
+ tab.addEventListener("click", () => {
+ const panel = tab.dataset.panel;
+ if (!panel) {
+ return;
+ }
+ setActivePanel(panel);
+ });
+ }
+}
+
+function bindConnection() {
+ const saveApiKey = () => {
+ const value = elements.apiKeyInput.value.trim();
+ if (!value) {
+ setOutput("API key is empty.");
+ return false;
+ }
+
+ window.sessionStorage.setItem(SESSION_KEY, value);
+ elements.apiKeyInput.value = "";
+ updateConnectionHint();
+ setOutput("API key saved in browser session storage.");
+ return true;
+ };
+
+ elements.saveKeyBtn.addEventListener("click", () => {
+ saveApiKey();
+ });
+
+ elements.apiKeyInput.addEventListener("keydown", (event) => {
+ if (event.key !== "Enter") {
+ return;
+ }
+ event.preventDefault();
+ saveApiKey();
+ });
+
+ elements.clearKeyBtn.addEventListener("click", () => {
+ window.sessionStorage.removeItem(SESSION_KEY);
+ updateConnectionHint();
+ setOutput("API key cleared from browser session.");
+ });
+}
+
+function bindFilesystem() {
+ bindFilesystemSort();
+ updateFilesystemSortHeaders();
+
+ elements.fsGoBtn.addEventListener("click", async () => {
+ try {
+ await loadFilesystem(elements.fsCurrentUri.value, { pushHistory: true });
+ } catch (error) {
+ setOutput(error.message);
+ }
+ });
+
+ elements.fsRefreshBtn.addEventListener("click", async () => {
+ try {
+ await loadFilesystem(state.fsCurrentUri);
+ } catch (error) {
+ setOutput(error.message);
+ }
+ });
+
+ elements.fsBackBtn.addEventListener("click", async () => {
+ if (!state.fsHistory.length) {
+ setOutput("No previous directory.");
+ return;
+ }
+
+ const previous = state.fsHistory.pop();
+ try {
+ await loadFilesystem(previous);
+ } catch (error) {
+ setOutput(error.message);
+ }
+ });
+
+ elements.fsUpBtn.addEventListener("click", async () => {
+ const parent = parentUri(state.fsCurrentUri);
+ if (parent === state.fsCurrentUri) {
+ setOutput("Already at viking:// root.");
+ return;
+ }
+
+ state.fsHistory.push(state.fsCurrentUri);
+ try {
+ await loadFilesystem(parent);
+ } catch (error) {
+ setOutput(error.message);
+ }
+ });
+
+ elements.fsModeListBtn.addEventListener("click", () => {
+ setFsViewMode("list");
+ loadFilesystem(state.fsCurrentUri).catch((e) => setOutput(e.message));
+ });
+
+ elements.fsModeTreeBtn.addEventListener("click", async () => {
+ if (state.fsViewMode === "tree") {
+ // Already in tree mode: toggle all collapse ↔ expand (first level)
+ if (state.fsTreeExpanded.size > 0) {
+ state.fsTreeExpanded.clear();
+ await renderFsTree();
+ } else {
+ const firstLevel = state.fsTreeData[state.fsCurrentUri] || [];
+ await Promise.all(
+ firstLevel
+ .filter((e) => e.isDir && !state.fsTreeData[e.uri])
+ .map(async (e) => {
+ try {
+ const payload = await callConsole(
+ `/ov/fs/ls?uri=${encodeURIComponent(e.uri)}&show_all_hidden=true`,
+ { method: "GET" }
+ );
+ const children = normalizeFsEntries(payload.result, e.uri);
+ children.sort((a, b) => {
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
+ return (a.uri || "").localeCompare(b.uri || "");
+ });
+ state.fsTreeData[e.uri] = children;
+ } catch (_) {}
+ })
+ );
+ for (const entry of firstLevel) {
+ if (entry.isDir) state.fsTreeExpanded.add(entry.uri);
+ }
+ await renderFsTree();
+ }
+ return;
+ }
+ setFsViewMode("tree");
+ state.fsTreeData = {};
+ state.fsTreeExpanded = new Set();
+ loadFilesystem(state.fsCurrentUri).catch((e) => setOutput(e.message));
+ });
+}
+
+function bindFind() {
+ elements.findBtn.addEventListener("click", async () => {
+ const query = elements.findQuery.value.trim();
+ if (!query) {
+ setOutput("Query cannot be empty.");
+ return;
+ }
+
+ try {
+ const payload = await callConsole("/ov/search/find", {
+ method: "POST",
+ body: JSON.stringify({
+ query,
+ target_uri: elements.findTarget.value.trim(),
+ limit: 10,
+ }),
+ });
+
+ const rows = normalizeFindRows(payload.result);
+ renderFindTable(rows);
+ setOutput(payload);
+ } catch (error) {
+ setOutput(error.message);
+ }
+ });
+}
+
+function buildAddResourcePayload() {
+ const payload = {
+ target: elements.addResourceTarget.value.trim(),
+ reason: elements.addResourceReason.value.trim(),
+ instruction: elements.addResourceInstruction.value.trim(),
+ wait: elements.addResourceWait.checked,
+ strict: elements.addResourceStrict.checked,
+ directly_upload_media: elements.addResourceUploadMedia.checked,
+ };
+
+ const timeoutRaw = elements.addResourceTimeout.value.trim();
+ if (timeoutRaw) {
+ const timeout = Number.parseFloat(timeoutRaw);
+ if (Number.isFinite(timeout) && timeout > 0) {
+ payload.timeout = timeout;
+ }
+ }
+
+ const ignoreDirs = elements.addResourceIgnoreDirs.value.trim();
+ if (ignoreDirs) {
+ payload.ignore_dirs = ignoreDirs;
+ }
+
+ const include = elements.addResourceInclude.value.trim();
+ if (include) {
+ payload.include = include;
+ }
+
+ const exclude = elements.addResourceExclude.value.trim();
+ if (exclude) {
+ payload.exclude = exclude;
+ }
+
+ return payload;
+}
+
+function bindAddResource() {
+ elements.addResourceBtn.addEventListener("click", async () => {
+ if (!state.writeEnabled) {
+ setOutput("Write mode is disabled on the server.");
+ return;
+ }
+
+ const path = elements.addResourcePath.value.trim();
+ if (!path) {
+ setOutput("Path cannot be empty. Or use Upload & Add.");
+ return;
+ }
+
+ try {
+ const payload = await callConsole("/ov/resources", {
+ method: "POST",
+ body: JSON.stringify({
+ ...buildAddResourcePayload(),
+ path,
+ }),
+ });
+ setOutput(payload);
+ } catch (error) {
+ setOutput(error.message);
+ }
+ });
+
+ elements.addResourceUploadBtn.addEventListener("click", async () => {
+ if (!state.writeEnabled) {
+ setOutput("Write mode is disabled on the server.");
+ return;
+ }
+
+ const file = elements.addResourceFile.files?.[0];
+ if (!file) {
+ setOutput("Please select a file first.");
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append("file", file);
+
+ try {
+ setOutput(`Uploading ${file.name} ...`);
+ const uploadPayload = await callConsole("/ov/resources/temp_upload", {
+ method: "POST",
+ body: formData,
+ });
+ const tempPath = uploadPayload.result?.temp_path;
+ if (!tempPath) {
+ throw new Error("Temp upload did not return temp_path.");
+ }
+
+ const addPayload = await callConsole("/ov/resources", {
+ method: "POST",
+ body: JSON.stringify({
+ ...buildAddResourcePayload(),
+ temp_path: tempPath,
+ }),
+ });
+
+ setOutput({
+ status: "ok",
+ result: {
+ upload: uploadPayload.result,
+ add_resource: addPayload.result,
+ },
+ });
+ } catch (error) {
+ setOutput(error.message);
+ }
+ });
+}
+
+function normalizeArrayResult(result, candidateKeys = []) {
+ if (Array.isArray(result)) {
+ return result;
+ }
+ if (isRecord(result)) {
+ for (const key of candidateKeys) {
+ if (Array.isArray(result[key])) {
+ return result[key];
+ }
+ }
+ }
+ return [];
+}
+
+function normalizeTenantAccount(item) {
+ if (typeof item === "string") {
+ const accountId = item.trim();
+ return accountId
+ ? {
+ accountId,
+ userCount: null,
+ raw: item,
+ }
+ : null;
+ }
+
+ if (!isRecord(item)) {
+ return null;
+ }
+
+ const accountIdValue = pickFirstNonEmpty([
+ item.account_id,
+ item.accountId,
+ item.id,
+ item.name,
+ item.uri,
+ ]);
+ if (accountIdValue === null) {
+ return null;
+ }
+
+ return {
+ accountId: String(accountIdValue),
+ userCount: pickFirstNonEmpty([item.user_count, item.userCount, item.users, item.member_count]),
+ raw: item,
+ };
+}
+
+function normalizeTenantUser(item) {
+ if (typeof item === "string") {
+ const userId = item.trim();
+ return userId ? { userId, role: "", raw: item } : null;
+ }
+
+ if (!isRecord(item)) {
+ return null;
+ }
+
+ const userIdValue = pickFirstNonEmpty([item.user_id, item.userId, item.id, item.name]);
+ if (userIdValue === null) {
+ return null;
+ }
+
+ let role = pickFirstNonEmpty([item.role, item.user_role, item.userRole, item.permission, item.permissions]);
+ if (role === null && typeof item.is_admin === "boolean") {
+ role = item.is_admin ? "admin" : "member";
+ }
+
+ return {
+ userId: String(userIdValue),
+ role: role === null ? "" : String(role),
+ raw: item,
+ };
+}
+
+function updateTenantCurrentAccountLabel() {
+ elements.tenantCurrentAccount.textContent = state.tenantSelectedAccountId
+ ? `Account: ${state.tenantSelectedAccountId}`
+ : "No account selected";
+}
+
+function compareTenantRows(left, right, field) {
+ const leftValue = isRecord(left) ? left[field] : undefined;
+ const rightValue = isRecord(right) ? right[field] : undefined;
+ return compareFindValues(leftValue, rightValue);
+}
+
+function sortTenantRows(rows, field, direction) {
+ const sorted = [...rows].sort((left, right) => compareTenantRows(left, right, field));
+ if (direction === "desc") {
+ sorted.reverse();
+ }
+ return sorted;
+}
+
+function applyTenantAccountFilter() {
+ const keyword = elements.tenantAccountSearch.value.trim().toLowerCase();
+ state.tenantFilteredAccounts = state.tenantAccounts.filter((account) =>
+ account.accountId.toLowerCase().includes(keyword)
+ );
+}
+
+function updateTenantSortButtons(buttons, activeField, direction) {
+ for (const button of buttons) {
+ const field = button.dataset.tenantAccountSort || button.dataset.tenantUserSort || "";
+ const isActive = field === activeField;
+ const suffix = !isActive ? "" : direction === "asc" ? " ↑" : " ↓";
+ button.textContent = `${field}${suffix}`;
+ button.setAttribute("aria-sort", isActive ? (direction === "asc" ? "ascending" : "descending") : "none");
+ }
+}
+
+function renderAccountsTable() {
+ if (!elements.tenantAccountsBody) {
+ return;
+ }
+
+ elements.tenantAccountsBody.innerHTML = "";
+ applyTenantAccountFilter();
+ const rows = sortTenantRows(
+ state.tenantFilteredAccounts,
+ state.tenantAccountSortField,
+ state.tenantAccountSortDirection
+ );
+ updateTenantSortButtons(
+ elements.tenantAccountSortBtns,
+ state.tenantAccountSortField,
+ state.tenantAccountSortDirection
+ );
+
+ if (!rows.length) {
+ const tr = document.createElement("tr");
+ const td = document.createElement("td");
+ td.colSpan = 3;
+ td.className = "tenant-empty";
+ td.textContent = "No accounts";
+ tr.appendChild(td);
+ elements.tenantAccountsBody.appendChild(tr);
+ return;
+ }
+
+ for (const account of rows) {
+ const tr = document.createElement("tr");
+ tr.classList.toggle("tenant-row-selected", account.accountId === state.tenantSelectedAccountId);
+
+ const accountCell = document.createElement("td");
+ const accountBtn = document.createElement("button");
+ accountBtn.type = "button";
+ accountBtn.className = "tenant-account-btn";
+ accountBtn.textContent = account.accountId;
+ accountBtn.addEventListener("click", async () => {
+ state.tenantSelectedAccountId = account.accountId;
+ updateTenantCurrentAccountLabel();
+ renderAccountsTable();
+ try {
+ await loadTenantUsers(account.accountId);
+ } catch (error) {
+ setOutput(error.message);
+ }
+ });
+ accountCell.appendChild(accountBtn);
+ tr.appendChild(accountCell);
+
+ const countCell = document.createElement("td");
+ countCell.textContent =
+ account.userCount === null || account.userCount === undefined || account.userCount === ""
+ ? "-"
+ : String(account.userCount);
+ tr.appendChild(countCell);
+
+ const actionCell = document.createElement("td");
+ const actions = document.createElement("div");
+ actions.className = "tenant-actions";
+
+ const deleteBtn = document.createElement("button");
+ deleteBtn.type = "button";
+ deleteBtn.className = "danger";
+ deleteBtn.textContent = "Delete";
+ deleteBtn.disabled = !state.writeEnabled;
+ deleteBtn.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ void executeTenantAction(
+ {
+ title: "Delete account",
+ message: `Delete account "${account.accountId}" and its tenant users?`,
+ confirmLabel: `Type ${account.accountId} to confirm`,
+ confirmToken: account.accountId,
+ actionLabel: "Delete account",
+ run: async () =>
+ callConsole(`/ov/admin/accounts/${encodeURIComponent(account.accountId)}`, {
+ method: "DELETE",
+ }),
+ afterSuccess: async () => {
+ await loadTenantAccounts({ showOutput: false });
+ },
+ },
+ { confirm: true }
+ );
+ });
+ actions.appendChild(deleteBtn);
+
+ actionCell.appendChild(actions);
+ tr.appendChild(actionCell);
+ elements.tenantAccountsBody.appendChild(tr);
+ }
+}
+
+function tenantRoleOptions(role) {
+ const defaults = ["user", "admin"];
+ if (role && !defaults.includes(role)) {
+ defaults.unshift(role);
+ }
+ return defaults;
+}
+
+function renderUsersTable() {
+ if (!elements.tenantUsersBody) {
+ return;
+ }
+
+ elements.tenantUsersBody.innerHTML = "";
+ updateTenantSortButtons(elements.tenantUserSortBtns, state.tenantUserSortField, state.tenantUserSortDirection);
+
+ if (!state.tenantSelectedAccountId) {
+ const tr = document.createElement("tr");
+ const td = document.createElement("td");
+ td.colSpan = 3;
+ td.className = "tenant-empty";
+ td.textContent = "Select an account to view users";
+ tr.appendChild(td);
+ elements.tenantUsersBody.appendChild(tr);
+ return;
+ }
+
+ const rows = sortTenantRows(state.tenantUsers, state.tenantUserSortField, state.tenantUserSortDirection);
+ if (!rows.length) {
+ const tr = document.createElement("tr");
+ const td = document.createElement("td");
+ td.colSpan = 3;
+ td.className = "tenant-empty";
+ td.textContent = "No users";
+ tr.appendChild(td);
+ elements.tenantUsersBody.appendChild(tr);
+ return;
+ }
+
+ for (const user of rows) {
+ const tr = document.createElement("tr");
+
+ const userIdCell = document.createElement("td");
+ userIdCell.textContent = user.userId;
+ tr.appendChild(userIdCell);
+
+ const roleCell = document.createElement("td");
+ roleCell.textContent = user.role || "-";
+ tr.appendChild(roleCell);
+
+ const actionCell = document.createElement("td");
+ const actions = document.createElement("div");
+ actions.className = "tenant-actions";
+
+ const roleSelect = document.createElement("select");
+ roleSelect.className = "tenant-role-select";
+ for (const optionValue of tenantRoleOptions(user.role)) {
+ const option = document.createElement("option");
+ option.value = optionValue;
+ option.textContent = optionValue;
+ option.selected = optionValue === (user.role || "member");
+ roleSelect.appendChild(option);
+ }
+ actions.appendChild(roleSelect);
+
+ const roleBtn = document.createElement("button");
+ roleBtn.type = "button";
+ roleBtn.textContent = "Update Role";
+ roleBtn.disabled = !state.writeEnabled;
+ roleBtn.addEventListener("click", () => {
+ void executeTenantAction({
+ title: "Update user role",
+ message: `Set role for "${user.userId}" under "${state.tenantSelectedAccountId}" to "${roleSelect.value}".`,
+ confirmLabel: `Type ${state.tenantSelectedAccountId}/${user.userId} to confirm`,
+ confirmToken: `${state.tenantSelectedAccountId}/${user.userId}`,
+ actionLabel: "Save role",
+ run: async () =>
+ callConsole(
+ `/ov/admin/accounts/${encodeURIComponent(state.tenantSelectedAccountId)}/users/${encodeURIComponent(
+ user.userId
+ )}/role`,
+ {
+ method: "PUT",
+ body: JSON.stringify({ role: roleSelect.value }),
+ }
+ ),
+ afterSuccess: async () => {
+ await loadTenantUsers(state.tenantSelectedAccountId, { showOutput: false });
+ },
+ });
+ });
+ actions.appendChild(roleBtn);
+
+ const keyBtn = document.createElement("button");
+ keyBtn.type = "button";
+ keyBtn.textContent = "Reset API Key";
+ keyBtn.disabled = !state.writeEnabled;
+ keyBtn.addEventListener("click", () => {
+ void executeTenantAction({
+ title: "Reset API key",
+ message: `Generate a new API key for "${user.userId}" under "${state.tenantSelectedAccountId}".`,
+ confirmLabel: `Type ${state.tenantSelectedAccountId}/${user.userId} to confirm`,
+ confirmToken: `${state.tenantSelectedAccountId}/${user.userId}`,
+ actionLabel: "Reset key",
+ run: async () =>
+ callConsole(
+ `/ov/admin/accounts/${encodeURIComponent(state.tenantSelectedAccountId)}/users/${encodeURIComponent(
+ user.userId
+ )}/key`,
+ { method: "POST", body: JSON.stringify({}) }
+ ),
+ });
+ });
+ actions.appendChild(keyBtn);
+
+ const deleteBtn = document.createElement("button");
+ deleteBtn.type = "button";
+ deleteBtn.className = "danger";
+ deleteBtn.textContent = "Remove";
+ deleteBtn.disabled = !state.writeEnabled;
+ deleteBtn.addEventListener("click", () => {
+ void executeTenantAction(
+ {
+ title: "Remove user",
+ message: `Remove "${user.userId}" from account "${state.tenantSelectedAccountId}".`,
+ confirmLabel: `Type ${state.tenantSelectedAccountId}/${user.userId} to confirm`,
+ confirmToken: `${state.tenantSelectedAccountId}/${user.userId}`,
+ actionLabel: "Remove user",
+ run: async () =>
+ callConsole(
+ `/ov/admin/accounts/${encodeURIComponent(state.tenantSelectedAccountId)}/users/${encodeURIComponent(
+ user.userId
+ )}`,
+ { method: "DELETE" }
+ ),
+ afterSuccess: async () => {
+ await loadTenantUsers(state.tenantSelectedAccountId, { showOutput: false });
+ },
+ },
+ { confirm: true }
+ );
+ });
+ actions.appendChild(deleteBtn);
+
+ actionCell.appendChild(actions);
+ tr.appendChild(actionCell);
+ elements.tenantUsersBody.appendChild(tr);
+ }
+}
+
+async function loadTenantUsers(accountId, { showOutput = true } = {}) {
+ if (!accountId) {
+ state.tenantUsers = [];
+ updateTenantCurrentAccountLabel();
+ renderUsersTable();
+ return null;
+ }
+
+ const payload = await callConsole(`/ov/admin/accounts/${encodeURIComponent(accountId)}/users`, {
+ method: "GET",
+ });
+ const normalizedUsers = normalizeArrayResult(payload.result, ["users", "items", "results"])
+ .map(normalizeTenantUser)
+ .filter(Boolean);
+ state.tenantSelectedAccountId = accountId;
+ state.tenantUsers = normalizedUsers;
+ updateTenantCurrentAccountLabel();
+ renderUsersTable();
+ if (showOutput) {
+ setOutput(payload);
+ }
+ return payload;
+}
+
+async function loadTenantAccounts({ showOutput = true } = {}) {
+ const payload = await callConsole("/ov/admin/accounts", { method: "GET" });
+ const normalizedAccounts = normalizeArrayResult(payload.result, ["accounts", "items", "results"])
+ .map(normalizeTenantAccount)
+ .filter(Boolean);
+ state.tenantAccounts = normalizedAccounts;
+ state.tenantAccountsLoaded = true;
+
+ const hasSelected = state.tenantSelectedAccountId
+ ? normalizedAccounts.some((account) => account.accountId === state.tenantSelectedAccountId)
+ : false;
+ if (!hasSelected) {
+ state.tenantSelectedAccountId = normalizedAccounts[0]?.accountId || "";
+ }
+
+ renderAccountsTable();
+ if (state.tenantSelectedAccountId) {
+ await loadTenantUsers(state.tenantSelectedAccountId, { showOutput: false });
+ } else {
+ state.tenantUsers = [];
+ updateTenantCurrentAccountLabel();
+ renderUsersTable();
+ }
+ if (showOutput) {
+ setOutput(payload);
+ }
+ return payload;
+}
+
+async function ensureTenantsLoaded() {
+ if (!state.tenantAccountsLoaded) {
+ await loadTenantAccounts({ showOutput: false });
+ }
+}
+
+function closeTenantConfirmModal() {
+ elements.tenantConfirmModal.hidden = true;
+ elements.tenantConfirmInput.value = "";
+ elements.tenantConfirmError.hidden = true;
+ elements.tenantConfirmError.textContent = "";
+ state.tenantConfirmRequest = null;
+}
+
+function updateTenantConfirmState() {
+ const request = state.tenantConfirmRequest;
+ if (!request) {
+ return;
+ }
+ const expected = request.confirmToken || "";
+ const value = elements.tenantConfirmInput.value.trim();
+ const valid = !expected || value === expected;
+ elements.tenantConfirmActionBtn.disabled = !valid;
+ elements.tenantConfirmError.hidden = true;
+ elements.tenantConfirmError.textContent = "";
+}
+
+function openTenantConfirmModal(request) {
+ state.tenantConfirmRequest = request;
+ elements.tenantConfirmTitle.textContent = request.title;
+ elements.tenantConfirmMessage.textContent = request.message;
+ elements.tenantConfirmLabel.textContent = request.confirmLabel || "Type to confirm";
+ elements.tenantConfirmActionBtn.textContent = request.actionLabel || "Confirm";
+ elements.tenantConfirmInput.value = "";
+ elements.tenantConfirmActionBtn.disabled = true;
+ elements.tenantConfirmError.hidden = true;
+ elements.tenantConfirmError.textContent = "";
+ elements.tenantConfirmModal.hidden = false;
+ updateTenantConfirmState();
+ elements.tenantConfirmInput.focus();
+}
+
+async function performTenantAction(request) {
+ const payload = await request.run();
+ if (request.afterSuccess) {
+ await request.afterSuccess(payload);
+ }
+ setOutput(payload);
+}
+
+async function executeTenantAction(request, { confirm = false } = {}) {
+ if (!state.writeEnabled) {
+ setOutput("Write mode is disabled on the server.");
+ return;
+ }
+
+ if (confirm) {
+ openTenantConfirmModal(request);
+ return;
+ }
+
+ try {
+ await performTenantAction(request);
+ } catch (error) {
+ setOutput(error.message);
+ }
+}
+
+function bindTenantSortButtons() {
+ for (const button of elements.tenantAccountSortBtns) {
+ button.addEventListener("click", () => {
+ const field = button.dataset.tenantAccountSort;
+ if (!field) {
+ return;
+ }
+ if (state.tenantAccountSortField === field) {
+ state.tenantAccountSortDirection = state.tenantAccountSortDirection === "asc" ? "desc" : "asc";
+ } else {
+ state.tenantAccountSortField = field;
+ state.tenantAccountSortDirection = "asc";
+ }
+ renderAccountsTable();
+ });
+ }
+
+ for (const button of elements.tenantUserSortBtns) {
+ button.addEventListener("click", () => {
+ const field = button.dataset.tenantUserSort;
+ if (!field) {
+ return;
+ }
+ if (state.tenantUserSortField === field) {
+ state.tenantUserSortDirection = state.tenantUserSortDirection === "asc" ? "desc" : "asc";
+ } else {
+ state.tenantUserSortField = field;
+ state.tenantUserSortDirection = "asc";
+ }
+ renderUsersTable();
+ });
+ }
+}
+
+function bindAddMemory() {
+ elements.addMemoryBtn.addEventListener("click", async () => {
+ if (!state.writeEnabled) {
+ setOutput("Write mode is disabled on the server.");
+ return;
+ }
+
+ const text = elements.addMemoryInput.value.trim();
+ if (!text) {
+ setOutput("Please enter content to add as memory.");
+ return;
+ }
+
+ let messages;
+ try {
+ const parsed = JSON.parse(text);
+ if (Array.isArray(parsed)) {
+ messages = parsed;
+ } else {
+ messages = [{ role: "user", content: text }];
+ }
+ } catch (_) {
+ messages = [{ role: "user", content: text }];
+ }
+
+ try {
+ setOutput("Creating session...");
+ const sessionPayload = await callConsole("/ov/sessions", {
+ method: "POST",
+ body: JSON.stringify({}),
+ });
+ const sessionId = sessionPayload.result?.session_id;
+ if (!sessionId) {
+ throw new Error("Failed to create session: no session_id returned.");
+ }
+
+ for (const msg of messages) {
+ await callConsole(`/ov/sessions/${sessionId}/messages`, {
+ method: "POST",
+ body: JSON.stringify(msg),
+ });
+ }
+
+ setOutput("Committing session...");
+ const commitPayload = await callConsole(`/ov/sessions/${sessionId}/commit`, {
+ method: "POST",
+ body: JSON.stringify({}),
+ });
+ setOutput(commitPayload);
+ } catch (error) {
+ setOutput({ error: error.message });
+ }
+ });
+}
+
+function bindTenants() {
+ bindTenantSortButtons();
+ renderAccountsTable();
+ renderUsersTable();
+ updateTenantCurrentAccountLabel();
+
+ elements.tenantAccountSearch.addEventListener("input", () => {
+ renderAccountsTable();
+ });
+
+ elements.tenantRefreshAccountsBtn.addEventListener("click", async () => {
+ try {
+ await loadTenantAccounts();
+ } catch (error) {
+ setOutput(error.message);
+ }
+ });
+
+ elements.tenantCreateAccountBtn.addEventListener("click", async () => {
+ const accountId = elements.tenantCreateAccountId.value.trim();
+ const adminUserId = elements.tenantCreateAdminUserId.value.trim();
+ if (!accountId || !adminUserId) {
+ setOutput("Please input account_id and first admin user_id.");
+ return;
+ }
+
+ await executeTenantAction({
+ title: "Create account",
+ message: `Create account "${accountId}" with initial admin "${adminUserId}".`,
+ confirmLabel: `Type ${accountId} to confirm`,
+ confirmToken: accountId,
+ actionLabel: "Create account",
+ run: async () =>
+ callConsole("/ov/admin/accounts", {
+ method: "POST",
+ body: JSON.stringify({ account_id: accountId, admin_user_id: adminUserId }),
+ }),
+ afterSuccess: async () => {
+ elements.tenantCreateAccountId.value = "";
+ await loadTenantAccounts({ showOutput: false });
+ },
+ });
+ });
+
+ elements.tenantAddUserBtn.addEventListener("click", async () => {
+ const accountId = state.tenantSelectedAccountId;
+ const userId = elements.tenantAddUserId.value.trim();
+ const role = elements.tenantAddUserRole.value;
+ if (!accountId) {
+ setOutput("Select an account before adding users.");
+ return;
+ }
+ if (!userId) {
+ setOutput("Please input new user_id.");
+ return;
+ }
+
+ await executeTenantAction({
+ title: "Add user",
+ message: `Add user "${userId}" to account "${accountId}" with role "${role}".`,
+ confirmLabel: `Type ${accountId}/${userId} to confirm`,
+ confirmToken: `${accountId}/${userId}`,
+ actionLabel: "Add user",
+ run: async () =>
+ callConsole(`/ov/admin/accounts/${encodeURIComponent(accountId)}/users`, {
+ method: "POST",
+ body: JSON.stringify({ user_id: userId, role }),
+ }),
+ afterSuccess: async () => {
+ elements.tenantAddUserId.value = "";
+ await loadTenantUsers(accountId, { showOutput: false });
+ },
+ });
+ });
+
+ elements.tenantConfirmInput.addEventListener("input", () => {
+ updateTenantConfirmState();
+ });
+
+ elements.tenantConfirmCancelBtn.addEventListener("click", () => {
+ closeTenantConfirmModal();
+ });
+
+ elements.tenantConfirmModal.addEventListener("click", (event) => {
+ if (event.target === elements.tenantConfirmModal) {
+ closeTenantConfirmModal();
+ }
+ });
+
+ elements.tenantConfirmActionBtn.addEventListener("click", async () => {
+ const request = state.tenantConfirmRequest;
+ if (!request) {
+ return;
+ }
+
+ const expected = request.confirmToken || "";
+ const typed = elements.tenantConfirmInput.value.trim();
+ if (expected && typed !== expected) {
+ elements.tenantConfirmError.hidden = false;
+ elements.tenantConfirmError.textContent = "Confirmation text mismatch.";
+ return;
+ }
+
+ elements.tenantConfirmActionBtn.disabled = true;
+ try {
+ await performTenantAction(request);
+ closeTenantConfirmModal();
+ } catch (error) {
+ closeTenantConfirmModal();
+ setOutput(error.message);
+ }
+ });
+}
+
+function bindMonitor() {
+ elements.systemBtn.addEventListener("click", async () => {
+ try {
+ const payload = await callConsole("/ov/system/status", { method: "GET" });
+ const rows = Object.entries(payload.result || {}).map(([key, value]) => ({
+ label: `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`,
+ }));
+ renderList(elements.monitorResults, rows);
+ setOutput(payload);
+ } catch (error) {
+ setOutput(error.message);
+ }
+ });
+
+ elements.observerBtn.addEventListener("click", async () => {
+ try {
+ const payload = await callConsole("/ov/observer/system", { method: "GET" });
+ const rows = Object.entries(payload.result?.components || {}).map(([name, value]) => ({
+ label: `${name}: ${value?.status || JSON.stringify(value)}`,
+ }));
+ renderList(elements.monitorResults, rows);
+ setOutput(payload);
+ } catch (error) {
+ setOutput(error.message);
+ }
+ });
+}
+
+async function init() {
+ initShellState();
+ bindShellControls();
+ initResizablePanes();
+ initFsColumnResize();
+ bindTabs();
+ bindConnection();
+ bindFilesystem();
+ bindFind();
+ renderFindTable([]);
+ bindAddResource();
+ bindAddMemory();
+ bindTenants();
+ bindMonitor();
+ updateConnectionHint();
+ setActivePanel(state.activePanel);
+ await refreshCapabilities();
+
+ try {
+ await loadFilesystem("viking://");
+ } catch (error) {
+ setOutput(error.message);
+ }
+}
+
+init();
diff --git a/examples/console/static/index.html b/examples/console/static/index.html
new file mode 100644
index 00000000..7286dd5e
--- /dev/null
+++ b/examples/console/static/index.html
@@ -0,0 +1,329 @@
+
+
+
+
+
+ OpenViking Console
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ FileSystem
+ Browse viking:// paths.
+
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ Add Memory
+ Store text or conversations as persistent memories.
+
+
+
+
+
+
+ Tenants
+ Manage accounts and users with guarded write operations.
+
+
+
+
+
+
Users
+ No account selected
+
+
+
+
+
+
+
+
+
+
+ |
+
+ |
+
+
+ |
+ actions |
+
+
+
+
+
+
+
+
+
+ Tip: Select an account to load users. Write operations require explicit confirmation.
+
+
+
+
+
Confirm action
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monitor
+ Check runtime status and observer health snapshots.
+
+
+
+
+
+
+
+
+ Settings
+
+ Configure console session values and set X-API-Key used by all API requests.
+
+
+
+
+
+
+
+ No API key in session.
+
+
+
+
+
+
+
+
Result
+
+
+ Ready.
+
+
+
+
+
+
+
+
+
diff --git a/examples/console/static/styles.css b/examples/console/static/styles.css
new file mode 100644
index 00000000..73fb4f75
--- /dev/null
+++ b/examples/console/static/styles.css
@@ -0,0 +1,1266 @@
+@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
+
+:root {
+ color-scheme: dark;
+
+ --sidebar-width: 260px;
+ --panel-height: 440px;
+ --panel-min-height: 0px;
+ --result-min-height: 72px;
+
+ --bg: #12141a;
+ --bg-accent: #161922;
+ --bg-elevated: #1c2029;
+ --bg-hover: #252b37;
+
+ --panel: #151820;
+ --panel-strong: #1a1f29;
+ --card: #1b1f28;
+ --surface-soft: rgba(255, 255, 255, 0.02);
+
+ --text: #e7e8ec;
+ --text-strong: #fafafa;
+ --muted: #a0a5b1;
+ --muted-soft: #7e8697;
+
+ --border: #2f3543;
+ --border-strong: #474f62;
+
+ --accent: #ff5c5c;
+ --accent-hover: #ff6f6f;
+ --accent-subtle: rgba(255, 92, 92, 0.15);
+ --accent-glow: rgba(255, 92, 92, 0.24);
+
+ --ok: #30c482;
+ --warn: #e5af3a;
+ --danger: #ef5b6f;
+
+ --radius-xs: 6px;
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --radius-pill: 999px;
+
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.22);
+ --shadow-md: 0 8px 18px rgba(0, 0, 0, 0.28);
+ --shadow-lg: 0 18px 34px rgba(0, 0, 0, 0.36);
+
+ --dur-fast: 130ms;
+ --dur-med: 210ms;
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+:root[data-theme="light"] {
+ color-scheme: light;
+
+ --bg: #f6f8fc;
+ --bg-accent: #eef2f8;
+ --bg-elevated: #ffffff;
+ --bg-hover: #e8edf6;
+
+ --panel: #f3f6fb;
+ --panel-strong: #edf1f9;
+ --card: #ffffff;
+ --surface-soft: rgba(12, 24, 44, 0.04);
+
+ --text: #2a3244;
+ --text-strong: #111828;
+ --muted: #5a657a;
+ --muted-soft: #7b8598;
+
+ --border: #d7deeb;
+ --border-strong: #bcc7db;
+
+ --accent: #dc3e3e;
+ --accent-hover: #eb4b4b;
+ --accent-subtle: rgba(220, 62, 62, 0.14);
+ --accent-glow: rgba(220, 62, 62, 0.2);
+
+ --ok: #198a57;
+ --warn: #b98516;
+ --danger: #d63f55;
+
+ --shadow-sm: 0 1px 2px rgba(6, 18, 38, 0.08);
+ --shadow-md: 0 10px 22px rgba(6, 18, 38, 0.11);
+ --shadow-lg: 0 18px 38px rgba(6, 18, 38, 0.14);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ font-family: "Space Grotesk", "Segoe UI", "Noto Sans", sans-serif;
+ color: var(--text);
+ background:
+ radial-gradient(1100px 600px at -20% -25%, rgba(255, 92, 92, 0.14), transparent 58%),
+ radial-gradient(900px 620px at 122% -18%, rgba(255, 163, 96, 0.11), transparent 58%),
+ linear-gradient(145deg, #10131a 0%, var(--bg) 54%);
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+}
+
+:root[data-theme="light"] body {
+ background:
+ radial-gradient(1000px 600px at -22% -22%, rgba(220, 62, 62, 0.09), transparent 60%),
+ radial-gradient(900px 650px at 120% -20%, rgba(176, 74, 74, 0.08), transparent 60%),
+ linear-gradient(145deg, #f0f4fa 0%, var(--bg) 55%);
+}
+
+h1,
+h2,
+h3 {
+ margin: 0;
+}
+
+p {
+ margin: 0;
+}
+
+.workspace {
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+@supports (height: 100dvh) {
+ .workspace {
+ height: 100dvh;
+ }
+}
+
+.topbar {
+ min-height: 60px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 12px 18px;
+ border-bottom: 1px solid var(--border);
+ background: rgba(18, 20, 26, 0.88);
+ backdrop-filter: blur(8px);
+ position: relative;
+ z-index: 20;
+}
+
+:root[data-theme="light"] .topbar {
+ background: rgba(246, 248, 252, 0.9);
+}
+
+.topbar-left,
+.topbar-right {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.brand {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.brand-title {
+ font-size: 15px;
+ letter-spacing: 0.01em;
+ font-weight: 700;
+ color: var(--text-strong);
+ white-space: nowrap;
+}
+
+.brand-subtitle {
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted-soft);
+}
+
+.icon-btn {
+ border: 1px solid var(--border);
+ width: 34px;
+ height: 34px;
+ border-radius: var(--radius-sm);
+ background: var(--panel-strong);
+ color: var(--muted);
+ cursor: pointer;
+ transition:
+ border-color var(--dur-fast) var(--ease-out),
+ color var(--dur-fast) var(--ease-out),
+ background var(--dur-fast) var(--ease-out);
+}
+
+.icon-btn:hover {
+ color: var(--text-strong);
+ border-color: var(--border-strong);
+ background: var(--bg-hover);
+}
+
+.pill,
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-pill);
+ padding: 6px 11px;
+ font-size: 12px;
+ background: var(--panel-strong);
+ color: var(--muted);
+}
+
+.badge {
+ font-family: "JetBrains Mono", "SF Mono", Consolas, monospace;
+}
+
+.badge.write {
+ border-color: rgba(48, 196, 130, 0.4);
+ color: var(--ok);
+ background: rgba(48, 196, 130, 0.12);
+}
+
+.badge-soft {
+ color: var(--muted);
+ border-color: var(--border);
+}
+
+.statusDot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--muted-soft);
+}
+
+.statusDot.ok {
+ background: var(--ok);
+ box-shadow: 0 0 0 3px rgba(48, 196, 130, 0.18);
+}
+
+.theme-toggle {
+ display: inline-flex;
+ align-items: center;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-pill);
+ padding: 2px;
+ background: var(--panel-strong);
+}
+
+.theme-btn {
+ border: 0;
+ border-radius: var(--radius-pill);
+ padding: 5px 10px;
+ font-size: 11px;
+ letter-spacing: 0.02em;
+ font-weight: 600;
+ color: var(--muted);
+ background: transparent;
+ cursor: pointer;
+ transition:
+ color var(--dur-fast) var(--ease-out),
+ background var(--dur-fast) var(--ease-out);
+}
+
+.theme-btn.active {
+ color: #fff;
+ background: var(--accent);
+}
+
+.shell-body {
+ flex: 1;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: var(--sidebar-width) 8px minmax(0, 1fr);
+ transition: grid-template-columns var(--dur-med) var(--ease-out);
+}
+
+.shell.shell--nav-collapsed .shell-body {
+ grid-template-columns: 0 0 minmax(0, 1fr);
+}
+
+.sidebar {
+ min-height: 0;
+ overflow: auto;
+ padding: 16px 12px;
+ border-right: 1px solid var(--border);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0));
+}
+
+:root[data-theme="light"] .sidebar {
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0));
+}
+
+.shell.shell--nav-collapsed .sidebar {
+ opacity: 0;
+ pointer-events: none;
+ overflow: hidden;
+}
+
+.nav-groups {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.nav-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.nav-group-title {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.11em;
+ color: var(--muted-soft);
+ padding: 0 8px;
+}
+
+.tab.nav-item {
+ width: 100%;
+ text-align: left;
+ border: 1px solid transparent;
+ border-radius: var(--radius-md);
+ padding: 10px 12px;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ transition:
+ color var(--dur-fast) var(--ease-out),
+ border-color var(--dur-fast) var(--ease-out),
+ background var(--dur-fast) var(--ease-out),
+ transform var(--dur-fast) var(--ease-out);
+}
+
+.tab.nav-item:hover {
+ color: var(--text-strong);
+ border-color: var(--border);
+ background: var(--surface-soft);
+ transform: translateX(1px);
+}
+
+.tab.nav-item.active {
+ color: var(--text-strong);
+ border-color: rgba(255, 92, 92, 0.34);
+ background: linear-gradient(90deg, rgba(255, 92, 92, 0.22), rgba(255, 92, 92, 0.06));
+}
+
+.resizer {
+ position: relative;
+ border: 0;
+ background: rgba(255, 255, 255, 0.08);
+ transition: background var(--dur-fast) var(--ease-out);
+}
+
+:root[data-theme="light"] .resizer {
+ background: rgba(20, 31, 57, 0.12);
+}
+
+.resizer:hover {
+ background: rgba(255, 92, 92, 0.5);
+}
+
+.resizer-vertical {
+ cursor: col-resize;
+}
+
+.resizer-horizontal {
+ cursor: row-resize;
+ border-radius: var(--radius-pill);
+}
+
+.content-area {
+ min-height: 0;
+ min-width: 0;
+ padding: 12px;
+}
+
+.content {
+ height: 100%;
+ min-height: 0;
+ display: grid;
+ grid-template-rows:
+ auto
+ minmax(var(--panel-min-height), var(--panel-height))
+ 8px
+ minmax(var(--result-min-height), 1fr);
+ gap: 10px;
+}
+
+.shell.shell--result-collapsed .content {
+ grid-template-rows: auto minmax(var(--panel-min-height), 1fr);
+}
+
+.shell.shell--result-collapsed #outputResizer,
+.shell.shell--result-collapsed .result-card {
+ display: none;
+}
+
+.content-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: rgba(255, 255, 255, 0.02);
+ box-shadow: var(--shadow-sm);
+}
+
+.title-block {
+ min-width: 0;
+}
+
+h1 {
+ font-size: 19px;
+ font-weight: 700;
+ color: var(--text-strong);
+}
+
+.subtitle {
+ margin-top: 4px;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.panel-stack {
+ min-height: 0;
+ display: flex;
+ overflow: hidden;
+}
+
+.panel {
+ display: none;
+ min-height: 0;
+ overflow: auto;
+ padding: 14px;
+ gap: 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: linear-gradient(170deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.01));
+ box-shadow: var(--shadow-md);
+}
+
+.panel.active {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+}
+
+.panel > h2 {
+ font-size: 12px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted-soft);
+}
+
+.panel-subtitle {
+ font-size: 12px;
+ color: var(--muted);
+ margin-top: -2px;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.row.wrap {
+ flex-wrap: wrap;
+}
+
+.checks {
+ gap: 12px;
+}
+
+.check-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+label {
+ font-size: 12px;
+ color: var(--muted-soft);
+}
+
+input,
+textarea,
+button,
+select,
+pre,
+.badge,
+.tenant-table,
+.find-table,
+.fs-table {
+ font-family: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
+}
+
+input,
+textarea,
+select,
+button {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ background: var(--bg-elevated);
+ padding: 8px 10px;
+ transition:
+ border-color var(--dur-fast) var(--ease-out),
+ box-shadow var(--dur-fast) var(--ease-out),
+ background var(--dur-fast) var(--ease-out),
+ color var(--dur-fast) var(--ease-out);
+}
+
+input,
+textarea,
+select {
+ width: 100%;
+}
+
+textarea {
+ min-height: 70px;
+ resize: vertical;
+}
+
+input::placeholder,
+textarea::placeholder {
+ color: var(--muted-soft);
+}
+
+button {
+ cursor: pointer;
+ font-weight: 600;
+ color: var(--text-strong);
+ background: linear-gradient(180deg, rgba(255, 92, 92, 0.17), rgba(255, 92, 92, 0.08));
+ border-color: rgba(255, 92, 92, 0.36);
+}
+
+button:hover {
+ border-color: rgba(255, 92, 92, 0.56);
+ background: linear-gradient(180deg, rgba(255, 92, 92, 0.24), rgba(255, 92, 92, 0.11));
+}
+
+button.ghost {
+ color: var(--text);
+ border-color: var(--border);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
+}
+
+button.ghost:hover {
+ border-color: var(--border-strong);
+ background: var(--bg-hover);
+}
+
+button.danger {
+ border-color: rgba(239, 91, 111, 0.54);
+ background: linear-gradient(180deg, rgba(239, 91, 111, 0.27), rgba(239, 91, 111, 0.12));
+ color: #ffd7dd;
+}
+
+button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+button:focus-visible,
+input:focus-visible,
+textarea:focus-visible,
+select:focus-visible,
+.tab:focus-visible,
+.theme-btn:focus-visible,
+.icon-btn:focus-visible {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-subtle);
+}
+
+.muted {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ background: var(--surface-soft);
+}
+
+.list li {
+ border-bottom: 1px solid var(--border);
+}
+
+.list li:last-child {
+ border-bottom: 0;
+}
+
+.list .row-item,
+.list button {
+ width: 100%;
+ text-align: left;
+ border: 0;
+ border-radius: 0;
+ padding: 10px 12px;
+ background: transparent;
+ color: var(--text);
+}
+
+.list button:hover {
+ background: var(--bg-hover);
+}
+
+.tenant-layout {
+ display: grid;
+ gap: 12px;
+ grid-template-columns: minmax(300px, 1fr) minmax(360px, 1.2fr);
+ min-height: 0;
+}
+
+.tenant-pane {
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface-soft);
+}
+
+.tenant-pane-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.tenant-pane h3 {
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted-soft);
+}
+
+.tenant-inline-form {
+ align-items: stretch;
+}
+
+.tenant-inline-form > * {
+ min-width: 0;
+}
+
+.tenant-inline-form input:first-child {
+ flex: 1.2;
+}
+
+.tenant-inline-form input:nth-child(2),
+.tenant-inline-form select {
+ flex: 1;
+}
+
+.tenant-table-wrap,
+.find-table-wrap,
+.fs-table-wrap {
+ min-height: 0;
+ overflow: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: rgba(0, 0, 0, 0.08);
+}
+
+:root[data-theme="light"] .tenant-table-wrap,
+:root[data-theme="light"] .find-table-wrap,
+:root[data-theme="light"] .fs-table-wrap {
+ background: rgba(255, 255, 255, 0.7);
+}
+
+.tenant-table,
+.find-table,
+.fs-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.tenant-table {
+ min-width: 620px;
+}
+
+.find-table {
+ min-width: 720px;
+}
+
+.fs-table {
+ min-width: 1140px;
+}
+
+.tenant-table th,
+.tenant-table td,
+.find-table th,
+.find-table td,
+.fs-table th,
+.fs-table td {
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+}
+
+.tenant-table th,
+.find-table th,
+.fs-table th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: var(--panel-strong);
+ padding: 8px 10px;
+}
+
+.tenant-table td,
+.find-table td,
+.fs-table td {
+ padding: 9px 10px;
+ color: var(--text);
+ vertical-align: top;
+}
+
+.tenant-table tbody tr:last-child td,
+.find-table tbody tr:last-child td,
+.fs-table tbody tr:last-child td {
+ border-bottom: 0;
+}
+
+.tenant-sort-btn,
+.find-sort-btn,
+.fs-sort-btn {
+ width: 100%;
+ border: 0;
+ border-radius: 0;
+ padding: 0;
+ background: transparent;
+ color: var(--muted);
+ text-align: left;
+ letter-spacing: 0.03em;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.tenant-sort-btn:hover,
+.find-sort-btn:hover,
+.fs-sort-btn:hover,
+.fs-sort-btn.active {
+ color: var(--text-strong);
+}
+
+.fs-col-action {
+ width: 34px;
+ min-width: 34px;
+ text-align: center;
+ padding: 6px 4px !important;
+}
+
+.fs-open-btn,
+.fs-uri-btn,
+.tenant-account-btn {
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ color: #ff8e8e;
+ padding: 0;
+ text-align: left;
+ font-weight: 600;
+}
+
+:root[data-theme="light"] .fs-open-btn,
+:root[data-theme="light"] .fs-uri-btn,
+:root[data-theme="light"] .tenant-account-btn {
+ color: #b33636;
+}
+
+.fs-open-btn:hover,
+.fs-uri-btn:hover,
+.tenant-account-btn:hover {
+ color: var(--accent-hover);
+ text-decoration: underline;
+}
+
+.fs-col-uri,
+.fs-col-size,
+.fs-col-dir,
+.fs-col-mod-time {
+ white-space: nowrap;
+}
+
+.fs-col-abstract {
+ min-width: 300px;
+}
+
+#fsColUri {
+ width: 360px;
+ min-width: 220px;
+}
+
+#fsColAction {
+ width: 34px;
+ min-width: 34px;
+ max-width: 34px;
+ padding: 8px 4px;
+}
+
+#fsColSize {
+ width: 120px;
+ min-width: 90px;
+}
+
+#fsColIsDir {
+ width: 110px;
+ min-width: 90px;
+}
+
+#fsColModTime {
+ width: 220px;
+ min-width: 160px;
+}
+
+#fsColAbstract {
+ width: 340px;
+ min-width: 220px;
+}
+
+.fs-col-resizer {
+ position: absolute;
+ top: 7px;
+ right: -4px;
+ width: 8px;
+ bottom: 7px;
+ cursor: col-resize;
+ z-index: 3;
+ touch-action: none;
+}
+
+.fs-col-resizer::before {
+ content: "";
+ position: absolute;
+ left: 3px;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ border-radius: var(--radius-pill);
+ background: rgba(255, 255, 255, 0.3);
+}
+
+:root[data-theme="light"] .fs-col-resizer::before {
+ background: rgba(26, 42, 74, 0.28);
+}
+
+.fs-col-resizer:hover::before {
+ background: var(--accent);
+}
+
+.tenant-actions {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.tenant-actions button {
+ padding: 4px 8px;
+ font-size: 11px;
+}
+
+.tenant-role-select {
+ max-width: 130px;
+ padding: 5px 8px;
+ font-size: 11px;
+}
+
+.tenant-row-selected td {
+ background: rgba(255, 92, 92, 0.12);
+}
+
+.tenant-empty,
+.find-empty,
+.fs-empty {
+ color: var(--muted-soft);
+}
+
+.tenant-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ background: rgba(12, 14, 18, 0.56);
+ backdrop-filter: blur(2px);
+}
+
+.tenant-modal[hidden] {
+ display: none;
+}
+
+.tenant-modal-card {
+ width: min(560px, 100%);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--card);
+ box-shadow: var(--shadow-lg);
+}
+
+.tenant-modal-card h3 {
+ font-size: 13px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--muted-soft);
+}
+
+.tenant-error {
+ color: var(--danger);
+ font-size: 12px;
+}
+
+.result-card {
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: linear-gradient(160deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0));
+ box-shadow: var(--shadow-md);
+}
+
+.result-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.result-head h2 {
+ font-size: 12px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted-soft);
+}
+
+pre {
+ margin: 0;
+ flex: 1;
+ min-height: 0;
+ padding: 12px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border);
+ background: rgba(0, 0, 0, 0.24);
+ color: var(--text);
+ overflow: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+:root[data-theme="light"] pre {
+ background: rgba(255, 255, 255, 0.8);
+}
+
+body.dragging-sidebar,
+body.dragging-sidebar *,
+body.dragging-fs-column,
+body.dragging-fs-column * {
+ cursor: col-resize !important;
+ user-select: none;
+}
+
+body.dragging-output,
+body.dragging-output * {
+ cursor: row-resize !important;
+ user-select: none;
+}
+
+::-webkit-scrollbar {
+ width: 9px;
+ height: 9px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ border-radius: var(--radius-pill);
+ border: 2px solid transparent;
+ background: rgba(255, 255, 255, 0.2);
+}
+
+:root[data-theme="light"] ::-webkit-scrollbar-thumb {
+ background: rgba(28, 40, 63, 0.22);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 92, 92, 0.55);
+}
+
+.fs-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.fs-toolbar input {
+ flex: 1;
+ width: 0;
+ min-width: 0;
+}
+
+.fs-toolbar-nav {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.fs-nav-btn {
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ font-size: 15px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-family: "Space Grotesk", "Segoe UI", sans-serif;
+}
+
+.fs-toolbar-controls {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.fs-view-toggle {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+}
+
+.fs-view-toggle button {
+ border: 0;
+ border-radius: 0;
+ padding: 5px 10px;
+ font-size: 11px;
+ background: transparent;
+ color: var(--muted);
+}
+
+.fs-view-toggle button:hover {
+ background: var(--bg-hover);
+ color: var(--text-strong);
+}
+
+.fs-view-toggle button.active {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+}
+
+.fs-tree {
+ overflow: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: rgba(0, 0, 0, 0.08);
+ padding: 6px 0;
+ min-height: 80px;
+ flex: 1;
+}
+
+:root[data-theme="light"] .fs-tree {
+ background: rgba(255, 255, 255, 0.7);
+}
+
+.fs-tree-item {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 10px;
+ font-size: 12px;
+ cursor: pointer;
+ user-select: none;
+ font-family: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
+}
+
+.fs-tree-item:hover {
+ background: var(--bg-hover);
+}
+
+.fs-tree-toggle {
+ flex-shrink: 0;
+ width: 14px;
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.fs-tree-name {
+ flex: 1;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.fs-tree-item--dir .fs-tree-name {
+ color: #ff8e8e;
+ font-weight: 600;
+}
+
+:root[data-theme="light"] .fs-tree-item--dir .fs-tree-name {
+ color: #b33636;
+}
+
+.fs-tree-info-btn {
+ flex-shrink: 0;
+ width: 18px;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ color: #ff8e8e;
+ padding: 0;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ text-align: center;
+}
+
+:root[data-theme="light"] .fs-tree-info-btn {
+ color: #b33636;
+}
+
+.fs-tree-info-btn:hover {
+ color: var(--accent-hover);
+ text-decoration: underline;
+}
+
+@media (max-width: 900px) {
+ .topbar {
+ padding: 10px 12px;
+ min-height: 56px;
+ }
+
+ .brand-subtitle {
+ display: none;
+ }
+
+ .topbar-right {
+ gap: 6px;
+ }
+
+ .pill {
+ display: none;
+ }
+
+ .shell-body {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .sidebar {
+ position: fixed;
+ top: 56px;
+ left: 0;
+ bottom: 0;
+ width: min(82vw, 320px);
+ border-right: 1px solid var(--border);
+ transform: translateX(-104%);
+ transition: transform var(--dur-med) var(--ease-out);
+ z-index: 35;
+ background: var(--panel-strong);
+ box-shadow: var(--shadow-lg);
+ }
+
+ .shell:not(.shell--nav-collapsed) .sidebar {
+ transform: translateX(0);
+ }
+
+ .shell::after {
+ content: "";
+ position: fixed;
+ inset: 56px 0 0 0;
+ background: rgba(0, 0, 0, 0.4);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity var(--dur-med) var(--ease-out);
+ z-index: 30;
+ }
+
+ .shell:not(.shell--nav-collapsed)::after {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ #sidebarResizer {
+ display: none;
+ }
+
+ .content-area {
+ padding: 10px;
+ }
+
+ .content {
+ grid-template-rows:
+ auto
+ minmax(230px, 1fr)
+ 6px
+ minmax(160px, 38vh);
+ }
+
+ .content-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .tenant-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .tenant-inline-form {
+ flex-wrap: wrap;
+ }
+
+ .tenant-inline-form button {
+ width: 100%;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation: none !important;
+ transition: none !important;
+ }
+}