This commit is contained in:
Timothy Jaeryang Baek
2026-02-27 16:01:57 -06:00
parent 2d82d260cc
commit 179a4ad9ea
11 changed files with 399 additions and 99 deletions
+5 -3
View File
@@ -216,12 +216,14 @@ async def set_tool_servers_config(
class TerminalServerConnection(BaseModel):
id: str
id: Optional[str] = ""
url: str
path: Optional[str] = "/openapi.json"
auth_type: Optional[str] = "bearer"
key: Optional[str] = ""
name: Optional[str] = ""
auth_type: Optional[str] = "bearer"
config: Optional[dict] = None # holds access_grants, etc.
enabled: Optional[bool] = True
config: Optional[dict] = None
model_config = ConfigDict(extra="allow")
+1 -1
View File
@@ -35,7 +35,7 @@ async def list_terminal_servers(request: Request, user=Depends(get_verified_user
return [
{"id": connection.get("id", ""), "url": connection.get("url", ""), "name": connection.get("name", "")}
for connection in connections
if has_connection_access(user, connection, user_group_ids)
if connection.get("enabled", True) and has_connection_access(user, connection, user_group_ids)
]
+2 -1
View File
@@ -210,7 +210,8 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
content = ""
for part in output_parts:
if part.get("type") == "input_text":
content += part.get("text", "")
text_val = part.get("text", "")
content += str(text_val) if not isinstance(text_val, str) else text_val
messages.append(
{
+12 -11
View File
@@ -900,26 +900,27 @@ async def set_terminal_servers(request: Request):
"""Load and cache OpenAPI specs from all TERMINAL_SERVER_CONNECTIONS."""
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
# Build server configs with info containing the connection ID
# Build server configs compatible with get_tool_servers_data
# Terminal connections store id/name at top level; translate to info dict
server_configs = []
for connection in connections:
conn_id = connection.get("id", "")
if not connection.get("url"):
continue
auth_type = connection.get("auth_type", "bearer")
token = None
if auth_type == "bearer":
token = connection.get("key", "")
enabled = connection.get("enabled", True)
server_configs.append({
"url": connection.get("url", ""),
"key": token or "",
"auth_type": auth_type,
"path": "openapi.json",
"key": connection.get("key", ""),
"auth_type": connection.get("auth_type", "bearer"),
"path": connection.get("path", "/openapi.json"),
"spec_type": "url",
"config": {"enable": True},
"info": {"id": conn_id, "name": connection.get("name", "")},
# get_tool_servers_data reads config.enable to filter active servers
"config": {"enable": enabled},
"info": {
"id": connection.get("id", ""),
"name": connection.get("name", ""),
},
})
request.app.state.TERMINAL_SERVERS = await get_tool_servers_data(server_configs)
@@ -35,7 +35,7 @@
let showConnectionModal = false;
// Terminal server admin connections
let terminalConnections: { id: string; url: string; key: string; name: string }[] = [];
let terminalConnections = [];
let showAddTerminalModal = false;
let editTerminalIdx: number | null = null;
let showDeleteTerminalConfirm = false;
@@ -72,20 +72,17 @@
}
};
const addTerminalConnection = (server: { url: string; key: string; name?: string }) => {
const addTerminalConnection = (server) => {
terminalConnections = [
...terminalConnections,
{ id: uuidv4(), url: server.url, key: server.key, name: server.name ?? '' }
{ ...server, id: server.id ?? uuidv4() }
];
saveTerminalServers();
};
const updateTerminalConnection = (
idx: number,
updated: { url: string; key: string; name?: string }
) => {
const updateTerminalConnection = (idx: number, updated) => {
terminalConnections = terminalConnections.map((c, i) =>
i === idx ? { ...c, url: updated.url, key: updated.key, name: updated.name ?? '' } : c
i === idx ? { ...c, ...updated, id: updated.id ?? c.id } : c
);
saveTerminalServers();
};
@@ -191,15 +188,15 @@
{/each}
</div>
{#if servers.length === 0}
<div class="text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('No tool server connections configured.')}
</div>
{/if}
{#if servers.length === 0}
<div class="text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('No tool server connections configured.')}
</div>
{/if}
<div class="my-1.5">
<div class="text-xs text-gray-500">
{$i18n.t('Connect to your own OpenAPI compatible external tool servers.')}
<div class="my-1.5">
<div class="text-xs text-gray-500">
{$i18n.t('Connect to your own OpenAPI compatible external tool servers.')}
</div>
</div>
</div>
@@ -235,7 +232,7 @@
<div class="flex w-full gap-2 items-center">
<Tooltip className="w-full relative" content={''} placement="top-start">
<div class="flex w-full">
<div class="flex-1 relative flex gap-1.5 items-center">
<div class="flex-1 relative flex gap-1.5 items-center {connection?.enabled === false ? 'opacity-50' : ''}">
<Tooltip content={$i18n.t('Terminal')}>
<Cloud className="size-4" strokeWidth="1.5" />
</Tooltip>
@@ -260,6 +257,18 @@
<Cog6 />
</button>
</Tooltip>
<Tooltip content={(connection?.enabled !== false) ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch
state={connection?.enabled !== false}
on:change={() => {
terminalConnections = terminalConnections.map((c, i) =>
i === idx ? { ...c, enabled: !(c?.enabled !== false) } : c
);
saveTerminalServers();
}}
/>
</Tooltip>
</div>
</div>
{/each}
+28 -10
View File
@@ -42,12 +42,12 @@
functions,
selectedFolder,
pinnedChats,
showEmbeds
showEmbeds,
selectedTerminalId
} from '$lib/stores';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import {
convertMessagesToHistory,
copyToClipboard,
@@ -140,13 +140,18 @@
let selectedToolIds = [];
let selectedFilterIds = [];
let imageGenerationEnabled = false;
let webSearchEnabled = false;
let codeInterpreterEnabled = false;
// Auto-inject terminal servers into selected tool IDs so they act like toggled-on tools
// Auto-inject direct terminal servers into selected tool IDs so they act like toggled-on tools
// System terminals (with id field) are handled server-side via terminal_id, not as direct tool servers
$: if ($terminalServers && $terminalServers.length > 0) {
const terminalIds = $terminalServers.map((_, i) => `direct_server:terminal_${i}`);
const directTerminalServers = $terminalServers.filter((t) => !t.id);
const terminalIds = directTerminalServers.map(
(_, i) => `direct_server:terminal_${$terminalServers.indexOf(directTerminalServers[i])}`
);
const missingIds = terminalIds.filter((id) => !selectedToolIds.includes(id));
if (missingIds.length > 0) {
selectedToolIds = [...selectedToolIds, ...missingIds];
@@ -155,7 +160,10 @@
// Remove disabled terminal servers from selectedToolIds automatically
$: if (selectedToolIds.length > 0) {
const terminalIds = ($terminalServers ?? []).map((_, i) => `direct_server:terminal_${i}`);
const directTerminalServers = ($terminalServers ?? []).filter((t) => !t.id);
const terminalIds = directTerminalServers.map(
(_, i) => `direct_server:terminal_${($terminalServers ?? []).indexOf(directTerminalServers[i])}`
);
const invalidTerminalIds = selectedToolIds.filter(
(id) => id.startsWith('direct_server:terminal_') && !terminalIds.includes(id)
);
@@ -354,9 +362,12 @@
selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:'));
}
// Auto-inject terminal servers
// Auto-inject direct terminal servers (system ones are handled via terminal_id)
if ($terminalServers && $terminalServers.length > 0) {
const terminalIds = $terminalServers.map((_, i) => `direct_server:terminal_${i}`);
const directTerminalServers = $terminalServers.filter((t) => !t.id);
const terminalIds = directTerminalServers.map(
(_, i) => `direct_server:terminal_${$terminalServers.indexOf(directTerminalServers[i])}`
);
selectedToolIds = [...new Set([...selectedToolIds, ...terminalIds])];
}
@@ -650,6 +661,14 @@
audioQueue.set(new AudioQueue(document.getElementById('audioElement')));
// Reset direct terminal enabled states — selectedTerminalId starts null on every page load
if ($settings?.terminalServers?.some((s) => s.enabled)) {
settings.set({
...$settings,
terminalServers: ($settings.terminalServers ?? []).map((s) => ({ ...s, enabled: false }))
});
}
pageSubscribe = page.subscribe(async (p) => {
if (p.url.pathname === '/') {
await tick();
@@ -2130,9 +2149,8 @@
});
}
// Determine the active terminal (first accessible backend terminal, or user-configured)
const activeTerminal = $terminalServers?.[0] ?? null;
const activeTerminalId = activeTerminal?.id ?? null;
// Use the user-selected terminal from the dropdown
const activeTerminalId = $selectedTerminalId ?? null;
const res = await generateOpenAIChatCompletion(
localStorage.token,
+55 -27
View File
@@ -17,7 +17,9 @@
showArtifacts,
showEmbeds,
settings,
showFileNavPath
showFileNavPath,
selectedTerminalId,
user
} from '$lib/stores';
import { uploadFile } from '$lib/apis/files';
@@ -59,10 +61,24 @@
// Tab state for Controls+Files panel
let activeTab: 'controls' | 'files' | 'overview' = savedTab;
$: savedTab = activeTab;
$: hasTerminal = !!($settings?.terminalServers ?? []).find((s) => s.enabled)?.url
|| $terminalServers.length > 0;
$: hasMessages = history?.messages && Object.keys(history.messages).length > 0;
$: if (!hasMessages && activeTab === 'overview') activeTab = 'controls';
$: showControlsTab = $user?.role === 'admin' || ($user?.permissions?.chat?.controls ?? true);
$: showFilesTab = !!$selectedTerminalId;
$: showOverviewTab = hasMessages;
// Tab fallback: if active tab becomes hidden, switch to next available
$: if (!showOverviewTab && activeTab === 'overview') activeTab = 'controls';
$: if (!showFilesTab && activeTab === 'files') activeTab = 'controls';
$: if (!showControlsTab && activeTab === 'controls') {
if (showFilesTab) activeTab = 'files';
else if (showOverviewTab) activeTab = 'overview';
}
// Auto-close if there are no visible tabs
$: if (!showControlsTab && !showFilesTab && !showOverviewTab) {
showControls.set(false);
}
// Auto-switch to Files tab when display_file is triggered
$: if ($showFileNavPath) {
@@ -70,6 +86,12 @@
showControls.set(true);
}
// Auto-open Files tab when a terminal is selected
$: if ($selectedTerminalId) {
activeTab = 'files';
showControls.set(true);
}
// Attach a terminal file to the chat input
const handleTerminalAttach = async (blob: Blob, name: string, contentType: string) => {
const tempItemId = uuidv4();
@@ -182,7 +204,9 @@
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('mouseup', onMouseUp);
setTimeout(() => { paneReady = true; }, 0);
setTimeout(() => {
paneReady = true;
}, 0);
// If controls were persisted as open, set the pane to the saved size
if ($showControls && pane) {
@@ -247,15 +271,17 @@
<!-- Tab bar -->
<div class="flex items-center justify-between px-2 pt-2.5 pb-2 shrink-0">
<div class="flex gap-1">
<button
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'controls'
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
on:click={() => (activeTab = 'controls')}
>
{$i18n.t('Controls')}
</button>
{#if hasTerminal}
{#if showControlsTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'controls'
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
on:click={() => (activeTab = 'controls')}
>
{$i18n.t('Controls')}
</button>
{/if}
{#if showFilesTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'files'
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
@@ -265,7 +291,7 @@
{$i18n.t('Files')}
</button>
{/if}
{#if hasMessages}
{#if showOverviewTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'overview'
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
@@ -310,7 +336,7 @@
}}
onClose={() => showControls.set(false)}
/>
{:else if activeTab === 'files' && hasTerminal}
{:else if activeTab === 'files' && $selectedTerminalId}
<FileNav onAttach={handleTerminalAttach} />
{:else}
<Controls embed={true} {models} bind:chatFiles bind:params />
@@ -383,15 +409,17 @@
<!-- Tab bar -->
<div class="flex items-center justify-between px-2 pt-2.5 pb-2 shrink-0">
<div class="flex gap-1">
<button
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'controls'
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
on:click={() => (activeTab = 'controls')}
>
{$i18n.t('Controls')}
</button>
{#if hasTerminal}
{#if showControlsTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'controls'
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
on:click={() => (activeTab = 'controls')}
>
{$i18n.t('Controls')}
</button>
{/if}
{#if showFilesTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'files'
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
@@ -401,7 +429,7 @@
{$i18n.t('Files')}
</button>
{/if}
{#if hasMessages}
{#if showOverviewTab}
<button
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'overview'
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
@@ -451,7 +479,7 @@
}}
onClose={() => showControls.set(false)}
/>
{:else if activeTab === 'files' && hasTerminal}
{:else if activeTab === 'files' && $selectedTerminalId}
<FileNav onAttach={handleTerminalAttach} />
{:else}
<Controls embed={true} {models} bind:chatFiles bind:params />
+23 -4
View File
@@ -6,7 +6,7 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount, onDestroy, tick } from 'svelte';
import { terminalServers, settings, showFileNavPath } from '$lib/stores';
import { terminalServers, settings, showFileNavPath, selectedTerminalId } from '$lib/stores';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import {
getCwd,
@@ -59,12 +59,31 @@
let showDeleteConfirm = false;
let shiftKey = false;
$: firstTerminal = $terminalServers?.[0] ?? null;
$: activeUserTerminal = ($settings?.terminalServers ?? []).find((s) => s.enabled);
$: firstTerminal = $selectedTerminalId
? ($terminalServers ?? []).find((t) => t.id === $selectedTerminalId) ?? null
: ($terminalServers?.[0] ?? null);
$: activeUserTerminal = ($settings?.terminalServers ?? []).find(
(s) => s.url === $selectedTerminalId
);
// System terminals go through the open-webui proxy and need the session token.
// Direct user terminals talk directly to the server and need the terminal's own key.
$: isSystemTerminal = !!firstTerminal;
$: terminalUrl = firstTerminal?.url ?? activeUserTerminal?.url ?? '';
$: terminalKey = firstTerminal?.key ?? activeUserTerminal?.key ?? '';
$: terminalKey = isSystemTerminal ? localStorage.token : (activeUserTerminal?.key ?? '');
$: configured = !!terminalUrl;
// Reload file list when terminal is swapped
let prevTerminalUrl = '';
$: if (terminalUrl && terminalUrl !== prevTerminalUrl) {
prevTerminalUrl = terminalUrl;
(async () => {
const cwd = await getCwd(terminalUrl, terminalKey);
const dir = cwd ? (cwd.endsWith('/') ? cwd : cwd + '/') : '/';
savedPath = dir;
loadDir(dir);
})();
}
$: breadcrumbs = currentPath
.split('/')
.filter(Boolean)
@@ -20,7 +20,10 @@
const MD_EXTS = new Set(['md', 'markdown', 'mdx']);
$: isMarkdown = MD_EXTS.has(selectedFile?.split('.').pop()?.toLowerCase() ?? '');
$: renderedHtml = isMarkdown && fileContent ? DOMPurify.sanitize(marked.parse(fileContent, { async: false }) as string) : '';
$: renderedHtml =
isMarkdown && fileContent
? DOMPurify.sanitize(marked.parse(fileContent, { async: false }) as string)
: '';
let showRaw = false;
// Reset to preview mode when switching files
@@ -55,7 +58,11 @@
});
</script>
<div class="flex-1 {fileImageUrl !== null || filePdfData !== null ? 'overflow-hidden' : 'overflow-y-auto'} min-h-0 relative h-full">
<div
class="flex-1 {fileImageUrl !== null || filePdfData !== null
? 'overflow-hidden'
: 'overflow-y-auto'} min-h-0 relative h-full"
>
<!-- Floating action buttons -->
<div class="absolute top-2 right-2 z-10 flex gap-1">
{#if fileImageUrl !== null}
@@ -78,14 +85,40 @@
>
{#if showRaw}
<!-- Eye icon: switch to preview -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
{:else}
<!-- Code icon: switch to raw -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5"
/>
</svg>
{/if}
</button>
+12 -18
View File
@@ -28,8 +28,11 @@
showCallOverlay,
tools,
toolServers,
terminalServers,
user as _user,
showControls,
showSettings,
selectedTerminalId,
TTSWorker,
temporaryChatEnabled
} from '$lib/stores';
@@ -81,8 +84,13 @@
import Voice from '../icons/Voice.svelte';
import Cloud from '../icons/Cloud.svelte';
import IntegrationsMenu from './MessageInput/IntegrationsMenu.svelte';
import TerminalMenu from './MessageInput/TerminalMenu.svelte';
import Component from '../icons/Component.svelte';
import PlusAlt from '../icons/PlusAlt.svelte';
import Dropdown from '../common/Dropdown.svelte';
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte';
import Knobs from '../icons/Knobs.svelte';
@@ -124,6 +132,8 @@
export let webSearchEnabled = false;
export let codeInterpreterEnabled = false;
let showTerminalMenu = false;
export let messageQueue: { id: string; prompt: string; files: any[] }[] = [];
export let onQueueSendNow: (id: string) => void = () => {};
export let onQueueEdit: (id: string) => void = () => {};
@@ -1019,7 +1029,6 @@
});
</script>
<ToolServersModal bind:show={showTools} {selectedToolIds} />
<InputVariablesModal
@@ -1793,23 +1802,8 @@
{/if}
{#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
<!-- Active Terminal Indicator (Always On) -->
{@const activeTerminal = ($settings?.terminalServers ?? []).find(
(s) => s.enabled
)}
{#if activeTerminal}
<div class="flex items-end mr-0.5">
<div
class="flex items-center gap-1.5 px-2.5 py-1 text-sm hover:bg-gray-50 hover:dark:bg-gray-850 transition-all rounded-lg cursor-pointer select-none max-w-[120px] sm:max-w-[200px] truncate"
>
<Cloud className="size-3 shrink-0" strokeWidth="2" />
<span class="truncate"
>{activeTerminal.name ||
activeTerminal.url.replace(/^https?:\/\//, '')}</span
>
</div>
</div>
{/if}
<!-- Terminal Server Selector -->
<TerminalMenu bind:show={showTerminalMenu} />
<!-- {$i18n.t('Record voice')} -->
<Tooltip content={$i18n.t('Dictate')}>
@@ -0,0 +1,195 @@
<script lang="ts">
import { getContext } from 'svelte';
import { goto } from '$app/navigation';
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { settings, showSettings, terminalServers, selectedTerminalId, user } from '$lib/stores';
import { getToolServersData } from '$lib/apis';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Cloud from '$lib/components/icons/Cloud.svelte';
const i18n = getContext('i18n');
export let show = false;
$: systemTerminals = ($terminalServers ?? []).filter((t) => t.id);
$: directTerminals = ($settings?.terminalServers ?? []).filter((s) => s.url);
const refreshTerminalServersStore = async (servers: typeof directTerminals) => {
const activeTerminals = servers.filter((s) => s.enabled);
if (activeTerminals.length > 0) {
let data = await getToolServersData(
activeTerminals.map((t) => ({
url: t.url,
auth_type: t.auth_type ?? 'bearer',
key: t.key ?? '',
path: t.path ?? '/openapi.json',
config: { enable: true }
}))
);
data = data.filter((d) => d && !d.error);
terminalServers.set(data);
} else {
terminalServers.set([]);
}
};
const selectDirect = async (terminal: (typeof directTerminals)[0]) => {
const newId = $selectedTerminalId === terminal.url ? null : terminal.url;
selectedTerminalId.set(newId);
// Enable the selected direct terminal, disable all others
const updatedServers = ($settings?.terminalServers ?? []).map((s) => ({
...s,
enabled: newId !== null && s.url === terminal.url
}));
settings.set({
...$settings,
terminalServers: updatedServers
});
show = false;
// Refresh the store so Chat.svelte can inject it as a tool
await refreshTerminalServersStore(updatedServers);
};
const selectSystem = async (terminal: (typeof systemTerminals)[0]) => {
selectedTerminalId.set($selectedTerminalId === terminal.id ? null : terminal.id);
// Disable all direct terminals when switching to a system terminal
if ($settings?.terminalServers?.some((s) => s.enabled)) {
const updatedServers = ($settings.terminalServers ?? []).map((s) => ({ ...s, enabled: false }));
settings.set({
...$settings,
terminalServers: updatedServers
});
await refreshTerminalServersStore(updatedServers);
}
show = false;
};
$: selectedSystemTerminal = systemTerminals.find((t) => t.id === $selectedTerminalId);
$: selectedDirectTerminal = directTerminals.find((t) => t.url === $selectedTerminalId);
$: selectedLabel =
selectedSystemTerminal?.name ??
selectedDirectTerminal?.name ??
selectedDirectTerminal?.url?.replace(/^https?:\/\//, '') ??
null;
</script>
<Dropdown bind:show>
<Tooltip content={$i18n.t('Terminal')} placement="top">
<button
type="button"
class="flex items-center gap-1.5 px-2 py-1.5 text-sm transition rounded-lg cursor-pointer"
>
<Cloud className="size-3.5" strokeWidth="2" />
{#if $selectedTerminalId && selectedLabel}
<span class="truncate text-xs max-w-[100px] sm:max-w-[150px]">{selectedLabel}</span>
{/if}
</button>
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-56 rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin"
sideOffset={4}
side="bottom"
align="end"
transition={flyAndScale}
>
<!-- Direct terminals (gated by permission) -->
{#if $user?.role === 'admin' || ($user?.permissions?.features?.direct_tool_servers ?? true)}
<div class="flex items-center justify-between px-3 py-1">
<span class="text-[10px] font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider">
{$i18n.t('Direct')}
</span>
<Tooltip content={$i18n.t('Add Terminal')} placement="top">
<button
type="button"
class="p-0.5 rounded-md text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition"
on:click|stopPropagation={() => { show = false; showSettings.set(true); }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
</svg>
</button>
</Tooltip>
</div>
{#each directTerminals as terminal}
<button
type="button"
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl {$selectedTerminalId === terminal.url
? 'bg-gray-50 dark:bg-gray-800/50'
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50'}"
on:click={() => selectDirect(terminal)}
>
<div class="flex flex-1 gap-2 items-center truncate">
<Cloud className="size-4 shrink-0" strokeWidth="2" />
<span class="truncate">{terminal.name || terminal.url.replace(/^https?:\/\//, '')}</span>
</div>
{#if $selectedTerminalId === terminal.url}
<div class="shrink-0 text-emerald-600 dark:text-emerald-400">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
</svg>
</div>
{/if}
</button>
{/each}
<hr class="border-gray-100 dark:border-gray-800 my-1" />
{/if}
<!-- System terminals -->
<div class="flex items-center justify-between px-3 py-1">
<span class="text-[10px] font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider">
{$i18n.t('System')}
</span>
{#if $user?.role === 'admin'}
<Tooltip content={$i18n.t('Add Terminal')} placement="top">
<button
type="button"
class="p-0.5 rounded-md text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition"
on:click|stopPropagation={() => { show = false; goto('/admin/settings/integrations'); }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
</svg>
</button>
</Tooltip>
{/if}
</div>
{#each systemTerminals as terminal}
<button
type="button"
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl {$selectedTerminalId === terminal.id
? 'bg-gray-50 dark:bg-gray-800/50'
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50'}"
on:click={() => selectSystem(terminal)}
>
<div class="flex flex-1 gap-2 items-center truncate">
<Cloud className="size-4 shrink-0" strokeWidth="2" />
<span class="truncate">{terminal.name || $i18n.t('Terminal')}</span>
</div>
{#if $selectedTerminalId === terminal.id}
<div class="shrink-0 text-emerald-600 dark:text-emerald-400">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
</svg>
</div>
{/if}
</button>
{/each}
</DropdownMenu.Content>
</div>
</Dropdown>