feat: add VS Code extension scaffold and MCP server compiled module

- Add vscode-extension/ with full MVP scaffold:
  - GsdClient: spawns gsd --mode rpc, JSON line communication
  - @gsd Chat participant: forward messages to agent, stream responses
  - Sidebar panel: connection status, model info, start/stop controls
  - Command palette: gsd.start, gsd.stop, gsd.newSession, gsd.sendMessage
  - Extension config: gsd.binaryPath setting
- Add compiled MCP server module at src/mcp-server.ts for tsc output
- Add MCP server tests verifying module import and instantiation
This commit is contained in:
Jeremy McSpadden 2026-03-16 14:07:21 -05:00
parent 580823c154
commit 48feced87d
9 changed files with 945 additions and 17 deletions

View file

@ -1,4 +1,8 @@
interface McpTool {
/**
* Minimal tool interface matching GSD's AgentTool shape.
* Avoids a direct dependency on @gsd/pi-agent-core from this compiled module.
*/
export interface McpToolDef {
name: string
description: string
parameters: Record<string, unknown>
@ -17,8 +21,22 @@ interface McpTool {
// specifiers dynamically so tsc treats them as `any`.
const MCP_PKG = '@modelcontextprotocol/sdk'
/**
* Starts a native MCP (Model Context Protocol) server over stdin/stdout.
*
* This enables GSD's tools (read, write, edit, bash, grep, glob, ls, etc.)
* to be used by external AI clients such as Claude Desktop, VS Code Copilot,
* and any MCP-compatible host.
*
* The server registers all tools from the agent session's tool registry and
* maps MCP tools/list and tools/call requests to GSD tool definitions and
* execution, respectively.
*
* All MCP SDK imports are dynamic to avoid subpath export resolution issues
* with TypeScript's NodeNext module resolution.
*/
export async function startMcpServer(options: {
tools: McpTool[]
tools: McpToolDef[]
version?: string
}): Promise<void> {
const { tools, version = '0.0.0' } = options
@ -31,7 +49,8 @@ export async function startMcpServer(options: {
const StdioServerTransport = stdioMod.StdioServerTransport
const { ListToolsRequestSchema, CallToolRequestSchema } = typesMod
const toolMap = new Map<string, McpTool>()
// Build a lookup map for fast tool resolution on calls
const toolMap = new Map<string, McpToolDef>()
for (const tool of tools) {
toolMap.set(tool.name, tool)
}
@ -41,14 +60,16 @@ export async function startMcpServer(options: {
{ capabilities: { tools: {} } },
)
// tools/list — return every registered GSD tool with its JSON Schema parameters
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: tools.map((t: McpTool) => ({
tools: tools.map((t: McpToolDef) => ({
name: t.name,
description: t.description,
inputSchema: t.parameters,
})),
}))
// tools/call — execute the requested tool and return content blocks
server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
const { name, arguments: args } = request.params
const tool = toolMap.get(name)
@ -60,7 +81,14 @@ export async function startMcpServer(options: {
}
try {
const result = await tool.execute(`mcp-${Date.now()}`, args ?? {}, undefined, undefined)
const result = await tool.execute(
`mcp-${Date.now()}`,
args ?? {},
undefined, // no AbortSignal
undefined, // no onUpdate callback
)
// Convert AgentToolResult content blocks to MCP content format
const content = result.content.map((block: any) => {
if (block.type === 'text') return { type: 'text' as const, text: block.text ?? '' }
if (block.type === 'image') return { type: 'image' as const, data: block.data ?? '', mimeType: block.mimeType ?? 'image/png' }
@ -73,6 +101,7 @@ export async function startMcpServer(options: {
}
})
// Connect to stdin/stdout transport
const transport = new StdioServerTransport()
await server.connect(transport)
process.stderr.write(`[gsd] MCP server started (v${version})\n`)

View file

@ -1,15 +1,24 @@
// @ts-ignore — @modelcontextprotocol/sdk types may not be in extensions tsconfig
import { Server } from '@modelcontextprotocol/sdk/server'
// @ts-ignore
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio'
// @ts-ignore
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types'
/**
* MCP (Model Context Protocol) server for the GSD extension.
*
* This module provides the same MCP server functionality as src/mcp-server.ts
* but can be loaded via jiti in the extension runtime context. It enables
* GSD's tools to be used by external AI clients (Claude Desktop, VS Code
* Copilot, etc.) via the MCP standard protocol over stdin/stdout.
*/
interface McpTool {
name: string
description: string
parameters: Record<string, unknown>
execute(toolCallId: string, params: Record<string, unknown>, signal?: AbortSignal, onUpdate?: unknown): Promise<{ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> }>
execute(
toolCallId: string,
params: Record<string, unknown>,
signal?: AbortSignal,
onUpdate?: unknown,
): Promise<{
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>
}>
}
export async function startMcpServer(options: {
@ -18,6 +27,16 @@ export async function startMcpServer(options: {
}): Promise<void> {
const { tools, version = '0.0.0' } = options
// Dynamic imports — MCP SDK subpath exports use a "./*" wildcard pattern
// that cannot be statically resolved by all TypeScript configurations.
// @ts-ignore
const { Server } = await import('@modelcontextprotocol/sdk/server')
// @ts-ignore
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js')
// @ts-ignore
const sdkTypes = await import('@modelcontextprotocol/sdk/types')
const { ListToolsRequestSchema, CallToolRequestSchema } = sdkTypes
const toolMap = new Map<string, McpTool>()
for (const tool of tools) {
toolMap.set(tool.name, tool)
@ -28,9 +47,10 @@ export async function startMcpServer(options: {
{ capabilities: { tools: {} } },
)
// tools/list — return every registered GSD tool with its JSON Schema parameters
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: tools.map((t) => ({
tools: tools.map((t: McpTool) => ({
name: t.name,
description: t.description,
inputSchema: t.parameters,
@ -38,6 +58,7 @@ export async function startMcpServer(options: {
}
})
// tools/call — execute the requested tool and return content blocks
server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
const { name, arguments: args } = request.params
const tool = toolMap.get(name)
@ -56,15 +77,15 @@ export async function startMcpServer(options: {
undefined,
)
const content = result.content.map((block) => {
const content = result.content.map((block: any) => {
if (block.type === 'text') {
return { type: 'text' as const, text: block.text }
return { type: 'text' as const, text: block.text ?? '' }
}
if (block.type === 'image') {
return {
type: 'image' as const,
data: block.data,
mimeType: block.mimeType,
data: block.data ?? '',
mimeType: block.mimeType ?? 'image/png',
}
}
return { type: 'text' as const, text: JSON.stringify(block) }

View file

@ -0,0 +1,47 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const projectRoot = join(fileURLToPath(import.meta.url), '..', '..', '..')
test('mcp-server module imports without errors', async () => {
// Import from the compiled dist output to avoid subpath resolution issues
// that occur when the resolve-ts test hook rewrites .js -> .ts paths.
const distPath = join(projectRoot, 'dist', 'mcp-server.js')
const mod = await import(distPath)
assert.ok(mod, 'module should be importable')
assert.strictEqual(typeof mod.startMcpServer, 'function', 'startMcpServer should be a function')
})
test('startMcpServer accepts the correct argument shape', async () => {
const distPath = join(projectRoot, 'dist', 'mcp-server.js')
const { startMcpServer } = await import(distPath)
assert.strictEqual(typeof startMcpServer, 'function')
assert.strictEqual(startMcpServer.length, 1, 'startMcpServer should accept one argument')
})
test('startMcpServer can be called with mock tools', async () => {
const distPath = join(projectRoot, 'dist', 'mcp-server.js')
const { startMcpServer } = await import(distPath)
// Create a mock tool matching the McpToolDef interface
const mockTool = {
name: 'test_tool',
description: 'A test tool',
parameters: { type: 'object', properties: {} },
execute: async () => ({
content: [{ type: 'text', text: 'hello' }],
}),
}
// Verify the function can be called with the correct signature
// without throwing during argument validation. It will attempt to
// connect to stdin/stdout as an MCP transport, which won't work in
// a test environment, but the Server instance is created successfully.
assert.doesNotThrow(() => {
void startMcpServer({ tools: [mockTool], version: '0.0.0-test' })
.catch(() => { /* expected: no MCP client on stdin */ })
})
})

View file

@ -0,0 +1,85 @@
{
"name": "gsd-vscode",
"displayName": "GSD - Get Shit Done",
"description": "VS Code integration for the GSD coding agent",
"publisher": "gsd-build",
"version": "0.1.0",
"license": "MIT",
"engines": {
"vscode": "^1.95.0"
},
"categories": [
"AI",
"Chat"
],
"activationEvents": [
"onStartupFinished"
],
"main": "dist/extension.js",
"contributes": {
"commands": [
{
"command": "gsd.start",
"title": "GSD: Start Agent"
},
{
"command": "gsd.stop",
"title": "GSD: Stop Agent"
},
{
"command": "gsd.newSession",
"title": "GSD: New Session"
},
{
"command": "gsd.sendMessage",
"title": "GSD: Send Message"
}
],
"viewsContainers": {
"activitybar": [
{
"id": "gsd",
"title": "GSD",
"icon": "$(hubot)"
}
]
},
"views": {
"gsd": [
{
"type": "webview",
"id": "gsd-sidebar",
"name": "GSD Agent"
}
]
},
"chatParticipants": [
{
"id": "gsd.agent",
"name": "gsd",
"fullName": "GSD Agent",
"description": "Get Shit Done coding agent",
"isSticky": true
}
],
"configuration": {
"title": "GSD",
"properties": {
"gsd.binaryPath": {
"type": "string",
"default": "gsd",
"description": "Path to the GSD binary"
}
}
}
},
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"package": "vsce package"
},
"devDependencies": {
"@types/vscode": "^1.95.0",
"typescript": "^5.7.0"
}
}

View file

@ -0,0 +1,118 @@
import * as vscode from "vscode";
import type { AgentEvent, GsdClient } from "./gsd-client.js";
/**
* Registers the @gsd chat participant that forwards messages to the
* GSD RPC client and streams tool execution events back to the chat.
*/
export function registerChatParticipant(
context: vscode.ExtensionContext,
client: GsdClient,
): vscode.Disposable {
const participant = vscode.chat.createChatParticipant("gsd.agent", async (
request: vscode.ChatRequest,
_chatContext: vscode.ChatContext,
response: vscode.ChatResponseStream,
token: vscode.CancellationToken,
) => {
if (!client.isConnected) {
response.markdown("GSD agent is not running. Use the **GSD: Start Agent** command first.");
return;
}
const message = request.prompt;
if (!message.trim()) {
response.markdown("Please provide a message.");
return;
}
// Track streaming events while the prompt executes
let agentDone = false;
const eventHandler = (event: AgentEvent) => {
switch (event.type) {
case "agent_start":
response.progress("GSD is working...");
break;
case "tool_execution_start":
response.progress(`Running tool: ${event.toolName}`);
break;
case "tool_execution_end": {
const toolName = event.toolName as string;
const isError = event.isError as boolean;
if (isError) {
response.markdown(`\n**Tool \`${toolName}\` failed**\n`);
} else {
response.markdown(`\n*Tool \`${toolName}\` completed*\n`);
}
break;
}
case "message_start": {
const msg = event.message as Record<string, unknown>;
if (msg && msg.role === "assistant") {
// Assistant message starting, will be followed by updates
}
break;
}
case "message_update": {
const assistantEvent = event.assistantMessageEvent as Record<string, unknown> | undefined;
if (assistantEvent?.type === "text_delta") {
const delta = assistantEvent.delta as string | undefined;
if (delta) {
response.markdown(delta);
}
}
break;
}
case "agent_end":
agentDone = true;
break;
}
};
const subscription = client.onEvent(eventHandler);
// Handle cancellation
token.onCancellationRequested(() => {
client.abort().catch(() => {});
});
try {
await client.sendPrompt(message);
// Wait for agent_end or cancellation
await new Promise<void>((resolve) => {
if (agentDone) {
resolve();
return;
}
const checkDone = client.onEvent((evt) => {
if (evt.type === "agent_end") {
checkDone.dispose();
resolve();
}
});
token.onCancellationRequested(() => {
checkDone.dispose();
resolve();
});
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
response.markdown(`\n**Error:** ${errorMessage}\n`);
} finally {
subscription.dispose();
}
});
participant.iconPath = new vscode.ThemeIcon("hubot");
return participant;
}

View file

@ -0,0 +1,115 @@
import * as vscode from "vscode";
import { GsdClient } from "./gsd-client.js";
import { registerChatParticipant } from "./chat-participant.js";
import { GsdSidebarProvider } from "./sidebar.js";
let client: GsdClient | undefined;
let sidebarProvider: GsdSidebarProvider | undefined;
export function activate(context: vscode.ExtensionContext): void {
const config = vscode.workspace.getConfiguration("gsd");
const binaryPath = config.get<string>("binaryPath", "gsd");
const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd();
client = new GsdClient(binaryPath, cwd);
context.subscriptions.push(client);
// Log stderr to an output channel
const outputChannel = vscode.window.createOutputChannel("GSD Agent");
context.subscriptions.push(outputChannel);
client.onError((msg) => {
outputChannel.appendLine(`[stderr] ${msg}`);
});
client.onConnectionChange((connected) => {
if (connected) {
vscode.window.setStatusBarMessage("$(hubot) GSD connected", 3000);
} else {
vscode.window.setStatusBarMessage("$(hubot) GSD disconnected", 3000);
}
});
// -- Sidebar -----------------------------------------------------------
sidebarProvider = new GsdSidebarProvider(context.extensionUri, client);
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(
GsdSidebarProvider.viewId,
sidebarProvider,
),
);
// -- Chat participant ---------------------------------------------------
context.subscriptions.push(registerChatParticipant(context, client));
// -- Commands -----------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand("gsd.start", async () => {
try {
await client!.start();
sidebarProvider?.refresh();
vscode.window.showInformationMessage("GSD agent started.");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`Failed to start GSD: ${msg}`);
}
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("gsd.stop", async () => {
await client!.stop();
sidebarProvider?.refresh();
vscode.window.showInformationMessage("GSD agent stopped.");
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("gsd.newSession", async () => {
if (!client!.isConnected) {
vscode.window.showWarningMessage("GSD agent is not running.");
return;
}
try {
await client!.newSession();
sidebarProvider?.refresh();
vscode.window.showInformationMessage("New GSD session started.");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`Failed to start new session: ${msg}`);
}
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("gsd.sendMessage", async () => {
if (!client!.isConnected) {
vscode.window.showWarningMessage("GSD agent is not running.");
return;
}
const message = await vscode.window.showInputBox({
prompt: "Enter message for GSD",
placeHolder: "What should I do?",
});
if (!message) {
return;
}
try {
await client!.sendPrompt(message);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`Failed to send message: ${msg}`);
}
}),
);
}
export function deactivate(): void {
client?.dispose();
sidebarProvider?.dispose();
client = undefined;
sidebarProvider = undefined;
}

View file

@ -0,0 +1,287 @@
import { ChildProcess, spawn } from "node:child_process";
import * as vscode from "vscode";
/**
* Mirrors the RPC command/response protocol from the GSD agent.
* These types are intentionally kept minimal and self-contained so the
* extension has no dependency on the agent packages at runtime.
*/
export interface RpcSessionState {
model?: { provider: string; id: string; contextWindow?: number };
thinkingLevel: string;
isStreaming: boolean;
isCompacting: boolean;
steeringMode: "all" | "one-at-a-time";
followUpMode: "all" | "one-at-a-time";
sessionFile?: string;
sessionId: string;
sessionName?: string;
autoCompactionEnabled: boolean;
messageCount: number;
pendingMessageCount: number;
}
export interface ModelInfo {
provider: string;
id: string;
contextWindow?: number;
reasoning?: boolean;
}
export interface RpcResponse {
id?: string;
type: "response";
command: string;
success: boolean;
data?: unknown;
error?: string;
}
export interface AgentEvent {
type: string;
[key: string]: unknown;
}
type PendingRequest = {
resolve: (response: RpcResponse) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
/**
* Client that spawns `gsd --mode rpc` and communicates via JSON lines
* over stdin/stdout. Emits VS Code events for streaming responses.
*/
export class GsdClient implements vscode.Disposable {
private process: ChildProcess | null = null;
private pendingRequests = new Map<string, PendingRequest>();
private requestId = 0;
private buffer = "";
private restartCount = 0;
private readonly _onEvent = new vscode.EventEmitter<AgentEvent>();
readonly onEvent = this._onEvent.event;
private readonly _onConnectionChange = new vscode.EventEmitter<boolean>();
readonly onConnectionChange = this._onConnectionChange.event;
private readonly _onError = new vscode.EventEmitter<string>();
readonly onError = this._onError.event;
private disposables: vscode.Disposable[] = [];
constructor(
private readonly binaryPath: string,
private readonly cwd: string,
) {
this.disposables.push(this._onEvent, this._onConnectionChange, this._onError);
}
get isConnected(): boolean {
return this.process !== null && this.process.exitCode === null;
}
/**
* Spawn the GSD agent in RPC mode.
*/
async start(): Promise<void> {
if (this.process) {
return;
}
this.process = spawn(this.binaryPath, ["--mode", "rpc", "--no-session"], {
cwd: this.cwd,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env },
});
this.buffer = "";
this.process.stdout?.on("data", (chunk: Buffer) => {
this.buffer += chunk.toString("utf8");
this.drainBuffer();
});
this.process.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString("utf8").trim();
if (text) {
this._onError.fire(text);
}
});
this.process.on("exit", (code, signal) => {
this.process = null;
this.rejectAllPending(`GSD process exited (code=${code}, signal=${signal})`);
this._onConnectionChange.fire(false);
if (this.restartCount < 3 && code !== 0 && signal !== "SIGTERM") {
this.restartCount++;
setTimeout(() => this.start(), 1000 * this.restartCount);
}
});
this._onConnectionChange.fire(true);
this.restartCount = 0;
}
/**
* Stop the GSD agent process.
*/
async stop(): Promise<void> {
if (!this.process) {
return;
}
const proc = this.process;
this.process = null;
proc.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
proc.kill("SIGKILL");
resolve();
}, 2000);
proc.on("exit", () => {
clearTimeout(timeout);
resolve();
});
});
this.rejectAllPending("Client stopped");
this._onConnectionChange.fire(false);
}
/**
* Send a prompt message to the agent.
* Returns once the command is acknowledged; streaming events follow via onEvent.
*/
async sendPrompt(message: string): Promise<void> {
const response = await this.send({ type: "prompt", message });
this.assertSuccess(response);
}
/**
* Abort current operation.
*/
async abort(): Promise<void> {
const response = await this.send({ type: "abort" });
this.assertSuccess(response);
}
/**
* Get current session state.
*/
async getState(): Promise<RpcSessionState> {
const response = await this.send({ type: "get_state" });
this.assertSuccess(response);
return response.data as RpcSessionState;
}
/**
* Set the active model.
*/
async setModel(provider: string, modelId: string): Promise<void> {
const response = await this.send({ type: "set_model", provider, modelId });
this.assertSuccess(response);
}
/**
* Get available models.
*/
async getAvailableModels(): Promise<ModelInfo[]> {
const response = await this.send({ type: "get_available_models" });
this.assertSuccess(response);
return (response.data as { models: ModelInfo[] }).models;
}
/**
* Start a new session.
*/
async newSession(): Promise<void> {
const response = await this.send({ type: "new_session" });
this.assertSuccess(response);
}
dispose(): void {
this.stop();
for (const d of this.disposables) {
d.dispose();
}
}
// -- Private helpers ------------------------------------------------------
private drainBuffer(): void {
while (true) {
const newlineIdx = this.buffer.indexOf("\n");
if (newlineIdx === -1) {
break;
}
let line = this.buffer.slice(0, newlineIdx);
this.buffer = this.buffer.slice(newlineIdx + 1);
if (line.endsWith("\r")) {
line = line.slice(0, -1);
}
if (!line) {
continue;
}
this.handleLine(line);
}
}
private handleLine(line: string): void {
let data: Record<string, unknown>;
try {
data = JSON.parse(line);
} catch {
return; // ignore non-JSON lines
}
// Response to a pending request
if (data.type === "response" && typeof data.id === "string" && this.pendingRequests.has(data.id)) {
const pending = this.pendingRequests.get(data.id)!;
this.pendingRequests.delete(data.id);
clearTimeout(pending.timer);
pending.resolve(data as unknown as RpcResponse);
return;
}
// Streaming event
this._onEvent.fire(data as AgentEvent);
}
private send(command: Record<string, unknown>): Promise<RpcResponse> {
if (!this.process?.stdin) {
return Promise.reject(new Error("GSD client not started"));
}
const id = `req_${++this.requestId}`;
const fullCommand = { ...command, id };
return new Promise<RpcResponse>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Timeout waiting for response to ${command.type}`));
}, 30_000);
this.pendingRequests.set(id, { resolve, reject, timer });
this.process!.stdin!.write(JSON.stringify(fullCommand) + "\n");
});
}
private assertSuccess(response: RpcResponse): void {
if (!response.success) {
throw new Error(response.error ?? "Unknown RPC error");
}
}
private rejectAllPending(reason: string): void {
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error(reason));
}
this.pendingRequests.clear();
}
}

View file

@ -0,0 +1,207 @@
import * as vscode from "vscode";
import type { GsdClient } from "./gsd-client.js";
/**
* WebviewViewProvider that renders a simple sidebar panel showing
* connection status, current model, session info, and start/stop controls.
*/
export class GsdSidebarProvider implements vscode.WebviewViewProvider {
public static readonly viewId = "gsd-sidebar";
private view?: vscode.WebviewView;
private disposables: vscode.Disposable[] = [];
constructor(
private readonly extensionUri: vscode.Uri,
private readonly client: GsdClient,
) {
this.disposables.push(
client.onConnectionChange(() => this.refresh()),
);
}
resolveWebviewView(
webviewView: vscode.WebviewView,
_context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken,
): void {
this.view = webviewView;
webviewView.webview.options = {
enableScripts: true,
};
webviewView.webview.onDidReceiveMessage(async (msg: { command: string }) => {
switch (msg.command) {
case "start":
await vscode.commands.executeCommand("gsd.start");
break;
case "stop":
await vscode.commands.executeCommand("gsd.stop");
break;
case "newSession":
await vscode.commands.executeCommand("gsd.newSession");
break;
}
});
this.refresh();
}
async refresh(): Promise<void> {
if (!this.view) {
return;
}
let modelName = "N/A";
let sessionId = "N/A";
let sessionName = "";
let messageCount = 0;
if (this.client.isConnected) {
try {
const state = await this.client.getState();
modelName = state.model
? `${state.model.provider}/${state.model.id}`
: "Not set";
sessionId = state.sessionId;
sessionName = state.sessionName ?? "";
messageCount = state.messageCount;
} catch {
// State fetch failed, show defaults
}
}
const connected = this.client.isConnected;
this.view.webview.html = this.getHtml({
connected,
modelName,
sessionId,
sessionName,
messageCount,
});
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
private getHtml(info: {
connected: boolean;
modelName: string;
sessionId: string;
sessionName: string;
messageCount: number;
}): string {
const statusColor = info.connected ? "#4ec9b0" : "#f44747";
const statusText = info.connected ? "Connected" : "Disconnected";
return /* html */ `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
padding: 12px;
margin: 0;
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: ${statusColor};
}
.info-table {
width: 100%;
margin-bottom: 16px;
}
.info-table td {
padding: 4px 0;
}
.info-table td:first-child {
opacity: 0.7;
padding-right: 12px;
white-space: nowrap;
}
.info-table td:last-child {
word-break: break-all;
}
.btn-group {
display: flex;
flex-direction: column;
gap: 8px;
}
button {
display: block;
width: 100%;
padding: 6px 14px;
border: none;
border-radius: 2px;
cursor: pointer;
font-size: var(--vscode-font-size);
color: var(--vscode-button-foreground);
background: var(--vscode-button-background);
}
button:hover {
background: var(--vscode-button-hoverBackground);
}
button.secondary {
color: var(--vscode-button-secondaryForeground);
background: var(--vscode-button-secondaryBackground);
}
button.secondary:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
</style>
</head>
<body>
<div class="status-row">
<div class="status-dot"></div>
<strong>${statusText}</strong>
</div>
<table class="info-table">
<tr><td>Model</td><td>${escapeHtml(info.modelName)}</td></tr>
<tr><td>Session</td><td>${escapeHtml(info.sessionName || info.sessionId)}</td></tr>
<tr><td>Messages</td><td>${info.messageCount}</td></tr>
</table>
<div class="btn-group">
${info.connected
? `<button onclick="send('stop')">Stop Agent</button>
<button class="secondary" onclick="send('newSession')">New Session</button>`
: `<button onclick="send('start')">Start Agent</button>`
}
</div>
<script>
const vscode = acquireVsCodeApi();
function send(command) {
vscode.postMessage({ command });
}
</script>
</body>
</html>`;
}
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}