mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-27 05:13:58 +02:00
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:
@@ -0,0 +1,628 @@
|
|||||||
|
# MCP / LSP Support Implementation Plan
|
||||||
|
|
||||||
|
Status: implemented v1 architecture reference
|
||||||
|
|
||||||
|
Intended execution order:
|
||||||
|
|
||||||
|
1. Backend agent executes backend phases first.
|
||||||
|
2. Frontend agent executes frontend phases second.
|
||||||
|
3. Frontend work should begin only after the backend contracts, persistence, and diagnostics are stable.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Add first-class support for MCP servers and LSP-backed project intelligence in Eryx without fighting the existing architecture. The design should preserve Eryx's current strengths:
|
||||||
|
|
||||||
|
- Electron main as the desktop control plane
|
||||||
|
- preload + typed IPC as the renderer boundary
|
||||||
|
- shared TS contracts mirrored into the .NET sidecar
|
||||||
|
- .NET sidecar as the agent runtime
|
||||||
|
- OS keychain storage for secrets
|
||||||
|
|
||||||
|
This plan is intentionally backend-first and split into backend/frontend task groups so different agents can execute them in order.
|
||||||
|
|
||||||
|
The clarified product direction for v1 is:
|
||||||
|
|
||||||
|
- users add MCPs and LSPs globally in app settings
|
||||||
|
- users enable or disable those global MCPs/LSPs per session in the right-side panel that is currently used for activity
|
||||||
|
- enabled tooling becomes available to the agent for that session
|
||||||
|
|
||||||
|
## Current state analysis
|
||||||
|
|
||||||
|
### Application architecture today
|
||||||
|
|
||||||
|
- `src/main/EryxAppService.ts`
|
||||||
|
- central orchestration layer for workspace persistence, project/session lifecycle, sidecar calls, git refresh, and event emission
|
||||||
|
- `src/main/ipc/registerIpcHandlers.ts`
|
||||||
|
- single place where Electron IPC methods are registered
|
||||||
|
- `src/preload/index.ts`
|
||||||
|
- exposes the typed `eryxApi` bridge to the renderer
|
||||||
|
- `src/renderer/App.tsx`
|
||||||
|
- loads workspace + sidecar capabilities and wires subscriptions into the UI
|
||||||
|
- `src/shared/contracts/ipc.ts`
|
||||||
|
- typed renderer/main API contract
|
||||||
|
- `src/shared/contracts/sidecar.ts`
|
||||||
|
- typed main/sidecar protocol contract
|
||||||
|
- `sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs`
|
||||||
|
- C# mirror of the sidecar DTOs
|
||||||
|
- `sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs`
|
||||||
|
- sidecar command dispatcher and capability provider
|
||||||
|
- `sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs`
|
||||||
|
- actual agent runtime construction and activity emission
|
||||||
|
|
||||||
|
### Signals that matter for MCP
|
||||||
|
|
||||||
|
- `CopilotWorkflowRunner.cs` already loads `McpServerToolCallContent` via reflection and reports MCP tool names through `TryGetToolName(...)`.
|
||||||
|
- `CopilotWorkflowRunner.cs` builds a `SessionConfig` for each agent and already owns `AvailableTools`, `WorkingDirectory`, and `OnPermissionRequest`.
|
||||||
|
- Scratchpad explicitly clears `AvailableTools`, so scratchpad is currently a "no tools" product surface.
|
||||||
|
|
||||||
|
### Signals that matter for LSP
|
||||||
|
|
||||||
|
- `package.json` includes `typescript-language-server` and an `lsp:typescript` script, which is a good TypeScript-first bootstrap hint.
|
||||||
|
- For v1, the implemented design starts LSP processes inside the sidecar as turn-scoped tool sessions so they can surface directly as agent tools without inventing a second bridge layer.
|
||||||
|
- The current session's project root remains the source of truth for LSP initialization.
|
||||||
|
|
||||||
|
### Persistence and secrets today
|
||||||
|
|
||||||
|
- `src/main/persistence/workspaceRepository.ts` persists a single additive `workspace.json`.
|
||||||
|
- `src/shared/domain/workspace.ts` defines the root persisted shape.
|
||||||
|
- `src/shared/domain/session.ts` already carries session-scoped overrides such as `scratchpadConfig`, which makes it the natural place for per-session MCP/LSP enablement.
|
||||||
|
- `src/main/secrets/secretStore.ts` already wraps `keytar`, so secrets should stay in the OS keychain and never be stored in `workspace.json`.
|
||||||
|
|
||||||
|
### Activity and diagnostics today
|
||||||
|
|
||||||
|
- `src/shared/domain/event.ts` only exposes coarse activity today: `thinking`, `tool-calling`, `handoff`, `completed`.
|
||||||
|
- `src/renderer/components/ActivityPanel.tsx` can already show per-agent activity but not rich MCP/LSP traces.
|
||||||
|
- `src/renderer/components/CopilotStatusCard.tsx` and `SettingsPanel.tsx` already establish a diagnostics-first UX pattern worth reusing for MCP/LSP health surfaces.
|
||||||
|
|
||||||
|
## Recommended design decisions
|
||||||
|
|
||||||
|
### 1. Put global tooling definitions in workspace settings
|
||||||
|
|
||||||
|
Additive, non-secret config should live in `WorkspaceState`, for example through a new `settings.tooling` section.
|
||||||
|
|
||||||
|
Illustrative shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface WorkspaceState {
|
||||||
|
projects: ProjectRecord[];
|
||||||
|
patterns: PatternDefinition[];
|
||||||
|
sessions: SessionRecord[];
|
||||||
|
selectedProjectId?: string;
|
||||||
|
selectedPatternId?: string;
|
||||||
|
selectedSessionId?: string;
|
||||||
|
lastUpdatedAt: string;
|
||||||
|
settings?: WorkspaceSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceSettings {
|
||||||
|
tooling?: {
|
||||||
|
mcpServers?: McpServerDefinition[];
|
||||||
|
lspProfiles?: LspProfileDefinition[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Why this fits:
|
||||||
|
|
||||||
|
- it follows the current single-workspace persistence model
|
||||||
|
- in the current architecture, that single local workspace file already behaves like the app-wide store on this computer
|
||||||
|
- it remains backward compatible because the new fields are optional
|
||||||
|
- it keeps MCP/LSP definitions reusable across future sessions
|
||||||
|
|
||||||
|
### 2. Put enable/disable state on `SessionRecord`
|
||||||
|
|
||||||
|
The user wants MCP/LSP enablement to be controlled per session from the right-side panel. That means the durable selection should live on `SessionRecord`, not on `ProjectRecord`.
|
||||||
|
|
||||||
|
Illustrative shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface SessionRecord {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
patternId: string;
|
||||||
|
title: string;
|
||||||
|
titleSource?: SessionTitleSource;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
status: SessionStatus;
|
||||||
|
isPinned?: boolean;
|
||||||
|
isArchived?: boolean;
|
||||||
|
messages: ChatMessageRecord[];
|
||||||
|
lastError?: string;
|
||||||
|
scratchpadConfig?: ScratchpadSessionConfig;
|
||||||
|
tooling?: {
|
||||||
|
enabledMcpServerIds?: string[];
|
||||||
|
enabledLspProfileIds?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
How this should behave:
|
||||||
|
|
||||||
|
- the settings screen owns the global registry of MCP and LSP definitions
|
||||||
|
- the session panel owns which global definitions are enabled for the current session
|
||||||
|
- the session's `projectId` provides the project root for LSP initialization when the session is project-backed
|
||||||
|
- scratchpad sessions should remain tool-free unless explicitly expanded later
|
||||||
|
|
||||||
|
### 3. Keep secrets out of persisted JSON
|
||||||
|
|
||||||
|
Use `src/main/secrets/secretStore.ts` for secret material. Persist only references in workspace config.
|
||||||
|
|
||||||
|
Recommended rule set:
|
||||||
|
|
||||||
|
- `workspace.json` stores server/profile definitions and secret references
|
||||||
|
- `keytar` stores secret values
|
||||||
|
- runtime logs, process IDs, and raw diagnostics stay out of `workspace.json`
|
||||||
|
|
||||||
|
### 4. MCP belongs in the .NET sidecar runtime
|
||||||
|
|
||||||
|
The sidecar already owns agent session creation, tool attachment, permission callbacks, and tool-call activity detection. MCP should build on that instead of inventing a second runtime stack in Electron or the renderer.
|
||||||
|
|
||||||
|
Recommended flow:
|
||||||
|
|
||||||
|
1. Electron main resolves session-enabled MCP definitions.
|
||||||
|
2. `EryxAppService.sendSessionMessage()` enriches the `run-turn` payload with runtime-ready MCP config.
|
||||||
|
3. The sidecar attaches MCP tools during `SessionConfig` creation.
|
||||||
|
4. The sidecar emits richer activity and error events back through the existing streaming channel.
|
||||||
|
|
||||||
|
### 5. LSP process ownership should start in the sidecar for v1
|
||||||
|
|
||||||
|
Implemented v1 LSP ownership:
|
||||||
|
|
||||||
|
- Electron main persists global definitions and resolves per-session selections.
|
||||||
|
- The sidecar starts the selected LSP server processes for a run, initializes them with the active project root, and exposes a focused tool surface through `AIFunction`s.
|
||||||
|
- The renderer only consumes typed config and per-session enablement state.
|
||||||
|
|
||||||
|
Why this shipped path fits v1:
|
||||||
|
|
||||||
|
- it keeps LSP capabilities closest to the agent tool runtime
|
||||||
|
- it avoids building a second transport bridge between Electron main and the sidecar before the tool surface is proven
|
||||||
|
- it preserves the user-facing model of "enable a tool for this session and let the agent use it"
|
||||||
|
|
||||||
|
### 6. Prefer one user-facing tooling model
|
||||||
|
|
||||||
|
The long-term product should not feel like "MCP over here, raw LSP over there" from a UX perspective.
|
||||||
|
|
||||||
|
Recommended path:
|
||||||
|
|
||||||
|
- implement direct MCP runtime support first
|
||||||
|
- implement LSP as a backend capability service second
|
||||||
|
- where feasible, expose LSP-backed actions to agents through MCP-like or tool-like abstractions instead of teaching the system two unrelated tool invocation models
|
||||||
|
|
||||||
|
## Proposed data models
|
||||||
|
|
||||||
|
The exact names can change, but the structure should follow these boundaries.
|
||||||
|
|
||||||
|
### MCP definitions
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface McpServerDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
transport: 'stdio' | 'sse' | 'streamable-http';
|
||||||
|
command?: string;
|
||||||
|
args?: string[];
|
||||||
|
url?: string;
|
||||||
|
env?: McpEnvironmentBinding[];
|
||||||
|
cwdStrategy?: 'project-root' | 'custom';
|
||||||
|
customCwd?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpEnvironmentBinding {
|
||||||
|
key: string;
|
||||||
|
valueSource: 'literal' | 'secret-ref';
|
||||||
|
value?: string;
|
||||||
|
secretRef?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### LSP profiles
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface LspProfileDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
languageIds: string[];
|
||||||
|
transport: 'stdio';
|
||||||
|
command: string;
|
||||||
|
args?: string[];
|
||||||
|
initialization?: {
|
||||||
|
rootStrategy: 'project-root' | 'workspace-roots';
|
||||||
|
initializationOptions?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime diagnostics
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface ToolingHealthSummary {
|
||||||
|
status: 'ready' | 'warning' | 'error' | 'disabled';
|
||||||
|
summary: string;
|
||||||
|
detail?: string;
|
||||||
|
checkedAt: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use separate summaries for:
|
||||||
|
|
||||||
|
- workspace MCP health
|
||||||
|
- current session MCP enablement health
|
||||||
|
- LSP profile health
|
||||||
|
- current session LSP enablement health
|
||||||
|
|
||||||
|
## Backend execution plan
|
||||||
|
|
||||||
|
Backend must be executed first.
|
||||||
|
|
||||||
|
### B0. Domain and persistence foundation
|
||||||
|
|
||||||
|
Primary files:
|
||||||
|
|
||||||
|
- `src/shared/domain/workspace.ts`
|
||||||
|
- `src/shared/domain/session.ts`
|
||||||
|
- `src/main/persistence/workspaceRepository.ts`
|
||||||
|
- `tests/shared/workspace.test.ts`
|
||||||
|
- `tests/shared/session.test.ts`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- add optional workspace-level tooling settings
|
||||||
|
- add optional session-level tooling enablement
|
||||||
|
- keep persisted migration additive and backward compatible
|
||||||
|
- ensure legacy workspaces load without mutation errors
|
||||||
|
- do not persist any secret values
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- old workspaces still load
|
||||||
|
- new fields round-trip through persistence
|
||||||
|
- tests cover legacy and new shapes
|
||||||
|
|
||||||
|
### B1. Main-process tooling services and IPC
|
||||||
|
|
||||||
|
Primary files:
|
||||||
|
|
||||||
|
- `src/main/EryxAppService.ts`
|
||||||
|
- `src/shared/contracts/ipc.ts`
|
||||||
|
- `src/shared/contracts/channels.ts`
|
||||||
|
- `src/main/ipc/registerIpcHandlers.ts`
|
||||||
|
- `src/preload/index.ts`
|
||||||
|
|
||||||
|
Recommended additions:
|
||||||
|
|
||||||
|
- CRUD for MCP server definitions
|
||||||
|
- CRUD for LSP profiles
|
||||||
|
- session enable/disable methods for both systems
|
||||||
|
- diagnostics/status methods
|
||||||
|
- secret-reference save/delete flows that route through `SecretStore`
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- renderer gets a typed, renderer-safe API
|
||||||
|
- all config changes persist through `WorkspaceRepository`
|
||||||
|
- diagnostics can be queried without starting a session
|
||||||
|
- session enablement can be updated without mutating global definitions
|
||||||
|
|
||||||
|
### B2. Sidecar protocol expansion for MCP
|
||||||
|
|
||||||
|
Primary files:
|
||||||
|
|
||||||
|
- `src/shared/contracts/sidecar.ts`
|
||||||
|
- `sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs`
|
||||||
|
- `sidecar/src/Eryx.AgentHost/Services/SidecarProtocolHost.cs`
|
||||||
|
- `src/main/sidecar/sidecarProcess.ts`
|
||||||
|
- `sidecar/tests/Eryx.AgentHost.Tests/SidecarProtocolHostTests.cs`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- extend capabilities with MCP availability/diagnostics
|
||||||
|
- extend `run-turn` with selected MCP runtime bindings
|
||||||
|
- optionally add dedicated MCP health validation commands if needed
|
||||||
|
- keep the protocol additive to avoid breaking existing clients
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- TS and C# contracts remain in sync
|
||||||
|
- protocol tests cover the new shapes
|
||||||
|
- existing capabilities and run-turn behavior still work without MCP config
|
||||||
|
|
||||||
|
### B3. MCP runtime attachment in the sidecar
|
||||||
|
|
||||||
|
Primary files:
|
||||||
|
|
||||||
|
- `sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs`
|
||||||
|
- new helper services under `sidecar/src/Eryx.AgentHost/Services/`
|
||||||
|
- `sidecar/tests/Eryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- translate main-process MCP definitions into runtime-attached tools
|
||||||
|
- attach tools during `SessionConfig` creation
|
||||||
|
- handle startup failures and unsupported transports cleanly
|
||||||
|
- emit richer activity when tool/server identity matters
|
||||||
|
- revisit the current blanket `ApprovePermissionAsync(...)` behavior before enabling arbitrary external tools
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- project-backed runs can invoke enabled MCP tools
|
||||||
|
- tool failures surface clearly to the app
|
||||||
|
- scratchpad behavior remains intentionally restricted unless product scope changes
|
||||||
|
|
||||||
|
### B4. LSP tool sessions in the sidecar
|
||||||
|
|
||||||
|
Primary files:
|
||||||
|
|
||||||
|
- `sidecar/src/Eryx.AgentHost/Services/LspToolSession.cs`
|
||||||
|
- `sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs`
|
||||||
|
- `package.json`
|
||||||
|
- `sidecar/tests/Eryx.AgentHost.Tests/*`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- start selected LSP server processes inside the sidecar for the active run
|
||||||
|
- initialize them against the current session project root
|
||||||
|
- expose a focused read-oriented tool surface to agents
|
||||||
|
- keep lifecycle cleanup bound to the run/session bundle
|
||||||
|
- start with TypeScript-first support
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- a project-backed session can initialize at least one supported LSP profile
|
||||||
|
- agents can call the exposed LSP tools without any renderer-managed process lifecycle
|
||||||
|
- no raw LSP JSON-RPC is exposed to the renderer
|
||||||
|
|
||||||
|
### B5. LSP bridge into agent runtime
|
||||||
|
|
||||||
|
Primary files:
|
||||||
|
|
||||||
|
- `src/main/EryxAppService.ts`
|
||||||
|
- `src/shared/contracts/sidecar.ts`
|
||||||
|
- `sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs`
|
||||||
|
- `sidecar/src/Eryx.AgentHost/Services/SessionToolingBundle.cs`
|
||||||
|
- `sidecar/src/Eryx.AgentHost/Services/LspToolSession.cs`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- compose selected LSP profiles into the `run-turn` payload from Electron main
|
||||||
|
- translate those profiles into sidecar-local tool sessions
|
||||||
|
- expose a stable subset of LSP-backed operations to agents
|
||||||
|
- avoid turning the renderer into a second IDE protocol stack
|
||||||
|
|
||||||
|
Recommended direction:
|
||||||
|
|
||||||
|
- use sidecar-local `AIFunction` tools for agent consumption
|
||||||
|
- keep the exposed operations narrow and project-oriented
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- agents can request useful LSP-backed project intelligence during project runs
|
||||||
|
- the contract is observable and testable from the app side
|
||||||
|
|
||||||
|
### B6. Backend hardening and validation
|
||||||
|
|
||||||
|
Primary files:
|
||||||
|
|
||||||
|
- impacted TS and C# tests
|
||||||
|
- `package.json`
|
||||||
|
|
||||||
|
Validation commands:
|
||||||
|
|
||||||
|
- `bun test`
|
||||||
|
- `bun run sidecar:test`
|
||||||
|
- `bun run build`
|
||||||
|
|
||||||
|
Hardening checklist:
|
||||||
|
|
||||||
|
- additive migration coverage
|
||||||
|
- bad configuration/error reporting coverage
|
||||||
|
- environment sanitization review for spawned tools/servers
|
||||||
|
- sidecar protocol regression coverage
|
||||||
|
|
||||||
|
## Frontend execution plan
|
||||||
|
|
||||||
|
Frontend should start only after backend APIs and diagnostics are stable.
|
||||||
|
|
||||||
|
### F1. Read-only diagnostics surfaces
|
||||||
|
|
||||||
|
Primary files:
|
||||||
|
|
||||||
|
- `src/renderer/App.tsx`
|
||||||
|
- `src/renderer/components/SettingsPanel.tsx`
|
||||||
|
- `src/renderer/components/CopilotStatusCard.tsx`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- add MCP and LSP sections in settings
|
||||||
|
- surface backend-reported health, readiness, and counts
|
||||||
|
- reuse the current diagnostics-card style before introducing complex editors
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- users can see whether MCP/LSP are configured and healthy
|
||||||
|
- failures are understandable without opening logs
|
||||||
|
|
||||||
|
### F2. Global configuration editors
|
||||||
|
|
||||||
|
Primary files:
|
||||||
|
|
||||||
|
- `src/renderer/components/SettingsPanel.tsx`
|
||||||
|
- new renderer components for MCP/LSP editors
|
||||||
|
- `src/renderer/App.tsx`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- create/edit/delete MCP server definitions
|
||||||
|
- create/edit/delete LSP profiles
|
||||||
|
- manage secret references safely
|
||||||
|
- do not mix global definition editing with per-session toggles
|
||||||
|
- allow secret references without ever echoing raw secrets back into the renderer state
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- all backend CRUD flows are reachable through the UI
|
||||||
|
- global definitions are understandable and recoverable
|
||||||
|
|
||||||
|
### F3. Session tooling panel controls
|
||||||
|
|
||||||
|
Primary files:
|
||||||
|
|
||||||
|
- `src/renderer/components/ActivityPanel.tsx`
|
||||||
|
- `src/renderer/App.tsx`
|
||||||
|
- supporting renderer state/helpers
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- extend the current right-side activity panel with MCP/LSP enable/disable controls for the selected session
|
||||||
|
- show which global tools are available vs enabled for the session
|
||||||
|
- disable LSP/MCP controls when the selected session cannot use them, such as scratchpad if that restriction remains
|
||||||
|
- surface lightweight health/state next to each toggle
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- users can enable or disable global MCPs/LSPs per session from the right-side panel
|
||||||
|
- session toggles are persisted on the session record and survive app reloads
|
||||||
|
|
||||||
|
### F4. Runtime visibility in chat/activity surfaces
|
||||||
|
|
||||||
|
Primary files:
|
||||||
|
|
||||||
|
- `src/renderer/components/ActivityPanel.tsx`
|
||||||
|
- `src/renderer/lib/sessionActivity.ts`
|
||||||
|
- `src/shared/domain/event.ts`
|
||||||
|
- possibly `src/renderer/components/ChatPane.tsx`
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- show tool server name + tool name clearly
|
||||||
|
- distinguish MCP activity from LSP-backed activity if both appear
|
||||||
|
- show meaningful failure summaries
|
||||||
|
- leave room for a future richer timeline without blocking this phase
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- users can tell what tool/server was used during a run
|
||||||
|
- failures do not collapse into generic "tool-calling" noise
|
||||||
|
|
||||||
|
### F5. Session-level polish and troubleshooting UX
|
||||||
|
|
||||||
|
Primary files:
|
||||||
|
|
||||||
|
- `src/renderer/components/ActivityPanel.tsx`
|
||||||
|
- `src/renderer/components/ChatPane.tsx`
|
||||||
|
- any supporting session detail surfaces
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- show current session tooling status clearly
|
||||||
|
- add "why unavailable?" and "fix configuration" entry points
|
||||||
|
- optionally add compact badges or chips for active MCP/LSP selections
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
|
||||||
|
- session-scoped tooling is visible without leaving the main workflow
|
||||||
|
- troubleshooting paths are discoverable
|
||||||
|
|
||||||
|
## Handoff between backend and frontend agents
|
||||||
|
|
||||||
|
### Backend agent handoff deliverables
|
||||||
|
|
||||||
|
The backend pass should hand off:
|
||||||
|
|
||||||
|
- finalized shared domain shapes
|
||||||
|
- finalized IPC contract
|
||||||
|
- finalized sidecar contract changes
|
||||||
|
- clear diagnostics payloads
|
||||||
|
- scratchpad/tool policy behavior
|
||||||
|
- finalized session enablement state shape
|
||||||
|
- test coverage for persistence, protocol, and runtime behavior
|
||||||
|
|
||||||
|
### Frontend agent prerequisites
|
||||||
|
|
||||||
|
The frontend agent should not start until it has:
|
||||||
|
|
||||||
|
- stable backend CRUD endpoints
|
||||||
|
- stable health/diagnostic payloads
|
||||||
|
- stable activity event shapes
|
||||||
|
- at least one working MCP path and one working LSP profile path to design against
|
||||||
|
- stable session enable/disable semantics for the right-side panel
|
||||||
|
|
||||||
|
## Risks and decisions to settle early
|
||||||
|
|
||||||
|
### A. Define what "LSP support" means in v1
|
||||||
|
|
||||||
|
This is the biggest scope decision. Options include:
|
||||||
|
|
||||||
|
- agent-facing project intelligence only
|
||||||
|
- agent-facing intelligence plus light read-only diagnostics in the UI
|
||||||
|
- a much larger editor-like in-app LSP experience
|
||||||
|
|
||||||
|
Recommended default:
|
||||||
|
|
||||||
|
- agent-facing project intelligence first
|
||||||
|
- diagnostics UI second
|
||||||
|
- editor-like UX later
|
||||||
|
|
||||||
|
### B. Decide the scratchpad policy
|
||||||
|
|
||||||
|
Current behavior is strongly tool-free. Keep that default unless product scope explicitly changes.
|
||||||
|
|
||||||
|
### C. Decide the default session behavior
|
||||||
|
|
||||||
|
Need to decide whether new sessions start with:
|
||||||
|
|
||||||
|
- no MCPs/LSPs enabled by default
|
||||||
|
- a safe recommended subset enabled by default
|
||||||
|
- the same enablement as the previously selected session
|
||||||
|
|
||||||
|
Recommended default:
|
||||||
|
|
||||||
|
- start disabled by default until a session explicitly enables a tool
|
||||||
|
|
||||||
|
### D. Decide provisioning and trust
|
||||||
|
|
||||||
|
Need explicit decisions for:
|
||||||
|
|
||||||
|
- bundled vs user-installed MCP/LSP servers
|
||||||
|
- which transports are allowed in v1
|
||||||
|
- whether arbitrary commands are allowed
|
||||||
|
- whether write-capable tools need approval
|
||||||
|
- whether LSP starts TypeScript-only first
|
||||||
|
|
||||||
|
### E. Decide the approval/safety model
|
||||||
|
|
||||||
|
`ApprovePermissionAsync(...)` currently auto-approves. That is not a sufficient trust model for arbitrary MCP tooling. A v1 safety story is needed before enabling broad external tool execution.
|
||||||
|
|
||||||
|
### F. Decide observability depth
|
||||||
|
|
||||||
|
Current activity events are coarse. MCP/LSP may require richer activity or trace events to stay understandable.
|
||||||
|
|
||||||
|
## Recommended sequencing summary
|
||||||
|
|
||||||
|
1. B0: persisted vocabulary and migration-safe shapes
|
||||||
|
2. B1: main-process API and diagnostics
|
||||||
|
3. B2: sidecar protocol expansion
|
||||||
|
4. B3: MCP runtime support
|
||||||
|
5. B4: Electron-main LSP service
|
||||||
|
6. B5: LSP bridge into agent runtime
|
||||||
|
7. F1-F5: frontend diagnostics, global editors, session toggles, visibility, and polish
|
||||||
|
|
||||||
|
## Out of scope for the first implementation pass
|
||||||
|
|
||||||
|
- a full IDE/editor inside Eryx
|
||||||
|
- raw LSP transport in the renderer
|
||||||
|
- persisted secret values in workspace files
|
||||||
|
- broad packaging/install automation for every possible LSP/MCP ecosystem on day one
|
||||||
|
|
||||||
|
## Suggested validation gate before frontend begins
|
||||||
|
|
||||||
|
Before handing to the frontend agent, the backend agent should be able to demonstrate:
|
||||||
|
|
||||||
|
- additive workspace persistence for MCP/LSP config
|
||||||
|
- at least one working MCP-backed agent flow in a project session
|
||||||
|
- one initialized, health-reporting LSP profile in Electron main
|
||||||
|
- per-session enable/disable persistence for selected MCPs/LSPs
|
||||||
|
- stable diagnostics payloads exposed to the renderer
|
||||||
|
- passing `bun test`, `bun run sidecar:test`, and `bun run build`
|
||||||
@@ -41,6 +41,12 @@ Eryx supports several ways of working:
|
|||||||
- **Handoff** for agent-to-agent delegation
|
- **Handoff** for agent-to-agent delegation
|
||||||
- **Group chat** for collaborative multi-agent discussion
|
- **Group chat** for collaborative multi-agent discussion
|
||||||
|
|
||||||
|
### Add global MCPs and LSPs
|
||||||
|
|
||||||
|
You can define MCP servers and LSP profiles once in **Settings**, then enable the ones you want for each project-backed session from the right-side **Activity** panel.
|
||||||
|
|
||||||
|
This keeps machine-wide tooling reusable while still letting each session decide which external tools the agent can use.
|
||||||
|
|
||||||
### Watch runs as they happen
|
### Watch runs as they happen
|
||||||
|
|
||||||
You can follow agent activity while a session is running, which makes longer or more complex workflows easier to trust and understand.
|
You can follow agent activity while a session is running, which makes longer or more complex workflows easier to trust and understand.
|
||||||
@@ -57,6 +63,7 @@ To use Eryx comfortably, make sure you have:
|
|||||||
- **GitHub Copilot CLI** installed and available as `copilot`
|
- **GitHub Copilot CLI** installed and available as `copilot`
|
||||||
- an active **GitHub Copilot sign-in**
|
- an active **GitHub Copilot sign-in**
|
||||||
- a local folder or git repository ready to connect if you want project-aware help
|
- a local folder or git repository ready to connect if you want project-aware help
|
||||||
|
- any MCP servers or language servers you want to use installed and reachable from your machine
|
||||||
|
|
||||||
Eryx includes connection status in the app so you can quickly tell whether Copilot is ready before you start a session.
|
Eryx includes connection status in the app so you can quickly tell whether Copilot is ready before you start a session.
|
||||||
|
|
||||||
@@ -74,7 +81,10 @@ Eryx includes connection status in the app so you can quickly tell whether Copil
|
|||||||
4. **Pick a pattern**
|
4. **Pick a pattern**
|
||||||
Use a simple single-agent setup to begin, or choose a saved multi-agent pattern when you want a more structured workflow.
|
Use a simple single-agent setup to begin, or choose a saved multi-agent pattern when you want a more structured workflow.
|
||||||
|
|
||||||
5. **Start working**
|
5. **Configure optional tooling**
|
||||||
|
If you want MCP or LSP support, add the global definitions in settings and then enable the ones you want for the current session from the Activity panel.
|
||||||
|
|
||||||
|
6. **Start working**
|
||||||
Ask a question, describe a task, or explore a project. As the run progresses, you can watch the participating agents and keep the session for later.
|
Ask a question, describe a task, or explore a project. As the run progresses, you can watch the participating agents and keep the session for later.
|
||||||
|
|
||||||
## When Eryx feels most useful
|
## When Eryx feels most useful
|
||||||
|
|||||||
@@ -113,6 +113,36 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
|||||||
public string WorkspaceKind { get; init; } = "project";
|
public string WorkspaceKind { get; init; } = "project";
|
||||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||||
|
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RunTurnToolingConfigDto
|
||||||
|
{
|
||||||
|
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
|
||||||
|
public IReadOnlyList<RunTurnLspProfileConfigDto> LspProfiles { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RunTurnMcpServerConfigDto
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = string.Empty;
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
public string Transport { get; init; } = "local";
|
||||||
|
public IReadOnlyList<string> Tools { get; init; } = [];
|
||||||
|
public int? TimeoutMs { get; init; }
|
||||||
|
public string? Command { get; init; }
|
||||||
|
public IReadOnlyList<string>? Args { get; init; }
|
||||||
|
public string? Cwd { get; init; }
|
||||||
|
public string? Url { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RunTurnLspProfileConfigDto
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = string.Empty;
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
public string Command { get; init; } = string.Empty;
|
||||||
|
public IReadOnlyList<string> Args { get; init; } = [];
|
||||||
|
public string LanguageId { get; init; } = string.Empty;
|
||||||
|
public IReadOnlyList<string> FileExtensions { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract class SidecarEventDto
|
public abstract class SidecarEventDto
|
||||||
|
|||||||
@@ -507,6 +507,15 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
List<AIAgent> agents = [];
|
List<AIAgent> agents = [];
|
||||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
||||||
bool isScratchpad = string.Equals(command.WorkspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase);
|
bool isScratchpad = string.Equals(command.WorkspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase);
|
||||||
|
SessionToolingBundle? toolingBundle = isScratchpad
|
||||||
|
? null
|
||||||
|
: await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (toolingBundle is not null)
|
||||||
|
{
|
||||||
|
disposables.Add(toolingBundle);
|
||||||
|
}
|
||||||
|
|
||||||
foreach ((PatternAgentDefinitionDto definition, int agentIndex) in command.Pattern.Agents.Select((definition, index) => (definition, index)))
|
foreach ((PatternAgentDefinitionDto definition, int agentIndex) in command.Pattern.Agents.Select((definition, index) => (definition, index)))
|
||||||
{
|
{
|
||||||
@@ -530,6 +539,18 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
{
|
{
|
||||||
sessionConfig.AvailableTools = [];
|
sessionConfig.AvailableTools = [];
|
||||||
}
|
}
|
||||||
|
else if (toolingBundle is not null)
|
||||||
|
{
|
||||||
|
if (toolingBundle.McpServers.Count > 0)
|
||||||
|
{
|
||||||
|
sessionConfig.McpServers = toolingBundle.McpServers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolingBundle.Tools.Count > 0)
|
||||||
|
{
|
||||||
|
sessionConfig.Tools = toolingBundle.Tools.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GitHubCopilotAgent agent = new(
|
GitHubCopilotAgent agent = new(
|
||||||
client,
|
client,
|
||||||
|
|||||||
@@ -0,0 +1,681 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Eryx.AgentHost.Contracts;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Eryx.AgentHost.Services;
|
||||||
|
|
||||||
|
internal sealed class LspToolSession : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
WriteIndented = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly RunTurnLspProfileConfigDto _profile;
|
||||||
|
private readonly string _projectPath;
|
||||||
|
private readonly Process _process;
|
||||||
|
private readonly Stream _stdin;
|
||||||
|
private readonly Stream _stdout;
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private readonly ConcurrentDictionary<int, TaskCompletionSource<JsonElement?>> _pending =
|
||||||
|
new();
|
||||||
|
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||||
|
private readonly SemaphoreSlim _documentLock = new(1, 1);
|
||||||
|
private readonly HashSet<string> _openedDocumentUris = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Task _stdoutReaderTask;
|
||||||
|
private readonly Task _stderrReaderTask;
|
||||||
|
private int _nextRequestId;
|
||||||
|
|
||||||
|
private LspToolSession(
|
||||||
|
RunTurnLspProfileConfigDto profile,
|
||||||
|
string projectPath,
|
||||||
|
Process process)
|
||||||
|
{
|
||||||
|
_profile = profile;
|
||||||
|
_projectPath = Path.GetFullPath(projectPath);
|
||||||
|
_process = process;
|
||||||
|
_stdin = process.StandardInput.BaseStream;
|
||||||
|
_stdout = process.StandardOutput.BaseStream;
|
||||||
|
_stdoutReaderTask = Task.Run(() => ReadLoopAsync(_cts.Token));
|
||||||
|
_stderrReaderTask = Task.Run(() => ReadErrorLoopAsync(_cts.Token));
|
||||||
|
Tools =
|
||||||
|
[
|
||||||
|
CreateTool(
|
||||||
|
methodName: nameof(WorkspaceSymbolsToolAsync),
|
||||||
|
toolName: $"{BuildToolPrefix(_profile.Id)}_workspace_symbols",
|
||||||
|
description: $"Search workspace symbols using the {_profile.Name} language server. Use this when you know a symbol name or partial symbol name."),
|
||||||
|
CreateTool(
|
||||||
|
methodName: nameof(DocumentSymbolsToolAsync),
|
||||||
|
toolName: $"{BuildToolPrefix(_profile.Id)}_document_symbols",
|
||||||
|
description: $"List document symbols for a file using the {_profile.Name} language server. Pass a path relative to the current project root."),
|
||||||
|
CreateTool(
|
||||||
|
methodName: nameof(DefinitionToolAsync),
|
||||||
|
toolName: $"{BuildToolPrefix(_profile.Id)}_definition",
|
||||||
|
description: $"Resolve the definition location for a symbol using the {_profile.Name} language server. Paths are relative to the project root. Line and character are 1-based."),
|
||||||
|
CreateTool(
|
||||||
|
methodName: nameof(HoverToolAsync),
|
||||||
|
toolName: $"{BuildToolPrefix(_profile.Id)}_hover",
|
||||||
|
description: $"Fetch hover information for a symbol using the {_profile.Name} language server. Paths are relative to the project root. Line and character are 1-based."),
|
||||||
|
CreateTool(
|
||||||
|
methodName: nameof(ReferencesToolAsync),
|
||||||
|
toolName: $"{BuildToolPrefix(_profile.Id)}_references",
|
||||||
|
description: $"Find references for a symbol using the {_profile.Name} language server. Paths are relative to the project root. Line and character are 1-based."),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<AIFunction> Tools { get; }
|
||||||
|
|
||||||
|
public static async Task<LspToolSession> StartAsync(
|
||||||
|
RunTurnLspProfileConfigDto profile,
|
||||||
|
string projectPath,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ProcessStartInfo startInfo = new()
|
||||||
|
{
|
||||||
|
FileName = profile.Command,
|
||||||
|
WorkingDirectory = projectPath,
|
||||||
|
RedirectStandardInput = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (string arg in profile.Args)
|
||||||
|
{
|
||||||
|
startInfo.ArgumentList.Add(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
Process? process;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
process = Process.Start(startInfo);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Could not start LSP profile \"{profile.Name}\" using command \"{profile.Command}\".",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Could not start LSP profile \"{profile.Name}\" using command \"{profile.Command}\".");
|
||||||
|
}
|
||||||
|
|
||||||
|
LspToolSession session = new(profile, projectPath, process);
|
||||||
|
await session.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_process.HasExited)
|
||||||
|
{
|
||||||
|
_process.Kill(entireProcessTree: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _stdoutReaderTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _stderrReaderTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
_writeLock.Dispose();
|
||||||
|
_documentLock.Dispose();
|
||||||
|
_stdin.Dispose();
|
||||||
|
_stdout.Dispose();
|
||||||
|
_process.Dispose();
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitializeAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string rootUri = ToFileUri(_projectPath);
|
||||||
|
await SendRequestAsync(
|
||||||
|
"initialize",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
processId = Environment.ProcessId,
|
||||||
|
rootUri,
|
||||||
|
capabilities = new { },
|
||||||
|
workspaceFolders = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
uri = rootUri,
|
||||||
|
name = Path.GetFileName(_projectPath),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clientInfo = new
|
||||||
|
{
|
||||||
|
name = "Eryx",
|
||||||
|
version = "1.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await SendNotificationAsync("initialized", new { }, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AIFunction CreateTool(string methodName, string toolName, string description)
|
||||||
|
{
|
||||||
|
MethodInfo method = GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||||
|
?? throw new InvalidOperationException($"LSP tool method \"{methodName}\" was not found.");
|
||||||
|
|
||||||
|
return AIFunctionFactory.Create(
|
||||||
|
method,
|
||||||
|
this,
|
||||||
|
new AIFunctionFactoryOptions
|
||||||
|
{
|
||||||
|
Name = toolName,
|
||||||
|
Description = description,
|
||||||
|
SerializerOptions = JsonOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> WorkspaceSymbolsToolAsync(string query, int limit = 20)
|
||||||
|
{
|
||||||
|
JsonElement? result = await SendRequestAsync(
|
||||||
|
"workspace/symbol",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return SerializeResult(result, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> DocumentSymbolsToolAsync(string relativePath)
|
||||||
|
{
|
||||||
|
string documentPath = ResolveProjectPath(relativePath);
|
||||||
|
string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
JsonElement? result = await SendRequestAsync(
|
||||||
|
"textDocument/documentSymbol",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
textDocument = new
|
||||||
|
{
|
||||||
|
uri = documentUri,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return SerializeResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> DefinitionToolAsync(string relativePath, int line, int character)
|
||||||
|
{
|
||||||
|
string documentPath = ResolveProjectPath(relativePath);
|
||||||
|
string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
JsonElement? result = await SendRequestAsync(
|
||||||
|
"textDocument/definition",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
textDocument = new
|
||||||
|
{
|
||||||
|
uri = documentUri,
|
||||||
|
},
|
||||||
|
position = new
|
||||||
|
{
|
||||||
|
line = NormalizePosition(line),
|
||||||
|
character = NormalizePosition(character),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return SerializeResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> HoverToolAsync(string relativePath, int line, int character)
|
||||||
|
{
|
||||||
|
string documentPath = ResolveProjectPath(relativePath);
|
||||||
|
string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
JsonElement? result = await SendRequestAsync(
|
||||||
|
"textDocument/hover",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
textDocument = new
|
||||||
|
{
|
||||||
|
uri = documentUri,
|
||||||
|
},
|
||||||
|
position = new
|
||||||
|
{
|
||||||
|
line = NormalizePosition(line),
|
||||||
|
character = NormalizePosition(character),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return SerializeResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> ReferencesToolAsync(string relativePath, int line, int character)
|
||||||
|
{
|
||||||
|
string documentPath = ResolveProjectPath(relativePath);
|
||||||
|
string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
JsonElement? result = await SendRequestAsync(
|
||||||
|
"textDocument/references",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
textDocument = new
|
||||||
|
{
|
||||||
|
uri = documentUri,
|
||||||
|
},
|
||||||
|
position = new
|
||||||
|
{
|
||||||
|
line = NormalizePosition(line),
|
||||||
|
character = NormalizePosition(character),
|
||||||
|
},
|
||||||
|
context = new
|
||||||
|
{
|
||||||
|
includeDeclaration = true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return SerializeResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> EnsureDocumentOpenedAsync(string documentPath, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string documentUri = ToFileUri(documentPath);
|
||||||
|
|
||||||
|
if (_openedDocumentUris.Contains(documentUri))
|
||||||
|
{
|
||||||
|
return documentUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _documentLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_openedDocumentUris.Contains(documentUri))
|
||||||
|
{
|
||||||
|
return documentUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
string text = await File.ReadAllTextAsync(documentPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
await SendNotificationAsync(
|
||||||
|
"textDocument/didOpen",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
textDocument = new
|
||||||
|
{
|
||||||
|
uri = documentUri,
|
||||||
|
languageId = ResolveLanguageId(documentPath),
|
||||||
|
version = 1,
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_openedDocumentUris.Add(documentUri);
|
||||||
|
return documentUri;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_documentLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonElement?> SendRequestAsync(
|
||||||
|
string method,
|
||||||
|
object? parameters,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
int requestId = Interlocked.Increment(ref _nextRequestId);
|
||||||
|
TaskCompletionSource<JsonElement?> tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
_pending[requestId] = tcs;
|
||||||
|
|
||||||
|
await WriteMessageAsync(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id = requestId,
|
||||||
|
method,
|
||||||
|
@params = parameters,
|
||||||
|
},
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
using CancellationTokenRegistration registration = cancellationToken.Register(
|
||||||
|
static state =>
|
||||||
|
{
|
||||||
|
((TaskCompletionSource<JsonElement?>)state!).TrySetCanceled();
|
||||||
|
},
|
||||||
|
tcs);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await tcs.Task.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_pending.TryRemove(requestId, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task SendNotificationAsync(string method, object? parameters, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return WriteMessageAsync(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
method,
|
||||||
|
@params = parameters,
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteMessageAsync(object payload, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||||
|
byte[] body = Encoding.UTF8.GetBytes(json);
|
||||||
|
byte[] header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n");
|
||||||
|
|
||||||
|
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _stdin.WriteAsync(header, cancellationToken).ConfigureAwait(false);
|
||||||
|
await _stdin.WriteAsync(body, cancellationToken).ConfigureAwait(false);
|
||||||
|
await _stdin.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_writeLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReadLoopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Dictionary<string, string>? headers = await ReadHeadersAsync(_stdout, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (headers is null)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headers.TryGetValue("Content-Length", out string? contentLengthHeader)
|
||||||
|
|| !int.TryParse(contentLengthHeader, out int contentLength))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("LSP response was missing a valid Content-Length header.");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] body = await ReadExactAsync(_stdout, contentLength, cancellationToken).ConfigureAwait(false);
|
||||||
|
using JsonDocument document = JsonDocument.Parse(body);
|
||||||
|
JsonElement root = document.RootElement;
|
||||||
|
|
||||||
|
if (root.TryGetProperty("id", out JsonElement idElement)
|
||||||
|
&& TryReadRequestId(idElement, out int requestId)
|
||||||
|
&& _pending.TryGetValue(requestId, out TaskCompletionSource<JsonElement?>? tcs))
|
||||||
|
{
|
||||||
|
if (root.TryGetProperty("error", out JsonElement errorElement))
|
||||||
|
{
|
||||||
|
string message = errorElement.TryGetProperty("message", out JsonElement messageElement)
|
||||||
|
? messageElement.GetString() ?? "Unknown LSP error."
|
||||||
|
: "Unknown LSP error.";
|
||||||
|
tcs.TrySetException(new InvalidOperationException(message));
|
||||||
|
}
|
||||||
|
else if (root.TryGetProperty("result", out JsonElement resultElement))
|
||||||
|
{
|
||||||
|
tcs.TrySetResult(resultElement.Clone());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
tcs.TrySetResult(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
FailPendingRequests(ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
FailPendingRequests(new InvalidOperationException(
|
||||||
|
$"LSP profile \"{_profile.Name}\" stopped before a pending request completed."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReadErrorLoopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
string? line = await _process.StandardError.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (line is null)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(line))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[eryx lsp:{_profile.Id}] {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Dictionary<string, string>?> ReadHeadersAsync(
|
||||||
|
Stream stream,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
List<byte> bytes = [];
|
||||||
|
byte[] buffer = new byte[1];
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
return bytes.Count == 0
|
||||||
|
? null
|
||||||
|
: throw new EndOfStreamException("LSP stream ended mid-header.");
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes.Add(buffer[0]);
|
||||||
|
int count = bytes.Count;
|
||||||
|
if (count >= 4
|
||||||
|
&& bytes[count - 4] == '\r'
|
||||||
|
&& bytes[count - 3] == '\n'
|
||||||
|
&& bytes[count - 2] == '\r'
|
||||||
|
&& bytes[count - 1] == '\n')
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string headerText = Encoding.ASCII.GetString(bytes.Take(bytes.Count - 4).ToArray());
|
||||||
|
Dictionary<string, string> headers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (string line in headerText.Split("\r\n", StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
int separatorIndex = line.IndexOf(':');
|
||||||
|
if (separatorIndex < 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string key = line[..separatorIndex].Trim();
|
||||||
|
string value = line[(separatorIndex + 1)..].Trim();
|
||||||
|
headers[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<byte[]> ReadExactAsync(
|
||||||
|
Stream stream,
|
||||||
|
int length,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
byte[] buffer = new byte[length];
|
||||||
|
int offset = 0;
|
||||||
|
|
||||||
|
while (offset < length)
|
||||||
|
{
|
||||||
|
int read = await stream.ReadAsync(buffer.AsMemory(offset, length - offset), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
throw new EndOfStreamException("LSP stream ended mid-message.");
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += read;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadRequestId(JsonElement idElement, out int requestId)
|
||||||
|
{
|
||||||
|
requestId = default;
|
||||||
|
return idElement.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Number => idElement.TryGetInt32(out requestId),
|
||||||
|
JsonValueKind.String => int.TryParse(idElement.GetString(), out requestId),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FailPendingRequests(Exception exception)
|
||||||
|
{
|
||||||
|
foreach ((_, TaskCompletionSource<JsonElement?> tcs) in _pending.ToArray())
|
||||||
|
{
|
||||||
|
tcs.TrySetException(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
_pending.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveProjectPath(string relativePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(relativePath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("A project-relative path is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string fullPath = Path.IsPathRooted(relativePath)
|
||||||
|
? Path.GetFullPath(relativePath)
|
||||||
|
: Path.GetFullPath(Path.Combine(_projectPath, relativePath));
|
||||||
|
|
||||||
|
if (!File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"Could not find \"{relativePath}\" in the current project.", fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
string normalizedProjectPath = Path.TrimEndingDirectorySeparator(_projectPath);
|
||||||
|
if (!fullPath.StartsWith(normalizedProjectPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("LSP tools can only access files inside the current project.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveLanguageId(string documentPath)
|
||||||
|
{
|
||||||
|
string extension = Path.GetExtension(documentPath);
|
||||||
|
if (_profile.FileExtensions.Any(candidate =>
|
||||||
|
string.Equals(candidate, extension, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return _profile.LanguageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _profile.LanguageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildToolPrefix(string value)
|
||||||
|
{
|
||||||
|
StringBuilder builder = new();
|
||||||
|
foreach (char ch in value)
|
||||||
|
{
|
||||||
|
if (char.IsLetterOrDigit(ch))
|
||||||
|
{
|
||||||
|
builder.Append(char.ToLowerInvariant(ch));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (builder.Length == 0 || builder[^1] == '_')
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
string prefix = builder.ToString().Trim('_');
|
||||||
|
return string.IsNullOrWhiteSpace(prefix) ? "lsp" : $"lsp_{prefix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int NormalizePosition(int value)
|
||||||
|
{
|
||||||
|
return Math.Max(value - 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToFileUri(string path)
|
||||||
|
{
|
||||||
|
return new Uri(path).AbsoluteUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SerializeResult(JsonElement? result, int? maxItems = null)
|
||||||
|
{
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonElement output = result.Value;
|
||||||
|
if (maxItems.HasValue && output.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
JsonElement[] limited = output.EnumerateArray().Take(Math.Max(maxItems.Value, 0)).ToArray();
|
||||||
|
return JsonSerializer.Serialize(limited, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(output, JsonOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using GitHub.Copilot.SDK;
|
||||||
|
using Eryx.AgentHost.Contracts;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
|
namespace Eryx.AgentHost.Services;
|
||||||
|
|
||||||
|
internal sealed class SessionToolingBundle : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly List<IAsyncDisposable> _disposables = [];
|
||||||
|
|
||||||
|
private SessionToolingBundle(
|
||||||
|
Dictionary<string, object> mcpServers,
|
||||||
|
IReadOnlyList<AIFunction> tools)
|
||||||
|
{
|
||||||
|
McpServers = mcpServers;
|
||||||
|
Tools = tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Dictionary<string, object> McpServers { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<AIFunction> Tools { get; }
|
||||||
|
|
||||||
|
public static async Task<SessionToolingBundle> CreateAsync(
|
||||||
|
RunTurnToolingConfigDto? tooling,
|
||||||
|
string projectPath,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Dictionary<string, object> mcpServers = BuildMcpServerConfigurations(tooling?.McpServers ?? []);
|
||||||
|
List<IAsyncDisposable> disposables = [];
|
||||||
|
List<AIFunction> tools = [];
|
||||||
|
|
||||||
|
foreach (RunTurnLspProfileConfigDto profile in tooling?.LspProfiles ?? [])
|
||||||
|
{
|
||||||
|
LspToolSession lspSession = await LspToolSession.StartAsync(profile, projectPath, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
disposables.Add(lspSession);
|
||||||
|
tools.AddRange(lspSession.Tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionToolingBundle bundle = new(mcpServers, tools);
|
||||||
|
bundle._disposables.AddRange(disposables);
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
foreach (IAsyncDisposable disposable in _disposables)
|
||||||
|
{
|
||||||
|
await disposable.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Dictionary<string, object> BuildMcpServerConfigurations(
|
||||||
|
IReadOnlyList<RunTurnMcpServerConfigDto> servers)
|
||||||
|
{
|
||||||
|
Dictionary<string, object> configurations = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (RunTurnMcpServerConfigDto server in servers)
|
||||||
|
{
|
||||||
|
string serverName = string.IsNullOrWhiteSpace(server.Name) ? server.Id : server.Name.Trim();
|
||||||
|
List<string> tools = server.Tools.Count == 0 ? ["*"] : server.Tools.ToList();
|
||||||
|
|
||||||
|
if (string.Equals(server.Transport, "local", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(server.Command))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"MCP server \"{serverName}\" is missing a command.");
|
||||||
|
}
|
||||||
|
|
||||||
|
configurations[serverName] = new McpLocalServerConfig
|
||||||
|
{
|
||||||
|
Type = "local",
|
||||||
|
Timeout = server.TimeoutMs,
|
||||||
|
Command = server.Command,
|
||||||
|
Args = server.Args?.ToList() ?? [],
|
||||||
|
Cwd = string.IsNullOrWhiteSpace(server.Cwd) ? null : server.Cwd,
|
||||||
|
Tools = tools,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(server.Url))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"MCP server \"{serverName}\" is missing a URL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
configurations[serverName] = new McpRemoteServerConfig
|
||||||
|
{
|
||||||
|
Type = server.Transport,
|
||||||
|
Timeout = server.TimeoutMs,
|
||||||
|
Url = server.Url,
|
||||||
|
Tools = tools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return configurations;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using Eryx.AgentHost.Contracts;
|
||||||
|
using Eryx.AgentHost.Services;
|
||||||
|
using GitHub.Copilot.SDK;
|
||||||
|
|
||||||
|
namespace Eryx.AgentHost.Tests;
|
||||||
|
|
||||||
|
public sealed class SessionToolingBundleTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildMcpServerConfigurations_MapsLocalAndRemoteServers()
|
||||||
|
{
|
||||||
|
IReadOnlyList<RunTurnMcpServerConfigDto> servers =
|
||||||
|
[
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = "mcp-local",
|
||||||
|
Name = "Local MCP",
|
||||||
|
Transport = "local",
|
||||||
|
Command = "node",
|
||||||
|
Args = ["server.js", "--stdio"],
|
||||||
|
Cwd = @"C:\workspace\repo",
|
||||||
|
Tools = ["git.status"],
|
||||||
|
TimeoutMs = 1500,
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = "mcp-remote",
|
||||||
|
Name = "Remote MCP",
|
||||||
|
Transport = "http",
|
||||||
|
Url = "https://example.com/mcp",
|
||||||
|
Tools = ["*"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
Dictionary<string, object> configurations = SessionToolingBundle.BuildMcpServerConfigurations(servers);
|
||||||
|
|
||||||
|
McpLocalServerConfig localConfig = Assert.IsType<McpLocalServerConfig>(configurations["Local MCP"]);
|
||||||
|
Assert.Equal("local", localConfig.Type);
|
||||||
|
Assert.Equal("node", localConfig.Command);
|
||||||
|
Assert.Equal(["server.js", "--stdio"], localConfig.Args);
|
||||||
|
Assert.Equal(@"C:\workspace\repo", localConfig.Cwd);
|
||||||
|
Assert.Equal(["git.status"], localConfig.Tools);
|
||||||
|
Assert.Equal(1500, localConfig.Timeout);
|
||||||
|
|
||||||
|
McpRemoteServerConfig remoteConfig = Assert.IsType<McpRemoteServerConfig>(configurations["Remote MCP"]);
|
||||||
|
Assert.Equal("http", remoteConfig.Type);
|
||||||
|
Assert.Equal("https://example.com/mcp", remoteConfig.Url);
|
||||||
|
Assert.Equal(["*"], remoteConfig.Tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildMcpServerConfigurations_DefaultsMissingToolsToWildcard()
|
||||||
|
{
|
||||||
|
IReadOnlyList<RunTurnMcpServerConfigDto> servers =
|
||||||
|
[
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = "mcp-local",
|
||||||
|
Transport = "local",
|
||||||
|
Command = "node",
|
||||||
|
Tools = [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
Dictionary<string, object> configurations = SessionToolingBundle.BuildMcpServerConfigurations(servers);
|
||||||
|
McpLocalServerConfig localConfig = Assert.IsType<McpLocalServerConfig>(configurations["mcp-local"]);
|
||||||
|
|
||||||
|
Assert.Equal(["*"], localConfig.Tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildMcpServerConfigurations_RejectsMissingTransportTargets()
|
||||||
|
{
|
||||||
|
InvalidOperationException localError = Assert.Throws<InvalidOperationException>(
|
||||||
|
() => SessionToolingBundle.BuildMcpServerConfigurations(
|
||||||
|
[
|
||||||
|
new RunTurnMcpServerConfigDto
|
||||||
|
{
|
||||||
|
Id = "mcp-local",
|
||||||
|
Name = "Local MCP",
|
||||||
|
Transport = "local",
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
Assert.Contains("missing a command", localError.Message);
|
||||||
|
|
||||||
|
InvalidOperationException remoteError = Assert.Throws<InvalidOperationException>(
|
||||||
|
() => SessionToolingBundle.BuildMcpServerConfigurations(
|
||||||
|
[
|
||||||
|
new RunTurnMcpServerConfigDto
|
||||||
|
{
|
||||||
|
Id = "mcp-remote",
|
||||||
|
Name = "Remote MCP",
|
||||||
|
Transport = "sse",
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
Assert.Contains("missing a URL", remoteError.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ import { dialog } from 'electron';
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentActivityEvent,
|
AgentActivityEvent,
|
||||||
|
RunTurnLspProfileConfig,
|
||||||
|
RunTurnMcpServerConfig,
|
||||||
|
RunTurnToolingConfig,
|
||||||
SidecarCapabilities,
|
SidecarCapabilities,
|
||||||
TurnDeltaEvent,
|
TurnDeltaEvent,
|
||||||
} from '@shared/contracts/sidecar';
|
} from '@shared/contracts/sidecar';
|
||||||
@@ -31,11 +34,22 @@ import {
|
|||||||
import type { SessionEventRecord } from '@shared/domain/event';
|
import type { SessionEventRecord } from '@shared/domain/event';
|
||||||
import {
|
import {
|
||||||
applyScratchpadSessionConfig,
|
applyScratchpadSessionConfig,
|
||||||
|
resolveSessionToolingSelection,
|
||||||
createScratchpadSessionConfig,
|
createScratchpadSessionConfig,
|
||||||
resolveSessionTitle,
|
resolveSessionTitle,
|
||||||
type ChatMessageRecord,
|
type ChatMessageRecord,
|
||||||
type SessionRecord,
|
type SessionRecord,
|
||||||
} from '@shared/domain/session';
|
} from '@shared/domain/session';
|
||||||
|
import {
|
||||||
|
createSessionToolingSelection,
|
||||||
|
type LspProfileDefinition,
|
||||||
|
type McpServerDefinition,
|
||||||
|
normalizeLspProfileDefinition,
|
||||||
|
normalizeMcpServerDefinition,
|
||||||
|
normalizeSessionToolingSelection,
|
||||||
|
validateLspProfileDefinition,
|
||||||
|
validateMcpServerDefinition,
|
||||||
|
} from '@shared/domain/tooling';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
import { createId, nowIso } from '@shared/utils/ids';
|
import { createId, nowIso } from '@shared/utils/ids';
|
||||||
import { mergeStreamingText } from '@shared/utils/streamingText';
|
import { mergeStreamingText } from '@shared/utils/streamingText';
|
||||||
@@ -193,6 +207,96 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async saveMcpServer(server: McpServerDefinition): Promise<WorkspaceState> {
|
||||||
|
const workspace = await this.loadWorkspace();
|
||||||
|
const existingIndex = workspace.settings.tooling.mcpServers.findIndex(
|
||||||
|
(current) => current.id === server.id,
|
||||||
|
);
|
||||||
|
const timestamp = nowIso();
|
||||||
|
const candidate = normalizeMcpServerDefinition({
|
||||||
|
...server,
|
||||||
|
createdAt:
|
||||||
|
existingIndex >= 0
|
||||||
|
? workspace.settings.tooling.mcpServers[existingIndex].createdAt
|
||||||
|
: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
});
|
||||||
|
const issue = validateMcpServerDefinition(candidate);
|
||||||
|
if (issue) {
|
||||||
|
throw new Error(issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
workspace.settings.tooling.mcpServers[existingIndex] = candidate;
|
||||||
|
} else {
|
||||||
|
workspace.settings.tooling.mcpServers.push(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.persistAndBroadcast(workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMcpServer(serverId: string): Promise<WorkspaceState> {
|
||||||
|
const workspace = await this.loadWorkspace();
|
||||||
|
workspace.settings.tooling.mcpServers = workspace.settings.tooling.mcpServers.filter(
|
||||||
|
(server) => server.id !== serverId,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const session of workspace.sessions) {
|
||||||
|
const selection = resolveSessionToolingSelection(session);
|
||||||
|
session.tooling = {
|
||||||
|
...selection,
|
||||||
|
enabledMcpServerIds: selection.enabledMcpServerIds.filter((id) => id !== serverId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.persistAndBroadcast(workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveLspProfile(profile: LspProfileDefinition): Promise<WorkspaceState> {
|
||||||
|
const workspace = await this.loadWorkspace();
|
||||||
|
const existingIndex = workspace.settings.tooling.lspProfiles.findIndex(
|
||||||
|
(current) => current.id === profile.id,
|
||||||
|
);
|
||||||
|
const timestamp = nowIso();
|
||||||
|
const candidate = normalizeLspProfileDefinition({
|
||||||
|
...profile,
|
||||||
|
createdAt:
|
||||||
|
existingIndex >= 0
|
||||||
|
? workspace.settings.tooling.lspProfiles[existingIndex].createdAt
|
||||||
|
: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
});
|
||||||
|
const issue = validateLspProfileDefinition(candidate);
|
||||||
|
if (issue) {
|
||||||
|
throw new Error(issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
workspace.settings.tooling.lspProfiles[existingIndex] = candidate;
|
||||||
|
} else {
|
||||||
|
workspace.settings.tooling.lspProfiles.push(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.persistAndBroadcast(workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLspProfile(profileId: string): Promise<WorkspaceState> {
|
||||||
|
const workspace = await this.loadWorkspace();
|
||||||
|
workspace.settings.tooling.lspProfiles = workspace.settings.tooling.lspProfiles.filter(
|
||||||
|
(profile) => profile.id !== profileId,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const session of workspace.sessions) {
|
||||||
|
const selection = resolveSessionToolingSelection(session);
|
||||||
|
session.tooling = {
|
||||||
|
...selection,
|
||||||
|
enabledLspProfileIds: selection.enabledLspProfileIds.filter((id) => id !== profileId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.persistAndBroadcast(workspace);
|
||||||
|
}
|
||||||
|
|
||||||
async createSession(projectId: string, patternId: string): Promise<WorkspaceState> {
|
async createSession(projectId: string, patternId: string): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const project = this.requireProject(workspace, projectId);
|
const project = this.requireProject(workspace, projectId);
|
||||||
@@ -213,6 +317,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
scratchpadConfig: isScratchpadProject(project)
|
scratchpadConfig: isScratchpadProject(project)
|
||||||
? createScratchpadSessionConfig(normalizedPattern)
|
? createScratchpadSessionConfig(normalizedPattern)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
tooling: createSessionToolingSelection(),
|
||||||
};
|
};
|
||||||
|
|
||||||
workspace.sessions.unshift(session);
|
workspace.sessions.unshift(session);
|
||||||
@@ -303,6 +408,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
workspaceKind,
|
workspaceKind,
|
||||||
pattern: effectivePattern,
|
pattern: effectivePattern,
|
||||||
messages: session.messages,
|
messages: session.messages,
|
||||||
|
tooling: this.buildRunTurnToolingConfig(workspace, project, session),
|
||||||
},
|
},
|
||||||
async (event) => {
|
async (event) => {
|
||||||
await this.applyTurnDelta(workspace, session.id, event);
|
await this.applyTurnDelta(workspace, session.id, event);
|
||||||
@@ -366,6 +472,57 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateSessionTooling(
|
||||||
|
sessionId: string,
|
||||||
|
enabledMcpServerIds: string[],
|
||||||
|
enabledLspProfileIds: string[],
|
||||||
|
): Promise<WorkspaceState> {
|
||||||
|
const workspace = await this.loadWorkspace();
|
||||||
|
const session = this.requireSession(workspace, sessionId);
|
||||||
|
const project = this.requireProject(workspace, session.projectId);
|
||||||
|
|
||||||
|
if (session.status === 'running') {
|
||||||
|
throw new Error('Wait for the current response to finish before changing session tools.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = normalizeSessionToolingSelection({
|
||||||
|
enabledMcpServerIds,
|
||||||
|
enabledLspProfileIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
isScratchpadProject(project)
|
||||||
|
&& (selection.enabledMcpServerIds.length > 0 || selection.enabledLspProfileIds.length > 0)
|
||||||
|
) {
|
||||||
|
throw new Error('Scratchpad sessions do not support MCP or LSP tools.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownMcpServerIds = new Set(
|
||||||
|
workspace.settings.tooling.mcpServers.map((server) => server.id),
|
||||||
|
);
|
||||||
|
const knownLspProfileIds = new Set(
|
||||||
|
workspace.settings.tooling.lspProfiles.map((profile) => profile.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unknownMcpServerIds = selection.enabledMcpServerIds.filter(
|
||||||
|
(id) => !knownMcpServerIds.has(id),
|
||||||
|
);
|
||||||
|
if (unknownMcpServerIds.length > 0) {
|
||||||
|
throw new Error(`Unknown MCP server "${unknownMcpServerIds[0]}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unknownLspProfileIds = selection.enabledLspProfileIds.filter(
|
||||||
|
(id) => !knownLspProfileIds.has(id),
|
||||||
|
);
|
||||||
|
if (unknownLspProfileIds.length > 0) {
|
||||||
|
throw new Error(`Unknown LSP profile "${unknownLspProfileIds[0]}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
session.tooling = selection;
|
||||||
|
session.updatedAt = nowIso();
|
||||||
|
return this.persistAndBroadcast(workspace);
|
||||||
|
}
|
||||||
|
|
||||||
async querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]> {
|
async querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
return queryWorkspaceSessions(workspace, input);
|
return queryWorkspaceSessions(workspace, input);
|
||||||
@@ -590,6 +747,86 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return normalizePatternModels(patternWithSessionConfig, modelCatalog);
|
return normalizePatternModels(patternWithSessionConfig, modelCatalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildRunTurnToolingConfig(
|
||||||
|
workspace: WorkspaceState,
|
||||||
|
project: ProjectRecord,
|
||||||
|
session: SessionRecord,
|
||||||
|
): RunTurnToolingConfig | undefined {
|
||||||
|
if (isScratchpadProject(project)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = resolveSessionToolingSelection(session);
|
||||||
|
const mcpServersById = new Map<string, McpServerDefinition>(
|
||||||
|
workspace.settings.tooling.mcpServers.map((server) => [server.id, server]),
|
||||||
|
);
|
||||||
|
const lspProfilesById = new Map<string, LspProfileDefinition>(
|
||||||
|
workspace.settings.tooling.lspProfiles.map((profile) => [profile.id, profile]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mcpServers = selection.enabledMcpServerIds.flatMap((id): RunTurnMcpServerConfig[] => {
|
||||||
|
const server = mcpServersById.get(id);
|
||||||
|
if (!server) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.transport === 'local') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
transport: 'local',
|
||||||
|
tools: [...server.tools],
|
||||||
|
timeoutMs: server.timeoutMs,
|
||||||
|
command: server.command,
|
||||||
|
args: [...server.args],
|
||||||
|
cwd: server.cwd,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
transport: server.transport,
|
||||||
|
tools: [...server.tools],
|
||||||
|
timeoutMs: server.timeoutMs,
|
||||||
|
url: server.url,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const lspProfiles = selection.enabledLspProfileIds.flatMap(
|
||||||
|
(id): RunTurnLspProfileConfig[] => {
|
||||||
|
const profile = lspProfilesById.get(id);
|
||||||
|
if (!profile) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: profile.id,
|
||||||
|
name: profile.name,
|
||||||
|
command: profile.command,
|
||||||
|
args: [...profile.args],
|
||||||
|
languageId: profile.languageId,
|
||||||
|
fileExtensions: [...profile.fileExtensions],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mcpServers.length === 0 && lspProfiles.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mcpServers,
|
||||||
|
lspProfiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private emitSessionEvent(event: SessionEventRecord): void {
|
private emitSessionEvent(event: SessionEventRecord): void {
|
||||||
this.emit('session-event', event);
|
this.emit('session-event', event);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import type {
|
|||||||
CreateSessionInput,
|
CreateSessionInput,
|
||||||
DuplicateSessionInput,
|
DuplicateSessionInput,
|
||||||
RenameSessionInput,
|
RenameSessionInput,
|
||||||
|
SaveLspProfileInput,
|
||||||
|
SaveMcpServerInput,
|
||||||
SavePatternInput,
|
SavePatternInput,
|
||||||
SendSessionMessageInput,
|
SendSessionMessageInput,
|
||||||
SetPatternFavoriteInput,
|
SetPatternFavoriteInput,
|
||||||
SetSessionArchivedInput,
|
SetSessionArchivedInput,
|
||||||
SetSessionPinnedInput,
|
SetSessionPinnedInput,
|
||||||
|
UpdateSessionToolingInput,
|
||||||
UpdateScratchpadSessionConfigInput,
|
UpdateScratchpadSessionConfigInput,
|
||||||
} from '@shared/contracts/ipc';
|
} from '@shared/contracts/ipc';
|
||||||
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
||||||
@@ -30,6 +33,25 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi
|
|||||||
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
|
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
|
||||||
service.setPatternFavorite(input.patternId, input.isFavorite),
|
service.setPatternFavorite(input.patternId, input.isFavorite),
|
||||||
);
|
);
|
||||||
|
ipcMain.handle(ipcChannels.saveMcpServer, (_event, input: SaveMcpServerInput) =>
|
||||||
|
service.saveMcpServer(input.server),
|
||||||
|
);
|
||||||
|
ipcMain.handle(ipcChannels.deleteMcpServer, (_event, serverId: string) =>
|
||||||
|
service.deleteMcpServer(serverId),
|
||||||
|
);
|
||||||
|
ipcMain.handle(ipcChannels.saveLspProfile, (_event, input: SaveLspProfileInput) =>
|
||||||
|
service.saveLspProfile(input.profile),
|
||||||
|
);
|
||||||
|
ipcMain.handle(ipcChannels.deleteLspProfile, (_event, profileId: string) =>
|
||||||
|
service.deleteLspProfile(profileId),
|
||||||
|
);
|
||||||
|
ipcMain.handle(ipcChannels.updateSessionTooling, (_event, input: UpdateSessionToolingInput) =>
|
||||||
|
service.updateSessionTooling(
|
||||||
|
input.sessionId,
|
||||||
|
input.enabledMcpServerIds,
|
||||||
|
input.enabledLspProfileIds,
|
||||||
|
),
|
||||||
|
);
|
||||||
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
|
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
|
||||||
service.createSession(input.projectId, input.patternId),
|
service.createSession(input.projectId, input.patternId),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises';
|
|||||||
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import { mergeScratchpadProject } from '@shared/domain/project';
|
import { mergeScratchpadProject } from '@shared/domain/project';
|
||||||
|
import { normalizeSessionToolingSelection, normalizeWorkspaceSettings } from '@shared/domain/tooling';
|
||||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||||
import { nowIso } from '@shared/utils/ids';
|
import { nowIso } from '@shared/utils/ids';
|
||||||
|
|
||||||
@@ -59,7 +60,11 @@ export class WorkspaceRepository {
|
|||||||
...stored,
|
...stored,
|
||||||
patterns: mergePatterns(stored.patterns ?? []),
|
patterns: mergePatterns(stored.patterns ?? []),
|
||||||
projects,
|
projects,
|
||||||
sessions: stored.sessions ?? [],
|
sessions: (stored.sessions ?? []).map((session) => ({
|
||||||
|
...session,
|
||||||
|
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||||
|
})),
|
||||||
|
settings: normalizeWorkspaceSettings(stored.settings),
|
||||||
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
|
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
|
||||||
? stored.selectedProjectId
|
? stored.selectedProjectId
|
||||||
: projects[0]?.id,
|
: projects[0]?.id,
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ const api: ElectronApi = {
|
|||||||
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
|
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
|
||||||
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
||||||
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
||||||
|
saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input),
|
||||||
|
deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId),
|
||||||
|
saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input),
|
||||||
|
deleteLspProfile: (profileId) => ipcRenderer.invoke(ipcChannels.deleteLspProfile, profileId),
|
||||||
|
updateSessionTooling: (input) => ipcRenderer.invoke(ipcChannels.updateSessionTooling, input),
|
||||||
createSession: (input) => ipcRenderer.invoke(ipcChannels.createSession, input),
|
createSession: (input) => ipcRenderer.invoke(ipcChannels.createSession, input),
|
||||||
duplicateSession: (input) => ipcRenderer.invoke(ipcChannels.duplicateSession, input),
|
duplicateSession: (input) => ipcRenderer.invoke(ipcChannels.duplicateSession, input),
|
||||||
renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input),
|
renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input),
|
||||||
|
|||||||
+66
-12
@@ -24,6 +24,7 @@ import {
|
|||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import { isScratchpadProject } from '@shared/domain/project';
|
import { isScratchpadProject } from '@shared/domain/project';
|
||||||
import { applyScratchpadSessionConfig } from '@shared/domain/session';
|
import { applyScratchpadSessionConfig } from '@shared/domain/session';
|
||||||
|
import type { LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
import { createId, nowIso } from '@shared/utils/ids';
|
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() {
|
export default function App() {
|
||||||
const api = getElectronApi();
|
const api = getElectronApi();
|
||||||
const [workspace, setWorkspace] = useState<WorkspaceState>();
|
const [workspace, setWorkspace] = useState<WorkspaceState>();
|
||||||
@@ -195,7 +224,17 @@ export default function App() {
|
|||||||
detailPanel = (
|
detailPanel = (
|
||||||
<ActivityPanel
|
<ActivityPanel
|
||||||
activity={activityForSession}
|
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}
|
pattern={patternForSession}
|
||||||
|
projectIsScratchpad={isScratchpadProject(projectForSession)}
|
||||||
session={selectedSession}
|
session={selectedSession}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -216,24 +255,39 @@ export default function App() {
|
|||||||
availableModels={availableModels}
|
availableModels={availableModels}
|
||||||
isRefreshingCapabilities={isRefreshingCapabilities}
|
isRefreshingCapabilities={isRefreshingCapabilities}
|
||||||
onClose={() => setShowSettings(false)}
|
onClose={() => setShowSettings(false)}
|
||||||
onDeletePattern={async (id) => {
|
onDeleteLspProfile={async (id) => {
|
||||||
await api.deletePattern(id);
|
await api.deleteLspProfile(id);
|
||||||
}}
|
}}
|
||||||
onNewPattern={() => {
|
onDeleteMcpServer={async (id) => {
|
||||||
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
|
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(
|
return createDraftPattern(
|
||||||
defaultModel?.id ?? 'gpt-5.4',
|
defaultModel?.id ?? 'gpt-5.4',
|
||||||
resolveReasoningEffort(defaultModel, 'high'),
|
resolveReasoningEffort(defaultModel, 'high'),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onRefreshCapabilities={refreshCapabilities}
|
onRefreshCapabilities={refreshCapabilities}
|
||||||
onSavePattern={async (pattern) => {
|
onSaveLspProfile={async (profile) => {
|
||||||
await api.savePattern({ pattern });
|
await api.saveLspProfile({ profile });
|
||||||
}}
|
}}
|
||||||
patterns={workspace.patterns}
|
onSaveMcpServer={async (server) => {
|
||||||
sidecarCapabilities={sidecarCapabilities}
|
await api.saveMcpServer({ server });
|
||||||
/>
|
}}
|
||||||
|
onSavePattern={async (pattern) => {
|
||||||
|
await api.savePattern({ pattern });
|
||||||
|
}}
|
||||||
|
patterns={workspace.patterns}
|
||||||
|
sidecarCapabilities={sidecarCapabilities}
|
||||||
|
toolingSettings={workspace.settings.tooling}
|
||||||
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ import {
|
|||||||
} from '@renderer/lib/sessionActivity';
|
} from '@renderer/lib/sessionActivity';
|
||||||
import { inferProvider } from '@shared/domain/models';
|
import { inferProvider } from '@shared/domain/models';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
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';
|
import { ProviderIcon } from './ProviderIcons';
|
||||||
|
|
||||||
function formatModel(model: string): string {
|
function formatModel(model: string): string {
|
||||||
@@ -30,17 +38,31 @@ function formatEffort(effort: string | undefined): string | undefined {
|
|||||||
|
|
||||||
interface ActivityPanelProps {
|
interface ActivityPanelProps {
|
||||||
activity?: SessionActivityState;
|
activity?: SessionActivityState;
|
||||||
|
lspProfiles: LspProfileDefinition[];
|
||||||
|
mcpServers: McpServerDefinition[];
|
||||||
|
onUpdateSessionTooling: (selection: SessionToolingSelection) => void;
|
||||||
pattern: PatternDefinition;
|
pattern: PatternDefinition;
|
||||||
|
projectIsScratchpad: boolean;
|
||||||
session: SessionRecord;
|
session: SessionRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityPanel({ activity, pattern, session }: ActivityPanelProps) {
|
export function ActivityPanel({
|
||||||
|
activity,
|
||||||
|
lspProfiles,
|
||||||
|
mcpServers,
|
||||||
|
onUpdateSessionTooling,
|
||||||
|
pattern,
|
||||||
|
projectIsScratchpad,
|
||||||
|
session,
|
||||||
|
}: ActivityPanelProps) {
|
||||||
const activityRows = useMemo(
|
const activityRows = useMemo(
|
||||||
() => buildAgentActivityRows(activity, pattern.agents),
|
() => buildAgentActivityRows(activity, pattern.agents),
|
||||||
[activity, pattern.agents],
|
[activity, pattern.agents],
|
||||||
);
|
);
|
||||||
|
const selection = useMemo(() => resolveSessionToolingSelection(session), [session]);
|
||||||
|
|
||||||
const isBusy = session.status === 'running';
|
const isBusy = session.status === 'running';
|
||||||
|
const toolsDisabled = isBusy || projectIsScratchpad;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
@@ -57,7 +79,71 @@ export function ActivityPanel({ activity, pattern, session }: ActivityPanelProps
|
|||||||
|
|
||||||
{/* Agent cards */}
|
{/* Agent cards */}
|
||||||
<div className="flex-1 overflow-y-auto px-3 py-3">
|
<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) => {
|
{activityRows.map((row, index) => {
|
||||||
const agent = pattern.agents[index];
|
const agent = pattern.agents[index];
|
||||||
const isActive = isAgentActivityActive(row.activity);
|
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];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { 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 { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
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 {
|
interface SettingsPanelProps {
|
||||||
availableModels: ReadonlyArray<ModelDefinition>;
|
availableModels: ReadonlyArray<ModelDefinition>;
|
||||||
patterns: PatternDefinition[];
|
patterns: PatternDefinition[];
|
||||||
sidecarCapabilities?: SidecarCapabilities;
|
sidecarCapabilities?: SidecarCapabilities;
|
||||||
|
toolingSettings: WorkspaceToolingSettings;
|
||||||
isRefreshingCapabilities: boolean;
|
isRefreshingCapabilities: boolean;
|
||||||
onRefreshCapabilities: () => void;
|
onRefreshCapabilities: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
|
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
|
||||||
onDeletePattern: (patternId: string) => Promise<void>;
|
onDeletePattern: (patternId: string) => Promise<void>;
|
||||||
onNewPattern: () => PatternDefinition;
|
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 {
|
interface NavItem {
|
||||||
id: SettingsSection;
|
id: SettingsSection;
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ReactNode;
|
icon: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavGroup {
|
interface NavGroup {
|
||||||
@@ -45,6 +62,13 @@ const navGroups: NavGroup[] = [
|
|||||||
{ id: 'patterns', label: 'Patterns', icon: <Workflow className="size-3.5" /> },
|
{ 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) {
|
function modeBadgeClasses(pattern: PatternDefinition) {
|
||||||
@@ -56,17 +80,25 @@ export function SettingsPanel({
|
|||||||
availableModels,
|
availableModels,
|
||||||
patterns,
|
patterns,
|
||||||
sidecarCapabilities,
|
sidecarCapabilities,
|
||||||
|
toolingSettings,
|
||||||
isRefreshingCapabilities,
|
isRefreshingCapabilities,
|
||||||
onRefreshCapabilities,
|
onRefreshCapabilities,
|
||||||
onClose,
|
onClose,
|
||||||
onSavePattern,
|
onSavePattern,
|
||||||
onDeletePattern,
|
onDeletePattern,
|
||||||
onNewPattern,
|
onNewPattern,
|
||||||
|
onSaveMcpServer,
|
||||||
|
onDeleteMcpServer,
|
||||||
|
onNewMcpServer,
|
||||||
|
onSaveLspProfile,
|
||||||
|
onDeleteLspProfile,
|
||||||
|
onNewLspProfile,
|
||||||
}: SettingsPanelProps) {
|
}: SettingsPanelProps) {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>('connection');
|
const [activeSection, setActiveSection] = useState<SettingsSection>('connection');
|
||||||
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
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) {
|
if (editingPattern) {
|
||||||
const isBuiltin = editingPattern.id.startsWith('pattern-');
|
const isBuiltin = editingPattern.id.startsWith('pattern-');
|
||||||
return (
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
<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">
|
<div className="flex items-center gap-3 border-b border-[var(--color-border)] px-5 pb-3 pt-12">
|
||||||
<button
|
<button
|
||||||
className="flex size-8 items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
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>
|
<h2 className="text-sm font-semibold text-zinc-100">Settings</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Two-column layout */}
|
|
||||||
<div className="flex min-h-0 flex-1">
|
<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">
|
<nav className="w-52 shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface-1)] p-3">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{navGroups.map((group) => (
|
{navGroups.map((group) => (
|
||||||
@@ -143,7 +222,6 @@ export function SettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Content area */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="mx-auto max-w-2xl px-8 py-6">
|
<div className="mx-auto max-w-2xl px-8 py-6">
|
||||||
{activeSection === 'connection' && (
|
{activeSection === 'connection' && (
|
||||||
@@ -156,11 +234,25 @@ export function SettingsPanel({
|
|||||||
)}
|
)}
|
||||||
{activeSection === 'patterns' && (
|
{activeSection === 'patterns' && (
|
||||||
<PatternsSection
|
<PatternsSection
|
||||||
onEditPattern={(p) => setEditingPattern(structuredClone(p))}
|
onEditPattern={(pattern) => setEditingPattern(structuredClone(pattern))}
|
||||||
onNewPattern={() => setEditingPattern(onNewPattern())}
|
onNewPattern={() => setEditingPattern(onNewPattern())}
|
||||||
patterns={patterns}
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,8 +260,6 @@ export function SettingsPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Section components ---------- */
|
|
||||||
|
|
||||||
function ConnectionSection({
|
function ConnectionSection({
|
||||||
connection,
|
connection,
|
||||||
modelCount,
|
modelCount,
|
||||||
@@ -212,22 +302,12 @@ function PatternsSection({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<SectionHeader
|
||||||
<div>
|
description="Define reusable agent configurations for your sessions"
|
||||||
<h3 className="text-sm font-semibold text-zinc-200">Orchestration Patterns</h3>
|
title="Orchestration Patterns"
|
||||||
<p className="mt-0.5 text-[12px] text-zinc-500">
|
>
|
||||||
Define reusable agent configurations for your sessions
|
<SectionAction label="New Pattern" onClick={onNewPattern} />
|
||||||
</p>
|
</SectionHeader>
|
||||||
</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>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{patterns.map((pattern) => (
|
{patterns.map((pattern) => (
|
||||||
@@ -258,3 +338,587 @@ function PatternsSection({
|
|||||||
</div>
|
</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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export const ipcChannels = {
|
|||||||
savePattern: 'patterns:save',
|
savePattern: 'patterns:save',
|
||||||
deletePattern: 'patterns:delete',
|
deletePattern: 'patterns:delete',
|
||||||
setPatternFavorite: 'patterns:set-favorite',
|
setPatternFavorite: 'patterns:set-favorite',
|
||||||
|
saveMcpServer: 'tooling:mcp:save',
|
||||||
|
deleteMcpServer: 'tooling:mcp:delete',
|
||||||
|
saveLspProfile: 'tooling:lsp:save',
|
||||||
|
deleteLspProfile: 'tooling:lsp:delete',
|
||||||
|
updateSessionTooling: 'sessions:update-tooling',
|
||||||
createSession: 'sessions:create',
|
createSession: 'sessions:create',
|
||||||
duplicateSession: 'sessions:duplicate',
|
duplicateSession: 'sessions:duplicate',
|
||||||
renameSession: 'sessions:rename',
|
renameSession: 'sessions:rename',
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'
|
|||||||
import type { ProjectRecord } from '@shared/domain/project';
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary';
|
import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary';
|
||||||
import type { SessionEventRecord } from '@shared/domain/event';
|
import type { SessionEventRecord } from '@shared/domain/event';
|
||||||
|
import type {
|
||||||
|
LspProfileDefinition,
|
||||||
|
McpServerDefinition,
|
||||||
|
SessionToolingSelection,
|
||||||
|
} from '@shared/domain/tooling';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
|
||||||
export interface CreateSessionInput {
|
export interface CreateSessionInput {
|
||||||
@@ -49,6 +54,18 @@ export interface SetPatternFavoriteInput {
|
|||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SaveMcpServerInput {
|
||||||
|
server: McpServerDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveLspProfileInput {
|
||||||
|
profile: LspProfileDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSessionToolingInput extends SessionToolingSelection {
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronApi {
|
export interface ElectronApi {
|
||||||
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
|
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
|
||||||
refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
|
refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
|
||||||
@@ -58,6 +75,11 @@ export interface ElectronApi {
|
|||||||
refreshProjectGitContext(projectId?: string): Promise<WorkspaceState>;
|
refreshProjectGitContext(projectId?: string): Promise<WorkspaceState>;
|
||||||
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
||||||
deletePattern(patternId: string): Promise<WorkspaceState>;
|
deletePattern(patternId: string): Promise<WorkspaceState>;
|
||||||
|
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
||||||
|
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
||||||
|
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
||||||
|
deleteLspProfile(profileId: string): Promise<WorkspaceState>;
|
||||||
|
updateSessionTooling(input: UpdateSessionToolingInput): Promise<WorkspaceState>;
|
||||||
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
||||||
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
|
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
|
||||||
renameSession(input: RenameSessionInput): Promise<WorkspaceState>;
|
renameSession(input: RenameSessionInput): Promise<WorkspaceState>;
|
||||||
|
|||||||
@@ -73,10 +73,47 @@ export interface RunTurnCommand {
|
|||||||
workspaceKind?: 'project' | 'scratchpad';
|
workspaceKind?: 'project' | 'scratchpad';
|
||||||
pattern: PatternDefinition;
|
pattern: PatternDefinition;
|
||||||
messages: ChatMessageRecord[];
|
messages: ChatMessageRecord[];
|
||||||
|
tooling?: RunTurnToolingConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SidecarCommand = DescribeCapabilitiesCommand | ValidatePatternCommand | RunTurnCommand;
|
export type SidecarCommand = DescribeCapabilitiesCommand | ValidatePatternCommand | RunTurnCommand;
|
||||||
|
|
||||||
|
export interface RunTurnLocalMcpServerConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
transport: 'local';
|
||||||
|
tools: string[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunTurnRemoteMcpServerConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
transport: 'http' | 'sse';
|
||||||
|
tools: string[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RunTurnMcpServerConfig = RunTurnLocalMcpServerConfig | RunTurnRemoteMcpServerConfig;
|
||||||
|
|
||||||
|
export interface RunTurnLspProfileConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
languageId: string;
|
||||||
|
fileExtensions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunTurnToolingConfig {
|
||||||
|
mcpServers: RunTurnMcpServerConfig[];
|
||||||
|
lspProfiles: RunTurnLspProfileConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CapabilitiesEvent {
|
export interface CapabilitiesEvent {
|
||||||
type: 'capabilities';
|
type: 'capabilities';
|
||||||
requestId: string;
|
requestId: string;
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { buildSessionTitle, type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
|
import { buildSessionTitle, type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
|
||||||
|
import {
|
||||||
|
createSessionToolingSelection,
|
||||||
|
normalizeSessionToolingSelection,
|
||||||
|
type SessionToolingSelection,
|
||||||
|
} from '@shared/domain/tooling';
|
||||||
|
|
||||||
export type ChatRole = 'system' | 'user' | 'assistant';
|
export type ChatRole = 'system' | 'user' | 'assistant';
|
||||||
export type SessionStatus = 'idle' | 'running' | 'error';
|
export type SessionStatus = 'idle' | 'running' | 'error';
|
||||||
@@ -32,6 +37,7 @@ export interface SessionRecord {
|
|||||||
messages: ChatMessageRecord[];
|
messages: ChatMessageRecord[];
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
scratchpadConfig?: ScratchpadSessionConfig;
|
scratchpadConfig?: ScratchpadSessionConfig;
|
||||||
|
tooling?: SessionToolingSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveSessionTitle(
|
export function resolveSessionTitle(
|
||||||
@@ -60,6 +66,12 @@ export function createScratchpadSessionConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveSessionToolingSelection(
|
||||||
|
session: Pick<SessionRecord, 'tooling'>,
|
||||||
|
): SessionToolingSelection {
|
||||||
|
return normalizeSessionToolingSelection(session.tooling ?? createSessionToolingSelection());
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveScratchpadSessionConfig(
|
export function resolveScratchpadSessionConfig(
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
pattern: PatternDefinition,
|
pattern: PatternDefinition,
|
||||||
|
|||||||
@@ -175,6 +175,12 @@ export function duplicateSessionRecord(
|
|||||||
isArchived: false,
|
isArchived: false,
|
||||||
lastError: undefined,
|
lastError: undefined,
|
||||||
scratchpadConfig: session.scratchpadConfig ? { ...session.scratchpadConfig } : undefined,
|
scratchpadConfig: session.scratchpadConfig ? { ...session.scratchpadConfig } : undefined,
|
||||||
|
tooling: session.tooling
|
||||||
|
? {
|
||||||
|
enabledMcpServerIds: [...session.tooling.enabledMcpServerIds],
|
||||||
|
enabledLspProfileIds: [...session.tooling.enabledLspProfileIds],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
messages: session.messages.map((message): ChatMessageRecord => ({
|
messages: session.messages.map((message): ChatMessageRecord => ({
|
||||||
...message,
|
...message,
|
||||||
pending: false,
|
pending: false,
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
export type McpServerTransport = 'local' | 'http' | 'sse';
|
||||||
|
|
||||||
|
export interface BaseMcpServerDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
transport: McpServerTransport;
|
||||||
|
tools: string[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalMcpServerDefinition extends BaseMcpServerDefinition {
|
||||||
|
transport: 'local';
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteMcpServerDefinition extends BaseMcpServerDefinition {
|
||||||
|
transport: 'http' | 'sse';
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type McpServerDefinition = LocalMcpServerDefinition | RemoteMcpServerDefinition;
|
||||||
|
|
||||||
|
export interface LspProfileDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
languageId: string;
|
||||||
|
fileExtensions: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceToolingSettings {
|
||||||
|
mcpServers: McpServerDefinition[];
|
||||||
|
lspProfiles: LspProfileDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceSettings {
|
||||||
|
tooling: WorkspaceToolingSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionToolingSelection {
|
||||||
|
enabledMcpServerIds: string[];
|
||||||
|
enabledLspProfileIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkspaceSettings(): WorkspaceSettings {
|
||||||
|
return {
|
||||||
|
tooling: {
|
||||||
|
mcpServers: [],
|
||||||
|
lspProfiles: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionToolingSelection(): SessionToolingSelection {
|
||||||
|
return {
|
||||||
|
enabledMcpServerIds: [],
|
||||||
|
enabledLspProfileIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>): WorkspaceSettings {
|
||||||
|
return {
|
||||||
|
tooling: {
|
||||||
|
mcpServers: (settings?.tooling?.mcpServers ?? []).map(normalizeMcpServerDefinition),
|
||||||
|
lspProfiles: (settings?.tooling?.lspProfiles ?? []).map(normalizeLspProfileDefinition),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSessionToolingSelection(
|
||||||
|
selection?: Partial<SessionToolingSelection>,
|
||||||
|
): SessionToolingSelection {
|
||||||
|
return {
|
||||||
|
enabledMcpServerIds: normalizeStringArray(selection?.enabledMcpServerIds),
|
||||||
|
enabledLspProfileIds: normalizeStringArray(selection?.enabledLspProfileIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMcpServerDefinition(server: McpServerDefinition): string | undefined {
|
||||||
|
if (!server.name.trim()) {
|
||||||
|
return 'MCP server name is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.transport === 'local') {
|
||||||
|
if (!server.command.trim()) {
|
||||||
|
return `MCP server "${server.name}" needs a command.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!server.url.trim()) {
|
||||||
|
return `MCP server "${server.name}" needs a URL.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(server.url);
|
||||||
|
} catch {
|
||||||
|
return `MCP server "${server.name}" has an invalid URL.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateLspProfileDefinition(profile: LspProfileDefinition): string | undefined {
|
||||||
|
if (!profile.name.trim()) {
|
||||||
|
return 'LSP profile name is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile.command.trim()) {
|
||||||
|
return `LSP profile "${profile.name}" needs a command.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile.languageId.trim()) {
|
||||||
|
return `LSP profile "${profile.name}" needs a language ID.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeStringArray(profile.fileExtensions).length === 0) {
|
||||||
|
return `LSP profile "${profile.name}" needs at least one file extension.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMcpServerDefinition(server: McpServerDefinition): McpServerDefinition {
|
||||||
|
const base = {
|
||||||
|
...server,
|
||||||
|
name: server.name.trim(),
|
||||||
|
tools: normalizeStringArray(server.tools),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (server.transport === 'local') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
transport: 'local',
|
||||||
|
command: server.command.trim(),
|
||||||
|
args: normalizeStringArray(server.args),
|
||||||
|
cwd: server.cwd?.trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
transport: server.transport,
|
||||||
|
url: server.url.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLspProfileDefinition(profile: LspProfileDefinition): LspProfileDefinition {
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
name: profile.name.trim(),
|
||||||
|
command: profile.command.trim(),
|
||||||
|
args: normalizeStringArray(profile.args),
|
||||||
|
languageId: profile.languageId.trim(),
|
||||||
|
fileExtensions: normalizeFileExtensions(profile.fileExtensions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFileExtensions(fileExtensions: string[]): string[] {
|
||||||
|
return normalizeStringArray(fileExtensions).map((value) => (value.startsWith('.') ? value : `.${value}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringArray(values?: ReadonlyArray<string>): string[] {
|
||||||
|
if (!values) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ import type { PatternDefinition } from '@shared/domain/pattern';
|
|||||||
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
||||||
import type { ProjectRecord } from '@shared/domain/project';
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
|
import { createWorkspaceSettings, type WorkspaceSettings } from '@shared/domain/tooling';
|
||||||
import { nowIso } from '@shared/utils/ids';
|
import { nowIso } from '@shared/utils/ids';
|
||||||
|
|
||||||
export interface WorkspaceState {
|
export interface WorkspaceState {
|
||||||
projects: ProjectRecord[];
|
projects: ProjectRecord[];
|
||||||
patterns: PatternDefinition[];
|
patterns: PatternDefinition[];
|
||||||
sessions: SessionRecord[];
|
sessions: SessionRecord[];
|
||||||
|
settings: WorkspaceSettings;
|
||||||
selectedProjectId?: string;
|
selectedProjectId?: string;
|
||||||
selectedPatternId?: string;
|
selectedPatternId?: string;
|
||||||
selectedSessionId?: string;
|
selectedSessionId?: string;
|
||||||
@@ -20,6 +22,7 @@ export function createWorkspaceSeed(): WorkspaceState {
|
|||||||
projects: [],
|
projects: [],
|
||||||
patterns: createBuiltinPatterns(timestamp),
|
patterns: createBuiltinPatterns(timestamp),
|
||||||
sessions: [],
|
sessions: [],
|
||||||
|
settings: createWorkspaceSettings(),
|
||||||
lastUpdatedAt: timestamp,
|
lastUpdatedAt: timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|||||||
|
|
||||||
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
|
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
|
||||||
import type { SessionEventRecord } from '@shared/domain/event';
|
import type { SessionEventRecord } from '@shared/domain/event';
|
||||||
|
import { createWorkspaceSettings } from '@shared/domain/tooling';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
|
||||||
describe('session workspace helpers', () => {
|
describe('session workspace helpers', () => {
|
||||||
@@ -9,6 +10,7 @@ describe('session workspace helpers', () => {
|
|||||||
return {
|
return {
|
||||||
projects: [],
|
projects: [],
|
||||||
patterns: [],
|
patterns: [],
|
||||||
|
settings: createWorkspaceSettings(),
|
||||||
sessions: [
|
sessions: [
|
||||||
{
|
{
|
||||||
id: 'session-1',
|
id: 'session-1',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { PatternDefinition } from '@shared/domain/pattern';
|
|||||||
import {
|
import {
|
||||||
applyScratchpadSessionConfig,
|
applyScratchpadSessionConfig,
|
||||||
createScratchpadSessionConfig,
|
createScratchpadSessionConfig,
|
||||||
|
resolveSessionToolingSelection,
|
||||||
resolveSessionTitle,
|
resolveSessionTitle,
|
||||||
resolveScratchpadSessionConfig,
|
resolveScratchpadSessionConfig,
|
||||||
type SessionRecord,
|
type SessionRecord,
|
||||||
@@ -118,3 +119,26 @@ describe('session title helpers', () => {
|
|||||||
).toBe('Release readiness review');
|
).toBe('Release readiness review');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('session tooling helpers', () => {
|
||||||
|
test('normalizes missing or duplicated tooling selections into stable arrays', () => {
|
||||||
|
expect(resolveSessionToolingSelection(createSession())).toEqual({
|
||||||
|
enabledMcpServerIds: [],
|
||||||
|
enabledLspProfileIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveSessionToolingSelection(
|
||||||
|
createSession({
|
||||||
|
tooling: {
|
||||||
|
enabledMcpServerIds: ['mcp-git', ' mcp-git ', ''],
|
||||||
|
enabledLspProfileIds: ['ts', ' ts ', ''],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
enabledMcpServerIds: ['mcp-git'],
|
||||||
|
enabledLspProfileIds: ['ts'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { querySessions, duplicateSessionRecord, renameSessionRecord } from '@sha
|
|||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import type { ProjectRecord } from '@shared/domain/project';
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
|
import { createWorkspaceSettings } from '@shared/domain/tooling';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
|
||||||
function createPattern(overrides?: Partial<PatternDefinition>): PatternDefinition {
|
function createPattern(overrides?: Partial<PatternDefinition>): PatternDefinition {
|
||||||
@@ -66,6 +67,7 @@ function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
|
|||||||
return {
|
return {
|
||||||
projects: [createProject(), createProject({ id: 'project-scratchpad', name: 'Scratchpad', path: 'C:\\scratchpad' })],
|
projects: [createProject(), createProject({ id: 'project-scratchpad', name: 'Scratchpad', path: 'C:\\scratchpad' })],
|
||||||
patterns: [createPattern(), createPattern({ id: 'pattern-single-chat', name: '1-on-1 Copilot Chat', mode: 'single' })],
|
patterns: [createPattern(), createPattern({ id: 'pattern-single-chat', name: '1-on-1 Copilot Chat', mode: 'single' })],
|
||||||
|
settings: createWorkspaceSettings(),
|
||||||
sessions: [
|
sessions: [
|
||||||
createSession(),
|
createSession(),
|
||||||
createSession({
|
createSession({
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeWorkspaceSettings,
|
||||||
|
validateLspProfileDefinition,
|
||||||
|
validateMcpServerDefinition,
|
||||||
|
type LspProfileDefinition,
|
||||||
|
type McpServerDefinition,
|
||||||
|
} from '@shared/domain/tooling';
|
||||||
|
|
||||||
|
const TIMESTAMP = '2026-03-23T00:00:00.000Z';
|
||||||
|
|
||||||
|
describe('tooling settings helpers', () => {
|
||||||
|
test('normalizes persisted MCP and LSP definitions into trimmed stable settings', () => {
|
||||||
|
const workspaceSettings = normalizeWorkspaceSettings({
|
||||||
|
tooling: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'mcp-local',
|
||||||
|
name: ' Local MCP ',
|
||||||
|
transport: 'local',
|
||||||
|
command: ' node ',
|
||||||
|
args: [' --stdio ', ' --stdio ', ''],
|
||||||
|
cwd: ' C:\\workspace\\repo ',
|
||||||
|
tools: [' git.status ', '', ' git.status '],
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
updatedAt: TIMESTAMP,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcp-remote',
|
||||||
|
name: ' Remote MCP ',
|
||||||
|
transport: 'http',
|
||||||
|
url: ' https://example.com/mcp ',
|
||||||
|
tools: [],
|
||||||
|
timeoutMs: 1000,
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
updatedAt: TIMESTAMP,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
lspProfiles: [
|
||||||
|
{
|
||||||
|
id: 'lsp-ts',
|
||||||
|
name: ' TypeScript ',
|
||||||
|
command: ' typescript-language-server ',
|
||||||
|
args: [' --stdio ', ' --stdio ', ''],
|
||||||
|
languageId: ' typescript ',
|
||||||
|
fileExtensions: ['ts', ' .tsx ', ''],
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
updatedAt: TIMESTAMP,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workspaceSettings).toEqual({
|
||||||
|
tooling: {
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: 'mcp-local',
|
||||||
|
name: 'Local MCP',
|
||||||
|
transport: 'local',
|
||||||
|
command: 'node',
|
||||||
|
args: ['--stdio'],
|
||||||
|
cwd: 'C:\\workspace\\repo',
|
||||||
|
tools: ['git.status'],
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
updatedAt: TIMESTAMP,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcp-remote',
|
||||||
|
name: 'Remote MCP',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
tools: [],
|
||||||
|
timeoutMs: 1000,
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
updatedAt: TIMESTAMP,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
lspProfiles: [
|
||||||
|
{
|
||||||
|
id: 'lsp-ts',
|
||||||
|
name: 'TypeScript',
|
||||||
|
command: 'typescript-language-server',
|
||||||
|
args: ['--stdio'],
|
||||||
|
languageId: 'typescript',
|
||||||
|
fileExtensions: ['.ts', '.tsx'],
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
updatedAt: TIMESTAMP,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates required MCP transport settings', () => {
|
||||||
|
const localServer: McpServerDefinition = {
|
||||||
|
id: 'mcp-local',
|
||||||
|
name: 'Local MCP',
|
||||||
|
transport: 'local',
|
||||||
|
command: '',
|
||||||
|
args: [],
|
||||||
|
tools: ['*'],
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
updatedAt: TIMESTAMP,
|
||||||
|
};
|
||||||
|
const remoteServer: McpServerDefinition = {
|
||||||
|
id: 'mcp-remote',
|
||||||
|
name: 'Remote MCP',
|
||||||
|
transport: 'sse',
|
||||||
|
url: 'not-a-url',
|
||||||
|
tools: ['*'],
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
updatedAt: TIMESTAMP,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateMcpServerDefinition(localServer)).toBe('MCP server "Local MCP" needs a command.');
|
||||||
|
expect(validateMcpServerDefinition(remoteServer)).toBe('MCP server "Remote MCP" has an invalid URL.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validates required LSP profile settings', () => {
|
||||||
|
const profile: LspProfileDefinition = {
|
||||||
|
id: 'lsp-empty',
|
||||||
|
name: 'TypeScript',
|
||||||
|
command: '',
|
||||||
|
args: [],
|
||||||
|
languageId: 'typescript',
|
||||||
|
fileExtensions: [],
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
updatedAt: TIMESTAMP,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateLspProfileDefinition(profile)).toBe('LSP profile "TypeScript" needs a command.');
|
||||||
|
expect(
|
||||||
|
validateLspProfileDefinition({
|
||||||
|
...profile,
|
||||||
|
command: 'typescript-language-server',
|
||||||
|
}),
|
||||||
|
).toBe('LSP profile "TypeScript" needs at least one file extension.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,12 @@ describe('workspace seed', () => {
|
|||||||
|
|
||||||
expect(workspace.projects).toEqual([]);
|
expect(workspace.projects).toEqual([]);
|
||||||
expect(workspace.sessions).toEqual([]);
|
expect(workspace.sessions).toEqual([]);
|
||||||
|
expect(workspace.settings).toEqual({
|
||||||
|
tooling: {
|
||||||
|
mcpServers: [],
|
||||||
|
lspProfiles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
expect(workspace.selectedProjectId).toBeUndefined();
|
expect(workspace.selectedProjectId).toBeUndefined();
|
||||||
expect(workspace.selectedPatternId).toBeUndefined();
|
expect(workspace.selectedPatternId).toBeUndefined();
|
||||||
expect(workspace.selectedSessionId).toBeUndefined();
|
expect(workspace.selectedSessionId).toBeUndefined();
|
||||||
|
|||||||
Reference in New Issue
Block a user