feat(vscode): status bar, file decorations, bash terminal, session tree, conversation history, code lens [1/2] (#2651)
* feat(vscode): status bar, auto-retry, session name, copy response, keyboard shortcuts, full stats * feat(vscode): file decorations, bash terminal, session tree view * feat(vscode): conversation history webview, slash completion, code lens - conversation-history.ts: GsdConversationHistoryPanel webview panel using getMessages() RPC; renders user/assistant turns with a Refresh button - slash-completion.ts: GsdSlashCompletionProvider triggers on '/' at line start in md/plaintext/ts/js; fetches getCommands() RPC and caches results - code-lens.ts: GsdCodeLensProvider adds 'Ask GSD' lens above named functions/classes in ts/js/py/go/rust; respects gsd.codeLens setting - extension.ts: registers all three providers and new commands (gsd.showHistory, gsd.askAboutSymbol) - package.json: declares new commands and gsd.codeLens config toggle
This commit is contained in:
parent
12713a547c
commit
c2cd8bcc0a
10 changed files with 1149 additions and 7 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "gsd-2",
|
||||
"displayName": "GSD-2",
|
||||
"description": "VS Code integration for the GSD-2 coding agent — sidebar dashboard, @gsd chat participant, and 15 commands",
|
||||
"description": "VS Code integration for the GSD-2 coding agent — sidebar dashboard, @gsd chat participant, conversation history, code lens, slash command completion, and 25 commands",
|
||||
"publisher": "FluxLabs",
|
||||
"version": "0.1.0",
|
||||
"icon": "logo.jpg",
|
||||
|
|
@ -102,6 +102,43 @@
|
|||
{
|
||||
"command": "gsd.listCommands",
|
||||
"title": "GSD: List Available Commands"
|
||||
},
|
||||
{
|
||||
"command": "gsd.toggleAutoRetry",
|
||||
"title": "GSD: Toggle Auto-Retry"
|
||||
},
|
||||
{
|
||||
"command": "gsd.abortRetry",
|
||||
"title": "GSD: Abort Retry"
|
||||
},
|
||||
{
|
||||
"command": "gsd.setSessionName",
|
||||
"title": "GSD: Set Session Name"
|
||||
},
|
||||
{
|
||||
"command": "gsd.copyLastResponse",
|
||||
"title": "GSD: Copy Last Response"
|
||||
},
|
||||
{
|
||||
"command": "gsd.switchSession",
|
||||
"title": "GSD: Switch Session"
|
||||
},
|
||||
{
|
||||
"command": "gsd.refreshSessions",
|
||||
"title": "GSD: Refresh Sessions",
|
||||
"icon": "$(refresh)"
|
||||
},
|
||||
{
|
||||
"command": "gsd.clearFileDecorations",
|
||||
"title": "GSD: Clear File Decorations"
|
||||
},
|
||||
{
|
||||
"command": "gsd.showHistory",
|
||||
"title": "GSD: Show Conversation History"
|
||||
},
|
||||
{
|
||||
"command": "gsd.askAboutSymbol",
|
||||
"title": "GSD: Ask About Symbol"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
|
|
@ -119,6 +156,21 @@
|
|||
"command": "gsd.cycleThinking",
|
||||
"key": "ctrl+shift+g ctrl+shift+t",
|
||||
"mac": "cmd+shift+g cmd+shift+t"
|
||||
},
|
||||
{
|
||||
"command": "gsd.abort",
|
||||
"key": "ctrl+shift+g ctrl+shift+a",
|
||||
"mac": "cmd+shift+g cmd+shift+a"
|
||||
},
|
||||
{
|
||||
"command": "gsd.steer",
|
||||
"key": "ctrl+shift+g ctrl+shift+i",
|
||||
"mac": "cmd+shift+g cmd+shift+i"
|
||||
},
|
||||
{
|
||||
"command": "gsd.sendMessage",
|
||||
"key": "ctrl+shift+g ctrl+shift+p",
|
||||
"mac": "cmd+shift+g cmd+shift+p"
|
||||
}
|
||||
],
|
||||
"viewsContainers": {
|
||||
|
|
@ -136,6 +188,19 @@
|
|||
"type": "webview",
|
||||
"id": "gsd-sidebar",
|
||||
"name": "GSD Agent"
|
||||
},
|
||||
{
|
||||
"id": "gsd-sessions",
|
||||
"name": "Sessions"
|
||||
}
|
||||
]
|
||||
},
|
||||
"menus": {
|
||||
"view/title": [
|
||||
{
|
||||
"command": "gsd.refreshSessions",
|
||||
"when": "view == gsd-sessions",
|
||||
"group": "navigation"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -165,6 +230,11 @@
|
|||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable automatic context compaction"
|
||||
},
|
||||
"gsd.codeLens": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show 'Ask GSD' code lens above functions and classes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
84
vscode-extension/src/bash-terminal.ts
Normal file
84
vscode-extension/src/bash-terminal.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import * as vscode from "vscode";
|
||||
import type { AgentEvent, GsdClient } from "./gsd-client.js";
|
||||
|
||||
/**
|
||||
* Routes the GSD agent's Bash tool output to a dedicated VS Code terminal panel.
|
||||
* Shows streaming output from tool_execution_update events in real time.
|
||||
*/
|
||||
export class GsdBashTerminal implements vscode.Disposable {
|
||||
private terminal: vscode.Terminal | undefined;
|
||||
private writeEmitter: vscode.EventEmitter<string> | undefined;
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
|
||||
constructor(client: GsdClient) {
|
||||
this.disposables.push(
|
||||
client.onEvent((evt: AgentEvent) => this.handleEvent(evt)),
|
||||
client.onConnectionChange((connected) => {
|
||||
if (!connected) {
|
||||
this.close();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private getOrCreateTerminal(): { terminal: vscode.Terminal; writeEmitter: vscode.EventEmitter<string> } {
|
||||
if (!this.terminal || this.terminal.exitStatus !== undefined) {
|
||||
this.writeEmitter?.dispose();
|
||||
this.writeEmitter = new vscode.EventEmitter<string>();
|
||||
const emitter = this.writeEmitter;
|
||||
const pty: vscode.Pseudoterminal = {
|
||||
onDidWrite: emitter.event,
|
||||
open: () => {},
|
||||
close: () => { this.terminal = undefined; },
|
||||
};
|
||||
this.terminal = vscode.window.createTerminal({ name: "GSD Agent", pty });
|
||||
}
|
||||
return { terminal: this.terminal, writeEmitter: this.writeEmitter! };
|
||||
}
|
||||
|
||||
private handleEvent(evt: AgentEvent): void {
|
||||
switch (evt.type) {
|
||||
case "tool_execution_start": {
|
||||
if (evt.toolName !== "Bash") {
|
||||
break;
|
||||
}
|
||||
const cmd = (evt.toolInput as Record<string, unknown> | undefined)?.command as string | undefined;
|
||||
const { terminal, writeEmitter } = this.getOrCreateTerminal();
|
||||
terminal.show(true); // preserve editor focus
|
||||
writeEmitter.fire(`\x1b[90m$ ${cmd ?? ""}\x1b[0m\r\n`);
|
||||
break;
|
||||
}
|
||||
case "tool_execution_update": {
|
||||
if (evt.toolName !== "Bash" || !this.writeEmitter) {
|
||||
break;
|
||||
}
|
||||
const partial = evt.partialResult as string | undefined;
|
||||
if (partial) {
|
||||
this.writeEmitter.fire(partial.replace(/\n/g, "\r\n"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
if (evt.toolName !== "Bash" || !this.writeEmitter) {
|
||||
break;
|
||||
}
|
||||
this.writeEmitter.fire("\r\n");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.terminal?.dispose();
|
||||
this.terminal = undefined;
|
||||
this.writeEmitter?.dispose();
|
||||
this.writeEmitter = undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.close();
|
||||
for (const d of this.disposables) {
|
||||
d.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
120
vscode-extension/src/code-lens.ts
Normal file
120
vscode-extension/src/code-lens.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import * as vscode from "vscode";
|
||||
import type { GsdClient } from "./gsd-client.js";
|
||||
|
||||
/**
|
||||
* Patterns that identify the start of a named function, class, or method
|
||||
* declaration in common languages. Each entry captures the symbol name in
|
||||
* capture group 1.
|
||||
*/
|
||||
const SYMBOL_PATTERNS: { languages: string[]; regex: RegExp }[] = [
|
||||
{
|
||||
// TypeScript / JavaScript: function foo(...) | async function foo(...)
|
||||
languages: ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
||||
regex: /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*[(<]/,
|
||||
},
|
||||
{
|
||||
// TypeScript / JavaScript: class Foo
|
||||
languages: ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
||||
regex: /^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/,
|
||||
},
|
||||
{
|
||||
// TypeScript / JavaScript: method declarations inside a class
|
||||
// foo(...) { | async foo(...) { | private foo(...): T {
|
||||
languages: ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
||||
regex: /^\s*(?:(?:public|private|protected|static|async|readonly)\s+)*(\w+)\s*\(/,
|
||||
},
|
||||
{
|
||||
// Python: def foo( | async def foo(
|
||||
languages: ["python"],
|
||||
regex: /^\s*(?:async\s+)?def\s+(\w+)\s*\(/,
|
||||
},
|
||||
{
|
||||
// Python: class Foo
|
||||
languages: ["python"],
|
||||
regex: /^\s*class\s+(\w+)/,
|
||||
},
|
||||
{
|
||||
// Go: func foo( | func (r Receiver) foo(
|
||||
languages: ["go"],
|
||||
regex: /^\s*func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(/,
|
||||
},
|
||||
{
|
||||
// Rust: fn foo( | pub fn foo( | async fn foo(
|
||||
languages: ["rust"],
|
||||
regex: /^\s*(?:pub(?:\([^)]+\))?\s+)?(?:async\s+)?fn\s+(\w+)\s*[(<]/,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* CodeLensProvider that adds an "Ask GSD" lens above named function and class
|
||||
* declarations. Clicking the lens sends a brief explanation request to the GSD
|
||||
* agent for that specific symbol.
|
||||
*/
|
||||
export class GsdCodeLensProvider implements vscode.CodeLensProvider, vscode.Disposable {
|
||||
private readonly _onDidChangeCodeLenses = new vscode.EventEmitter<void>();
|
||||
readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event;
|
||||
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
|
||||
constructor(private readonly client: GsdClient) {
|
||||
this.disposables.push(
|
||||
this._onDidChangeCodeLenses,
|
||||
client.onConnectionChange(() => this._onDidChangeCodeLenses.fire()),
|
||||
vscode.workspace.onDidChangeConfiguration((e) => {
|
||||
if (e.affectsConfiguration("gsd.codeLens")) {
|
||||
this._onDidChangeCodeLenses.fire();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
provideCodeLenses(
|
||||
document: vscode.TextDocument,
|
||||
_token: vscode.CancellationToken,
|
||||
): vscode.CodeLens[] {
|
||||
const lenses: vscode.CodeLens[] = [];
|
||||
|
||||
if (!vscode.workspace.getConfiguration("gsd").get<boolean>("codeLens", true)) {
|
||||
return lenses;
|
||||
}
|
||||
const langId = document.languageId;
|
||||
const patterns = SYMBOL_PATTERNS.filter((p) => p.languages.includes(langId));
|
||||
|
||||
if (patterns.length === 0) {
|
||||
return lenses;
|
||||
}
|
||||
|
||||
const fileName = document.fileName.split(/[\\/]/).pop() ?? document.fileName;
|
||||
const seen = new Set<number>();
|
||||
|
||||
for (let i = 0; i < document.lineCount; i++) {
|
||||
const text = document.lineAt(i).text;
|
||||
|
||||
for (const { regex } of patterns) {
|
||||
const match = regex.exec(text);
|
||||
if (match && match[1] && !seen.has(i)) {
|
||||
seen.add(i);
|
||||
const symbolName = match[1];
|
||||
const range = new vscode.Range(i, 0, i, text.length);
|
||||
|
||||
lenses.push(
|
||||
new vscode.CodeLens(range, {
|
||||
title: "$(hubot) Ask GSD",
|
||||
tooltip: `Ask GSD to explain ${symbolName}`,
|
||||
command: "gsd.askAboutSymbol",
|
||||
arguments: [symbolName, fileName, i + 1],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lenses;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) {
|
||||
d.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
244
vscode-extension/src/conversation-history.ts
Normal file
244
vscode-extension/src/conversation-history.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import * as vscode from "vscode";
|
||||
import type { GsdClient } from "./gsd-client.js";
|
||||
|
||||
interface ContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ConversationMessage {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string | ContentBlock[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Webview panel that displays the full conversation history for the
|
||||
* current GSD session using the get_messages RPC call.
|
||||
*/
|
||||
export class GsdConversationHistoryPanel implements vscode.Disposable {
|
||||
private static currentPanel: GsdConversationHistoryPanel | undefined;
|
||||
|
||||
private readonly panel: vscode.WebviewPanel;
|
||||
private readonly client: GsdClient;
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
|
||||
static createOrShow(
|
||||
extensionUri: vscode.Uri,
|
||||
client: GsdClient,
|
||||
): GsdConversationHistoryPanel {
|
||||
const column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One;
|
||||
|
||||
if (GsdConversationHistoryPanel.currentPanel) {
|
||||
GsdConversationHistoryPanel.currentPanel.panel.reveal(column);
|
||||
void GsdConversationHistoryPanel.currentPanel.refresh();
|
||||
return GsdConversationHistoryPanel.currentPanel;
|
||||
}
|
||||
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
"gsd-history",
|
||||
"GSD Conversation History",
|
||||
column,
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
},
|
||||
);
|
||||
|
||||
GsdConversationHistoryPanel.currentPanel = new GsdConversationHistoryPanel(
|
||||
panel,
|
||||
extensionUri,
|
||||
client,
|
||||
);
|
||||
void GsdConversationHistoryPanel.currentPanel.refresh();
|
||||
return GsdConversationHistoryPanel.currentPanel;
|
||||
}
|
||||
|
||||
private constructor(
|
||||
panel: vscode.WebviewPanel,
|
||||
_extensionUri: vscode.Uri,
|
||||
client: GsdClient,
|
||||
) {
|
||||
this.panel = panel;
|
||||
this.client = client;
|
||||
|
||||
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
|
||||
|
||||
this.panel.webview.onDidReceiveMessage(
|
||||
async (msg: { command: string }) => {
|
||||
if (msg.command === "refresh") {
|
||||
await this.refresh();
|
||||
}
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
if (!this.client.isConnected) {
|
||||
this.panel.webview.html = this.getHtml([], "Not connected to GSD agent.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await this.client.getMessages();
|
||||
this.panel.webview.html = this.getHtml(raw as ConversationMessage[]);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.panel.webview.html = this.getHtml([], `Error loading messages: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
GsdConversationHistoryPanel.currentPanel = undefined;
|
||||
this.panel.dispose();
|
||||
for (const d of this.disposables) {
|
||||
d.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private getHtml(messages: ConversationMessage[], errorMessage?: string): string {
|
||||
const nonce = getNonce();
|
||||
|
||||
const renderedMessages = messages
|
||||
.filter((m) => m.role === "user" || m.role === "assistant")
|
||||
.map((msg) => {
|
||||
const text = extractText(msg.content);
|
||||
if (!text.trim()) return "";
|
||||
const isUser = msg.role === "user";
|
||||
return `<div class="message ${isUser ? "user" : "assistant"}">
|
||||
<div class="role">${isUser ? "You" : "GSD"}</div>
|
||||
<div class="content">${escapeHtml(text)}</div>
|
||||
</div>`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
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: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.btn {
|
||||
padding: 5px 12px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: var(--vscode-font-size);
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
}
|
||||
.btn:hover { background: var(--vscode-button-hoverBackground); }
|
||||
.count {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.error {
|
||||
color: var(--vscode-errorForeground);
|
||||
padding: 10px 12px;
|
||||
background: var(--vscode-inputValidation-errorBackground);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.55;
|
||||
font-style: italic;
|
||||
}
|
||||
.message {
|
||||
margin-bottom: 14px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
.role {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
padding: 3px 10px;
|
||||
background: var(--vscode-panel-border);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.message.assistant .role {
|
||||
background: var(--vscode-focusBorder);
|
||||
color: var(--vscode-button-foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
.content {
|
||||
padding: 10px 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Conversation History</h2>
|
||||
<div class="toolbar">
|
||||
<button class="btn" id="refresh">Refresh</button>
|
||||
${messages.length > 0 ? `<span class="count">${messages.length} message${messages.length === 1 ? "" : "s"}</span>` : ""}
|
||||
</div>
|
||||
${errorMessage ? `<div class="error">${escapeHtml(errorMessage)}</div>` : ""}
|
||||
${!errorMessage && renderedMessages === "" ? '<div class="empty">No messages in this session.</div>' : renderedMessages}
|
||||
<script nonce="${nonce}">
|
||||
const vscode = acquireVsCodeApi();
|
||||
document.getElementById('refresh').addEventListener('click', () => {
|
||||
vscode.postMessage({ command: 'refresh' });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
function extractText(content: string | ContentBlock[]): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block) => {
|
||||
if (typeof block === "string") return block;
|
||||
if (block?.type === "text" && typeof block.text === "string") return block.text;
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -2,9 +2,17 @@ import * as vscode from "vscode";
|
|||
import { GsdClient, ThinkingLevel } from "./gsd-client.js";
|
||||
import { registerChatParticipant } from "./chat-participant.js";
|
||||
import { GsdSidebarProvider } from "./sidebar.js";
|
||||
import { GsdFileDecorationProvider } from "./file-decorations.js";
|
||||
import { GsdBashTerminal } from "./bash-terminal.js";
|
||||
import { GsdSessionTreeProvider } from "./session-tree.js";
|
||||
import { GsdConversationHistoryPanel } from "./conversation-history.js";
|
||||
import { GsdSlashCompletionProvider } from "./slash-completion.js";
|
||||
import { GsdCodeLensProvider } from "./code-lens.js";
|
||||
|
||||
let client: GsdClient | undefined;
|
||||
let sidebarProvider: GsdSidebarProvider | undefined;
|
||||
let fileDecorations: GsdFileDecorationProvider | undefined;
|
||||
let sessionTreeProvider: GsdSessionTreeProvider | undefined;
|
||||
|
||||
function requireConnected(): boolean {
|
||||
if (!client?.isConnected) {
|
||||
|
|
@ -35,7 +43,43 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||
outputChannel.appendLine(`[stderr] ${msg}`);
|
||||
});
|
||||
|
||||
client.onConnectionChange((connected) => {
|
||||
// -- Persistent status bar item ----------------------------------------
|
||||
|
||||
const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0);
|
||||
statusBarItem.command = "workbench.view.extension.gsd";
|
||||
statusBarItem.text = "$(hubot) GSD";
|
||||
statusBarItem.tooltip = "GSD Agent — click to open";
|
||||
statusBarItem.show();
|
||||
context.subscriptions.push(statusBarItem);
|
||||
|
||||
async function refreshStatusBar(): Promise<void> {
|
||||
if (!client?.isConnected) {
|
||||
statusBarItem.text = "$(hubot) GSD";
|
||||
statusBarItem.tooltip = "GSD: Disconnected";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [state, stats] = await Promise.all([
|
||||
client.getState().catch(() => null),
|
||||
client.getSessionStats().catch(() => null),
|
||||
]);
|
||||
const modelId = state?.model?.id ?? "";
|
||||
const costPart = stats?.totalCost !== undefined ? ` | $${stats.totalCost.toFixed(4)}` : "";
|
||||
const streamPart = state?.isStreaming ? " $(sync~spin)" : "";
|
||||
statusBarItem.text = `$(hubot) GSD${modelId ? ` | ${modelId}` : ""}${costPart}${streamPart}`;
|
||||
statusBarItem.tooltip = state?.model
|
||||
? `GSD: Connected — ${state.model.provider}/${state.model.id}`
|
||||
: "GSD: Connected";
|
||||
} catch {
|
||||
// ignore fetch errors
|
||||
}
|
||||
}
|
||||
|
||||
const statusBarTimer = setInterval(() => refreshStatusBar(), 10_000);
|
||||
context.subscriptions.push({ dispose: () => clearInterval(statusBarTimer) });
|
||||
|
||||
client.onConnectionChange(async (connected) => {
|
||||
await refreshStatusBar();
|
||||
if (connected) {
|
||||
vscode.window.setStatusBarMessage("$(hubot) GSD connected", 3000);
|
||||
} else {
|
||||
|
|
@ -53,10 +97,73 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||
),
|
||||
);
|
||||
|
||||
// -- File decorations --------------------------------------------------
|
||||
|
||||
fileDecorations = new GsdFileDecorationProvider(client);
|
||||
context.subscriptions.push(
|
||||
fileDecorations,
|
||||
vscode.window.registerFileDecorationProvider(fileDecorations),
|
||||
);
|
||||
|
||||
// -- Bash terminal -----------------------------------------------------
|
||||
|
||||
const bashTerminal = new GsdBashTerminal(client);
|
||||
context.subscriptions.push(bashTerminal);
|
||||
|
||||
// -- Session tree view -------------------------------------------------
|
||||
|
||||
sessionTreeProvider = new GsdSessionTreeProvider(client);
|
||||
context.subscriptions.push(
|
||||
sessionTreeProvider,
|
||||
vscode.window.registerTreeDataProvider(GsdSessionTreeProvider.viewId, sessionTreeProvider),
|
||||
);
|
||||
|
||||
// -- Chat participant ---------------------------------------------------
|
||||
|
||||
context.subscriptions.push(registerChatParticipant(context, client));
|
||||
|
||||
// -- Conversation history panel ----------------------------------------
|
||||
|
||||
// (panel is created on demand via gsd.showHistory command)
|
||||
|
||||
// -- Slash command completion ------------------------------------------
|
||||
|
||||
const slashCompletion = new GsdSlashCompletionProvider(client);
|
||||
context.subscriptions.push(
|
||||
slashCompletion,
|
||||
vscode.languages.registerCompletionItemProvider(
|
||||
[
|
||||
{ language: "markdown" },
|
||||
{ language: "plaintext" },
|
||||
{ language: "typescript" },
|
||||
{ language: "typescriptreact" },
|
||||
{ language: "javascript" },
|
||||
{ language: "javascriptreact" },
|
||||
],
|
||||
slashCompletion,
|
||||
"/",
|
||||
),
|
||||
);
|
||||
|
||||
// -- Code lens "Ask GSD" -----------------------------------------------
|
||||
|
||||
const codeLensProvider = new GsdCodeLensProvider(client);
|
||||
context.subscriptions.push(
|
||||
codeLensProvider,
|
||||
vscode.languages.registerCodeLensProvider(
|
||||
[
|
||||
{ language: "typescript" },
|
||||
{ language: "typescriptreact" },
|
||||
{ language: "javascript" },
|
||||
{ language: "javascriptreact" },
|
||||
{ language: "python" },
|
||||
{ language: "go" },
|
||||
{ language: "rust" },
|
||||
],
|
||||
codeLensProvider,
|
||||
),
|
||||
);
|
||||
|
||||
// -- Commands -----------------------------------------------------------
|
||||
|
||||
// Start
|
||||
|
|
@ -68,6 +175,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||
const autoCompaction = vscode.workspace.getConfiguration("gsd").get<boolean>("autoCompaction", true);
|
||||
await client!.setAutoCompaction(autoCompaction).catch(() => {});
|
||||
sidebarProvider?.refresh();
|
||||
refreshStatusBar();
|
||||
vscode.window.showInformationMessage("GSD agent started.");
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to start GSD");
|
||||
|
|
@ -91,6 +199,8 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||
try {
|
||||
await client!.newSession();
|
||||
sidebarProvider?.refresh();
|
||||
sessionTreeProvider?.refresh();
|
||||
fileDecorations?.clear();
|
||||
vscode.window.showInformationMessage("New GSD session started.");
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to start new session");
|
||||
|
|
@ -344,6 +454,132 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||
}),
|
||||
);
|
||||
|
||||
// Switch Session
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.switchSession", async (sessionFile?: string) => {
|
||||
if (!requireConnected()) return;
|
||||
const file = sessionFile ?? await (async () => {
|
||||
const input = await vscode.window.showInputBox({
|
||||
prompt: "Enter session file path",
|
||||
placeHolder: "/path/to/session.jsonl",
|
||||
});
|
||||
return input;
|
||||
})();
|
||||
if (!file) return;
|
||||
try {
|
||||
await client!.switchSession(file);
|
||||
sidebarProvider?.refresh();
|
||||
sessionTreeProvider?.refresh();
|
||||
vscode.window.showInformationMessage("Switched session.");
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to switch session");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Refresh Sessions
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.refreshSessions", () => {
|
||||
sessionTreeProvider?.refresh();
|
||||
}),
|
||||
);
|
||||
|
||||
// Show Conversation History
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.showHistory", () => {
|
||||
if (!requireConnected()) return;
|
||||
GsdConversationHistoryPanel.createOrShow(context.extensionUri, client!);
|
||||
}),
|
||||
);
|
||||
|
||||
// Ask About Symbol (triggered by code lens)
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"gsd.askAboutSymbol",
|
||||
async (symbolName: string, fileName: string, lineNumber: number) => {
|
||||
if (!requireConnected()) return;
|
||||
try {
|
||||
const prompt = `Explain the \`${symbolName}\` function/class in ${fileName} (line ${lineNumber}). Be concise.`;
|
||||
await client!.sendPrompt(prompt);
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to send Ask GSD request");
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Clear File Decorations
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.clearFileDecorations", () => {
|
||||
fileDecorations?.clear();
|
||||
}),
|
||||
);
|
||||
|
||||
// Toggle Auto-Retry
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.toggleAutoRetry", async () => {
|
||||
if (!requireConnected()) return;
|
||||
try {
|
||||
const next = !client!.autoRetryEnabled;
|
||||
await client!.setAutoRetry(next);
|
||||
vscode.window.showInformationMessage(`Auto-retry ${next ? "enabled" : "disabled"}.`);
|
||||
sidebarProvider?.refresh();
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to toggle auto-retry");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Abort Retry
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.abortRetry", async () => {
|
||||
if (!requireConnected()) return;
|
||||
try {
|
||||
await client!.abortRetry();
|
||||
vscode.window.showInformationMessage("Retry aborted.");
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to abort retry");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Set Session Name
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.setSessionName", async () => {
|
||||
if (!requireConnected()) return;
|
||||
const name = await vscode.window.showInputBox({
|
||||
prompt: "Enter a name for this session",
|
||||
placeHolder: "e.g. auth-refactor",
|
||||
});
|
||||
if (!name) return;
|
||||
try {
|
||||
await client!.setSessionName(name);
|
||||
sidebarProvider?.refresh();
|
||||
vscode.window.showInformationMessage(`Session named "${name}".`);
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to set session name");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Copy Last Response
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("gsd.copyLastResponse", async () => {
|
||||
if (!requireConnected()) return;
|
||||
try {
|
||||
const text = await client!.getLastAssistantText();
|
||||
if (!text) {
|
||||
vscode.window.showInformationMessage("No response to copy.");
|
||||
return;
|
||||
}
|
||||
await vscode.env.clipboard.writeText(text);
|
||||
vscode.window.showInformationMessage("Last response copied to clipboard.");
|
||||
} catch (err) {
|
||||
handleError(err, "Failed to copy last response");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -- Auto-start ---------------------------------------------------------
|
||||
|
||||
if (config.get<boolean>("autoStart", false)) {
|
||||
|
|
@ -354,6 +590,10 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||
export function deactivate(): void {
|
||||
client?.dispose();
|
||||
sidebarProvider?.dispose();
|
||||
fileDecorations?.dispose();
|
||||
sessionTreeProvider?.dispose();
|
||||
client = undefined;
|
||||
sidebarProvider = undefined;
|
||||
fileDecorations = undefined;
|
||||
sessionTreeProvider = undefined;
|
||||
}
|
||||
|
|
|
|||
84
vscode-extension/src/file-decorations.ts
Normal file
84
vscode-extension/src/file-decorations.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import * as vscode from "vscode";
|
||||
import type { AgentEvent, GsdClient } from "./gsd-client.js";
|
||||
|
||||
/**
|
||||
* Badges files in the VS Code explorer that GSD has written or edited
|
||||
* during the current session.
|
||||
*/
|
||||
export class GsdFileDecorationProvider implements vscode.FileDecorationProvider, vscode.Disposable {
|
||||
private readonly _onDidChangeFileDecorations = new vscode.EventEmitter<vscode.Uri | vscode.Uri[] | undefined>();
|
||||
readonly onDidChangeFileDecorations = this._onDidChangeFileDecorations.event;
|
||||
|
||||
private modifiedUris = new Set<string>();
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
|
||||
constructor(private readonly client: GsdClient) {
|
||||
this.disposables.push(
|
||||
this._onDidChangeFileDecorations,
|
||||
client.onEvent((evt: AgentEvent) => this.handleEvent(evt)),
|
||||
client.onConnectionChange((connected) => {
|
||||
if (!connected) {
|
||||
this.clear();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handleEvent(evt: AgentEvent): void {
|
||||
if (evt.type !== "tool_execution_start") {
|
||||
return;
|
||||
}
|
||||
const toolName = evt.toolName as string | undefined;
|
||||
if (toolName !== "Write" && toolName !== "Edit") {
|
||||
return;
|
||||
}
|
||||
const toolInput = evt.toolInput as Record<string, unknown> | undefined;
|
||||
const fp = toolInput?.file_path ? String(toolInput.file_path) : undefined;
|
||||
if (!fp) {
|
||||
return;
|
||||
}
|
||||
const uri = resolveUri(fp);
|
||||
if (uri) {
|
||||
this.modifiedUris.add(uri.toString());
|
||||
this._onDidChangeFileDecorations.fire(uri);
|
||||
}
|
||||
}
|
||||
|
||||
provideFileDecoration(uri: vscode.Uri): vscode.FileDecoration | undefined {
|
||||
if (this.modifiedUris.has(uri.toString())) {
|
||||
return {
|
||||
badge: "G",
|
||||
tooltip: "Modified by GSD",
|
||||
color: new vscode.ThemeColor("gitDecoration.modifiedResourceForeground"),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.modifiedUris.clear();
|
||||
this._onDidChangeFileDecorations.fire(undefined);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clear();
|
||||
for (const d of this.disposables) {
|
||||
d.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUri(fp: string): vscode.Uri | null {
|
||||
try {
|
||||
if (fp.startsWith("/") || /^[A-Za-z]:[\\/]/.test(fp)) {
|
||||
return vscode.Uri.file(fp);
|
||||
}
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders?.length) {
|
||||
return null;
|
||||
}
|
||||
return vscode.Uri.joinPath(folders[0].uri, fp);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ export class GsdClient implements vscode.Disposable {
|
|||
private buffer = "";
|
||||
private restartCount = 0;
|
||||
private restartTimestamps: number[] = [];
|
||||
private _autoRetryEnabled = false;
|
||||
|
||||
private readonly _onEvent = new vscode.EventEmitter<AgentEvent>();
|
||||
readonly onEvent = this._onEvent.event;
|
||||
|
|
@ -110,6 +111,10 @@ export class GsdClient implements vscode.Disposable {
|
|||
return this.process !== null && this.process.exitCode === null;
|
||||
}
|
||||
|
||||
get autoRetryEnabled(): boolean {
|
||||
return this._autoRetryEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the GSD agent in RPC mode.
|
||||
*/
|
||||
|
|
@ -377,6 +382,7 @@ export class GsdClient implements vscode.Disposable {
|
|||
async setAutoRetry(enabled: boolean): Promise<void> {
|
||||
const response = await this.send({ type: "set_auto_retry", enabled });
|
||||
this.assertSuccess(response);
|
||||
this._autoRetryEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -418,6 +424,7 @@ export class GsdClient implements vscode.Disposable {
|
|||
async newSession(): Promise<void> {
|
||||
const response = await this.send({ type: "new_session" });
|
||||
this.assertSuccess(response);
|
||||
this._autoRetryEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
126
vscode-extension/src/session-tree.ts
Normal file
126
vscode-extension/src/session-tree.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import * as vscode from "vscode";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { GsdClient } from "./gsd-client.js";
|
||||
|
||||
export interface SessionItem {
|
||||
label: string;
|
||||
sessionFile: string;
|
||||
timestamp: Date;
|
||||
sessionId: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tree view provider that lists GSD session files from the same directory
|
||||
* as the currently active session.
|
||||
*/
|
||||
export class GsdSessionTreeProvider implements vscode.TreeDataProvider<SessionItem>, vscode.Disposable {
|
||||
public static readonly viewId = "gsd-sessions";
|
||||
|
||||
private readonly _onDidChangeTreeData = new vscode.EventEmitter<void>();
|
||||
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
|
||||
|
||||
private sessions: SessionItem[] = [];
|
||||
private currentSessionFile: string | undefined;
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
|
||||
constructor(private readonly client: GsdClient) {
|
||||
this.disposables.push(
|
||||
this._onDidChangeTreeData,
|
||||
client.onConnectionChange(() => this.refresh()),
|
||||
);
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
this.sessions = await this.loadSessions();
|
||||
this._onDidChangeTreeData.fire();
|
||||
}
|
||||
|
||||
private async loadSessions(): Promise<SessionItem[]> {
|
||||
if (!this.client.isConnected) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const state = await this.client.getState();
|
||||
this.currentSessionFile = state.sessionFile;
|
||||
if (!state.sessionFile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessionDir = path.dirname(state.sessionFile);
|
||||
const files = fs.readdirSync(sessionDir)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.sort()
|
||||
.reverse(); // newest first
|
||||
|
||||
const items: SessionItem[] = [];
|
||||
for (const file of files) {
|
||||
// Filename format: <unixTimestampMs>_<sessionId>.jsonl
|
||||
const match = file.match(/^(\d+)_(.+)\.jsonl$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const ts = parseInt(match[1], 10);
|
||||
const sessionId = match[2];
|
||||
const sessionFile = path.join(sessionDir, file);
|
||||
items.push({
|
||||
label: formatDate(new Date(ts)),
|
||||
sessionFile,
|
||||
timestamp: new Date(ts),
|
||||
sessionId,
|
||||
isCurrent: sessionFile === state.sessionFile,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getTreeItem(element: SessionItem): vscode.TreeItem {
|
||||
const item = new vscode.TreeItem(element.label, vscode.TreeItemCollapsibleState.None);
|
||||
item.description = element.sessionId.slice(0, 8);
|
||||
item.tooltip = new vscode.MarkdownString(
|
||||
`**${element.label}**\n\nID: \`${element.sessionId}\`\n\nFile: \`${element.sessionFile}\``,
|
||||
);
|
||||
item.iconPath = new vscode.ThemeIcon(
|
||||
element.isCurrent ? "comment-discussion" : "history",
|
||||
element.isCurrent ? new vscode.ThemeColor("terminal.ansiGreen") : undefined,
|
||||
);
|
||||
if (!element.isCurrent) {
|
||||
item.command = {
|
||||
command: "gsd.switchSession",
|
||||
title: "Switch to Session",
|
||||
arguments: [element.sessionFile],
|
||||
};
|
||||
}
|
||||
item.contextValue = element.isCurrent ? "currentSession" : "session";
|
||||
return item;
|
||||
}
|
||||
|
||||
getChildren(): SessionItem[] {
|
||||
return this.sessions;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) {
|
||||
d.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
|
||||
if (diffDays === 0) {
|
||||
return `Today ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
} else if (diffDays === 1) {
|
||||
return `Yesterday ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
} else if (diffDays < 7) {
|
||||
return d.toLocaleDateString([], { weekday: "short", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
return d.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
|
@ -19,9 +19,17 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
|
|||
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();
|
||||
switch (evt.type) {
|
||||
case "agent_start":
|
||||
case "agent_end":
|
||||
case "model_switched":
|
||||
case "compaction_start":
|
||||
case "compaction_end":
|
||||
case "retry_start":
|
||||
case "retry_end":
|
||||
case "retry_error":
|
||||
this.refresh();
|
||||
break;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
@ -85,6 +93,18 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
|
|||
}
|
||||
}
|
||||
break;
|
||||
case "toggleAutoRetry":
|
||||
if (this.client.isConnected) {
|
||||
await this.client.setAutoRetry(!this.client.autoRetryEnabled).catch(() => {});
|
||||
this.refresh();
|
||||
}
|
||||
break;
|
||||
case "setSessionName":
|
||||
await vscode.commands.executeCommand("gsd.setSessionName");
|
||||
break;
|
||||
case "copyLastResponse":
|
||||
await vscode.commands.executeCommand("gsd.copyLastResponse");
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -107,13 +127,16 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
|
|||
let sessionId = "N/A";
|
||||
let sessionName = "";
|
||||
let messageCount = 0;
|
||||
let pendingMessageCount = 0;
|
||||
let thinkingLevel: ThinkingLevel = "off";
|
||||
let isStreaming = false;
|
||||
let isCompacting = false;
|
||||
let autoCompaction = false;
|
||||
let autoRetry = false;
|
||||
let stats: SessionStats | null = null;
|
||||
|
||||
if (this.client.isConnected) {
|
||||
autoRetry = this.client.autoRetryEnabled;
|
||||
try {
|
||||
const state = await this.client.getState();
|
||||
modelName = state.model
|
||||
|
|
@ -122,6 +145,7 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
|
|||
sessionId = state.sessionId;
|
||||
sessionName = state.sessionName ?? "";
|
||||
messageCount = state.messageCount;
|
||||
pendingMessageCount = state.pendingMessageCount;
|
||||
thinkingLevel = state.thinkingLevel as ThinkingLevel;
|
||||
isStreaming = state.isStreaming;
|
||||
isCompacting = state.isCompacting;
|
||||
|
|
@ -145,10 +169,12 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
|
|||
sessionId,
|
||||
sessionName,
|
||||
messageCount,
|
||||
pendingMessageCount,
|
||||
thinkingLevel,
|
||||
isStreaming,
|
||||
isCompacting,
|
||||
autoCompaction,
|
||||
autoRetry,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
|
|
@ -168,10 +194,12 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
|
|||
sessionId: string;
|
||||
sessionName: string;
|
||||
messageCount: number;
|
||||
pendingMessageCount: number;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
isStreaming: boolean;
|
||||
isCompacting: boolean;
|
||||
autoCompaction: boolean;
|
||||
autoRetry: boolean;
|
||||
stats: SessionStats | null;
|
||||
}): string {
|
||||
const statusColor = info.connected ? "#4ec9b0" : "#f44747";
|
||||
|
|
@ -185,6 +213,12 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
|
|||
|
||||
const inputTokens = info.stats?.inputTokens?.toLocaleString() ?? "-";
|
||||
const outputTokens = info.stats?.outputTokens?.toLocaleString() ?? "-";
|
||||
const cacheRead = info.stats?.cacheReadTokens?.toLocaleString() ?? "-";
|
||||
const cacheWrite = info.stats?.cacheWriteTokens?.toLocaleString() ?? "-";
|
||||
const turnCount = info.stats?.turnCount?.toString() ?? "-";
|
||||
const duration = info.stats?.duration !== undefined
|
||||
? `${Math.round(info.stats.duration / 1000)}s`
|
||||
: "-";
|
||||
const cost = info.stats?.totalCost !== undefined ? `$${info.stats.totalCost.toFixed(4)}` : "-";
|
||||
|
||||
const thinkingBadge = info.thinkingLevel !== "off"
|
||||
|
|
@ -195,6 +229,10 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
|
|||
? `<span class="badge">on</span>`
|
||||
: `<span class="badge muted">off</span>`;
|
||||
|
||||
const autoRetryBadge = info.autoRetry
|
||||
? `<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>`
|
||||
: "";
|
||||
|
|
@ -352,8 +390,14 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
|
|||
<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>Session</td>
|
||||
<td>
|
||||
${escapeHtml(info.sessionName || info.sessionId)}
|
||||
${info.connected ? `<span class="badge clickable" data-command="setSessionName" title="Rename session" style="margin-left:4px">✎</span>` : ""}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Messages</td><td>${info.messageCount}${info.pendingMessageCount > 0 ? ` <span class="badge muted">+${info.pendingMessageCount} pending</span>` : ""}</td></tr>
|
||||
<tr>
|
||||
<td>Thinking</td>
|
||||
<td>${thinkingBadge}</td>
|
||||
|
|
@ -362,6 +406,10 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
|
|||
<td>Auto-compact</td>
|
||||
<td>${autoCompBadge}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Auto-retry</td>
|
||||
<td>${autoRetryBadge}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
|
@ -373,6 +421,14 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
|
|||
<span class="value">${inputTokens}</span>
|
||||
<span class="label">Output</span>
|
||||
<span class="value">${outputTokens}</span>
|
||||
<span class="label">Cache read</span>
|
||||
<span class="value">${cacheRead}</span>
|
||||
<span class="label">Cache write</span>
|
||||
<span class="value">${cacheWrite}</span>
|
||||
<span class="label">Turns</span>
|
||||
<span class="value">${turnCount}</span>
|
||||
<span class="label">Duration</span>
|
||||
<span class="value">${duration}</span>
|
||||
<span class="label">Cost</span>
|
||||
<span class="value">${cost}</span>
|
||||
</div>
|
||||
|
|
@ -391,6 +447,10 @@ export class GsdSidebarProvider implements vscode.WebviewViewProvider {
|
|||
<div class="btn-row">
|
||||
<button class="secondary" data-command="cycleThinking">Thinking</button>
|
||||
<button class="secondary" data-command="toggleAutoCompaction">Auto-Compact</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="secondary" data-command="toggleAutoRetry">Auto-Retry</button>
|
||||
<button class="secondary" data-command="copyLastResponse">Copy Response</button>
|
||||
</div>`
|
||||
: `<button data-command="start">Start Agent</button>`
|
||||
}
|
||||
|
|
|
|||
107
vscode-extension/src/slash-completion.ts
Normal file
107
vscode-extension/src/slash-completion.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import * as vscode from "vscode";
|
||||
import type { GsdClient, SlashCommand } from "./gsd-client.js";
|
||||
|
||||
/**
|
||||
* CompletionItemProvider that surfaces GSD slash commands when the user
|
||||
* types `/` at the start of a line (or after only whitespace) in Markdown,
|
||||
* plaintext, and TypeScript/JavaScript files.
|
||||
*
|
||||
* Commands are fetched from the running agent via get_commands RPC and
|
||||
* cached so the list remains available between keystrokes.
|
||||
*/
|
||||
export class GsdSlashCompletionProvider
|
||||
implements vscode.CompletionItemProvider, vscode.Disposable
|
||||
{
|
||||
private cachedCommands: SlashCommand[] = [];
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
|
||||
constructor(private readonly client: GsdClient) {
|
||||
// Refresh cache whenever the connection (re)establishes.
|
||||
this.disposables.push(
|
||||
client.onConnectionChange(async (connected) => {
|
||||
if (connected) {
|
||||
await this.refreshCache();
|
||||
} else {
|
||||
this.cachedCommands = [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async provideCompletionItems(
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position,
|
||||
_token: vscode.CancellationToken,
|
||||
): Promise<vscode.CompletionItem[] | undefined> {
|
||||
const lineText = document.lineAt(position).text;
|
||||
const linePrefix = lineText.slice(0, position.character);
|
||||
|
||||
// Only activate when the non-whitespace content starts with `/`.
|
||||
if (!/^\s*\/\S*$/.test(linePrefix)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Lazily populate the cache on first use.
|
||||
if (this.cachedCommands.length === 0 && this.client.isConnected) {
|
||||
await this.refreshCache();
|
||||
}
|
||||
|
||||
if (this.cachedCommands.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// The text the user has typed after the `/` — used for pre-filtering.
|
||||
const slashIndex = linePrefix.lastIndexOf("/");
|
||||
const typedAfterSlash = linePrefix.slice(slashIndex + 1);
|
||||
|
||||
// Range to replace: from the `/` to the current cursor position.
|
||||
const replaceRange = new vscode.Range(
|
||||
new vscode.Position(position.line, slashIndex),
|
||||
position,
|
||||
);
|
||||
|
||||
return this.cachedCommands
|
||||
.filter(
|
||||
(cmd) =>
|
||||
typedAfterSlash.length === 0 ||
|
||||
cmd.name.toLowerCase().startsWith(typedAfterSlash.toLowerCase()),
|
||||
)
|
||||
.map((cmd) => this.toCompletionItem(cmd, replaceRange));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) {
|
||||
d.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshCache(): Promise<void> {
|
||||
try {
|
||||
this.cachedCommands = await this.client.getCommands();
|
||||
} catch {
|
||||
// Silently ignore — agent may not be ready yet.
|
||||
}
|
||||
}
|
||||
|
||||
private toCompletionItem(cmd: SlashCommand, replaceRange: vscode.Range): vscode.CompletionItem {
|
||||
const item = new vscode.CompletionItem(`/${cmd.name}`, vscode.CompletionItemKind.Event);
|
||||
|
||||
item.insertText = `/${cmd.name}`;
|
||||
item.filterText = `/${cmd.name}`;
|
||||
item.sortText = cmd.name;
|
||||
item.range = replaceRange;
|
||||
item.commitCharacters = [" ", "\n"];
|
||||
|
||||
const sourceNote = `Source: \`${cmd.source}\`${cmd.location ? ` (${cmd.location})` : ""}`;
|
||||
if (cmd.description) {
|
||||
item.detail = cmd.description;
|
||||
item.documentation = new vscode.MarkdownString(
|
||||
`**/${cmd.name}** — ${cmd.description}\n\n${sourceNote}`,
|
||||
);
|
||||
} else {
|
||||
item.documentation = new vscode.MarkdownString(`**/${cmd.name}**\n\n${sourceNote}`);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue