feat: add MCP and LSP tooling support

Add global MCP/LSP settings, per-session Activity toggles, sidecar runtime integration, tests, and documentation updates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-23 22:51:03 +01:00
co-authored by Copilot
parent 50a8e5dfbe
commit efa5c44e07
26 changed files with 3196 additions and 47 deletions
+66 -12
View File
@@ -24,6 +24,7 @@ import {
import type { PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject } from '@shared/domain/project';
import { applyScratchpadSessionConfig } from '@shared/domain/session';
import type { LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace';
import { createId, nowIso } from '@shared/utils/ids';
@@ -51,6 +52,34 @@ function createDraftPattern(defaultModelId: string, defaultReasoningEffort: Patt
};
}
function createDraftMcpServer(): McpServerDefinition {
const timestamp = nowIso();
return {
id: createId('mcp'),
name: 'New MCP Server',
transport: 'local',
command: '',
args: [],
tools: ['*'],
createdAt: timestamp,
updatedAt: timestamp,
};
}
function createDraftLspProfile(): LspProfileDefinition {
const timestamp = nowIso();
return {
id: createId('lsp'),
name: 'New LSP Profile',
command: '',
args: [],
languageId: 'typescript',
fileExtensions: ['.ts', '.tsx'],
createdAt: timestamp,
updatedAt: timestamp,
};
}
export default function App() {
const api = getElectronApi();
const [workspace, setWorkspace] = useState<WorkspaceState>();
@@ -195,7 +224,17 @@ export default function App() {
detailPanel = (
<ActivityPanel
activity={activityForSession}
lspProfiles={workspace.settings.tooling.lspProfiles}
mcpServers={workspace.settings.tooling.mcpServers}
onUpdateSessionTooling={(selection) => {
void api.updateSessionTooling({
sessionId: selectedSession.id,
enabledMcpServerIds: selection.enabledMcpServerIds,
enabledLspProfileIds: selection.enabledLspProfileIds,
});
}}
pattern={patternForSession}
projectIsScratchpad={isScratchpadProject(projectForSession)}
session={selectedSession}
/>
);
@@ -216,24 +255,39 @@ export default function App() {
availableModels={availableModels}
isRefreshingCapabilities={isRefreshingCapabilities}
onClose={() => setShowSettings(false)}
onDeletePattern={async (id) => {
await api.deletePattern(id);
}}
onNewPattern={() => {
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
onDeleteLspProfile={async (id) => {
await api.deleteLspProfile(id);
}}
onDeleteMcpServer={async (id) => {
await api.deleteMcpServer(id);
}}
onDeletePattern={async (id) => {
await api.deletePattern(id);
}}
onNewLspProfile={createDraftLspProfile}
onNewMcpServer={createDraftMcpServer}
onNewPattern={() => {
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
return createDraftPattern(
defaultModel?.id ?? 'gpt-5.4',
resolveReasoningEffort(defaultModel, 'high'),
);
}}
onRefreshCapabilities={refreshCapabilities}
onSavePattern={async (pattern) => {
await api.savePattern({ pattern });
}}
patterns={workspace.patterns}
sidecarCapabilities={sidecarCapabilities}
/>
onRefreshCapabilities={refreshCapabilities}
onSaveLspProfile={async (profile) => {
await api.saveLspProfile({ profile });
}}
onSaveMcpServer={async (server) => {
await api.saveMcpServer({ server });
}}
onSavePattern={async (pattern) => {
await api.savePattern({ pattern });
}}
patterns={workspace.patterns}
sidecarCapabilities={sidecarCapabilities}
toolingSettings={workspace.settings.tooling}
/>
) : null;
return (
+162 -3
View File
@@ -10,7 +10,15 @@ import {
} from '@renderer/lib/sessionActivity';
import { inferProvider } from '@shared/domain/models';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { SessionRecord } from '@shared/domain/session';
import {
resolveSessionToolingSelection,
type SessionRecord,
} from '@shared/domain/session';
import type {
LspProfileDefinition,
McpServerDefinition,
SessionToolingSelection,
} from '@shared/domain/tooling';
import { ProviderIcon } from './ProviderIcons';
function formatModel(model: string): string {
@@ -30,17 +38,31 @@ function formatEffort(effort: string | undefined): string | undefined {
interface ActivityPanelProps {
activity?: SessionActivityState;
lspProfiles: LspProfileDefinition[];
mcpServers: McpServerDefinition[];
onUpdateSessionTooling: (selection: SessionToolingSelection) => void;
pattern: PatternDefinition;
projectIsScratchpad: boolean;
session: SessionRecord;
}
export function ActivityPanel({ activity, pattern, session }: ActivityPanelProps) {
export function ActivityPanel({
activity,
lspProfiles,
mcpServers,
onUpdateSessionTooling,
pattern,
projectIsScratchpad,
session,
}: ActivityPanelProps) {
const activityRows = useMemo(
() => buildAgentActivityRows(activity, pattern.agents),
[activity, pattern.agents],
);
const selection = useMemo(() => resolveSessionToolingSelection(session), [session]);
const isBusy = session.status === 'running';
const toolsDisabled = isBusy || projectIsScratchpad;
return (
<div className="flex h-full flex-col">
@@ -57,7 +79,71 @@ export function ActivityPanel({ activity, pattern, session }: ActivityPanelProps
{/* Agent cards */}
<div className="flex-1 overflow-y-auto px-3 py-3">
<div className="space-y-2">
<div className="space-y-3">
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-3">
<div className="flex items-center justify-between gap-2">
<div>
<h3 className="text-[12px] font-semibold text-zinc-200">Session tools</h3>
<p className="mt-0.5 text-[11px] text-zinc-500">
Enable globally configured MCPs and LSPs for this session.
</p>
</div>
{toolsDisabled && (
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-medium text-zinc-400">
{projectIsScratchpad ? 'Scratchpad disabled' : 'Locked while running'}
</span>
)}
</div>
{projectIsScratchpad ? (
<p className="mt-3 text-[11px] leading-relaxed text-zinc-500">
Scratchpad stays tool-free. Start a project-backed session to use MCPs or LSPs.
</p>
) : (
<div className="mt-3 space-y-3">
<ToolToggleGroup
description="Globally configured MCP servers"
emptyMessage="No MCP servers configured in Settings."
enabledIds={selection.enabledMcpServerIds}
items={mcpServers.map((server) => ({
id: server.id,
label: server.name,
detail:
server.transport === 'local'
? server.command
: server.url,
}))}
onToggle={(id) =>
onUpdateSessionTooling({
...selection,
enabledMcpServerIds: toggleId(selection.enabledMcpServerIds, id),
})
}
title="MCP servers"
disabled={toolsDisabled}
/>
<ToolToggleGroup
description="Globally configured LSP profiles"
emptyMessage="No LSP profiles configured in Settings."
enabledIds={selection.enabledLspProfileIds}
items={lspProfiles.map((profile) => ({
id: profile.id,
label: profile.name,
detail: `${profile.languageId} · ${profile.command}`,
}))}
onToggle={(id) =>
onUpdateSessionTooling({
...selection,
enabledLspProfileIds: toggleId(selection.enabledLspProfileIds, id),
})
}
title="LSP profiles"
disabled={toolsDisabled}
/>
</div>
)}
</div>
{activityRows.map((row, index) => {
const agent = pattern.agents[index];
const isActive = isAgentActivityActive(row.activity);
@@ -149,3 +235,76 @@ export function ActivityPanel({ activity, pattern, session }: ActivityPanelProps
);
}
function ToolToggleGroup({
title,
description,
items,
enabledIds,
onToggle,
emptyMessage,
disabled,
}: {
title: string;
description: string;
items: Array<{ id: string; label: string; detail?: string }>;
enabledIds: string[];
onToggle: (id: string) => void;
emptyMessage: string;
disabled: boolean;
}) {
return (
<div>
<div className="mb-2">
<h4 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-zinc-500">
{title}
</h4>
<p className="mt-0.5 text-[11px] text-zinc-600">{description}</p>
</div>
{items.length === 0 ? (
<p className="text-[11px] text-zinc-600">{emptyMessage}</p>
) : (
<div className="space-y-1.5">
{items.map((item) => {
const enabled = enabledIds.includes(item.id);
return (
<button
className={`flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left transition ${
enabled
? 'border-blue-500/30 bg-blue-500/5'
: 'border-zinc-800 bg-zinc-900/30'
} ${disabled ? 'cursor-not-allowed opacity-60' : 'hover:border-zinc-700 hover:bg-zinc-900/60'}`}
disabled={disabled}
key={item.id}
onClick={() => onToggle(item.id)}
type="button"
>
<div className="min-w-0">
<div className="text-[12px] font-medium text-zinc-200">{item.label}</div>
{item.detail && (
<div className="truncate text-[11px] text-zinc-500">{item.detail}</div>
)}
</div>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
enabled
? 'bg-blue-500/10 text-blue-300'
: 'bg-zinc-800 text-zinc-500'
}`}
>
{enabled ? 'Enabled' : 'Disabled'}
</span>
</button>
);
})}
</div>
)}
</div>
);
}
function toggleId(current: string[], id: string): string[] {
return current.includes(id)
? current.filter((currentId) => currentId !== id)
: [...current, id];
}
+694 -30
View File
@@ -1,30 +1,47 @@
import { useState } from 'react';
import { useState, type HTMLAttributes, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, Cpu, Layers, Plus, Workflow } from 'lucide-react';
import type { ModelDefinition } from '@shared/domain/models';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
import { PatternEditor } from '@renderer/components/PatternEditor';
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
import type { ModelDefinition } from '@shared/domain/models';
import type { PatternDefinition } from '@shared/domain/pattern';
import {
normalizeLspProfileDefinition,
normalizeMcpServerDefinition,
type LspProfileDefinition,
type McpServerDefinition,
type WorkspaceToolingSettings,
validateLspProfileDefinition,
validateMcpServerDefinition,
} from '@shared/domain/tooling';
import { nowIso } from '@shared/utils/ids';
interface SettingsPanelProps {
availableModels: ReadonlyArray<ModelDefinition>;
patterns: PatternDefinition[];
sidecarCapabilities?: SidecarCapabilities;
toolingSettings: WorkspaceToolingSettings;
isRefreshingCapabilities: boolean;
onRefreshCapabilities: () => void;
onClose: () => void;
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
onDeletePattern: (patternId: string) => Promise<void>;
onNewPattern: () => PatternDefinition;
onSaveMcpServer: (server: McpServerDefinition) => Promise<void>;
onDeleteMcpServer: (serverId: string) => Promise<void>;
onNewMcpServer: () => McpServerDefinition;
onSaveLspProfile: (profile: LspProfileDefinition) => Promise<void>;
onDeleteLspProfile: (profileId: string) => Promise<void>;
onNewLspProfile: () => LspProfileDefinition;
}
type SettingsSection = 'connection' | 'patterns';
type SettingsSection = 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles';
interface NavItem {
id: SettingsSection;
label: string;
icon: React.ReactNode;
icon: ReactNode;
}
interface NavGroup {
@@ -45,6 +62,13 @@ const navGroups: NavGroup[] = [
{ id: 'patterns', label: 'Patterns', icon: <Workflow className="size-3.5" /> },
],
},
{
label: 'Tooling',
items: [
{ id: 'mcp-servers', label: 'MCP Servers', icon: <Layers className="size-3.5" /> },
{ id: 'lsp-profiles', label: 'LSP Profiles', icon: <Cpu className="size-3.5" /> },
],
},
];
function modeBadgeClasses(pattern: PatternDefinition) {
@@ -56,17 +80,25 @@ export function SettingsPanel({
availableModels,
patterns,
sidecarCapabilities,
toolingSettings,
isRefreshingCapabilities,
onRefreshCapabilities,
onClose,
onSavePattern,
onDeletePattern,
onNewPattern,
onSaveMcpServer,
onDeleteMcpServer,
onNewMcpServer,
onSaveLspProfile,
onDeleteLspProfile,
onNewLspProfile,
}: SettingsPanelProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('connection');
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
// Pattern editor sub-view
if (editingPattern) {
const isBuiltin = editingPattern.id.startsWith('pattern-');
return (
@@ -94,9 +126,58 @@ export function SettingsPanel({
);
}
if (editingMcpServer) {
const exists = toolingSettings.mcpServers.some((server) => server.id === editingMcpServer.id);
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<McpServerEditor
onBack={() => setEditingMcpServer(null)}
onChange={setEditingMcpServer}
onDelete={
exists
? async () => {
await onDeleteMcpServer(editingMcpServer.id);
setEditingMcpServer(null);
}
: undefined
}
onSave={async () => {
await onSaveMcpServer(normalizeMcpServerDefinition(editingMcpServer));
setEditingMcpServer(null);
}}
server={editingMcpServer}
/>
</div>
);
}
if (editingLspProfile) {
const exists = toolingSettings.lspProfiles.some((profile) => profile.id === editingLspProfile.id);
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
<LspProfileEditor
onBack={() => setEditingLspProfile(null)}
onChange={setEditingLspProfile}
onDelete={
exists
? async () => {
await onDeleteLspProfile(editingLspProfile.id);
setEditingLspProfile(null);
}
: undefined
}
onSave={async () => {
await onSaveLspProfile(normalizeLspProfileDefinition(editingLspProfile));
setEditingLspProfile(null);
}}
profile={editingLspProfile}
/>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
{/* Header */}
<div className="flex items-center gap-3 border-b border-[var(--color-border)] px-5 pb-3 pt-12">
<button
className="flex size-8 items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
@@ -108,9 +189,7 @@ export function SettingsPanel({
<h2 className="text-sm font-semibold text-zinc-100">Settings</h2>
</div>
{/* Two-column layout */}
<div className="flex min-h-0 flex-1">
{/* Left navigation */}
<nav className="w-52 shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface-1)] p-3">
<div className="space-y-4">
{navGroups.map((group) => (
@@ -143,7 +222,6 @@ export function SettingsPanel({
</div>
</nav>
{/* Content area */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl px-8 py-6">
{activeSection === 'connection' && (
@@ -156,11 +234,25 @@ export function SettingsPanel({
)}
{activeSection === 'patterns' && (
<PatternsSection
onEditPattern={(p) => setEditingPattern(structuredClone(p))}
onEditPattern={(pattern) => setEditingPattern(structuredClone(pattern))}
onNewPattern={() => setEditingPattern(onNewPattern())}
patterns={patterns}
/>
)}
{activeSection === 'mcp-servers' && (
<McpServersSection
onEditServer={(server) => setEditingMcpServer(structuredClone(server))}
onNewServer={() => setEditingMcpServer(onNewMcpServer())}
servers={toolingSettings.mcpServers}
/>
)}
{activeSection === 'lsp-profiles' && (
<LspProfilesSection
onEditProfile={(profile) => setEditingLspProfile(structuredClone(profile))}
onNewProfile={() => setEditingLspProfile(onNewLspProfile())}
profiles={toolingSettings.lspProfiles}
/>
)}
</div>
</div>
</div>
@@ -168,8 +260,6 @@ export function SettingsPanel({
);
}
/* ---------- Section components ---------- */
function ConnectionSection({
connection,
modelCount,
@@ -212,22 +302,12 @@ function PatternsSection({
}) {
return (
<div>
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-zinc-200">Orchestration Patterns</h3>
<p className="mt-0.5 text-[12px] text-zinc-500">
Define reusable agent configurations for your sessions
</p>
</div>
<button
className="flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
onClick={onNewPattern}
type="button"
>
<Plus className="size-3.5" />
New Pattern
</button>
</div>
<SectionHeader
description="Define reusable agent configurations for your sessions"
title="Orchestration Patterns"
>
<SectionAction label="New Pattern" onClick={onNewPattern} />
</SectionHeader>
<div className="space-y-1">
{patterns.map((pattern) => (
@@ -258,3 +338,587 @@ function PatternsSection({
</div>
);
}
function McpServersSection({
servers,
onEditServer,
onNewServer,
}: {
servers: McpServerDefinition[];
onEditServer: (server: McpServerDefinition) => void;
onNewServer: () => void;
}) {
return (
<div>
<SectionHeader
description="Define machine-wide MCP servers that sessions can enable from the Activity panel."
title="MCP Servers"
>
<SectionAction label="New MCP Server" onClick={onNewServer} />
</SectionHeader>
<div className="space-y-1">
{servers.length === 0 && (
<EmptyState>
No MCP servers configured yet. Add one here, then enable it per session from the Activity panel.
</EmptyState>
)}
{servers.map((server) => (
<ToolingListButton
detail={
server.transport === 'local'
? `${server.command || 'No command'} · ${server.tools.length} tool filter${server.tools.length === 1 ? '' : 's'}`
: `${server.url} · ${server.transport.toUpperCase()}`
}
key={server.id}
label={server.name}
meta={server.transport.toUpperCase()}
onClick={() => onEditServer(server)}
/>
))}
</div>
</div>
);
}
function LspProfilesSection({
profiles,
onEditProfile,
onNewProfile,
}: {
profiles: LspProfileDefinition[];
onEditProfile: (profile: LspProfileDefinition) => void;
onNewProfile: () => void;
}) {
return (
<div>
<SectionHeader
description="Define machine-wide LSP commands that sessions can enable from the Activity panel."
title="LSP Profiles"
>
<SectionAction label="New LSP Profile" onClick={onNewProfile} />
</SectionHeader>
<div className="space-y-1">
{profiles.length === 0 && (
<EmptyState>
No LSP profiles configured yet. Add one here, then enable it per session from the Activity panel.
</EmptyState>
)}
{profiles.map((profile) => (
<ToolingListButton
detail={`${profile.languageId} · ${profile.command || 'No command'}`}
key={profile.id}
label={profile.name}
meta={profile.fileExtensions.join(', ')}
onClick={() => onEditProfile(profile)}
/>
))}
</div>
</div>
);
}
function McpServerEditor({
server,
onChange,
onBack,
onSave,
onDelete,
}: {
server: McpServerDefinition;
onChange: (server: McpServerDefinition) => void;
onBack: () => void;
onSave: () => Promise<void>;
onDelete?: () => Promise<void>;
}) {
const validationError = validateMcpServerDefinition(server);
return (
<ToolingEditorShell
description="Configure a machine-wide MCP server. Sessions can opt into this server from the Activity panel."
disableSave={Boolean(validationError)}
error={validationError}
onBack={onBack}
onDelete={onDelete}
onSave={onSave}
title="MCP Server"
>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Name" required>
<TextInput
onChange={(value) => onChange(updateMcpServer(server, { name: value }))}
value={server.name}
/>
</FormField>
<FormField label="Transport" required>
<SelectInput
onChange={(value) => onChange(changeMcpTransport(server, value as McpServerDefinition['transport']))}
options={[
{ value: 'local', label: 'Local process' },
{ value: 'http', label: 'HTTP' },
{ value: 'sse', label: 'SSE' },
]}
value={server.transport}
/>
</FormField>
</div>
{server.transport === 'local' ? (
<div className="grid gap-4 md:grid-cols-2">
<FormField className="md:col-span-2" label="Command" required>
<TextInput
onChange={(value) => onChange(updateMcpServer(server, { command: value }))}
placeholder="node"
value={server.command}
/>
</FormField>
<FormField className="md:col-span-2" label="Arguments">
<TextareaInput
onChange={(value) => onChange(updateMcpServer(server, { args: splitMultiline(value) }))}
placeholder="One argument per line"
rows={4}
value={joinMultiline(server.args)}
/>
</FormField>
<FormField className="md:col-span-2" label="Working directory">
<TextInput
onChange={(value) => onChange(updateMcpServer(server, { cwd: value || undefined }))}
placeholder="Optional"
value={server.cwd ?? ''}
/>
</FormField>
</div>
) : (
<FormField label="Server URL" required>
<TextInput
onChange={(value) => onChange(updateMcpServer(server, { url: value }))}
placeholder="https://example.com/mcp"
value={server.url}
/>
</FormField>
)}
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Allowed tools">
<TextareaInput
onChange={(value) => onChange(updateMcpServer(server, { tools: splitTokens(value) }))}
placeholder="Use * for all tools, or list one tool per line"
rows={4}
value={joinMultiline(server.tools)}
/>
</FormField>
<FormField label="Timeout (ms)">
<TextInput
inputMode="numeric"
onChange={(value) =>
onChange(
updateMcpServer(server, {
timeoutMs: value.trim() ? Number(value) : undefined,
}),
)
}
placeholder="Optional"
value={server.timeoutMs?.toString() ?? ''}
/>
</FormField>
</div>
<InfoCallout>
Keep secrets out of this form. Use commands or endpoints that authenticate through the OS or external tooling.
</InfoCallout>
</ToolingEditorShell>
);
}
function LspProfileEditor({
profile,
onChange,
onBack,
onSave,
onDelete,
}: {
profile: LspProfileDefinition;
onChange: (profile: LspProfileDefinition) => void;
onBack: () => void;
onSave: () => Promise<void>;
onDelete?: () => Promise<void>;
}) {
const validationError = validateLspProfileDefinition(profile);
return (
<ToolingEditorShell
description="Configure a machine-wide LSP command. Sessions can opt into this profile from the Activity panel."
disableSave={Boolean(validationError)}
error={validationError}
onBack={onBack}
onDelete={onDelete}
onSave={onSave}
title="LSP Profile"
>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Name" required>
<TextInput
onChange={(value) => onChange(updateLspProfile(profile, { name: value }))}
value={profile.name}
/>
</FormField>
<FormField label="Language ID" required>
<TextInput
onChange={(value) => onChange(updateLspProfile(profile, { languageId: value }))}
placeholder="typescript"
value={profile.languageId}
/>
</FormField>
</div>
<FormField label="Command" required>
<TextInput
onChange={(value) => onChange(updateLspProfile(profile, { command: value }))}
placeholder="typescript-language-server"
value={profile.command}
/>
</FormField>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Arguments">
<TextareaInput
onChange={(value) => onChange(updateLspProfile(profile, { args: splitMultiline(value) }))}
placeholder="One argument per line"
rows={4}
value={joinMultiline(profile.args)}
/>
</FormField>
<FormField label="File extensions" required>
<TextareaInput
onChange={(value) => onChange(updateLspProfile(profile, { fileExtensions: splitTokens(value) }))}
placeholder={'.ts\n.tsx'}
rows={4}
value={joinMultiline(profile.fileExtensions)}
/>
</FormField>
</div>
<InfoCallout>
Profiles are global definitions only. Project root resolution still comes from the active session's project.
</InfoCallout>
</ToolingEditorShell>
);
}
function SectionHeader({
title,
description,
children,
}: {
title: string;
description: string;
children?: ReactNode;
}) {
return (
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-zinc-200">{title}</h3>
<p className="mt-0.5 text-[12px] text-zinc-500">{description}</p>
</div>
{children}
</div>
);
}
function SectionAction({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
className="flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
onClick={onClick}
type="button"
>
<Plus className="size-3.5" />
{label}
</button>
);
}
function ToolingListButton({
label,
detail,
meta,
onClick,
}: {
label: string;
detail: string;
meta: string;
onClick: () => void;
}) {
return (
<button
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition hover:border-zinc-800 hover:bg-zinc-900"
onClick={onClick}
type="button"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-medium text-zinc-200">{label}</span>
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-zinc-400">
{meta}
</span>
</div>
<p className="mt-0.5 truncate text-[12px] text-zinc-500">{detail}</p>
</div>
<ChevronRight className="size-4 text-zinc-700 transition group-hover:text-zinc-500" />
</button>
);
}
function EmptyState({ children }: { children: ReactNode }) {
return (
<div className="rounded-xl border border-dashed border-zinc-800 bg-zinc-900/30 px-4 py-6 text-[12px] leading-relaxed text-zinc-500">
{children}
</div>
);
}
function ToolingEditorShell({
title,
description,
error,
disableSave,
onBack,
onSave,
onDelete,
children,
}: {
title: string;
description: string;
error?: string;
disableSave: boolean;
onBack: () => void;
onSave: () => Promise<void>;
onDelete?: () => Promise<void>;
children: ReactNode;
}) {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] px-5 pb-3 pt-12">
<div className="flex items-center gap-3">
<button
className="flex size-8 items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
onClick={onBack}
type="button"
>
<ChevronLeft className="size-4" />
</button>
<div>
<h2 className="text-sm font-semibold text-zinc-100">{title}</h2>
<p className="mt-0.5 text-[12px] text-zinc-500">{description}</p>
</div>
</div>
<div className="flex items-center gap-2">
{onDelete && (
<button
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-[13px] font-medium text-zinc-300 transition hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-300"
onClick={() => void onDelete()}
type="button"
>
Delete
</button>
)}
<button
className="rounded-lg bg-zinc-100 px-3 py-1.5 text-[13px] font-semibold text-zinc-900 transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-50"
disabled={disableSave}
onClick={() => void onSave()}
type="button"
>
Save
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl space-y-5 px-8 py-6">
{error && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-[12px] text-amber-200">
{error}
</div>
)}
{children}
</div>
</div>
</div>
);
}
function FormField({
label,
required,
className,
children,
}: {
label: string;
required?: boolean;
className?: string;
children: ReactNode;
}) {
return (
<label className={`block ${className ?? ''}`}>
<span className="mb-1.5 block text-[12px] font-medium text-zinc-300">
{label}
{required && <span className="ml-1 text-amber-300">*</span>}
</span>
{children}
</label>
);
}
function TextInput({
value,
onChange,
placeholder,
inputMode,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
inputMode?: HTMLAttributes<HTMLInputElement>['inputMode'];
}) {
return (
<input
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-[13px] text-zinc-100 outline-none transition placeholder:text-zinc-600 focus:border-zinc-600"
inputMode={inputMode}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
value={value}
/>
);
}
function TextareaInput({
value,
onChange,
placeholder,
rows,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows: number;
}) {
return (
<textarea
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-[13px] text-zinc-100 outline-none transition placeholder:text-zinc-600 focus:border-zinc-600"
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
rows={rows}
value={value}
/>
);
}
function SelectInput({
value,
options,
onChange,
}: {
value: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
}) {
return (
<select
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-[13px] text-zinc-100 outline-none transition focus:border-zinc-600"
onChange={(event) => onChange(event.target.value)}
value={value}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
function InfoCallout({ children }: { children: ReactNode }) {
return (
<div className="rounded-xl border border-zinc-800 bg-zinc-900/30 px-4 py-3 text-[12px] leading-relaxed text-zinc-500">
{children}
</div>
);
}
function updateMcpServer(
server: McpServerDefinition,
patch: Partial<McpServerDefinition>,
): McpServerDefinition;
function updateMcpServer<T extends McpServerDefinition>(server: T, patch: Partial<T>): T;
function updateMcpServer<T extends McpServerDefinition>(server: T, patch: Partial<T>): T {
return {
...server,
...patch,
updatedAt: nowIso(),
};
}
function changeMcpTransport(
server: McpServerDefinition,
transport: McpServerDefinition['transport'],
): McpServerDefinition {
if (transport === server.transport) {
return server;
}
if (transport === 'local') {
return {
id: server.id,
name: server.name,
transport: 'local',
command: '',
args: [],
cwd: undefined,
tools: server.tools,
timeoutMs: server.timeoutMs,
createdAt: server.createdAt,
updatedAt: nowIso(),
};
}
return {
id: server.id,
name: server.name,
transport,
url: server.transport === 'local' ? '' : server.url,
tools: server.tools,
timeoutMs: server.timeoutMs,
createdAt: server.createdAt,
updatedAt: nowIso(),
};
}
function updateLspProfile(
profile: LspProfileDefinition,
patch: Partial<LspProfileDefinition>,
): LspProfileDefinition {
return {
...profile,
...patch,
updatedAt: nowIso(),
};
}
function splitMultiline(value: string): string[] {
return value
.split(/\r?\n/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
function splitTokens(value: string): string[] {
return value
.split(/[\r\n,]+/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
function joinMultiline(value: string[]): string {
return value.join('\n');
}