From de40a7e221e49f1ea8afded388761029f8645430 Mon Sep 17 00:00:00 2001 From: Gourav Bansal Date: Wed, 27 May 2026 22:21:02 +0530 Subject: [PATCH 1/9] fix(ide): secure API keys; fix JetBrains chat + VSCode chat UX Security: - VSCode: store the provider API key in SecretStorage instead of settings.json (which syncs in plaintext). Migrate + clear any legacy value, deprecate the codesetu.apiKey setting, cache the key in memory and refresh on change. - JetBrains: store the API key in PasswordSafe instead of the plaintext settings XML, with one-time migration off the legacy field. JetBrains chat correctness: - Include workspace skill/check bodies in the system message (were loaded then silently dropped). - Keep multi-turn conversation history instead of single-turn. - Capture editor context on the EDT; load workspace instructions inside a read action. - Skip malformed SSE chunks instead of aborting the whole stream. VSCode chat UX: - Show the real configured provider/model in the composer; remove the non-functional model/reasoning/plugins/permissions/plan/goal controls. - Render assistant messages as HTML-escaped markdown (code fences, inline code, bold, lists) with no XSS surface. - Trim transcript history to a char budget; drop the optimistic user turn on error so retries don't stack two user messages. - Exclude likely-secret files (.env, keys, secrets/) from the auto-collected workspace snippets. Co-Authored-By: Claude Opus 4.7 --- .../ai/codesetu/prompts/PromptBuilder.kt | 24 +- .../provider/CodeSetuProviderClient.kt | 11 +- .../codesetu/settings/CodeSetuApiKeyStore.kt | 26 + .../settings/CodeSetuSettingsConfigurable.kt | 8 +- .../settings/CodeSetuSettingsState.kt | 20 + .../toolwindow/CodeSetuToolWindowFactory.kt | 43 +- apps/vscode/package.json | 3 +- apps/vscode/src/chatPanel.ts | 32 ++ apps/vscode/src/chatPanelHtml.ts | 481 ++++++------------ apps/vscode/src/configuration.ts | 12 +- apps/vscode/src/extension.ts | 41 +- apps/vscode/src/ideContext.ts | 3 +- apps/vscode/src/providerDiagnostics.ts | 5 +- apps/vscode/src/providerSetup.ts | 7 +- apps/vscode/src/secretStorage.ts | 92 ++++ apps/vscode/test/chatPanelHtml.test.ts | 41 +- 16 files changed, 489 insertions(+), 360 deletions(-) create mode 100644 apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuApiKeyStore.kt create mode 100644 apps/vscode/src/secretStorage.ts diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/prompts/PromptBuilder.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/prompts/PromptBuilder.kt index 71edb61..c50dee6 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/prompts/PromptBuilder.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/prompts/PromptBuilder.kt @@ -4,13 +4,27 @@ import ai.codesetu.model.IdeActionId import ai.codesetu.model.IdeContextPayload import ai.codesetu.model.WorkspaceInstruction -fun buildSystemMessage(instructions: List): String = - if (instructions.isEmpty()) { - "You are CodeSetu, an AI coding assistant for Indian developers. Be concise, correct, practical, and privacy-aware." - } else { - "You are CodeSetu, an AI coding assistant for Indian developers. Be concise, correct, practical, and privacy-aware.\n\nFollow applicable workspace guidance when it helps the user's request." +fun buildSystemMessage(instructions: List): String { + val parts = mutableListOf( + "You are CodeSetu, an AI coding assistant for Indian developers. Be concise, correct, practical, and privacy-aware.", + "Use the supplied IDE context as the source of truth. Ask for missing context when needed.", + ) + + if (instructions.isNotEmpty()) { + parts.add(formatWorkspaceInstructions(instructions)) } + return parts.joinToString("\n\n") +} + +private fun formatWorkspaceInstructions(instructions: List): String { + val rendered = instructions.joinToString("\n\n") { instruction -> + listOf("${instruction.name} (${instruction.id})", instruction.description, instruction.body) + .joinToString("\n") + } + return "Workspace instructions\n\n$rendered" +} + fun buildActionInstruction(actionId: IdeActionId): String = when (actionId) { IdeActionId.EXPLAIN -> "Explain the selected code clearly and concisely. Include key control flow, inputs, outputs, and risks." diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/provider/CodeSetuProviderClient.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/provider/CodeSetuProviderClient.kt index 7a987ab..af21cd2 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/provider/CodeSetuProviderClient.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/provider/CodeSetuProviderClient.kt @@ -29,7 +29,7 @@ class CodeSetuProviderClient( ) val request = HttpRequest.newBuilder() .uri(URI.create(state.baseUrl.trimEnd('/') + "/chat/completions")) - .header("Authorization", "Bearer ${state.apiKey}") + .header("Authorization", "Bearer ${CodeSetuSettingsState.getInstance().getApiKey()}") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build() @@ -60,7 +60,7 @@ class CodeSetuProviderClient( ) val request = HttpRequest.newBuilder() .uri(URI.create(state.baseUrl.trimEnd('/') + "/chat/completions")) - .header("Authorization", "Bearer ${state.apiKey}") + .header("Authorization", "Bearer ${CodeSetuSettingsState.getInstance().getApiKey()}") .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build() @@ -87,7 +87,12 @@ class CodeSetuProviderClient( break } - val text = getAssistantChunkText(json.decodeFromString(data)) + // Skip malformed/partial SSE payloads instead of aborting the whole stream. + val text = try { + getAssistantChunkText(json.decodeFromString(data)) + } catch (error: Exception) { + "" + } if (text.isNotEmpty()) { assistantText.append(text) diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuApiKeyStore.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuApiKeyStore.kt new file mode 100644 index 0000000..d0d3d62 --- /dev/null +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuApiKeyStore.kt @@ -0,0 +1,26 @@ +package ai.codesetu.settings + +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.credentialStore.Credentials +import com.intellij.credentialStore.generateServiceName +import com.intellij.ide.passwordSafe.PasswordSafe + +/** + * Stores the CodeSetu provider API key in the IDE's [PasswordSafe] (OS keychain + * / encrypted credential store) instead of the plaintext settings XML. + */ +object CodeSetuApiKeyStore { + private val attributes: CredentialAttributes = + CredentialAttributes(generateServiceName("CodeSetu", "apiKey")) + + fun get(): String = PasswordSafe.instance.getPassword(attributes).orEmpty() + + fun set(value: String) { + val trimmed = value.trim() + if (trimmed.isEmpty()) { + PasswordSafe.instance.set(attributes, null) + } else { + PasswordSafe.instance.set(attributes, Credentials(null, trimmed)) + } + } +} diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsConfigurable.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsConfigurable.kt index b70a5e9..7442a7d 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsConfigurable.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsConfigurable.kt @@ -10,7 +10,7 @@ class CodeSetuSettingsConfigurable : Configurable { private var provider = settings.state.provider private var baseUrl = settings.state.baseUrl private var model = settings.state.model - private var apiKey = settings.state.apiKey + private var apiKey = settings.getApiKey() override fun getDisplayName(): String = "CodeSetu" @@ -25,19 +25,19 @@ class CodeSetuSettingsConfigurable : Configurable { provider != settings.state.provider || baseUrl != settings.state.baseUrl || model != settings.state.model || - apiKey != settings.state.apiKey + apiKey != settings.getApiKey() override fun apply() { settings.state.provider = provider.trim() settings.state.baseUrl = baseUrl.trim() settings.state.model = model.trim() - settings.state.apiKey = apiKey.trim() + settings.setApiKey(apiKey) } override fun reset() { provider = settings.state.provider baseUrl = settings.state.baseUrl model = settings.state.model - apiKey = settings.state.apiKey + apiKey = settings.getApiKey() } } diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsState.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsState.kt index aec25b9..54eb015 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsState.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuSettingsState.kt @@ -14,6 +14,8 @@ class CodeSetuSettingsState : PersistentStateComponent() init { transcript.isEditable = false @@ -50,18 +53,23 @@ class CodeSetuChatPanel(private val project: Project) { input.text = "" send.isEnabled = false + // Capture editor/document context on the EDT (this method runs on the EDT); + // reading the selected editor or document text off the EDT is unsafe. + val ideContext = capturedIdeContext ?: collectIdeContext(project) + ApplicationManager.getApplication().executeOnPooledThread { - val instructions = loadWorkspaceInstructions(project) - val ideContext = buildContextMarkdown(capturedIdeContext ?: collectIdeContext(project)) - val userMessage = if (ideContext.isBlank()) { + val instructions = ReadAction.compute, RuntimeException> { + loadWorkspaceInstructions(project) + } + val contextMarkdown = buildContextMarkdown(ideContext) + val userMessage = if (contextMarkdown.isBlank()) { trimmed } else { - "$trimmed\n\nCurrent IDE context:\n\n$ideContext" + "$trimmed\n\nCurrent IDE context:\n\n$contextMarkdown" } - val messages = listOf( - ChatMessage("system", buildSystemMessage(instructions)), - ChatMessage("user", userMessage), - ) + history.add(ChatMessage("user", userMessage)) + val messages = listOf(ChatMessage("system", buildSystemMessage(instructions))) + history + var receivedChunk = false val response = try { client.streamChat(messages) { chunk -> @@ -79,6 +87,9 @@ class CodeSetuChatPanel(private val project: Project) { } } catch (error: Exception) { if (receivedChunk) { + // Partial stream then failure: drop the user turn so the next message + // doesn't stack two consecutive user turns. + history.removeLastOrNull() val message = "\n\nCodeSetu could not complete that request: ${error.message ?: error}" ApplicationManager.getApplication().invokeLater { appendChunk(message) @@ -91,10 +102,24 @@ class CodeSetuChatPanel(private val project: Project) { try { client.chat(messages) } catch (fallbackError: Exception) { - "CodeSetu could not complete that request: ${fallbackError.message ?: fallbackError}" + history.removeLastOrNull() + ApplicationManager.getApplication().invokeLater { + append( + "CodeSetu", + "CodeSetu could not complete that request: ${fallbackError.message ?: fallbackError}", + ) + send.isEnabled = true + } + return@executeOnPooledThread } } + if (response.isNotBlank()) { + history.add(ChatMessage("assistant", response)) + } else { + history.removeLastOrNull() + } + ApplicationManager.getApplication().invokeLater { if (receivedChunk) { if (response.isBlank()) { diff --git a/apps/vscode/package.json b/apps/vscode/package.json index ea2986a..5dae625 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -168,7 +168,8 @@ "codesetu.apiKey": { "type": "string", "default": "", - "description": "API key for the configured provider. If empty, CodeSetu reads provider environment variables." + "markdownDeprecationMessage": "Storing the API key in settings is insecure — it is saved in plaintext and synced by Settings Sync. CodeSetu now keeps it in the OS secret store. Run **CodeSetu: Setup Provider**; any value left here is migrated to secure storage and cleared automatically.", + "description": "Deprecated. Use the CodeSetu: Setup Provider command (the key is stored in the OS secret store). If empty, CodeSetu reads provider environment variables." }, "codesetu.baseUrl": { "type": "string", diff --git a/apps/vscode/src/chatPanel.ts b/apps/vscode/src/chatPanel.ts index 9926c49..39618ee 100644 --- a/apps/vscode/src/chatPanel.ts +++ b/apps/vscode/src/chatPanel.ts @@ -20,6 +20,11 @@ import type { ChatMessage, IdeContextPayload } from "@codesetu/core"; import * as vscode from "vscode"; import { renderChatPanelHtml } from "./chatPanelHtml"; +import { summarizeCodeSetuConfiguration } from "./configuration"; + +// Cap the rolling transcript sent to the provider so long sessions don't +// overflow the context window. The most recent turns are always kept. +const MAX_HISTORY_CHARS = 100_000; export interface ChatResponderContext { ideContext?: IdeContextPayload; @@ -130,6 +135,7 @@ export class ChatPanel { void this.panel.webview.postMessage({ type: "busy", value: true }); void this.panel.webview.postMessage({ type: "userMessage", text }); this.history.push({ role: "user", content: text }); + this.trimHistory(); try { let isStreamingAssistantMessage = false; @@ -152,6 +158,11 @@ export class ChatPanel { : { type: "assistantMessage", text: response }, ); } catch (error: unknown) { + // Drop the optimistic user turn so a retry doesn't stack two user + // messages with no assistant reply between them. + if (this.history[this.history.length - 1]?.role === "user") { + this.history.pop(); + } this.outputChannel.appendLine(`Chat request failed: ${formatErrorMessage(error)}`); void this.panel.webview.postMessage({ type: "error", @@ -163,14 +174,35 @@ export class ChatPanel { } } + private trimHistory(): void { + let total = this.history.reduce((sum, message) => sum + messageLength(message), 0); + + while (this.history.length > 1 && total > MAX_HISTORY_CHARS) { + const removed = this.history.shift(); + + if (removed === undefined) { + break; + } + + total -= messageLength(removed); + } + } + private renderHtml(webview: vscode.Webview): string { + const summary = summarizeCodeSetuConfiguration(); + return renderChatPanelHtml({ cspSource: webview.cspSource, nonce: crypto.randomUUID(), + modelLabel: `${summary.provider} · ${summary.model ?? "default"}`, }); } } +function messageLength(message: ChatMessage): number { + return typeof message.content === "string" ? message.content.length : 0; +} + function isSendMessageRequest(message: unknown): message is SendMessageRequest { if (typeof message !== "object" || message === null) { return false; diff --git a/apps/vscode/src/chatPanelHtml.ts b/apps/vscode/src/chatPanelHtml.ts index 9bf585a..2c40398 100644 --- a/apps/vscode/src/chatPanelHtml.ts +++ b/apps/vscode/src/chatPanelHtml.ts @@ -17,9 +17,21 @@ export interface RenderChatPanelHtmlOptions { cspSource: string; nonce: string; + /** Human-readable "provider · model" shown in the composer (real, configured values). */ + modelLabel: string; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); } export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string { + const modelLabel = escapeHtml(options.modelLabel); + return /* html */ ` @@ -70,6 +82,7 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string line-height: 1.5; padding: 11px 14px; white-space: pre-wrap; + overflow-wrap: anywhere; } .user { @@ -80,6 +93,38 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string .assistant { background: var(--vscode-editor-inactiveSelectionBackground); border: 1px solid rgba(127, 127, 127, 0.08); + white-space: normal; + } + + .assistant > :first-child { + margin-top: 0; + } + + .assistant pre { + margin: 8px 0; + padding: 10px 12px; + overflow-x: auto; + border-radius: 8px; + background: var(--vscode-textCodeBlock-background, rgba(127, 127, 127, 0.12)); + white-space: pre; + } + + .assistant pre code { + padding: 0; + background: none; + } + + .assistant code { + font-family: var(--vscode-editor-font-family, monospace); + font-size: 0.95em; + padding: 1px 4px; + border-radius: 4px; + background: var(--vscode-textCodeBlock-background, rgba(127, 127, 127, 0.12)); + } + + .assistant ul { + margin: 6px 0; + padding-left: 22px; } .error { @@ -97,7 +142,7 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string display: flex; flex-direction: column; gap: 10px; - min-height: 132px; + min-height: 120px; padding: 16px 18px 14px; border: 1px solid var(--vscode-input-border, var(--vscode-widget-border)); border-radius: 20px; @@ -136,13 +181,6 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string color: var(--vscode-input-placeholderForeground); } - .composer-toolbar, - .toolbar-group { - display: flex; - align-items: center; - gap: 10px; - } - .composer-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -152,6 +190,9 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string } .toolbar-group { + display: flex; + align-items: center; + gap: 10px; min-width: 0; } @@ -164,11 +205,8 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string } .icon-button, - .pill-button, .send-button, - .local-button, - .menu-row, - .menu-button { + .menu-row { border: 0; color: var(--vscode-foreground); background: transparent; @@ -190,19 +228,13 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string } .icon-button:hover, - .pill-button:hover, - .local-button:hover, - .menu-row:hover, - .menu-button:hover { + .menu-row:hover { background: var(--vscode-toolbar-hoverBackground); } .icon-button:focus-visible, - .pill-button:focus-visible, .send-button:focus-visible, - .local-button:focus-visible, - .menu-row:focus-visible, - .menu-button:focus-visible { + .menu-row:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 2px; } @@ -211,23 +243,17 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string outline: none; } - .pill-button, - .local-button { - min-height: 34px; - padding: 0 9px; - border-radius: 9px; - } - - .pill-button { + .model-chip { display: inline-flex; align-items: center; - gap: 8px; - min-width: 0; + min-height: 30px; + padding: 0 10px; + border-radius: 9px; color: var(--vscode-descriptionForeground); white-space: nowrap; } - .pill-button strong { + .model-chip strong { color: var(--vscode-foreground); font-weight: 500; } @@ -245,22 +271,11 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string .send-button:disabled, textarea:disabled, - .pill-button:disabled, .icon-button:disabled { cursor: not-allowed; opacity: 0.55; } - .local-button { - justify-self: start; - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 30px; - padding-inline: 8px; - color: var(--vscode-descriptionForeground); - } - .composer-icon { width: 18px; height: 18px; @@ -283,12 +298,6 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string stroke-width: 2.4; } - .chevron { - width: 14px; - height: 14px; - opacity: 0.86; - } - .menu { position: absolute; z-index: 10; @@ -310,33 +319,11 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string bottom: 54px; } - .plugins-menu { - left: 254px; - bottom: 56px; - min-width: 220px; - } - - .model-menu { - right: 50px; - bottom: 54px; - min-width: 280px; - } - - .menu-title { - padding: 7px 10px; - color: var(--vscode-descriptionForeground); - } - - .menu-divider { - height: 1px; - margin: 6px 8px; - background: var(--vscode-widget-border); - } - - .menu-row, - .menu-button { + .menu-row { display: flex; align-items: center; + justify-content: space-between; + gap: 16px; width: 100%; min-height: 36px; padding: 7px 10px; @@ -344,21 +331,6 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string text-align: left; } - .menu-row { - justify-content: space-between; - gap: 16px; - } - - .menu-button { - justify-content: space-between; - gap: 12px; - } - - .menu-row[aria-disabled="true"], - .menu-button[aria-disabled="true"] { - opacity: 0.52; - } - .menu-leading { display: inline-flex; align-items: center; @@ -417,10 +389,6 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string transform: translateX(16px); } - .checkmark { - color: var(--vscode-button-background); - } - @media (max-width: 520px) { body { padding: 12px; @@ -435,35 +403,9 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string } .composer-shell { - min-height: 136px; + min-height: 124px; border-radius: 14px; } - - .composer-toolbar { - display: flex; - align-items: stretch; - flex-direction: column; - } - - .toolbar-group { - justify-content: space-between; - } - - .toolbar-group.secondary { - justify-self: stretch; - } - - .pill-button { - min-width: 0; - } - - .model-menu, - .plugins-menu { - left: 0; - right: auto; - bottom: 54px; - width: min(100%, 300px); - } } CodeSetu @@ -489,32 +431,9 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string -
- + ${modelLabel}
- - - @@ -664,61 +470,126 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string const transcript = document.getElementById("transcript"); const composerMenuToggle = document.getElementById("composer-menu-toggle"); const composerMenu = document.getElementById("composer-menu"); - const pluginsMenuToggle = document.getElementById("plugins-menu-toggle"); - const pluginsMenu = document.getElementById("plugins-menu"); - const modelMenuToggle = document.getElementById("model-menu-toggle"); - const modelMenu = document.getElementById("model-menu"); - const modelLabel = document.getElementById("model-label"); const includeContext = document.getElementById("include-context"); - const reasoningOptions = [...document.querySelectorAll(".reasoning-option")]; let activeAssistantMessage; + let activeAssistantRaw = ""; + + const NEWLINE = String.fromCharCode(10); + const FENCE = String.fromCharCode(96, 96, 96); + const TICK = String.fromCharCode(96); + + function escapeHtml(value) { + return String(value) + .split("&").join("&") + .split("<").join("<") + .split(">").join(">"); + } + + function renderInline(escaped) { + let bolded = ""; + let rest = escaped; + while (true) { + const open = rest.indexOf("**"); + if (open === -1) { bolded += rest; break; } + const close = rest.indexOf("**", open + 2); + if (close === -1) { bolded += rest; break; } + bolded += rest.slice(0, open) + "" + rest.slice(open + 2, close) + ""; + rest = rest.slice(close + 2); + } + + let out = ""; + let seg = bolded; + while (true) { + const start = seg.indexOf(TICK); + if (start === -1) { out += seg; break; } + const end = seg.indexOf(TICK, start + 1); + if (end === -1) { out += seg; break; } + out += seg.slice(0, start) + "" + seg.slice(start + 1, end) + ""; + seg = seg.slice(end + 1); + } + return out; + } + + function renderProse(escaped) { + if (escaped.split(NEWLINE).join("").trim() === "") { + return ""; + } + const lines = escaped.split(NEWLINE); + let out = ""; + let inList = false; + for (const line of lines) { + const trimmed = line.trimStart(); + const isItem = trimmed.indexOf("- ") === 0 || trimmed.indexOf("* ") === 0; + if (isItem) { + if (!inList) { out += "
    "; inList = true; } + out += "
  • " + renderInline(trimmed.slice(2)) + "
  • "; + } else { + if (inList) { out += "
"; inList = false; } + out += line.trim() === "" ? "
" : renderInline(line) + "
"; + } + } + if (inList) { out += ""; } + return out; + } + + // Renders a safe subset of markdown. All model text is HTML-escaped before + // any tags are introduced, so this never injects untrusted markup. + function renderMarkdown(raw) { + const parts = String(raw).split(FENCE); + let html = ""; + for (let i = 0; i < parts.length; i++) { + if (i % 2 === 1) { + let block = parts[i]; + const firstNewline = block.indexOf(NEWLINE); + let code = firstNewline === -1 ? block : block.slice(firstNewline + 1); + if (code.length > 0 && code.charAt(code.length - 1) === NEWLINE) { + code = code.slice(0, code.length - 1); + } + html += "
" + escapeHtml(code) + "
"; + } else { + html += renderProse(escapeHtml(parts[i])); + } + } + return html; + } function appendMessage(kind, text) { const message = document.createElement("article"); message.className = "message " + kind; - message.textContent = text; + if (kind === "assistant") { + message.innerHTML = renderMarkdown(text); + } else { + message.textContent = text; + } transcript.appendChild(message); message.scrollIntoView({ block: "end", behavior: "smooth" }); return message; } + function startAssistantMessage() { + activeAssistantRaw = ""; + activeAssistantMessage = appendMessage("assistant", ""); + return activeAssistantMessage; + } + function appendAssistantDelta(text) { if (!activeAssistantMessage) { - activeAssistantMessage = appendMessage("assistant", ""); + startAssistantMessage(); } - activeAssistantMessage.textContent += text; + activeAssistantRaw += text; + activeAssistantMessage.innerHTML = renderMarkdown(activeAssistantRaw); activeAssistantMessage.scrollIntoView({ block: "end", behavior: "smooth" }); } - function setMenuOpen(menu, toggle, isOpen) { - menu.hidden = !isOpen; - toggle.setAttribute("aria-expanded", String(isOpen)); - } - - function closeMenus() { - setMenuOpen(composerMenu, composerMenuToggle, false); - setMenuOpen(pluginsMenu, pluginsMenuToggle, false); - setMenuOpen(modelMenu, modelMenuToggle, false); + function setMenuOpen(isOpen) { + composerMenu.hidden = !isOpen; + composerMenuToggle.setAttribute("aria-expanded", String(isOpen)); } composerMenuToggle.addEventListener("click", (event) => { event.stopPropagation(); - const isOpen = composerMenu.hidden; - closeMenus(); - setMenuOpen(composerMenu, composerMenuToggle, isOpen); - }); - - pluginsMenuToggle.addEventListener("click", (event) => { - event.stopPropagation(); - setMenuOpen(pluginsMenu, pluginsMenuToggle, pluginsMenu.hidden); - }); - - modelMenuToggle.addEventListener("click", (event) => { - event.stopPropagation(); - const isOpen = modelMenu.hidden; - closeMenus(); - setMenuOpen(modelMenu, modelMenuToggle, isOpen); + setMenuOpen(composerMenu.hidden); }); document.addEventListener("click", (event) => { @@ -728,30 +599,11 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string return; } - if (!target.closest(".menu") && !target.closest(".pill-button") && !target.closest(".icon-button")) { - closeMenus(); + if (!target.closest(".menu") && !target.closest(".icon-button")) { + setMenuOpen(false); } }); - reasoningOptions.forEach((option) => { - option.addEventListener("click", () => { - const reasoning = option.dataset.reasoning; - modelLabel.innerHTML = "5.5  " + reasoning; - reasoningOptions.forEach((candidate) => { - const marker = candidate.querySelector(".checkmark"); - if (marker) { - marker.remove(); - } - }); - const marker = document.createElement("span"); - marker.className = "checkmark"; - marker.setAttribute("aria-hidden", "true"); - marker.textContent = "\\u2713"; - option.appendChild(marker); - closeMenus(); - }); - }); - form.addEventListener("submit", (event) => { event.preventDefault(); const text = textarea.value.trim(); @@ -761,7 +613,7 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string } textarea.value = ""; - closeMenus(); + setMenuOpen(false); vscode.postMessage({ type: "sendMessage", text, @@ -774,16 +626,17 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string if (message.type === "assistantMessage") { if (activeAssistantMessage) { - activeAssistantMessage.textContent = message.text; + activeAssistantMessage.innerHTML = renderMarkdown(message.text); activeAssistantMessage.scrollIntoView({ block: "end", behavior: "smooth" }); activeAssistantMessage = undefined; + activeAssistantRaw = ""; } else { appendMessage("assistant", message.text); } } if (message.type === "assistantMessageStart") { - activeAssistantMessage = appendMessage("assistant", ""); + startAssistantMessage(); } if (message.type === "assistantMessageDelta") { @@ -792,6 +645,7 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string if (message.type === "assistantMessageDone") { activeAssistantMessage = undefined; + activeAssistantRaw = ""; } if (message.type === "userMessage") { @@ -807,7 +661,6 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string send.disabled = isBusy; textarea.disabled = isBusy; composerMenuToggle.disabled = isBusy; - modelMenuToggle.disabled = isBusy; } }); diff --git a/apps/vscode/src/configuration.ts b/apps/vscode/src/configuration.ts index 94f6cbd..de72ed4 100644 --- a/apps/vscode/src/configuration.ts +++ b/apps/vscode/src/configuration.ts @@ -49,7 +49,9 @@ export function readCodeSetuConfiguration(): CodeSetuConfiguration { return { providerOptions: { provider: readProvider(configuration), - apiKey: readOptionalString(configuration, "apiKey"), + // The API key is intentionally NOT read from settings. It lives in the OS + // secret store and is injected by the extension host (see secretStorage.ts). + // The core provider falls back to env vars when apiKey is undefined. baseURL: readOptionalString(configuration, "baseUrl"), model: readOptionalString(configuration, "model"), }, @@ -67,7 +69,9 @@ export function readCodeSetuConfiguration(): CodeSetuConfiguration { }; } -export function summarizeCodeSetuConfiguration(): CodeSetuConfigurationSummary { +export function summarizeCodeSetuConfiguration( + secretApiKey?: string, +): CodeSetuConfigurationSummary { const configuration = readCodeSetuConfiguration(); const provider = configuration.providerOptions.provider === "openai-compatible" ? "openai-compatible" : "sarvam"; @@ -90,7 +94,7 @@ export function summarizeCodeSetuConfiguration(): CodeSetuConfigurationSummary { DEFAULT_SARVAM_MODEL, ) ?? DEFAULT_SARVAM_MODEL, hasApiKey: hasConfigValue( - configuration.providerOptions.apiKey, + secretApiKey, process.env.SARVAM_API_KEY, process.env.CODESETU_API_KEY, ), @@ -111,7 +115,7 @@ export function summarizeCodeSetuConfiguration(): CodeSetuConfigurationSummary { process.env.CODESETU_MODEL, DEFAULT_OPENAI_COMPATIBLE_MODEL, ) ?? DEFAULT_OPENAI_COMPATIBLE_MODEL, - hasApiKey: hasConfigValue(configuration.providerOptions.apiKey, process.env.CODESETU_API_KEY), + hasApiKey: hasConfigValue(secretApiKey, process.env.CODESETU_API_KEY), }; } diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 3cca501..ec5ccfb 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -21,6 +21,7 @@ import { type ChatCompletionRequest, type ChatMessage, type IdeContextPayload, + type ProviderFactoryOptions, type WorkspaceInstruction, } from "@codesetu/core"; import * as vscode from "vscode"; @@ -34,9 +35,10 @@ import { readCodeSetuConfiguration, summarizeCodeSetuConfiguration } from "./con import { collectVSCodeContext } from "./ideContext"; import { formatChatProviderLine, runCodeSetuProviderDiagnostics } from "./providerDiagnostics"; import { setupCodeSetuProvider } from "./providerSetup"; +import { getStoredApiKey, migrateApiKeyFromConfiguration } from "./secretStorage"; import { loadWorkspaceInstructions } from "./workspaceInstructions"; -export function activate(context: vscode.ExtensionContext): void { +export async function activate(context: vscode.ExtensionContext): Promise { const outputChannel = vscode.window.createOutputChannel("CodeSetu"); const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); statusBarItem.text = "CodeSetu: Ready"; @@ -44,10 +46,30 @@ export function activate(context: vscode.ExtensionContext): void { statusBarItem.command = "codesetu.openChat"; statusBarItem.show(); + // The API key lives in the OS secret store. Migrate any legacy plaintext value + // out of settings.json, then keep an in-memory copy refreshed on change so the + // synchronous provider-creation hot paths (inline completions) stay fast. + await migrateApiKeyFromConfiguration(context.secrets); + let apiKey = await getStoredApiKey(context.secrets); + context.subscriptions.push( + context.secrets.onDidChange((event) => { + if (event.key === "codesetu.apiKey") { + void getStoredApiKey(context.secrets).then((value) => { + apiKey = value; + }); + } + }), + ); + + const buildProviderOptions = (): ProviderFactoryOptions => ({ + ...readCodeSetuConfiguration().providerOptions, + apiKey, + }); + const inlineCompletionProvider = vscode.languages.registerInlineCompletionItemProvider( { scheme: "file" }, new CodeSetuInlineCompletionProvider({ - createProvider: () => createProvider(readCodeSetuConfiguration().providerOptions), + createProvider: () => createProvider(buildProviderOptions()), getConfiguration: readCodeSetuConfiguration, outputChannel, }), @@ -61,6 +83,7 @@ export function activate(context: vscode.ExtensionContext): void { const responder: ChatResponder = async (messages, requestContext) => sendChatRequest( messages, + buildProviderOptions(), statusBarItem, outputChannel, await loadInstructions(), @@ -72,12 +95,11 @@ export function activate(context: vscode.ExtensionContext): void { const openChatCommand = vscode.commands.registerCommand("codesetu.openChat", () => { ChatPanel.createOrShow(context.extensionUri, responder, outputChannel); }); - const setupProviderCommand = vscode.commands.registerCommand( - "codesetu.setupProvider", - setupCodeSetuProvider, + const setupProviderCommand = vscode.commands.registerCommand("codesetu.setupProvider", () => + setupCodeSetuProvider(context.secrets), ); const diagnoseProviderCommand = vscode.commands.registerCommand("codesetu.diagnoseProvider", () => - runCodeSetuProviderDiagnostics(outputChannel), + runCodeSetuProviderDiagnostics(outputChannel, apiKey), ); const editorActions = registerCodeSetuEditorActions({ @@ -109,6 +131,7 @@ export function deactivate(): void { async function sendChatRequest( messages: ChatMessage[], + providerOptions: ProviderFactoryOptions, statusBarItem: vscode.StatusBarItem, outputChannel: vscode.OutputChannel, instructions: readonly WorkspaceInstruction[] = [], @@ -116,8 +139,10 @@ async function sendChatRequest( onChunk?: (chunk: string) => void, ): Promise { const configuration = readCodeSetuConfiguration(); - outputChannel.appendLine(formatChatProviderLine(summarizeCodeSetuConfiguration())); - const provider = createProvider(configuration.providerOptions); + outputChannel.appendLine( + formatChatProviderLine(summarizeCodeSetuConfiguration(providerOptions.apiKey)), + ); + const provider = createProvider(providerOptions); statusBarItem.text = "CodeSetu: Thinking"; const contextualMessages = hasIdeContext(ideContext) ? [ diff --git a/apps/vscode/src/ideContext.ts b/apps/vscode/src/ideContext.ts index 2c93eee..e13e07b 100644 --- a/apps/vscode/src/ideContext.ts +++ b/apps/vscode/src/ideContext.ts @@ -78,7 +78,8 @@ async function collectWorkspaceSnippets( ): Promise { const files = await vscode.workspace.findFiles( "**/*.{ts,tsx,js,jsx,py,java,kt,go,rs,md,json,yml,yaml}", - "{**/node_modules/**,**/dist/**,**/build/**,**/.git/**}", + // Skip build output and likely-secret files so they aren't auto-sent to the provider. + "{**/node_modules/**,**/dist/**,**/build/**,**/.git/**,**/.env*,**/*.pem,**/*.key,**/*.pfx,**/*.p12,**/secrets/**,**/.aws/**,**/id_rsa*}", 8, ); const snippets: WorkspaceSnippet[] = []; diff --git a/apps/vscode/src/providerDiagnostics.ts b/apps/vscode/src/providerDiagnostics.ts index f96323d..7c75973 100644 --- a/apps/vscode/src/providerDiagnostics.ts +++ b/apps/vscode/src/providerDiagnostics.ts @@ -40,14 +40,15 @@ export function formatChatProviderLine(summary: CodeSetuConfigurationSummary): s export async function runCodeSetuProviderDiagnostics( outputChannel: vscodeTypes.OutputChannel, + secretApiKey?: string, ): Promise { const vscode: VSCodeApi = await import("vscode"); const { readCodeSetuConfiguration, summarizeCodeSetuConfiguration } = await import("./configuration"); const configuration = readCodeSetuConfiguration(); - const summary = summarizeCodeSetuConfiguration(); + const summary = summarizeCodeSetuConfiguration(secretApiKey); const result = await diagnoseProvider({ - providerOptions: configuration.providerOptions, + providerOptions: { ...configuration.providerOptions, apiKey: secretApiKey }, createProvider: (providerOptions) => createProvider(providerOptions), }); diff --git a/apps/vscode/src/providerSetup.ts b/apps/vscode/src/providerSetup.ts index 116f932..8a37b03 100644 --- a/apps/vscode/src/providerSetup.ts +++ b/apps/vscode/src/providerSetup.ts @@ -10,9 +10,11 @@ import * as vscode from "vscode"; +import { storeApiKey } from "./secretStorage"; + const DEFAULT_SARVAM_CHAT_MODEL = "sarvam-30b"; -export async function setupCodeSetuProvider(): Promise { +export async function setupCodeSetuProvider(secrets: vscode.SecretStorage): Promise { const provider = await vscode.window.showQuickPick( [ { label: "sarvam", description: "Sarvam hosted or compatible endpoint" }, @@ -60,7 +62,8 @@ export async function setupCodeSetuProvider(): Promise { await configuration.update("provider", provider.label, vscode.ConfigurationTarget.Global); await configuration.update("baseUrl", baseUrl.trim(), vscode.ConfigurationTarget.Global); await configuration.update("model", model.trim(), vscode.ConfigurationTarget.Global); - await configuration.update("apiKey", apiKey.trim(), vscode.ConfigurationTarget.Global); + // The API key goes to the OS secret store, never to settings.json. + await storeApiKey(secrets, apiKey); void vscode.window.showInformationMessage("CodeSetu provider settings updated."); } diff --git a/apps/vscode/src/secretStorage.ts b/apps/vscode/src/secretStorage.ts new file mode 100644 index 0000000..b9abbc9 --- /dev/null +++ b/apps/vscode/src/secretStorage.ts @@ -0,0 +1,92 @@ +/** + * Copyright 2026 CodeSetu Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as vscode from "vscode"; + +/** Key under which the provider API key is stored in {@link vscode.SecretStorage}. */ +export const API_KEY_SECRET = "codesetu.apiKey"; + +/** Reads the stored API key, returning `undefined` when none is set. */ +export async function getStoredApiKey(secrets: vscode.SecretStorage): Promise { + const value = (await secrets.get(API_KEY_SECRET))?.trim(); + return value === undefined || value.length === 0 ? undefined : value; +} + +/** Stores (or clears, when blank) the provider API key. */ +export async function storeApiKey(secrets: vscode.SecretStorage, apiKey: string): Promise { + const trimmed = apiKey.trim(); + + if (trimmed.length === 0) { + await secrets.delete(API_KEY_SECRET); + return; + } + + await secrets.store(API_KEY_SECRET, trimmed); +} + +/** + * Moves any API key left in the (now deprecated) `codesetu.apiKey` setting into + * the OS secret store, then clears the plaintext copy. Safe to call on every + * activation; it is a no-op once the setting is empty. + */ +export async function migrateApiKeyFromConfiguration(secrets: vscode.SecretStorage): Promise { + const configuration = vscode.workspace.getConfiguration("codesetu"); + const inspected = configuration.inspect("apiKey"); + const legacyValue = ( + inspected?.workspaceFolderValue ?? + inspected?.workspaceValue ?? + inspected?.globalValue ?? + "" + ).trim(); + + if (legacyValue.length === 0) { + return; + } + + if ((await getStoredApiKey(secrets)) === undefined) { + await storeApiKey(secrets, legacyValue); + } + + await clearConfigurationApiKey(configuration, inspected); +} + +async function clearConfigurationApiKey( + configuration: vscode.WorkspaceConfiguration, + inspected: ReturnType, +): Promise { + const targets: vscode.ConfigurationTarget[] = []; + + if (inspected?.globalValue !== undefined) { + targets.push(vscode.ConfigurationTarget.Global); + } + + if (inspected?.workspaceValue !== undefined) { + targets.push(vscode.ConfigurationTarget.Workspace); + } + + if (inspected?.workspaceFolderValue !== undefined) { + targets.push(vscode.ConfigurationTarget.WorkspaceFolder); + } + + for (const target of targets) { + // Best-effort: clearing a workspace-scoped value fails when no workspace is + // open, which is fine — the value simply stays until a workspace is loaded. + await Promise.resolve(configuration.update("apiKey", undefined, target)).then( + undefined, + () => undefined, + ); + } +} diff --git a/apps/vscode/test/chatPanelHtml.test.ts b/apps/vscode/test/chatPanelHtml.test.ts index d25046a..ea81ccb 100644 --- a/apps/vscode/test/chatPanelHtml.test.ts +++ b/apps/vscode/test/chatPanelHtml.test.ts @@ -19,26 +19,56 @@ import { describe, expect, it } from "vitest"; import { renderChatPanelHtml } from "../src/chatPanelHtml"; describe("renderChatPanelHtml", () => { - it("renders the polished composer controls", () => { + it("renders the composer with the real configured model and the IDE-context toggle", () => { const html = renderChatPanelHtml({ cspSource: "vscode-resource:", nonce: "test-nonce", + modelLabel: "sarvam · sarvam-30b", }); expect(html).toContain('class="composer-shell"'); expect(html).toContain('id="composer-menu-toggle"'); expect(html).toContain("Include IDE context"); expect(html).toContain('id="include-context"'); - expect(html).toContain('id="model-menu-toggle"'); - expect(html).toContain("Extra High"); - expect(html).toContain("Work locally"); + expect(html).toContain('class="model-chip"'); + expect(html).toContain("sarvam · sarvam-30b"); expect(html).toContain('aria-label="Send message"'); }); + it("does not advertise non-functional or fictional controls", () => { + const html = renderChatPanelHtml({ + cspSource: "vscode-resource:", + nonce: "test-nonce", + modelLabel: "sarvam · sarvam-30b", + }); + + expect(html).not.toContain("Extra High"); + expect(html).not.toContain("GPT-5.5"); + expect(html).not.toContain("Work locally"); + expect(html).not.toContain("Default permissions"); + expect(html).not.toContain("Plan mode"); + expect(html).not.toContain('id="model-menu-toggle"'); + expect(html).not.toContain('id="plugins-menu-toggle"'); + }); + + it("escapes the model label and renders assistant text through safe markdown", () => { + const html = renderChatPanelHtml({ + cspSource: "vscode-resource:", + nonce: "test-nonce", + modelLabel: "", + }); + + expect(html).not.toContain(""); + expect(html).toContain("<script>evil</script>"); + expect(html).toContain("function renderMarkdown"); + expect(html).toContain(".assistant pre"); + }); + it("uses the composer shell as the focus surface with real icon markup", () => { const html = renderChatPanelHtml({ cspSource: "vscode-resource:", nonce: "test-nonce", + modelLabel: "sarvam · sarvam-30b", }); expect(html).toContain(".composer-shell:focus-within"); @@ -46,8 +76,5 @@ describe("renderChatPanelHtml", () => { expect(html).toContain("outline: none;"); expect(html).toContain('data-icon="plus"'); expect(html).toContain('data-icon="send"'); - expect(html).toContain('data-icon="chevron-down"'); - expect(html).not.toContain(''); - expect(html).not.toContain(''); }); }); From 5e54b706c93ae75a66fae5f04f6f5372ad431f96 Mon Sep 17 00:00:00 2001 From: Gourav Bansal Date: Wed, 27 May 2026 23:12:23 +0530 Subject: [PATCH 2/9] feat(ide): add Hugging Face provider and VS Code model picker Core: - Add HuggingFaceProvider (extends OpenAICompatibleProvider): default base URL https://router.huggingface.co/v1, HF_TOKEN auth, default chat model. Register in the factory, exports, and diagnostics. It does not send Sarvam's reasoning_effort (which HF-served models reject). VS Code: - Add "huggingface" to the provider enum and the Setup Provider picker (router default + hf_... token prompt, key stored in SecretStorage). - Add a "CodeSetu: Select Model" command and make the composer model chip clickable: a curated list of reliably-served HF chat models plus an "Enter a custom model id..." option; writes codesetu.model and updates the chip live. JetBrains (provider backend): - Add ProviderKind.HUGGING_FACE; gate reasoning_effort to Sarvam only in the provider client so HF / OpenAI-compatible models don't get an unsupported field. Docs: README provider list, prerequisites, and settings updated. Co-Authored-By: Claude Opus 4.7 --- README.md | 12 +- .../ai/codesetu/model/CodeSetuModels.kt | 3 +- .../provider/CodeSetuProviderClient.kt | 10 +- apps/vscode/package.json | 13 +- apps/vscode/src/chatPanel.ts | 27 +++ apps/vscode/src/chatPanelHtml.ts | 47 ++++- apps/vscode/src/configuration.ts | 30 ++- apps/vscode/src/extension.ts | 6 + apps/vscode/src/modelPicker.ts | 83 ++++++++ apps/vscode/src/providerSetup.ts | 40 +++- apps/vscode/test/chatPanelHtml.test.ts | 15 +- apps/vscode/test/packageCommands.test.ts | 1 + packages/core/src/ide/diagnostics.ts | 29 +++ packages/core/src/index.ts | 8 + packages/core/src/providers/huggingface.ts | 50 +++++ packages/core/src/providers/registry.ts | 22 +- packages/core/test/huggingface.test.ts | 189 ++++++++++++++++++ packages/core/test/providerFactory.test.ts | 27 ++- 18 files changed, 583 insertions(+), 29 deletions(-) create mode 100644 apps/vscode/src/modelPicker.ts create mode 100644 packages/core/src/providers/huggingface.ts create mode 100644 packages/core/test/huggingface.test.ts diff --git a/README.md b/README.md index d0128f6..472c35f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/sjVKU8cpC6) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) -A **Copilot / Cursor alternative** designed for Indian developers, enterprises, public sector teams, and air-gapped deployments. Bring your own model — CodeSetu works with **Sarvam, OpenAI-compatible APIs (Ollama, vLLM, OpenRouter, SGLang)**, and local self-hosted deployments. AI chat, repo-aware context, selected-code actions, inline FIM completions, tool-calling, and an extensible plugin and skill system across VSCode and JetBrains. +A **Copilot / Cursor alternative** designed for Indian developers, enterprises, public sector teams, and air-gapped deployments. Bring your own model — CodeSetu works with **Sarvam, Hugging Face (any served chat model, dedicated endpoints, or self-hosted TGI), OpenAI-compatible APIs (Ollama, vLLM, OpenRouter, SGLang)**, and local self-hosted deployments. AI chat, repo-aware context, selected-code actions, inline FIM completions, tool-calling, and an extensible plugin and skill system across VSCode and JetBrains. **Highlights**: AI chat in IDE · Repo-aware context · Selected-code actions · Inline (FIM) code completions · Provider setup and diagnostics · Workspace skills/checks · Air-gapped friendly · Hindi / Indic-aware · Plugin + skill SDK · 100% open-source (Apache 2.0) @@ -27,7 +27,7 @@ This repository is a pnpm + Gradle monorepo organized as: - Node.js 18+ - pnpm 9+ -- A provider — Sarvam API key, OpenRouter key, or a local OpenAI-compatible endpoint (Ollama, vLLM, SGLang) +- A provider — Sarvam API key, a Hugging Face token (`hf_…`), OpenRouter key, or a local OpenAI-compatible endpoint (Ollama, vLLM, SGLang) ## Setup @@ -50,10 +50,10 @@ Marketplace, Open VSX, and private VSIX hosting, see The VSCode extension reads these settings: -- `codesetu.provider` - `sarvam` or `openai-compatible` -- `codesetu.apiKey` - optional provider API key -- `codesetu.baseUrl` - optional OpenAI-compatible base URL -- `codesetu.model` - optional model name +- `codesetu.provider` - `sarvam`, `openai-compatible`, or `huggingface` +- `codesetu.baseUrl` - optional base URL (e.g. `https://router.huggingface.co/v1`, a dedicated HF endpoint, or a local server) +- `codesetu.model` - optional model name (for Hugging Face, the model repo id, e.g. `meta-llama/Llama-3.3-70B-Instruct`) +- API key — set via the **CodeSetu: Setup Provider** command (stored in the OS secret store), or the `SARVAM_API_KEY` / `HF_TOKEN` / `CODESETU_API_KEY` environment variables - `codesetu.inlineCompletions.enabled` - enable FIM inline completions - `codesetu.chat.maxTokens` / `codesetu.chat.temperature` diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/model/CodeSetuModels.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/model/CodeSetuModels.kt index 550bc8f..46eaa0c 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/model/CodeSetuModels.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/model/CodeSetuModels.kt @@ -5,7 +5,8 @@ import kotlinx.serialization.Serializable enum class ProviderKind(val id: String) { SARVAM("sarvam"), - OPENAI_COMPATIBLE("openai-compatible"); + OPENAI_COMPATIBLE("openai-compatible"), + HUGGING_FACE("huggingface"); companion object { fun fromId(id: String): ProviderKind = diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/provider/CodeSetuProviderClient.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/provider/CodeSetuProviderClient.kt index af21cd2..61209b6 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/provider/CodeSetuProviderClient.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/provider/CodeSetuProviderClient.kt @@ -4,6 +4,7 @@ import ai.codesetu.model.ChatCompletionRequest import ai.codesetu.model.ChatCompletionResponse import ai.codesetu.model.ChatCompletionChunk import ai.codesetu.model.ChatMessage +import ai.codesetu.model.ProviderKind import ai.codesetu.settings.CodeSetuSettingsState import ai.codesetu.settings.resolveCodeSetuModel import java.net.URI @@ -24,7 +25,7 @@ class CodeSetuProviderClient( messages = messages, maxTokens = maxTokens, temperature = temperature, - reasoningEffort = "low", + reasoningEffort = reasoningEffortFor(state.provider), json = json, ) val request = HttpRequest.newBuilder() @@ -54,7 +55,7 @@ class CodeSetuProviderClient( messages = messages, maxTokens = maxTokens, temperature = temperature, - reasoningEffort = "low", + reasoningEffort = reasoningEffortFor(state.provider), stream = true, json = json, ) @@ -105,6 +106,11 @@ class CodeSetuProviderClient( } } +// Sarvam needs a low reasoning effort to avoid exhausting its token budget; +// other providers (OpenAI-compatible, Hugging Face) may reject an unknown field. +private fun reasoningEffortFor(providerId: String): String? = + if (ProviderKind.fromId(providerId) == ProviderKind.SARVAM) "low" else null + fun getAssistantText(response: ChatCompletionResponse): String { val message = response.choices.firstOrNull()?.message return message?.content ?: message?.refusal.orEmpty() diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 5dae625..9f47b32 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -73,6 +73,11 @@ "title": "CodeSetu: Diagnose Provider", "category": "CodeSetu" }, + { + "command": "codesetu.selectModel", + "title": "CodeSetu: Select Model", + "category": "CodeSetu" + }, { "command": "codesetu.explainSelection", "title": "Explain with CodeSetu", @@ -160,7 +165,13 @@ "type": "string", "enum": [ "sarvam", - "openai-compatible" + "openai-compatible", + "huggingface" + ], + "enumDescriptions": [ + "Sarvam hosted or compatible endpoint.", + "Any OpenAI-compatible API (Ollama, vLLM, SGLang, OpenRouter, local).", + "Hugging Face Inference — the hosted router, a dedicated Inference Endpoint, or self-hosted TGI." ], "default": "sarvam", "description": "Model provider used by CodeSetu." diff --git a/apps/vscode/src/chatPanel.ts b/apps/vscode/src/chatPanel.ts index 39618ee..e9ba688 100644 --- a/apps/vscode/src/chatPanel.ts +++ b/apps/vscode/src/chatPanel.ts @@ -109,7 +109,18 @@ export class ChatPanel { await this.submitMessage(text, options); } + public static refreshModelLabel(): void { + ChatPanel.currentPanel?.postModelLabel(); + } + private async handleMessage(message: unknown): Promise { + if (isSelectModelRequest(message)) { + // The command updates codesetu.model and calls refreshModelLabel(), which + // posts the new label back to this webview. + await vscode.commands.executeCommand("codesetu.selectModel"); + return; + } + if (!isSendMessageRequest(message) || this.inFlight) { return; } @@ -117,6 +128,14 @@ export class ChatPanel { await this.submitMessage(message.text, { includeIdeContext: message.includeIdeContext }); } + private postModelLabel(): void { + const summary = summarizeCodeSetuConfiguration(); + void this.panel.webview.postMessage({ + type: "modelLabel", + text: `${summary.provider} · ${summary.model ?? "default"}`, + }); + } + private async submitMessage( rawText: string, options: SendUserMessageOptions = {}, @@ -203,6 +222,14 @@ function messageLength(message: ChatMessage): number { return typeof message.content === "string" ? message.content.length : 0; } +function isSelectModelRequest(message: unknown): boolean { + return ( + typeof message === "object" && + message !== null && + (message as { type?: unknown }).type === "selectModel" + ); +} + function isSendMessageRequest(message: unknown): message is SendMessageRequest { if (typeof message !== "object" || message === null) { return false; diff --git a/apps/vscode/src/chatPanelHtml.ts b/apps/vscode/src/chatPanelHtml.ts index 2c40398..6d2952c 100644 --- a/apps/vscode/src/chatPanelHtml.ts +++ b/apps/vscode/src/chatPanelHtml.ts @@ -246,16 +246,36 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string .model-chip { display: inline-flex; align-items: center; + gap: 6px; min-height: 30px; - padding: 0 10px; + padding: 0 8px; + border: 0; border-radius: 9px; color: var(--vscode-descriptionForeground); + background: transparent; white-space: nowrap; + cursor: pointer; + font: inherit; } - .model-chip strong { - color: var(--vscode-foreground); - font-weight: 500; + .model-chip:hover { + background: var(--vscode-toolbar-hoverBackground); + } + + .model-chip:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; + } + + .model-chip:disabled { + cursor: not-allowed; + opacity: 0.55; + } + + .chevron { + width: 14px; + height: 14px; + opacity: 0.8; } .send-button { @@ -433,7 +453,12 @@ export function renderChatPanelHtml(options: RenderChatPanelHtmlOptions): string
- ${modelLabel} + +
+
+ + +
+ + + + + + + + From c59328fcd734a166e14e9728a750266f3e6075f4 Mon Sep 17 00:00:00 2001 From: Gourav Bansal Date: Wed, 27 May 2026 23:47:10 +0530 Subject: [PATCH 6/9] feat(ide): configure provider from chat; compact JetBrains webview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a "Configure provider / endpoint…" entry to the model picker on both platforms so users can switch between Sarvam, OpenAI-compatible (Ollama / vLLM / local), and Hugging Face — and set the base URL, model, and API key — without leaving the chat. VS Code hands off to the Setup Provider command; JetBrains runs an inline provider/baseURL/ model/token flow and stores the token in PasswordSafe. - Compact the JetBrains JCEF chat: drop the redundant heading (the tool window already shows "CodeSetu"), and tighten padding, composer size, and icon/button sizes so it fits a narrow tool window. Co-Authored-By: Claude Opus 4.7 --- .../settings/CodeSetuProviderDefaults.kt | 13 ++++ .../toolwindow/CodeSetuToolWindowFactory.kt | 78 ++++++++++++++++--- .../src/main/resources/webview/chat.html | 65 +++++++--------- apps/vscode/src/modelPicker.ts | 8 ++ 4 files changed, 117 insertions(+), 47 deletions(-) diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuProviderDefaults.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuProviderDefaults.kt index f3d9bec..f57f372 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuProviderDefaults.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/settings/CodeSetuProviderDefaults.kt @@ -1,7 +1,20 @@ package ai.codesetu.settings +import ai.codesetu.model.ProviderKind + const val DEFAULT_CODESETU_BASE_URL = "https://api.sarvam.ai/v1" const val DEFAULT_CODESETU_MODEL = "sarvam-30b" fun resolveCodeSetuModel(model: String): String = model.ifBlank { DEFAULT_CODESETU_MODEL } + +data class ProviderDefaults(val baseUrl: String, val model: String) + +fun providerDefaults(providerId: String): ProviderDefaults = + when (ProviderKind.fromId(providerId)) { + ProviderKind.SARVAM -> ProviderDefaults("https://api.sarvam.ai/v1", "sarvam-30b") + ProviderKind.OPENAI_COMPATIBLE -> + ProviderDefaults("http://localhost:11434/v1", "qwen2.5-coder:7b") + ProviderKind.HUGGING_FACE -> + ProviderDefaults("https://router.huggingface.co/v1", "meta-llama/Llama-3.3-70B-Instruct") + } diff --git a/apps/jetbrains/src/main/kotlin/ai/codesetu/toolwindow/CodeSetuToolWindowFactory.kt b/apps/jetbrains/src/main/kotlin/ai/codesetu/toolwindow/CodeSetuToolWindowFactory.kt index 38ddad0..932224e 100644 --- a/apps/jetbrains/src/main/kotlin/ai/codesetu/toolwindow/CodeSetuToolWindowFactory.kt +++ b/apps/jetbrains/src/main/kotlin/ai/codesetu/toolwindow/CodeSetuToolWindowFactory.kt @@ -10,6 +10,7 @@ import ai.codesetu.prompts.buildContextMarkdown import ai.codesetu.prompts.buildSystemMessage import ai.codesetu.settings.CodeSetuModelCatalog import ai.codesetu.settings.CodeSetuSettingsState +import ai.codesetu.settings.providerDefaults import ai.codesetu.settings.resolveCodeSetuModel import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -195,22 +196,20 @@ class CodeSetuChatPanel(private val project: Project) : Disposable { ApplicationManager.getApplication().invokeLater { val state = CodeSetuSettingsState.getInstance().state val current = resolveCodeSetuModel(state.model) + val configure = "⚙ Configure provider / endpoint…" val custom = "Enter a custom model id…" - val items = (listOf(custom, current) + CodeSetuModelCatalog.suggestionsFor(state.provider)).distinct() + val items = + (listOf(configure, custom, current) + CodeSetuModelCatalog.suggestionsFor(state.provider)) + .distinct() JBPopupFactory.getInstance() .createPopupChooserBuilder(items) - .setTitle("Select Model") + .setTitle("CodeSetu model") .setItemChosenCallback { choice -> - val picked = if (choice == custom) { - Messages.showInputDialog(project, "Model id", "Select Model", null, current, null) - } else { - choice - } - val trimmed = picked?.trim().orEmpty() - if (trimmed.isNotEmpty()) { - state.model = trimmed - pushModelLabel() + when (choice) { + configure -> configureProvider() + custom -> applyModel(Messages.showInputDialog(project, "Model id", "Select Model", null, current, null)) + else -> applyModel(choice) } } .createPopup() @@ -218,6 +217,63 @@ class CodeSetuChatPanel(private val project: Project) : Disposable { } } + private fun applyModel(model: String?) { + val trimmed = model?.trim().orEmpty() + if (trimmed.isEmpty()) return + CodeSetuSettingsState.getInstance().state.model = trimmed + pushModelLabel() + } + + // Lets the user switch provider (Sarvam / OpenAI-compatible (Ollama, local) / + // Hugging Face) and set its base URL, model, and API key from the chat. + private fun configureProvider() { + val providers = listOf( + "Sarvam" to "sarvam", + "OpenAI-compatible (Ollama, vLLM, local)" to "openai-compatible", + "Hugging Face" to "huggingface", + ) + + JBPopupFactory.getInstance() + .createPopupChooserBuilder(providers.map { it.first }) + .setTitle("Configure provider") + .setItemChosenCallback { label -> + providers.firstOrNull { it.first == label }?.let { applyProvider(it.second) } + } + .createPopup() + .showInFocusCenter() + } + + private fun applyProvider(providerId: String) { + val state = CodeSetuSettingsState.getInstance().state + val defaults = providerDefaults(providerId) + val baseUrlSeed = + if (state.provider == providerId && state.baseUrl.isNotBlank()) state.baseUrl else defaults.baseUrl + val modelSeed = + if (state.provider == providerId && state.model.isNotBlank()) state.model else defaults.model + + val baseUrl = + Messages.showInputDialog(project, "Base URL", "Configure Provider", null, baseUrlSeed, null) + ?: return + val model = + Messages.showInputDialog(project, "Model id", "Configure Provider", null, modelSeed, null) + ?: return + val token = + Messages.showPasswordDialog( + project, + "API key / token (leave blank to keep the current one)", + "Configure Provider", + null, + ) + + state.provider = providerId + state.baseUrl = baseUrl.trim().ifBlank { defaults.baseUrl } + state.model = model.trim().ifBlank { defaults.model } + if (!token.isNullOrBlank()) { + CodeSetuSettingsState.getInstance().setApiKey(token) + } + pushModelLabel() + } + private fun pushModelLabel() { push(message("modelLabel") { put("text", modelLabelText()) }) } diff --git a/apps/jetbrains/src/main/resources/webview/chat.html b/apps/jetbrains/src/main/resources/webview/chat.html index 331520b..e22233f 100644 --- a/apps/jetbrains/src/main/resources/webview/chat.html +++ b/apps/jetbrains/src/main/resources/webview/chat.html @@ -30,8 +30,9 @@ body { min-height: 100vh; font-family: var(--vscode-font-family); + font-size: 13px; margin: 0; - padding: 16px; + padding: 10px; color: var(--vscode-foreground); background: var(--vscode-editor-background); } @@ -39,29 +40,22 @@ main { display: flex; flex-direction: column; - gap: 14px; - max-width: 820px; - min-height: calc(100vh - 32px); - } - - h1 { - margin: 0; - font-size: 22px; - line-height: 1.15; + gap: 8px; + min-height: calc(100vh - 20px); } #transcript { display: grid; align-content: start; - gap: 10px; + gap: 8px; flex: 1; - min-height: 120px; + min-height: 40px; } .message { - border-radius: 10px; - line-height: 1.5; - padding: 11px 14px; + border-radius: 8px; + line-height: 1.45; + padding: 8px 11px; white-space: pre-wrap; overflow-wrap: anywhere; } @@ -116,20 +110,20 @@ .composer-wrap { position: relative; display: grid; - gap: 10px; + gap: 8px; } .composer-shell { display: flex; flex-direction: column; - gap: 10px; - min-height: 120px; - padding: 16px 18px 14px; + gap: 8px; + min-height: 72px; + padding: 9px 11px; border: 1px solid var(--vscode-input-border, var(--vscode-widget-border)); - border-radius: 20px; + border-radius: 12px; color: var(--vscode-input-foreground); background: var(--vscode-input-background); - box-shadow: 0 14px 34px rgba(0, 0, 0, 0.08); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.05); transition: border-color 120ms ease, box-shadow 120ms ease; @@ -143,8 +137,8 @@ textarea { width: 100%; flex: 1; - min-height: 60px; - max-height: 180px; + min-height: 38px; + max-height: 140px; resize: none; overflow-y: auto; color: var(--vscode-input-foreground); @@ -164,8 +158,8 @@ display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; - gap: 12px; - min-height: 36px; + gap: 8px; + min-height: 30px; } .toolbar-group { @@ -195,8 +189,8 @@ .send-button { display: inline-grid; place-items: center; - width: 34px; - height: 34px; + width: 30px; + height: 30px; padding: 0; border-radius: 50%; } @@ -260,8 +254,8 @@ .send-button { color: var(--vscode-button-foreground); background: var(--vscode-button-background); - width: 38px; - height: 38px; + width: 32px; + height: 32px; } .send-button:hover { @@ -276,8 +270,8 @@ } .composer-icon { - width: 18px; - height: 18px; + width: 16px; + height: 16px; flex: 0 0 auto; stroke: currentColor; stroke-width: 2; @@ -287,13 +281,13 @@ } .icon-button .composer-icon { - width: 21px; - height: 21px; + width: 18px; + height: 18px; } .send-button .composer-icon { - width: 22px; - height: 22px; + width: 18px; + height: 18px; stroke-width: 2.4; } @@ -397,7 +391,6 @@
-

CodeSetu

diff --git a/apps/vscode/src/modelPicker.ts b/apps/vscode/src/modelPicker.ts index 76b0e06..abe8549 100644 --- a/apps/vscode/src/modelPicker.ts +++ b/apps/vscode/src/modelPicker.ts @@ -18,6 +18,7 @@ import * as vscode from "vscode"; import { summarizeCodeSetuConfiguration } from "./configuration"; +const CONFIGURE_PROVIDER_LABEL = "$(gear) Configure provider (base URL, API key)…"; const CUSTOM_ENTRY_LABEL = "$(edit) Enter a custom model id…"; // A short, hand-picked set of chat models that the Hugging Face router reliably @@ -40,6 +41,7 @@ export async function selectCodeSetuModel(): Promise { const ordered = dedupeWithCurrentFirst(suggestions, current); const items: vscode.QuickPickItem[] = [ + { label: CONFIGURE_PROVIDER_LABEL, alwaysShow: true }, { label: CUSTOM_ENTRY_LABEL, alwaysShow: true }, ...ordered.map( (model): vscode.QuickPickItem => @@ -56,6 +58,12 @@ export async function selectCodeSetuModel(): Promise { return; } + if (picked.label === CONFIGURE_PROVIDER_LABEL) { + // Hand off to the full provider setup (provider, base URL, model, token). + await vscode.commands.executeCommand("codesetu.setupProvider"); + return; + } + const model = picked.label === CUSTOM_ENTRY_LABEL ? await vscode.window.showInputBox({ From 8856702f93b5ea34d3691c1eb296e8f92b3ed371 Mon Sep 17 00:00:00 2001 From: Gourav Bansal Date: Wed, 27 May 2026 23:52:13 +0530 Subject: [PATCH 7/9] chore(jetbrains): bump plugin version to 0.2.0 Set pluginVersion (gradle.properties) and package.json to 0.2.0 and add 0.2.0 change notes covering the webview chat UI, Hugging Face provider, in-chat provider/model switching, and PasswordSafe key storage. Co-Authored-By: Claude Opus 4.7 --- apps/jetbrains/gradle.properties | 2 +- apps/jetbrains/package.json | 2 +- apps/jetbrains/src/main/resources/META-INF/plugin.xml | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/jetbrains/gradle.properties b/apps/jetbrains/gradle.properties index 19eaedf..3623439 100644 --- a/apps/jetbrains/gradle.properties +++ b/apps/jetbrains/gradle.properties @@ -6,7 +6,7 @@ org.gradle.parallel=true # Plugin version baseline. CI overrides with -PpluginVersion=... # Bump the major.minor here when you want a semantic version bump; CI keeps the # patch incrementing automatically. -pluginVersion=0.1.0 +pluginVersion=0.2.0 # Tell Gradle's toolchain detection where to find JDK 21 installed via Homebrew. # Falls back to other auto-detected JDKs if this path doesn't exist on a contributor's machine. diff --git a/apps/jetbrains/package.json b/apps/jetbrains/package.json index e88576b..a65df52 100644 --- a/apps/jetbrains/package.json +++ b/apps/jetbrains/package.json @@ -1,6 +1,6 @@ { "name": "@codesetu/jetbrains", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "JetBrains plugin for CodeSetu. This Kotlin/Gradle project is integrated into the pnpm workspace via thin script delegations — actual build/test runs through Gradle.", "license": "Apache-2.0", diff --git a/apps/jetbrains/src/main/resources/META-INF/plugin.xml b/apps/jetbrains/src/main/resources/META-INF/plugin.xml index 9a6e7d7..7c991c4 100644 --- a/apps/jetbrains/src/main/resources/META-INF/plugin.xml +++ b/apps/jetbrains/src/main/resources/META-INF/plugin.xml @@ -12,6 +12,12 @@ ]]> 0.2.0 +
    +
  • Chat tool window rendered with the CodeSetu webview design (markdown replies, model chip)
  • +
  • Hugging Face provider support, plus in-chat provider/model switching (Sarvam, OpenAI-compatible, Hugging Face)
  • +
  • API key stored securely in PasswordSafe; multi-turn chat history
  • +

0.1.0

  • Initial JetBrains plugin scaffold
  • From a344fcad896ba9d0dfc8b56cd45b5d694b4812a5 Mon Sep 17 00:00:00 2001 From: Gourav Bansal Date: Wed, 27 May 2026 23:57:53 +0530 Subject: [PATCH 8/9] ci: add JetBrains CI, fix Open VSX gating, bump VS Code to 0.2.0 - ci.yml: add a JetBrains job (JDK 21 + Gradle) that runs compileKotlin, compileTestKotlin, test, and buildPlugin and uploads the zip. Kotlin was previously never built or tested on PRs (the Node scripts for apps/jetbrains are echo stubs). - release-vscode.yml: hoist OVSX_PAT to job-level env so the Open VSX publish step's `if` can actually read it (step-level env is not visible in that same step's condition, so it was always skipped). - Bump the VS Code extension to 0.2.0 (manifest + changelog) to align with the JetBrains 0.2.0 release. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++ .github/workflows/release-vscode.yml | 6 +++-- apps/vscode/CHANGELOG.md | 6 +++++ apps/vscode/package.json | 2 +- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adccee2..65c57fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,39 @@ jobs: path: artifacts/*.vsix if-no-files-found: error retention-days: 14 + + jetbrains: + name: JetBrains build and test + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('apps/jetbrains/**/*.gradle.kts', 'apps/jetbrains/gradle.properties', 'apps/jetbrains/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Compile, test, and package plugin + working-directory: apps/jetbrains + run: ./gradlew compileKotlin compileTestKotlin test buildPlugin --no-daemon + + - name: Upload plugin zip (smoke build) + uses: actions/upload-artifact@v4 + with: + name: codesetu-jetbrains-zip + path: apps/jetbrains/build/distributions/*.zip + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release-vscode.yml b/.github/workflows/release-vscode.yml index fdd2e5a..e1a845e 100644 --- a/.github/workflows/release-vscode.yml +++ b/.github/workflows/release-vscode.yml @@ -15,6 +15,10 @@ jobs: release: name: Build and publish runs-on: ubuntu-latest + # Job-level env so the Open VSX step's `if` can read it (step-level env is + # not visible in that same step's `if` condition). + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} steps: - name: Check out repository @@ -72,8 +76,6 @@ jobs: - name: Publish to Open VSX if: ${{ !inputs.dry-run && env.OVSX_PAT != '' }} - env: - OVSX_PAT: ${{ secrets.OVSX_PAT }} run: | VSIX=$(ls artifacts/codesetu-*.vsix | head -n1) pnpm dlx ovsx publish "$VSIX" --pat "$OVSX_PAT" diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 47d779d..c3e042a 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.0 + +- Store the provider API key in the OS secret store instead of settings.json. +- Add Hugging Face provider support and in-chat provider/model switching. +- Render assistant replies as markdown; refine the chat composer. + ## 0.0.0 - Initial VS Code extension scaffold. diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 9f47b32..e953d99 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -2,7 +2,7 @@ "name": "codesetu", "displayName": "CodeSetu", "description": "Open-source AI coding assistant for VSCode with multi-provider chat and inline completions. Works with Sarvam, OpenAI-compatible APIs, and local models.", - "version": "0.1.2", + "version": "0.2.0", "publisher": "codesetu", "license": "Apache-2.0", "repository": { From da1594e142f2ea776c9043ac062b9a7fa87d09bf Mon Sep 17 00:00:00 2001 From: Gourav Bansal Date: Thu, 28 May 2026 00:08:13 +0530 Subject: [PATCH 9/9] ci: add Plugin Verifier and cover both JetBrains plugins - Configure the JetBrains Plugin Verifier in both apps/jetbrains and apps/jetbrains-apiclient: add pluginVerifier() to the intellijPlatform dependencies and a pluginVerification block. Pin verification to the build target IDE (IC 2025.2.5) instead of recommended(), which tried to resolve an unreleased IDE (2025.3) and failed. - ci.yml: run the JetBrains job as a matrix over [jetbrains, jetbrains-apiclient] and add `verifyPlugin` to the Gradle invocation. - release-jetbrains.yml: also run `verifyPlugin` before publishing. Both plugins verify Compatible against IC-252 locally. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 20 ++++++++++++------ .github/workflows/release-jetbrains.yml | 4 ++-- .../executionHistory/executionHistory.bin | Bin 4128908 -> 4836545 bytes .../executionHistory/executionHistory.lock | Bin 17 -> 17 bytes .../.gradle/8.10/fileHashes/fileHashes.bin | Bin 41347 -> 41447 bytes .../.gradle/8.10/fileHashes/fileHashes.lock | Bin 17 -> 17 bytes .../buildOutputCleanup.lock | Bin 17 -> 17 bytes .../.gradle/file-system.probe | Bin 8 -> 8 bytes .../.intellijPlatform/self-update.lock | 2 +- apps/jetbrains-apiclient/build.gradle.kts | 12 +++++++++++ apps/jetbrains/build.gradle.kts | 12 +++++++++++ 11 files changed, 40 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65c57fc..b7d679a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,9 +58,14 @@ jobs: retention-days: 14 jetbrains: - name: JetBrains build and test + name: JetBrains build, test, verify runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + project: [jetbrains, jetbrains-apiclient] + steps: - name: Check out repository uses: actions/checkout@v4 @@ -77,18 +82,19 @@ jobs: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('apps/jetbrains/**/*.gradle.kts', 'apps/jetbrains/gradle.properties', 'apps/jetbrains/gradle/wrapper/gradle-wrapper.properties') }} + key: gradle-${{ runner.os }}-${{ matrix.project }}-${{ hashFiles(format('apps/{0}/**/*.gradle.kts', matrix.project), format('apps/{0}/gradle.properties', matrix.project), format('apps/{0}/gradle/wrapper/gradle-wrapper.properties', matrix.project)) }} restore-keys: | + gradle-${{ runner.os }}-${{ matrix.project }}- gradle-${{ runner.os }}- - - name: Compile, test, and package plugin - working-directory: apps/jetbrains - run: ./gradlew compileKotlin compileTestKotlin test buildPlugin --no-daemon + - name: Compile, test, build, and verify plugin + working-directory: apps/${{ matrix.project }} + run: ./gradlew compileKotlin compileTestKotlin test buildPlugin verifyPlugin --no-daemon - name: Upload plugin zip (smoke build) uses: actions/upload-artifact@v4 with: - name: codesetu-jetbrains-zip - path: apps/jetbrains/build/distributions/*.zip + name: codesetu-${{ matrix.project }}-zip + path: apps/${{ matrix.project }}/build/distributions/*.zip if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/release-jetbrains.yml b/.github/workflows/release-jetbrains.yml index 3082b9f..d44ebc8 100644 --- a/.github/workflows/release-jetbrains.yml +++ b/.github/workflows/release-jetbrains.yml @@ -48,9 +48,9 @@ jobs: echo "tag=jetbrains-v${VERSION}" >> "$GITHUB_OUTPUT" echo "Computed version: ${VERSION}" - - name: Build plugin + - name: Build and verify plugin working-directory: apps/jetbrains - run: ./gradlew -PpluginVersion=${{ steps.version.outputs.version }} buildPlugin --no-daemon + run: ./gradlew -PpluginVersion=${{ steps.version.outputs.version }} buildPlugin verifyPlugin --no-daemon - name: Verify plugin zip exists run: | diff --git a/apps/jetbrains-apiclient/.gradle/8.10/executionHistory/executionHistory.bin b/apps/jetbrains-apiclient/.gradle/8.10/executionHistory/executionHistory.bin index cda5a8b072db0b8a05f6b50b4c4426122733b1c0..aa5f98b9d541e17d614de8de721a64898823b5ae 100644 GIT binary patch delta 834 zcmbu+T}V@590u@nc8*`0%d>KB@A{hM^4qzcvpL;7vWU9qCa)^A;AktgDU~@gr4PCo zE1`{1uMv?%Sr>NU)iJ_i^aUZ^)VdQBS|1k^CA^#2bMB&`n_l?A^FF-1@aI2vImf1M zJ=DcZjd_fAh>&5J)3b^4N+ZKm$zzQE%?;G;)9o)UNn(xZ+L+{h>vP>w^h$_CLxcq# zaKJ+zJO;8Cbxoh6t%n?=d79#*n;yzR%qh@q|NX zyN>mRLxVl1dcyrmc<{tfufTLqio*$o9T{CcyyCz3p!2UrZ8VXq99uvWlC^_gA|$9D zG?PaxEnJpOs$d~Dj+zCojD8xo?xmwv5~QxyFyC-M9eQ$3|+24=Q4>t159uEfe1J{S{eWYo6;aqC{?c+@Pi@5${ zisf0%G(o4Y5hp!rkq)WZ63Jf_&2l!_3|n9;Y=iA!hjQ2fJD~y`unQ`o3aX(7YM~D5 zp#hxG2rh7g2oiX}3r*mIW@rIF1fW&Ta`N5Wev#47)j@^kUwiO)i;u?^V>7YUPP5_N z{8NYUXG2#c>4>^4NtGP!*j+Um9rsD#0WUOx51OF`{1AXvb=)UMUeF?)_?pj5Wzuws z7oD_tO&0jC+SR@$yZFyqO6+q4`2{V-IWfr3YiVIkuHhH8Wc(=~pblPq^}oN==3|BW GIoB@~*GSC( delta 418 zcmZ9`yDvjw0EY3N)2dQ!52`&MrCRjVy`?CsoBx8rrHdHY#H21cDiWlLFD4=qbEjjF zD7(=nVlfyh5(YyDkJX#}mUnvY&#u+Od{sI2dmOgEi4W6e3%r~}3}v>oa1%UP(~Ckc zsPSy^O21RJx1F3Yb3%a%I~=Hi6SZ)m4)t)O0UmhagN8;lp&5R(Ab=orDJy9cHP2rNQ8@k(CH^q`7Z!cq-HCC7U@|hKTE7}l3J30_XCnAWV3*G2J3ER}hH*?FjY&*l8X3%36?^u)`U{>gn=}9b diff --git a/apps/jetbrains-apiclient/.gradle/8.10/executionHistory/executionHistory.lock b/apps/jetbrains-apiclient/.gradle/8.10/executionHistory/executionHistory.lock index d6d2cb0d8fc0fc6dd2c5ae8202c63372aab3034c..10298547a80d297a14b3b01e79b4ef9119824c90 100644 GIT binary patch literal 17 TcmZRc?cd54#a1W700HIzAi@Hw literal 17 TcmZRc?cd54#a1W700D*oAiM&k diff --git a/apps/jetbrains-apiclient/.gradle/8.10/fileHashes/fileHashes.bin b/apps/jetbrains-apiclient/.gradle/8.10/fileHashes/fileHashes.bin index bc8876839360c982be748409c6af2752d7710df2..3c64138c9b5959f7b63194c3d4b0541c44c2d02e 100644 GIT binary patch delta 225 zcmZoZ%=G*)(*_d>#?zZkB_!GSk4-*MmG|N>0~jpay4h8(QIvnrx&wX(@ zj^bz1mfWcLgJrX$M^gw(*3Pi_$w#N?ff(tN^`}Tp7M?2t;-pO0o(mRDog52c#7^#? zE2S{O(S|FpzjV>H=~t)MRUG`!z`!^UsQeVz3T?~(n@`T&#jIxx#*>>(B_!E4r^!`_ZvJK*$j_wpa-*UK>t;ufrVzV~4!IZx5Mcf8 z4J7r9frKUyD}t~b1X2J1 diff --git a/apps/jetbrains-apiclient/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/apps/jetbrains-apiclient/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 6698e1132a08ee644d4f9263f50efdae125dd720..674f7a9416f9283fde4243eec38cf69bc33e4a0c 100644 GIT binary patch literal 17 UcmZR+{3pFkwsegY0|cxD05*mMu>b%7 literal 17 UcmZR+{3pFkwsegY0|fK~05)s{mjD0& diff --git a/apps/jetbrains-apiclient/.gradle/file-system.probe b/apps/jetbrains-apiclient/.gradle/file-system.probe index 7cd72bdf977b51d5ba61c6ca0aa6c9c7b2442ed4..bf30ca74f7a5c764f91a89a8420c227d3be7bc64 100644 GIT binary patch literal 8 PcmZQzV4Rn=Gc*JM2lWDM literal 8 PcmZQzV4RmxAoB|V2Sx&p diff --git a/apps/jetbrains-apiclient/.intellijPlatform/self-update.lock b/apps/jetbrains-apiclient/.intellijPlatform/self-update.lock index 75e1797..6c71513 100644 --- a/apps/jetbrains-apiclient/.intellijPlatform/self-update.lock +++ b/apps/jetbrains-apiclient/.intellijPlatform/self-update.lock @@ -1 +1 @@ -2026-05-27 \ No newline at end of file +2026-05-28 \ No newline at end of file diff --git a/apps/jetbrains-apiclient/build.gradle.kts b/apps/jetbrains-apiclient/build.gradle.kts index 60d21ec..261e4c8 100644 --- a/apps/jetbrains-apiclient/build.gradle.kts +++ b/apps/jetbrains-apiclient/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType + plugins { // Kotlin version must be >= the version bundled in the target IntelliJ Platform. // IDEA 2025.2 (IC-252.x) ships with Kotlin 2.2.x. @@ -36,6 +38,8 @@ dependencies { intellijIdeaCommunity("2025.2.5") } instrumentationTools() + // CLI used by the `verifyPlugin` task (JetBrains Plugin Verifier). + pluginVerifier() } } @@ -61,6 +65,14 @@ intellijPlatform { } } buildSearchableOptions = false + // `verifyPlugin` runs the JetBrains Plugin Verifier. Pin it to the build + // target so it's deterministic and always resolvable (recommended() can pick + // an unreleased IDE that fails to download). + pluginVerification { + ides { + ide(IntelliJPlatformType.IntellijIdeaCommunity, "2025.2.5") + } + } publishing { token = providers.environmentVariable("JETBRAINS_MARKETPLACE_TOKEN") } diff --git a/apps/jetbrains/build.gradle.kts b/apps/jetbrains/build.gradle.kts index 2cb58b0..4bd0a8c 100644 --- a/apps/jetbrains/build.gradle.kts +++ b/apps/jetbrains/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType + plugins { // Kotlin version must be >= the version bundled in the target IntelliJ Platform. // IDEA 2025.2 (IC-252.x) ships with Kotlin 2.2.x. @@ -35,6 +37,8 @@ dependencies { intellijIdeaCommunity("2025.2.5") } instrumentationTools() + // CLI used by the `verifyPlugin` task (JetBrains Plugin Verifier). + pluginVerifier() } } @@ -65,6 +69,14 @@ intellijPlatform { // compute a settings-search index; our scaffold has no settings panel, so // it's pure overhead. Re-enable once we add settings UI. buildSearchableOptions = false + // `verifyPlugin` runs the JetBrains Plugin Verifier. Pin it to the build + // target so it's deterministic and always resolvable (recommended() can pick + // an unreleased IDE that fails to download). + pluginVerification { + ides { + ide(IntelliJPlatformType.IntellijIdeaCommunity, "2025.2.5") + } + } publishing { token = providers.environmentVariable("JETBRAINS_MARKETPLACE_TOKEN") }