Merge pull request #718 from jeremymcs/fix/682-vscode-extension-rebase
feat: VS Code extension — rebased with CI + review fixes (#682)
This commit is contained in:
commit
1e951f9648
10 changed files with 1827 additions and 17 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
54
src/tests/mcp-server.test.ts
Normal file
54
src/tests/mcp-server.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const projectRoot = join(fileURLToPath(import.meta.url), '..', '..', '..')
|
||||
|
||||
/**
|
||||
* Resolve dist path as a file:// URL for cross-platform dynamic import.
|
||||
* On Windows, bare paths like `D:\...\mcp-server.js` fail with
|
||||
* ERR_UNSUPPORTED_ESM_URL_SCHEME because Node's ESM loader requires
|
||||
* file:// URLs for absolute paths.
|
||||
*/
|
||||
function distUrl(filename: string): string {
|
||||
return pathToFileURL(join(projectRoot, 'dist', filename)).href
|
||||
}
|
||||
|
||||
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 mod = await import(distUrl('mcp-server.js'))
|
||||
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 { startMcpServer } = await import(distUrl('mcp-server.js'))
|
||||
|
||||
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 { startMcpServer } = await import(distUrl('mcp-server.js'))
|
||||
|
||||
// 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 */ })
|
||||
})
|
||||
})
|
||||
41
vscode-extension/package-lock.json
generated
Normal file
41
vscode-extension/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"name": "gsd-vscode",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gsd-vscode",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/vscode": "^1.95.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.95.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/vscode": {
|
||||
"version": "1.110.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.110.0.tgz",
|
||||
"integrity": "sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
156
vscode-extension/package.json
Normal file
156
vscode-extension/package.json
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"command": "gsd.cycleModel",
|
||||
"title": "GSD: Cycle Model"
|
||||
},
|
||||
{
|
||||
"command": "gsd.cycleThinking",
|
||||
"title": "GSD: Cycle Thinking Level"
|
||||
},
|
||||
{
|
||||
"command": "gsd.compact",
|
||||
"title": "GSD: Compact Context"
|
||||
},
|
||||
{
|
||||
"command": "gsd.abort",
|
||||
"title": "GSD: Abort Current Operation"
|
||||
},
|
||||
{
|
||||
"command": "gsd.exportHtml",
|
||||
"title": "GSD: Export Conversation as HTML"
|
||||
},
|
||||
{
|
||||
"command": "gsd.sessionStats",
|
||||
"title": "GSD: Show Session Stats"
|
||||
},
|
||||
{
|
||||
"command": "gsd.runBash",
|
||||
"title": "GSD: Run Bash Command"
|
||||
},
|
||||
{
|
||||
"command": "gsd.switchModel",
|
||||
"title": "GSD: Switch Model"
|
||||
},
|
||||
{
|
||||
"command": "gsd.setThinking",
|
||||
"title": "GSD: Set Thinking Level"
|
||||
},
|
||||
{
|
||||
"command": "gsd.steer",
|
||||
"title": "GSD: Steer Agent"
|
||||
},
|
||||
{
|
||||
"command": "gsd.listCommands",
|
||||
"title": "GSD: List Available Commands"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "gsd.newSession",
|
||||
"key": "ctrl+shift+g ctrl+shift+n",
|
||||
"mac": "cmd+shift+g cmd+shift+n"
|
||||
},
|
||||
{
|
||||
"command": "gsd.cycleModel",
|
||||
"key": "ctrl+shift+g ctrl+shift+m",
|
||||
"mac": "cmd+shift+g cmd+shift+m"
|
||||
},
|
||||
{
|
||||
"command": "gsd.cycleThinking",
|
||||
"key": "ctrl+shift+g ctrl+shift+t",
|
||||
"mac": "cmd+shift+g cmd+shift+t"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"gsd.autoStart": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Automatically start the GSD agent when the extension activates"
|
||||
},
|
||||
"gsd.autoCompaction": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable automatic context compaction"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"package": "vsce package"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/vscode": "^1.95.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
166
vscode-extension/src/chat-participant.ts
Normal file
166
vscode-extension/src/chat-participant.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
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;
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
|
||||
const eventHandler = (event: AgentEvent) => {
|
||||
switch (event.type) {
|
||||
case "agent_start":
|
||||
response.progress("GSD is working...");
|
||||
break;
|
||||
|
||||
case "tool_execution_start": {
|
||||
const toolName = event.toolName as string;
|
||||
const toolInput = event.toolInput as Record<string, unknown> | undefined;
|
||||
|
||||
let detail = `Running tool: ${toolName}`;
|
||||
|
||||
// Show relevant parameters for common tools
|
||||
if (toolInput) {
|
||||
if (toolName === "Read" && toolInput.file_path) {
|
||||
detail = `Reading: ${toolInput.file_path}`;
|
||||
} else if (toolName === "Write" && toolInput.file_path) {
|
||||
detail = `Writing: ${toolInput.file_path}`;
|
||||
} else if (toolName === "Edit" && toolInput.file_path) {
|
||||
detail = `Editing: ${toolInput.file_path}`;
|
||||
} else if (toolName === "Bash" && toolInput.command) {
|
||||
const cmd = String(toolInput.command);
|
||||
detail = `Running: $ ${cmd.length > 80 ? cmd.slice(0, 77) + "..." : cmd}`;
|
||||
} else if (toolName === "Glob" && toolInput.pattern) {
|
||||
detail = `Searching: ${toolInput.pattern}`;
|
||||
} else if (toolName === "Grep" && toolInput.pattern) {
|
||||
detail = `Grep: ${toolInput.pattern}`;
|
||||
}
|
||||
}
|
||||
|
||||
response.progress(detail);
|
||||
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": {
|
||||
// Assistant message starting
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_update": {
|
||||
const assistantEvent = event.assistantMessageEvent as Record<string, unknown> | undefined;
|
||||
if (!assistantEvent) break;
|
||||
|
||||
if (assistantEvent.type === "text_delta") {
|
||||
const delta = assistantEvent.delta as string | undefined;
|
||||
if (delta) {
|
||||
response.markdown(delta);
|
||||
}
|
||||
} else if (assistantEvent.type === "thinking_delta") {
|
||||
// Show thinking content in a collapsed section
|
||||
const delta = assistantEvent.delta as string | undefined;
|
||||
if (delta) {
|
||||
response.markdown(delta);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_end": {
|
||||
// Capture token usage from message end events
|
||||
const usage = event.usage as { inputTokens?: number; outputTokens?: number } | undefined;
|
||||
if (usage) {
|
||||
if (usage.inputTokens) totalInputTokens += usage.inputTokens;
|
||||
if (usage.outputTokens) totalOutputTokens += usage.outputTokens;
|
||||
}
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// Show token usage summary at the end
|
||||
if (totalInputTokens > 0 || totalOutputTokens > 0) {
|
||||
response.markdown(
|
||||
`\n\n---\n*Tokens: ${totalInputTokens.toLocaleString()} in / ${totalOutputTokens.toLocaleString()} out*\n`,
|
||||
);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
359
vscode-extension/src/extension.ts
Normal file
359
vscode-extension/src/extension.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
import * as vscode from "vscode";
|
||||
import { GsdClient, ThinkingLevel } from "./gsd-client.js";
|
||||
import { registerChatParticipant } from "./chat-participant.js";
|
||||
import { GsdSidebarProvider } from "./sidebar.js";
|
||||
|
||||
let client: GsdClient | undefined;
|
||||
let sidebarProvider: GsdSidebarProvider | undefined;
|
||||
|
||||
function requireConnected(): boolean {
|
||||
if (!client?.isConnected) {
|
||||
vscode.window.showWarningMessage("GSD agent is not running.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleError(err: unknown, context: string): void {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
vscode.window.showErrorMessage(`${context}: ${msg}`);
|
||||
}
|
||||
|
||||
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 -----------------------------------------------------------
|
||||
|
||||
// Start
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.start", async () => {
|
||||
try {
|
||||
await client!.start();
|
||||
// Apply auto-compaction setting
|
||||
const autoCompaction = vscode.workspace.getConfiguration("gsd").get<boolean>("autoCompaction", true);
|
||||
await client!.setAutoCompaction(autoCompaction).catch(() => {});
|
||||
sidebarProvider?.refresh();
|
||||
vscode.window.showInformationMessage("GSD agent started.");
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to start GSD");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Stop
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.stop", async () => {
|
||||
await client!.stop();
|
||||
sidebarProvider?.refresh();
|
||||
vscode.window.showInformationMessage("GSD agent stopped.");
|
||||
}),
|
||||
);
|
||||
|
||||
// New Session
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.newSession", async () => {
|
||||
if (!requireConnected()) return;
|
||||
try {
|
||||
await client!.newSession();
|
||||
sidebarProvider?.refresh();
|
||||
vscode.window.showInformationMessage("New GSD session started.");
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to start new session");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Send Message
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.sendMessage", async () => {
|
||||
if (!requireConnected()) 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) {
|
||||
handleError(err, "Failed to send message");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Abort
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.abort", async () => {
|
||||
if (!requireConnected()) return;
|
||||
try {
|
||||
await client!.abort();
|
||||
vscode.window.showInformationMessage("Operation aborted.");
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to abort");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Cycle Model
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.cycleModel", async () => {
|
||||
if (!requireConnected()) return;
|
||||
try {
|
||||
const result = await client!.cycleModel();
|
||||
if (result) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Model: ${result.model.provider}/${result.model.id} (thinking: ${result.thinkingLevel})`,
|
||||
);
|
||||
} else {
|
||||
vscode.window.showInformationMessage("No other models available.");
|
||||
}
|
||||
sidebarProvider?.refresh();
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to cycle model");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Switch Model (QuickPick)
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.switchModel", async () => {
|
||||
if (!requireConnected()) return;
|
||||
try {
|
||||
const models = await client!.getAvailableModels();
|
||||
if (models.length === 0) {
|
||||
vscode.window.showInformationMessage("No models available.");
|
||||
return;
|
||||
}
|
||||
const items = models.map((m) => ({
|
||||
label: `${m.provider}/${m.id}`,
|
||||
description: m.contextWindow ? `${Math.round(m.contextWindow / 1000)}k context` : undefined,
|
||||
provider: m.provider,
|
||||
modelId: m.id,
|
||||
}));
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: "Select a model",
|
||||
});
|
||||
if (!selected) return;
|
||||
await client!.setModel(selected.provider, selected.modelId);
|
||||
vscode.window.showInformationMessage(`Model set to ${selected.label}`);
|
||||
sidebarProvider?.refresh();
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to switch model");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Cycle Thinking Level
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.cycleThinking", async () => {
|
||||
if (!requireConnected()) return;
|
||||
try {
|
||||
const result = await client!.cycleThinkingLevel();
|
||||
if (result) {
|
||||
vscode.window.showInformationMessage(`Thinking level: ${result.level}`);
|
||||
} else {
|
||||
vscode.window.showInformationMessage("Cannot change thinking level for this model.");
|
||||
}
|
||||
sidebarProvider?.refresh();
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to cycle thinking level");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Set Thinking Level (QuickPick)
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.setThinking", async () => {
|
||||
if (!requireConnected()) return;
|
||||
const levels: ThinkingLevel[] = ["off", "low", "medium", "high"];
|
||||
const selected = await vscode.window.showQuickPick(levels, {
|
||||
placeHolder: "Select thinking level",
|
||||
});
|
||||
if (!selected) return;
|
||||
try {
|
||||
await client!.setThinkingLevel(selected as ThinkingLevel);
|
||||
vscode.window.showInformationMessage(`Thinking level set to ${selected}`);
|
||||
sidebarProvider?.refresh();
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to set thinking level");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Compact Context
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.compact", async () => {
|
||||
if (!requireConnected()) return;
|
||||
try {
|
||||
await client!.compact();
|
||||
vscode.window.showInformationMessage("Context compacted.");
|
||||
sidebarProvider?.refresh();
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to compact context");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Export HTML
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.exportHtml", async () => {
|
||||
if (!requireConnected()) return;
|
||||
try {
|
||||
const saveUri = await vscode.window.showSaveDialog({
|
||||
defaultUri: vscode.Uri.file("gsd-conversation.html"),
|
||||
filters: { "HTML Files": ["html"] },
|
||||
});
|
||||
const outputPath = saveUri?.fsPath;
|
||||
const result = await client!.exportHtml(outputPath);
|
||||
vscode.window.showInformationMessage(`Conversation exported to ${result.path}`);
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to export HTML");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Session Stats
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.sessionStats", async () => {
|
||||
if (!requireConnected()) return;
|
||||
try {
|
||||
const stats = await client!.getSessionStats();
|
||||
const lines: string[] = [];
|
||||
if (stats.inputTokens !== undefined) lines.push(`Input tokens: ${stats.inputTokens.toLocaleString()}`);
|
||||
if (stats.outputTokens !== undefined) lines.push(`Output tokens: ${stats.outputTokens.toLocaleString()}`);
|
||||
if (stats.cacheReadTokens !== undefined) lines.push(`Cache read: ${stats.cacheReadTokens.toLocaleString()}`);
|
||||
if (stats.cacheWriteTokens !== undefined) lines.push(`Cache write: ${stats.cacheWriteTokens.toLocaleString()}`);
|
||||
if (stats.totalCost !== undefined) lines.push(`Cost: $${stats.totalCost.toFixed(4)}`);
|
||||
if (stats.turnCount !== undefined) lines.push(`Turns: ${stats.turnCount}`);
|
||||
if (stats.messageCount !== undefined) lines.push(`Messages: ${stats.messageCount}`);
|
||||
if (stats.duration !== undefined) lines.push(`Duration: ${Math.round(stats.duration / 1000)}s`);
|
||||
|
||||
vscode.window.showInformationMessage(
|
||||
lines.length > 0 ? lines.join(" | ") : "No stats available.",
|
||||
);
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to get session stats");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Run Bash Command
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.runBash", async () => {
|
||||
if (!requireConnected()) return;
|
||||
const command = await vscode.window.showInputBox({
|
||||
prompt: "Enter bash command to execute",
|
||||
placeHolder: "ls -la",
|
||||
});
|
||||
if (!command) return;
|
||||
try {
|
||||
const result = await client!.runBash(command);
|
||||
outputChannel.appendLine(`[bash] $ ${command}`);
|
||||
if (result.stdout) outputChannel.appendLine(result.stdout);
|
||||
if (result.stderr) outputChannel.appendLine(`[stderr] ${result.stderr}`);
|
||||
outputChannel.appendLine(`[exit code: ${result.exitCode}]`);
|
||||
outputChannel.show(true);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
vscode.window.showInformationMessage("Bash command completed successfully.");
|
||||
} else {
|
||||
vscode.window.showWarningMessage(`Bash command exited with code ${result.exitCode}`);
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to run bash command");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Steer Agent
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.steer", async () => {
|
||||
if (!requireConnected()) return;
|
||||
const message = await vscode.window.showInputBox({
|
||||
prompt: "Enter steering message (interrupts current operation)",
|
||||
placeHolder: "Focus on the error handling instead",
|
||||
});
|
||||
if (!message) return;
|
||||
try {
|
||||
await client!.steer(message);
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to steer agent");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// List Available Commands
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.listCommands", async () => {
|
||||
if (!requireConnected()) return;
|
||||
try {
|
||||
const commands = await client!.getCommands();
|
||||
if (commands.length === 0) {
|
||||
vscode.window.showInformationMessage("No slash commands available.");
|
||||
return;
|
||||
}
|
||||
const items = commands.map((cmd) => ({
|
||||
label: `/${cmd.name}`,
|
||||
description: cmd.description ?? "",
|
||||
detail: `Source: ${cmd.source}${cmd.location ? ` (${cmd.location})` : ""}`,
|
||||
}));
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: "Available slash commands",
|
||||
});
|
||||
if (selected) {
|
||||
// Send the selected command as a prompt
|
||||
await client!.sendPrompt(selected.label);
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to list commands");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -- Auto-start ---------------------------------------------------------
|
||||
|
||||
if (config.get<boolean>("autoStart", false)) {
|
||||
vscode.commands.executeCommand("gsd.start");
|
||||
}
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
client?.dispose();
|
||||
sidebarProvider?.dispose();
|
||||
client = undefined;
|
||||
sidebarProvider = undefined;
|
||||
}
|
||||
520
vscode-extension/src/gsd-client.ts
Normal file
520
vscode-extension/src/gsd-client.ts
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
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 type ThinkingLevel = "off" | "low" | "medium" | "high";
|
||||
|
||||
export interface RpcSessionState {
|
||||
model?: { provider: string; id: string; contextWindow?: number };
|
||||
thinkingLevel: ThinkingLevel;
|
||||
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 SessionStats {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheWriteTokens?: number;
|
||||
totalCost?: number;
|
||||
messageCount?: number;
|
||||
turnCount?: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface BashResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
source: "extension" | "prompt" | "skill";
|
||||
location?: "user" | "project" | "path";
|
||||
path?: string;
|
||||
}
|
||||
|
||||
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 restartTimestamps: number[] = [];
|
||||
|
||||
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 (code !== 0 && signal !== "SIGTERM") {
|
||||
const now = Date.now();
|
||||
this.restartTimestamps.push(now);
|
||||
// Keep only timestamps within the last 60 seconds
|
||||
this.restartTimestamps = this.restartTimestamps.filter(t => now - t < 60_000);
|
||||
|
||||
if (this.restartTimestamps.length > 3) {
|
||||
// Too many crashes within 60s — stop retrying
|
||||
this._onError.fire(
|
||||
`GSD process crashed ${this.restartTimestamps.length} times within 60s. Not restarting. Use "GSD: Start Agent" to retry manually.`,
|
||||
);
|
||||
} else if (this.restartCount < 3) {
|
||||
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);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Prompting
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupt the agent with a steering message while it is streaming.
|
||||
*/
|
||||
async steer(message: string): Promise<void> {
|
||||
const response = await this.send({ type: "steer", message });
|
||||
this.assertSuccess(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a follow-up message after the agent has completed.
|
||||
*/
|
||||
async followUp(message: string): Promise<void> {
|
||||
const response = await this.send({ type: "follow_up", message });
|
||||
this.assertSuccess(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort current operation.
|
||||
*/
|
||||
async abort(): Promise<void> {
|
||||
const response = await this.send({ type: "abort" });
|
||||
this.assertSuccess(response);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// State
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get current session state.
|
||||
*/
|
||||
async getState(): Promise<RpcSessionState> {
|
||||
const response = await this.send({ type: "get_state" });
|
||||
this.assertSuccess(response);
|
||||
return response.data as RpcSessionState;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Model
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycle through available models.
|
||||
*/
|
||||
async cycleModel(): Promise<{ model: ModelInfo; thinkingLevel: ThinkingLevel; isScoped: boolean } | null> {
|
||||
const response = await this.send({ type: "cycle_model" });
|
||||
this.assertSuccess(response);
|
||||
return response.data as { model: ModelInfo; thinkingLevel: ThinkingLevel; isScoped: boolean } | null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Thinking
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Set the thinking level explicitly.
|
||||
*/
|
||||
async setThinkingLevel(level: ThinkingLevel): Promise<void> {
|
||||
const response = await this.send({ type: "set_thinking_level", level });
|
||||
this.assertSuccess(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycle through thinking levels (off -> low -> medium -> high -> off).
|
||||
*/
|
||||
async cycleThinkingLevel(): Promise<{ level: ThinkingLevel } | null> {
|
||||
const response = await this.send({ type: "cycle_thinking_level" });
|
||||
this.assertSuccess(response);
|
||||
return response.data as { level: ThinkingLevel } | null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Compaction
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Manually compact the conversation context.
|
||||
*/
|
||||
async compact(customInstructions?: string): Promise<unknown> {
|
||||
const cmd: Record<string, unknown> = { type: "compact" };
|
||||
if (customInstructions) {
|
||||
cmd.customInstructions = customInstructions;
|
||||
}
|
||||
const response = await this.send(cmd);
|
||||
this.assertSuccess(response);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable automatic compaction.
|
||||
*/
|
||||
async setAutoCompaction(enabled: boolean): Promise<void> {
|
||||
const response = await this.send({ type: "set_auto_compaction", enabled });
|
||||
this.assertSuccess(response);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Retry
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Enable or disable automatic retry on failure.
|
||||
*/
|
||||
async setAutoRetry(enabled: boolean): Promise<void> {
|
||||
const response = await this.send({ type: "set_auto_retry", enabled });
|
||||
this.assertSuccess(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort a pending retry.
|
||||
*/
|
||||
async abortRetry(): Promise<void> {
|
||||
const response = await this.send({ type: "abort_retry" });
|
||||
this.assertSuccess(response);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Bash
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Execute a bash command via the agent.
|
||||
*/
|
||||
async runBash(command: string): Promise<BashResult> {
|
||||
const response = await this.send({ type: "bash", command });
|
||||
this.assertSuccess(response);
|
||||
return response.data as BashResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort a running bash command.
|
||||
*/
|
||||
async abortBash(): Promise<void> {
|
||||
const response = await this.send({ type: "abort_bash" });
|
||||
this.assertSuccess(response);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Session
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Start a new session.
|
||||
*/
|
||||
async newSession(): Promise<void> {
|
||||
const response = await this.send({ type: "new_session" });
|
||||
this.assertSuccess(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session statistics (token counts, cost, etc.).
|
||||
*/
|
||||
async getSessionStats(): Promise<SessionStats> {
|
||||
const response = await this.send({ type: "get_session_stats" });
|
||||
this.assertSuccess(response);
|
||||
return response.data as SessionStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the conversation as HTML.
|
||||
*/
|
||||
async exportHtml(outputPath?: string): Promise<{ path: string }> {
|
||||
const cmd: Record<string, unknown> = { type: "export_html" };
|
||||
if (outputPath) {
|
||||
cmd.outputPath = outputPath;
|
||||
}
|
||||
const response = await this.send(cmd);
|
||||
this.assertSuccess(response);
|
||||
return response.data as { path: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different session file.
|
||||
*/
|
||||
async switchSession(sessionPath: string): Promise<void> {
|
||||
const response = await this.send({ type: "switch_session", sessionPath });
|
||||
this.assertSuccess(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the display name for the current session.
|
||||
*/
|
||||
async setSessionName(name: string): Promise<void> {
|
||||
const response = await this.send({ type: "set_session_name", name });
|
||||
this.assertSuccess(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all conversation messages.
|
||||
*/
|
||||
async getMessages(): Promise<unknown[]> {
|
||||
const response = await this.send({ type: "get_messages" });
|
||||
this.assertSuccess(response);
|
||||
return (response.data as { messages: unknown[] }).messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the text of the last assistant response.
|
||||
*/
|
||||
async getLastAssistantText(): Promise<string | null> {
|
||||
const response = await this.send({ type: "get_last_assistant_text" });
|
||||
this.assertSuccess(response);
|
||||
return (response.data as { text: string | null }).text;
|
||||
}
|
||||
|
||||
/**
|
||||
* List available slash commands.
|
||||
*/
|
||||
async getCommands(): Promise<SlashCommand[]> {
|
||||
const response = await this.send({ type: "get_commands" });
|
||||
this.assertSuccess(response);
|
||||
return (response.data as { commands: SlashCommand[] }).commands;
|
||||
}
|
||||
|
||||
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 [, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error(reason));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
}
|
||||
445
vscode-extension/src/sidebar.ts
Normal file
445
vscode-extension/src/sidebar.ts
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
import * as vscode from "vscode";
|
||||
import type { GsdClient, SessionStats, ThinkingLevel } from "./gsd-client.js";
|
||||
|
||||
/**
|
||||
* WebviewViewProvider that renders a sidebar panel showing connection status,
|
||||
* model info, thinking level, token usage, cost, and quick action controls.
|
||||
*/
|
||||
export class GsdSidebarProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly viewId = "gsd-sidebar";
|
||||
|
||||
private view?: vscode.WebviewView;
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
private refreshTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
private readonly client: GsdClient,
|
||||
) {
|
||||
this.disposables.push(
|
||||
client.onConnectionChange(() => this.refresh()),
|
||||
client.onEvent((evt) => {
|
||||
// Refresh on streaming state changes
|
||||
if (evt.type === "agent_start" || evt.type === "agent_end") {
|
||||
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; value?: 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;
|
||||
case "cycleModel":
|
||||
await vscode.commands.executeCommand("gsd.cycleModel");
|
||||
break;
|
||||
case "cycleThinking":
|
||||
await vscode.commands.executeCommand("gsd.cycleThinking");
|
||||
break;
|
||||
case "switchModel":
|
||||
await vscode.commands.executeCommand("gsd.switchModel");
|
||||
break;
|
||||
case "setThinking":
|
||||
await vscode.commands.executeCommand("gsd.setThinking");
|
||||
break;
|
||||
case "compact":
|
||||
await vscode.commands.executeCommand("gsd.compact");
|
||||
break;
|
||||
case "abort":
|
||||
await vscode.commands.executeCommand("gsd.abort");
|
||||
break;
|
||||
case "exportHtml":
|
||||
await vscode.commands.executeCommand("gsd.exportHtml");
|
||||
break;
|
||||
case "sessionStats":
|
||||
await vscode.commands.executeCommand("gsd.sessionStats");
|
||||
break;
|
||||
case "listCommands":
|
||||
await vscode.commands.executeCommand("gsd.listCommands");
|
||||
break;
|
||||
case "toggleAutoCompaction":
|
||||
if (this.client.isConnected) {
|
||||
const state = await this.client.getState().catch(() => null);
|
||||
if (state) {
|
||||
await this.client.setAutoCompaction(!state.autoCompactionEnabled).catch(() => {});
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Periodic refresh while connected (for token stats)
|
||||
this.refreshTimer = setInterval(() => {
|
||||
if (this.client.isConnected) {
|
||||
this.refresh();
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
if (!this.view) {
|
||||
return;
|
||||
}
|
||||
|
||||
let modelName = "N/A";
|
||||
let sessionId = "N/A";
|
||||
let sessionName = "";
|
||||
let messageCount = 0;
|
||||
let thinkingLevel: ThinkingLevel = "off";
|
||||
let isStreaming = false;
|
||||
let isCompacting = false;
|
||||
let autoCompaction = false;
|
||||
let stats: SessionStats | null = null;
|
||||
|
||||
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;
|
||||
thinkingLevel = state.thinkingLevel as ThinkingLevel;
|
||||
isStreaming = state.isStreaming;
|
||||
isCompacting = state.isCompacting;
|
||||
autoCompaction = state.autoCompactionEnabled;
|
||||
} catch {
|
||||
// State fetch failed, show defaults
|
||||
}
|
||||
|
||||
try {
|
||||
stats = await this.client.getSessionStats();
|
||||
} catch {
|
||||
// Stats fetch failed
|
||||
}
|
||||
}
|
||||
|
||||
const connected = this.client.isConnected;
|
||||
|
||||
this.view.webview.html = this.getHtml({
|
||||
connected,
|
||||
modelName,
|
||||
sessionId,
|
||||
sessionName,
|
||||
messageCount,
|
||||
thinkingLevel,
|
||||
isStreaming,
|
||||
isCompacting,
|
||||
autoCompaction,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
}
|
||||
for (const d of this.disposables) {
|
||||
d.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private getHtml(info: {
|
||||
connected: boolean;
|
||||
modelName: string;
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
messageCount: number;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
isStreaming: boolean;
|
||||
isCompacting: boolean;
|
||||
autoCompaction: boolean;
|
||||
stats: SessionStats | null;
|
||||
}): string {
|
||||
const statusColor = info.connected ? "#4ec9b0" : "#f44747";
|
||||
const statusText = info.connected
|
||||
? info.isStreaming
|
||||
? "Processing..."
|
||||
: info.isCompacting
|
||||
? "Compacting..."
|
||||
: "Connected"
|
||||
: "Disconnected";
|
||||
|
||||
const inputTokens = info.stats?.inputTokens?.toLocaleString() ?? "-";
|
||||
const outputTokens = info.stats?.outputTokens?.toLocaleString() ?? "-";
|
||||
const cost = info.stats?.totalCost !== undefined ? `$${info.stats.totalCost.toFixed(4)}` : "-";
|
||||
|
||||
const thinkingBadge = info.thinkingLevel !== "off"
|
||||
? `<span class="badge">${info.thinkingLevel}</span>`
|
||||
: `<span class="badge muted">off</span>`;
|
||||
|
||||
const autoCompBadge = info.autoCompaction
|
||||
? `<span class="badge">on</span>`
|
||||
: `<span class="badge muted">off</span>`;
|
||||
|
||||
const streamingIndicator = info.isStreaming
|
||||
? `<div class="streaming-indicator"><span class="spinner"></span> Agent is working...</div>`
|
||||
: "";
|
||||
|
||||
const nonce = getNonce();
|
||||
|
||||
return /* html */ `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
|
||||
<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: 12px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: ${statusColor};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.streaming-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--vscode-editor-background);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vscode-focusBorder);
|
||||
font-size: 12px;
|
||||
}
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--vscode-foreground);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.info-table {
|
||||
width: 100%;
|
||||
}
|
||||
.info-table td {
|
||||
padding: 3px 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.info-table td:first-child {
|
||||
opacity: 0.7;
|
||||
padding-right: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.info-table td:last-child {
|
||||
word-break: break-all;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
background: var(--vscode-badge-background);
|
||||
color: var(--vscode-badge-foreground);
|
||||
}
|
||||
.badge.muted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.badge.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.badge.clickable:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.btn-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn-row button {
|
||||
flex: 1;
|
||||
}
|
||||
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);
|
||||
}
|
||||
.token-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.token-stats .label {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.token-stats .value {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="status-row">
|
||||
<div class="status-dot"></div>
|
||||
<strong>${statusText}</strong>
|
||||
</div>
|
||||
|
||||
${streamingIndicator}
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Session</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>
|
||||
<tr>
|
||||
<td>Thinking</td>
|
||||
<td>${thinkingBadge}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Auto-compact</td>
|
||||
<td>${autoCompBadge}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${info.connected && info.stats ? `
|
||||
<div class="section">
|
||||
<div class="section-title">Token Usage</div>
|
||||
<div class="token-stats">
|
||||
<span class="label">Input</span>
|
||||
<span class="value">${inputTokens}</span>
|
||||
<span class="label">Output</span>
|
||||
<span class="value">${outputTokens}</span>
|
||||
<span class="label">Cost</span>
|
||||
<span class="value">${cost}</span>
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Controls</div>
|
||||
<div class="btn-group">
|
||||
${info.connected
|
||||
? `<button data-command="stop">Stop Agent</button>
|
||||
<div class="btn-row">
|
||||
<button class="secondary" data-command="newSession">New Session</button>
|
||||
<button class="secondary" data-command="switchModel">Model</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="secondary" data-command="cycleThinking">Thinking</button>
|
||||
<button class="secondary" data-command="toggleAutoCompaction">Auto-Compact</button>
|
||||
</div>`
|
||||
: `<button data-command="start">Start Agent</button>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${info.connected ? `
|
||||
<div class="section">
|
||||
<div class="section-title">Actions</div>
|
||||
<div class="btn-group">
|
||||
<div class="btn-row">
|
||||
<button class="secondary" data-command="compact">Compact</button>
|
||||
<button class="secondary" data-command="exportHtml">Export</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="secondary" data-command="abort">Abort</button>
|
||||
<button class="secondary" data-command="listCommands">Commands</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
<script nonce="${nonce}">
|
||||
const vscode = acquireVsCodeApi();
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-command]');
|
||||
if (btn) {
|
||||
vscode.postMessage({ command: btn.dataset.command });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function getNonce(): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let nonce = "";
|
||||
for (let i = 0; i < 32; i++) {
|
||||
nonce += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return nonce;
|
||||
}
|
||||
19
vscode-extension/tsconfig.json
Normal file
19
vscode-extension/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue