From 73b591d3f394bf6a139c5d1d3378a8aa269b98b9 Mon Sep 17 00:00:00 2001 From: rbinar Date: Fri, 12 Jun 2026 23:39:38 +0300 Subject: [PATCH 1/6] feat: add selectable context window (200K/1M) in model picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Context Window" dropdown in the Copilot Chat model configuration panel, letting users switch between 200K and 1M token context windows per model. - Combine reasoningEffort and contextSize into a single configurationSchema so both controls appear in the model picker dropdown (non-public VS Code API, same mechanism used by Copilot for thinking effort). - maxInputTokens raised from 656K to 1,000,000 to support 1M mode. - maxOutputTokens set to 128,000 — a conservative middle ground that keeps the displayed context window reasonable (200K input + 128K output = 328K, 1M input + 128K output = ~1.1M) without sacrificing DeepSeek's actual generation headroom. This does NOT affect API-level max_tokens, which is controlled by a separate VS Code setting. - Sync dropdown choice back to workspace setting (deepseek-copilot.contextSize) after each request so the value persists across sessions. - Both DeepSeek V4 Flash and Pro supported. - English and Chinese i18n for dropdown labels and descriptions. Why: the default 656K input window was too small for long sessions, while always reserving 1M adds latency and cost. Giving users control lets them pick the right trade-off between speed/cost (200K) and capacity (1M). --- package-lock.json | 12 ------- package.json | 14 ++++++++ package.nls.json | 5 +++ src/config.ts | 9 ++++++ src/consts.ts | 8 ++--- src/i18n.ts | 14 ++++++++ src/provider/index.ts | 18 +++++++++-- src/provider/models.ts | 72 ++++++++++++++++++++++++++++------------- src/provider/request.ts | 14 +++++--- 9 files changed, 120 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index 453d529..3bf20dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -430,9 +430,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -450,9 +447,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -470,9 +464,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -490,9 +481,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index e0c586c..21e616c 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,20 @@ "minimum": 0, "description": "%deepseek-copilot.config.maxTokens.description%" }, + "deepseek-copilot.contextSize": { + "type": "number", + "default": 1000000, + "enum": [200000, 1000000], + "enumItemLabels": [ + "%deepseek-copilot.config.contextSize.200k.label%", + "%deepseek-copilot.config.contextSize.1m.label%" + ], + "markdownEnumDescriptions": [ + "%deepseek-copilot.config.contextSize.200k.description%", + "%deepseek-copilot.config.contextSize.1m.description%" + ], + "markdownDescription": "%deepseek-copilot.config.contextSize.description%" + }, "deepseek-copilot.experimental.stabilizeToolList": { "type": "boolean", "default": false, diff --git a/package.nls.json b/package.nls.json index 34d81b6..f27a8c1 100644 --- a/package.nls.json +++ b/package.nls.json @@ -17,6 +17,11 @@ "deepseek-copilot.config.title": "DeepSeek Copilot", "deepseek-copilot.config.baseUrl.description": "DeepSeek API base URL. Defaults to official DeepSeek API endpoint.", "deepseek-copilot.config.maxTokens.description": "Maximum number of output tokens per request. Set to 0 to use the API default (no limit). Useful for controlling costs.", + "deepseek-copilot.config.contextSize.description": "Input context window size reported to VS Code. Larger context allows longer conversations without compaction but may increase cost.", + "deepseek-copilot.config.contextSize.200k.label": "200K", + "deepseek-copilot.config.contextSize.200k.description": "200K tokens — safe default for most sessions.", + "deepseek-copilot.config.contextSize.1m.label": "1M", + "deepseek-copilot.config.contextSize.1m.description": "1M tokens — longer sessions without compaction.", "deepseek-copilot.config.experimental.stabilizeToolList.description": "**Experimental**: improve DeepSeek context-cache hit rate by pre-activating available tools.\n- When the enabled tools list changes across turns, this may improve DeepSeek context-cache hit rate.\n- Requests will include more function definitions, so input tokens may increase. Cache-hit input tokens are billed at a lower price, but still count toward usage.\n- This may add internal preflight tool calls to the current Copilot chat history. If you switch to another model in the same conversation, that model provider may reject or mishandle the replayed history. Start a new chat if model switching behaves unexpectedly.\n\nUse [Configure Tools](command:workbench.action.chat.configureTools) to **view and manage** your tool list:\n\n- 64 or fewer enabled tools: usually no need to enable this unless the tool list still changes across turns.\n- More than 128 enabled tools: not recommended. DeepSeek supports at most 128 functions in one `tools` request. Consider disabling tools you rarely use.", "deepseek-copilot.config.debugMode.description": "Controls what diagnostic information DeepSeek Copilot writes. Token usage is always reported to Copilot regardless of this setting.\n\n- **Minimal** — Token usage only. No diagnostic logs or request dumps.\n- **Metadata** — Privacy-safe diagnostic metadata (request hashes, prefix overlap, tool schema changes). Does not contain prompt text — safe to share in public issue reports. View with [`DeepSeek: Show Logs`](command:deepseek-copilot.showLogs).\n- **Verbose** — Complete request payloads written to disk for local debugging. **Warning: contains sensitive prompt content.** View with [`DeepSeek: Open Request Dumps Folder`](command:deepseek-copilot.openRequestDumpsFolder).", "deepseek-copilot.config.debugMode.minimal.label": "Minimal", diff --git a/src/config.ts b/src/config.ts index 477ca09..472a3a8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,6 +36,15 @@ export function getMaxTokens(): number | undefined { return value > 0 ? value : undefined; } +/** + * Get the configured context window size in tokens. + * Returns the value set by the user (200K or 1M), defaulting to 1M. + */ +export function getContextSize(): number { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + return config.get('contextSize', 1000000); +} + /** * Diagnostic mode. `verbose` also enables metadata logs. * diff --git a/src/consts.ts b/src/consts.ts index b087544..d6d4de9 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -54,8 +54,8 @@ export const MODELS: ModelDefinition[] = [ family: 'deepseek', version: 'v4', detail: 'Fast, general-purpose model', - maxInputTokens: 655360, - maxOutputTokens: 393216, + maxInputTokens: 1000000, + maxOutputTokens: 128000, capabilities: { toolCalling: DEEPSEEK_TOOLS_LIMIT, imageInput: true, @@ -74,8 +74,8 @@ export const MODELS: ModelDefinition[] = [ family: 'deepseek', version: 'v4', detail: 'Most capable reasoning model', - maxInputTokens: 655360, - maxOutputTokens: 393216, + maxInputTokens: 1000000, + maxOutputTokens: 128000, capabilities: { toolCalling: DEEPSEEK_TOOLS_LIMIT, imageInput: true, diff --git a/src/i18n.ts b/src/i18n.ts index 847eac4..d95f377 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -42,6 +42,13 @@ const zh: Translations = { 'thinking.max': '深度', 'thinking.max.desc': '深度推理,适合复杂任务', + // Context Size — model picker dropdown + 'contextSize.title': '上下文窗口', + 'contextSize.200k': '200K', + 'contextSize.200k.desc': '200K token 上下文(更快)', + 'contextSize.1m': '1M', + 'contextSize.1m.desc': '1M token 上下文(更大容量)', + // Vision 'vision.proxyUsing': '视觉代理:{0}', 'vision.notFound': '未找到视觉模型 "{0}"', @@ -232,6 +239,13 @@ const en: Translations = { 'thinking.max': 'Max', 'thinking.max.desc': 'Maximum reasoning depth for complex agent tasks', + // Context Size — model picker dropdown + 'contextSize.title': 'Context Window', + 'contextSize.200k': '200K', + 'contextSize.200k.desc': '200K token context (faster)', + 'contextSize.1m': '1M', + 'contextSize.1m.desc': '1M token context (larger capacity)', + // Vision // NOTE: vision.unableToDescribe has been moved to consts.ts as // IMAGE_DESCRIPTION_UNAVAILABLE — it is prompt content, not UI text. diff --git a/src/provider/index.ts b/src/provider/index.ts index 0456a58..258e2f4 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -1,7 +1,7 @@ import vscode from 'vscode'; import { AuthManager } from '../auth'; -import { getStabilizeToolListEnabled } from '../config'; -import { MODELS } from '../consts'; +import { getContextSize, getStabilizeToolListEnabled } from '../config'; +import { CONFIG_SECTION, MODELS } from '../consts'; import { t } from '../i18n'; import { logger } from '../logger'; import { createCacheDiagnosticsRecorder, dumpProviderInput } from './debug'; @@ -133,10 +133,11 @@ export class DeepSeekChatProvider implements vscode.LanguageModelChatProvider { const hasKey = await this.authManager.hasApiKey(); const pricingCurrency = this.balanceCurrencyResolver.getDisplayCurrency(); + const contextSize = getContextSize(); if (hasKey) { this.balanceCurrencyResolver.refreshInBackground(); } - return MODELS.map((model) => toChatInfo(model, hasKey, pricingCurrency)); + return MODELS.map((model) => toChatInfo(model, hasKey, pricingCurrency, contextSize)); } async provideLanguageModelChatResponse( @@ -184,6 +185,17 @@ export class DeepSeekChatProvider implements vscode.LanguageModelChatProvider { getVisionDescriber: () => this.vision.get(), }); + // Sync context size back to VS Code setting when user changes it via the + // model-picker dropdown. The updated maxInputTokens takes effect on the + // next request after Copilot Chat re-queries model information. + const currentContextSize = getContextSize(); + if (prepared.configuredContextSize !== currentContextSize) { + await vscode.workspace + .getConfiguration(CONFIG_SECTION) + .update('contextSize', prepared.configuredContextSize, vscode.ConfigurationTarget.Global); + this.onDidChangeLanguageModelChatInformationEmitter.fire(); + } + return streamChatCompletion({ prepared, progress, diff --git a/src/provider/models.ts b/src/provider/models.ts index 65fcedd..c22a669 100644 --- a/src/provider/models.ts +++ b/src/provider/models.ts @@ -15,24 +15,27 @@ import { toModelCostInfo, type ModelCostInformation } from './pricing/costs'; export type ThinkingEffort = 'none' | 'high' | 'max'; +export type ContextSize = 200000 | 1000000; + export type ModelConfigurationOptions = vscode.ProvideLanguageModelChatResponseOptions & { readonly modelConfiguration?: Record; readonly configuration?: Record; }; -type ThinkingEffortConfigurationSchema = ReturnType; +type ModelConfigurationSchema = ReturnType; export type ModelPickerChatInformation = vscode.LanguageModelChatInformation & ModelCostInformation & { readonly isUserSelectable: boolean; readonly statusIcon?: vscode.ThemeIcon; - readonly configurationSchema?: ThinkingEffortConfigurationSchema; + readonly configurationSchema?: ModelConfigurationSchema; }; export function toChatInfo( m: ModelDefinition, hasApiKey: boolean, pricingCurrency?: PricingCurrency, + contextSize?: number, ): ModelPickerChatInformation { const modelDetail = resolveModelText(m, 'detail') ?? m.detail; const modelTooltip = resolveModelText(m, 'tooltip'); @@ -44,7 +47,7 @@ export function toChatInfo( detail: hasApiKey ? modelDetail : t('auth.apiKeyRequiredDetail'), tooltip: hasApiKey ? modelTooltip : t('auth.apiKeyRequiredDetail'), statusIcon: hasApiKey ? undefined : new vscode.ThemeIcon('warning'), - maxInputTokens: m.maxInputTokens, + maxInputTokens: contextSize ?? m.maxInputTokens, maxOutputTokens: m.maxOutputTokens, isUserSelectable: true, capabilities: { @@ -52,7 +55,7 @@ export function toChatInfo( imageInput: m.capabilities.imageInput, }, ...toModelCostInfo(m, pricingCurrency), - ...(m.capabilities.thinking ? { configurationSchema: buildThinkingEffortSchema() } : {}), + ...(hasApiKey ? { configurationSchema: buildModelConfigurationSchema(m) } : {}), }; } @@ -71,24 +74,49 @@ export function getConfiguredThinkingEffort(options: ModelConfigurationOptions): return configuredEffort === 'max' ? 'max' : 'high'; } -function buildThinkingEffortSchema() { - return { - properties: { - reasoningEffort: { - type: 'string', - title: t('status.thinking'), - enum: ['none', 'high', 'max'], - enumItemLabels: [t('thinking.none'), t('thinking.high'), t('thinking.max')], - enumDescriptions: [ - t('thinking.none.desc'), - t('thinking.high.desc'), - t('thinking.max.desc'), - ], - default: 'high', - group: 'navigation', - }, - }, - } as const; +/** + * Read the context size selected by the user via the model-picker dropdown. + * Falls back to the VS Code setting when the dropdown hasn't been used yet. + */ +export function getConfiguredContextSize(options: ModelConfigurationOptions): ContextSize { + const configured = + options.modelConfiguration?.contextSize ?? options.configuration?.contextSize; + if (configured === 200000) { + return 200000; + } + return 1000000; +} + +function buildModelConfigurationSchema(m: ModelDefinition) { + const properties: Record = {}; + + if (m.capabilities.thinking) { + properties.reasoningEffort = { + type: 'string', + title: t('status.thinking'), + enum: ['none', 'high', 'max'], + enumItemLabels: [t('thinking.none'), t('thinking.high'), t('thinking.max')], + enumDescriptions: [ + t('thinking.none.desc'), + t('thinking.high.desc'), + t('thinking.max.desc'), + ], + default: 'high', + group: 'navigation', + }; + } + + properties.contextSize = { + type: 'number', + title: t('contextSize.title'), + enum: [200000, 1000000], + enumItemLabels: [t('contextSize.200k'), t('contextSize.1m')], + enumDescriptions: [t('contextSize.200k.desc'), t('contextSize.1m.desc')], + default: 1000000, + group: 'tokens', + }; + + return { properties } as const; } function resolveModelText(m: ModelDefinition, field: 'detail' | 'tooltip'): string | undefined { diff --git a/src/provider/request.ts b/src/provider/request.ts index c143325..84c811f 100644 --- a/src/provider/request.ts +++ b/src/provider/request.ts @@ -8,13 +8,13 @@ import { t } from '../i18n'; import type { DeepSeekRequest } from '../types'; import { convertMessages, countMessageChars } from './convert'; import { - dumpDeepSeekRequest, - type CacheDiagnosticsRecorder, - type CacheDiagnosticsRun, + dumpDeepSeekRequest, + type CacheDiagnosticsRecorder, + type CacheDiagnosticsRun, } from './debug'; -import { getConfiguredThinkingEffort, type ModelConfigurationOptions } from './models'; -import { classifyDeepSeekRequest, shouldForceThinkingNone, type RequestKind } from './routing'; +import { getConfiguredContextSize, getConfiguredThinkingEffort, type ContextSize, type ModelConfigurationOptions } from './models'; import type { ReplayMarkerMetadata } from './replay'; +import { classifyDeepSeekRequest, shouldForceThinkingNone, type RequestKind } from './routing'; import type { ConversationSegment } from './segment'; import { collectTrailingToolResultIds, prepareRequestTools } from './tools/request'; import { resolveImageMessages, type VisionDescriber } from './vision'; @@ -31,6 +31,8 @@ export interface PreparedChatRequest { replayMarkerMetadata: ReplayMarkerMetadata; visionMarkerTextChars?: number; initialResponseNotice?: string; + /** The context size selected via the model-picker dropdown (if any). */ + configuredContextSize: ContextSize; } export interface PrepareChatRequestOptions { @@ -88,6 +90,7 @@ export async function prepareChatRequest({ const configuredThinkingEffort = getConfiguredThinkingEffort( options as ModelConfigurationOptions, ); + const configuredContextSize = getConfiguredContextSize(options as ModelConfigurationOptions); // Only force helper requests into disabled thinking on the official API. // Custom endpoints keep their configured effort to preserve pre-#137 request shape. const forceNoneThinking = @@ -147,5 +150,6 @@ export async function prepareChatRequest({ replayMarkerMetadata: visionResolution.replayMarkerMetadata, visionMarkerTextChars: visionResolution.stats.markerVisionTextChars || undefined, initialResponseNotice: visionResolution.initialResponseNotice, + configuredContextSize, }; } From 34cfbe19ce071702a14611e49977ff1356ca4d52 Mon Sep 17 00:00:00 2001 From: rbinar Date: Sat, 13 Jun 2026 17:37:19 +0300 Subject: [PATCH 2/6] feat: DeepSeek status bar panel with balance, tokens, cost tracking - Status bar button with hubot icon opening persistent webview panel - Balance display from /user/balance API with refresh - Session token tracker with per-request history - Estimated cost calculation using model pricing - Click-to-expand per-request breakdown table - DeepSeek links (API Keys, Usage, Status) - Extension version in footer --- package.json | 32 +++-- resources/deepseek-logo.svg | 1 + src/balance.ts | 51 +++++++ src/provider/request.ts | 3 + src/provider/stream.ts | 2 + src/runtime/lifecycle.ts | 261 ++++++++++++++++++++++++++++++++++++ src/tracker.ts | 80 +++++++++++ 7 files changed, 416 insertions(+), 14 deletions(-) create mode 100644 resources/deepseek-logo.svg create mode 100644 src/balance.ts create mode 100644 src/tracker.ts diff --git a/package.json b/package.json index 21e616c..31b3e8d 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,24 @@ "title": "%deepseek-copilot.command.openRequestDumpsFolder%" } ], + "views": { + "deepseek-copilot": [ + { + "id": "deepseek-copilot.panelView", + "name": "Overview", + "type": "webview" + } + ] + }, + "viewsContainers": { + "panel": [ + { + "id": "deepseek-copilot", + "title": "DeepSeek", + "icon": "resources/deepseek-logo.svg" + } + ] + }, "walkthroughs": [ { "id": "deepseekGettingStarted", @@ -127,20 +145,6 @@ "minimum": 0, "description": "%deepseek-copilot.config.maxTokens.description%" }, - "deepseek-copilot.contextSize": { - "type": "number", - "default": 1000000, - "enum": [200000, 1000000], - "enumItemLabels": [ - "%deepseek-copilot.config.contextSize.200k.label%", - "%deepseek-copilot.config.contextSize.1m.label%" - ], - "markdownEnumDescriptions": [ - "%deepseek-copilot.config.contextSize.200k.description%", - "%deepseek-copilot.config.contextSize.1m.description%" - ], - "markdownDescription": "%deepseek-copilot.config.contextSize.description%" - }, "deepseek-copilot.experimental.stabilizeToolList": { "type": "boolean", "default": false, diff --git a/resources/deepseek-logo.svg b/resources/deepseek-logo.svg new file mode 100644 index 0000000..48b845e --- /dev/null +++ b/resources/deepseek-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/balance.ts b/src/balance.ts new file mode 100644 index 0000000..249b67f --- /dev/null +++ b/src/balance.ts @@ -0,0 +1,51 @@ +import vscode from 'vscode'; +import { getBaseUrl } from './config'; + +export interface BalanceInfo { + /** Single balance entry (DeepSeek returns array of these). */ + currency: string; + total_balance: string; + granted_balance: string; + topped_up_balance: string; +} + +export interface BalanceResponse { + is_available: boolean; + balance_infos: BalanceInfo[]; +} + +/** + * Fetch DeepSeek user balance from the API. + * Returns the raw response or undefined on failure. + */ +export async function fetchBalance(apiKey: string): Promise { + try { + const baseUrl = getBaseUrl(); + const response = await fetch(`${baseUrl}/user/balance`, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + void vscode.window.showWarningMessage(`DeepSeek balance check failed: HTTP ${response.status}`); + return undefined; + } + + return (await response.json()) as BalanceResponse; + } catch (error) { + void vscode.window.showWarningMessage(`DeepSeek balance check failed: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } +} + +/** + * Format a balance entry for display. + */ +export function formatBalance(balance: BalanceInfo): string { + const total = parseFloat(balance.total_balance); + const granted = parseFloat(balance.granted_balance); + const toppedUp = parseFloat(balance.topped_up_balance); + return `${balance.currency} ${total.toFixed(2)}`; +} diff --git a/src/provider/request.ts b/src/provider/request.ts index 84c811f..5712686 100644 --- a/src/provider/request.ts +++ b/src/provider/request.ts @@ -22,6 +22,8 @@ import { resolveImageMessages, type VisionDescriber } from './vision'; export interface PreparedChatRequest { client: DeepSeekClient; request: DeepSeekRequest; + /** The VS Code model ID (e.g. "deepseek-v4-pro"). */ + modelId: string; isThinkingModel: boolean; totalRequestChars: number; trailingToolResultIds: string[]; @@ -141,6 +143,7 @@ export async function prepareChatRequest({ return { client, request, + modelId: modelInfo.id, isThinkingModel, totalRequestChars, trailingToolResultIds: collectTrailingToolResultIds(deepseekMessages), diff --git a/src/provider/stream.ts b/src/provider/stream.ts index a4c5477..d1f24ed 100644 --- a/src/provider/stream.ts +++ b/src/provider/stream.ts @@ -1,6 +1,7 @@ import vscode from 'vscode'; import { createUserFacingError } from '../client'; import { logger } from '../logger'; +import { recordUsage } from '../tracker'; import type { DeepSeekToolCall, DeepSeekUsage } from '../types'; import { observeCancellationToken, @@ -82,6 +83,7 @@ export function streamChatCompletion({ }, onUsage: (usage) => { + recordUsage(usage, prepared.modelId); const charsPerToken = updateCharsPerToken( prepared.totalRequestChars, usage, diff --git a/src/runtime/lifecycle.ts b/src/runtime/lifecycle.ts index 707aa0c..4afb8fe 100644 --- a/src/runtime/lifecycle.ts +++ b/src/runtime/lifecycle.ts @@ -1,7 +1,11 @@ import vscode from 'vscode'; +import { AuthManager } from '../auth'; +import { fetchBalance } from '../balance'; +import { EXTERNAL_URLS } from '../consts'; import { t } from '../i18n'; import { logger } from '../logger'; import { DeepSeekChatProvider } from '../provider'; +import { getSessionTokens, getSessionRequests } from '../tracker'; import { registerActionUrls } from './actions'; import { registerCommands } from './commands'; import { initializeDiagnostics } from './diagnostics'; @@ -15,6 +19,75 @@ export async function activate(context: vscode.ExtensionContext): Promise registerCommands(context); registerActionUrls(context); + // --- Status Bar Button --- + const statusBarButton = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 100, + ); + statusBarButton.text = '$(hubot)'; + statusBarButton.tooltip = 'Open DeepSeek Copilot Panel'; + statusBarButton.command = 'deepseek-copilot.showPanel'; + statusBarButton.show(); + context.subscriptions.push(statusBarButton); + + context.subscriptions.push( + vscode.commands.registerCommand('deepseek-copilot.showPanel', () => { + void vscode.commands.executeCommand('deepseek-copilot.panelView.focus'); + }), + ); + + // --- Panel Webview --- + const authManager = new AuthManager(context); + const panelProvider = new (class implements vscode.WebviewViewProvider { + resolveWebviewView(webviewView: vscode.WebviewView): void { + webviewView.webview.options = { enableScripts: true }; + + webviewView.webview.onDidReceiveMessage(async (msg) => { + if (msg === 'refreshBalance') { + const key = await authManager.getApiKey(); + if (!key) { + webviewView.webview.postMessage({ type: 'balance', data: null, error: 'No API key configured' }); + return; + } + const balance = await fetchBalance(key); + webviewView.webview.postMessage({ type: 'balance', data: balance, error: null }); + } + if (msg === 'getTokens') { + const tokens = getSessionTokens(); + webviewView.webview.postMessage({ type: 'tokens', data: tokens }); + } + if (msg === 'getRequests') { + const reqs = getSessionRequests(); + webviewView.webview.postMessage({ type: 'requests', data: reqs }); + } + if (msg.type === 'openUrl' && msg.url) { + void vscode.env.openExternal(vscode.Uri.parse(msg.url)); + } + }); + + webviewView.webview.html = getPanelHtml(context.extension.packageJSON.version); + + // Auto-fetch on load + void (async () => { + const key = await authManager.getApiKey(); + if (!key) { + webviewView.webview.postMessage({ type: 'balance', data: null, error: 'No API key configured' }); + } else { + const balance = await fetchBalance(key); + webviewView.webview.postMessage({ type: 'balance', data: balance, error: null }); + } + webviewView.webview.postMessage({ type: 'tokens', data: getSessionTokens() }); + webviewView.webview.postMessage({ type: 'requests', data: getSessionRequests() }); + })(); + } + })(); + + context.subscriptions.push( + vscode.window.registerWebviewViewProvider('deepseek-copilot.panelView', panelProvider, { + webviewOptions: { retainContextWhenHidden: true }, + }), + ); + try { const provider = await registerProvider(context); activeProvider = provider; @@ -43,3 +116,191 @@ export async function deactivate(): Promise { logger.dispose(); } } + +function getPanelHtml(version: string): string { + return ` + + + + + + + +
+ +
Loading...
+
+
+
+ +
Loading...
+
+
+
+
+ +
+ + + +
+
+ + + +`; +} diff --git a/src/tracker.ts b/src/tracker.ts new file mode 100644 index 0000000..48e8b5e --- /dev/null +++ b/src/tracker.ts @@ -0,0 +1,80 @@ +import { MODELS } from './consts'; +import type { DeepSeekUsage, ModelDefinition } from './types'; + +/** + * Lightweight in-memory session token tracker. + * Accumulates across requests within a single VS Code session. + */ +export interface SessionTokens { + inputTokens: number; + outputTokens: number; + requestCount: number; + /** Estimated cost in USD based on model pricing. */ + costUsd: number; +} + +export interface SessionRequest { + timestamp: number; + modelId: string; + modelName: string; + inputTokens: number; + outputTokens: number; + costUsd: number; +} + +const session: SessionTokens = { + inputTokens: 0, + outputTokens: 0, + requestCount: 0, + costUsd: 0, +}; + +const requests: SessionRequest[] = []; + +// Build pricing lookup from MODELS const +const pricingMap = new Map(); +for (const m of MODELS as ModelDefinition[]) { + const usd = m.pricing?.USD; + if (usd) { + pricingMap.set(m.id, { inputPerM: usd.cacheMissInput, outputPerM: usd.output }); + } +} + +function calcCost(inputTokens: number, outputTokens: number, modelId: string): number { + const p = pricingMap.get(modelId); + if (!p) return 0; + return (inputTokens / 1_000_000) * p.inputPerM + (outputTokens / 1_000_000) * p.outputPerM; +} + +function findModelName(modelId: string): string { + const m = (MODELS as ModelDefinition[]).find(m => m.id === modelId); + return m?.name ?? modelId; +} + +export function recordUsage(usage: DeepSeekUsage, modelId?: string): void { + const input = usage.prompt_tokens ?? 0; + const output = usage.completion_tokens ?? 0; + const cost = modelId ? calcCost(input, output, modelId) : 0; + + session.inputTokens += input; + session.outputTokens += output; + session.requestCount += 1; + session.costUsd += cost; + + requests.push({ + timestamp: Date.now(), + modelId: modelId ?? 'unknown', + modelName: findModelName(modelId ?? 'unknown'), + inputTokens: input, + outputTokens: output, + costUsd: cost, + }); +} + +export function getSessionTokens(): Readonly { + return session; +} + +export function getSessionRequests(): readonly SessionRequest[] { + return requests; +} From 2117f7d6c4ea547079dff96a38f9f32663363376 Mon Sep 17 00:00:00 2001 From: rbinar Date: Sat, 13 Jun 2026 17:37:40 +0300 Subject: [PATCH 3/6] chore: remove unused deepseek-logo.svg --- package.json | 3 +-- resources/deepseek-logo.svg | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 resources/deepseek-logo.svg diff --git a/package.json b/package.json index 31b3e8d..48edb77 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,7 @@ "panel": [ { "id": "deepseek-copilot", - "title": "DeepSeek", - "icon": "resources/deepseek-logo.svg" + "title": "DeepSeek" } ] }, diff --git a/resources/deepseek-logo.svg b/resources/deepseek-logo.svg deleted file mode 100644 index 48b845e..0000000 --- a/resources/deepseek-logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From a6b37fe85ce34da02a372eb151165854b5dbda74 Mon Sep 17 00:00:00 2001 From: rbinar Date: Sat, 13 Jun 2026 17:48:34 +0300 Subject: [PATCH 4/6] feat: persist token tracker across restarts with daily history - Sessions survive VS Code restarts via globalState - Auto-reset at day boundary, saves previous day to history - Daily history table in panel (click 'history' to expand) - Mid-session date change detection --- src/provider/stream.ts | 14 +++---- src/runtime/lifecycle.ts | 55 ++++++++++++++++++++++++++- src/tracker.ts | 82 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 137 insertions(+), 14 deletions(-) diff --git a/src/provider/stream.ts b/src/provider/stream.ts index d1f24ed..76f3628 100644 --- a/src/provider/stream.ts +++ b/src/provider/stream.ts @@ -4,17 +4,17 @@ import { logger } from '../logger'; import { recordUsage } from '../tracker'; import type { DeepSeekToolCall, DeepSeekUsage } from '../types'; import { - observeCancellationToken, - type CacheDiagnosticsRun, - type ReplayMarkerReportTrigger, + observeCancellationToken, + type CacheDiagnosticsRun, + type ReplayMarkerReportTrigger, } from './debug'; -import { formatRequestLogLine, type RequestKind } from './routing'; import { - createReplayMarkerPart, - hasReplayMarkerMetadata, - type ReplayMarkerMetadata, + createReplayMarkerPart, + hasReplayMarkerMetadata, + type ReplayMarkerMetadata, } from './replay'; import type { PreparedChatRequest } from './request'; +import { formatRequestLogLine, type RequestKind } from './routing'; interface ResponseStreamState { accumulatedReasoning: string; diff --git a/src/runtime/lifecycle.ts b/src/runtime/lifecycle.ts index 4afb8fe..169e8f9 100644 --- a/src/runtime/lifecycle.ts +++ b/src/runtime/lifecycle.ts @@ -5,7 +5,7 @@ import { EXTERNAL_URLS } from '../consts'; import { t } from '../i18n'; import { logger } from '../logger'; import { DeepSeekChatProvider } from '../provider'; -import { getSessionTokens, getSessionRequests } from '../tracker'; +import { getSessionRequests, getSessionTokens, getDailyHistory, initTracker } from '../tracker'; import { registerActionUrls } from './actions'; import { registerCommands } from './commands'; import { initializeDiagnostics } from './diagnostics'; @@ -16,6 +16,7 @@ let activeProvider: DeepSeekChatProvider | undefined; export async function activate(context: vscode.ExtensionContext): Promise { await initializeDiagnostics(context); + initTracker(context); registerCommands(context); registerActionUrls(context); @@ -60,6 +61,10 @@ export async function activate(context: vscode.ExtensionContext): Promise const reqs = getSessionRequests(); webviewView.webview.postMessage({ type: 'requests', data: reqs }); } + if (msg === 'getHistory') { + const hist = getDailyHistory(); + webviewView.webview.postMessage({ type: 'history', data: hist }); + } if (msg.type === 'openUrl' && msg.url) { void vscode.env.openExternal(vscode.Uri.parse(msg.url)); } @@ -78,6 +83,7 @@ export async function activate(context: vscode.ExtensionContext): Promise } webviewView.webview.postMessage({ type: 'tokens', data: getSessionTokens() }); webviewView.webview.postMessage({ type: 'requests', data: getSessionRequests() }); + webviewView.webview.postMessage({ type: 'history', data: getDailyHistory() }); })(); } })(); @@ -184,10 +190,11 @@ function getPanelHtml(version: string): string {
- +
Loading...
+
@@ -201,19 +208,29 @@ function getPanelHtml(version: string): string { `; diff --git a/src/tracker.ts b/src/tracker.ts index 48e8b5e..1164503 100644 --- a/src/tracker.ts +++ b/src/tracker.ts @@ -1,9 +1,10 @@ +import vscode from 'vscode'; import { MODELS } from './consts'; import type { DeepSeekUsage, ModelDefinition } from './types'; /** - * Lightweight in-memory session token tracker. - * Accumulates across requests within a single VS Code session. + * Persistent session token tracker. + * Survives VS Code restarts via globalState. Resets daily. */ export interface SessionTokens { inputTokens: number; @@ -22,14 +23,31 @@ export interface SessionRequest { costUsd: number; } -const session: SessionTokens = { +export interface DailyTokens extends SessionTokens { + date: string; // YYYY-MM-DD +} + +interface PersistedState { + date: string; + tokens: SessionTokens; + requests: SessionRequest[]; + history: DailyTokens[]; +} + +const STORAGE_KEY = 'deepseek-copilot.tokenTracker'; + +const emptySession = (): SessionTokens => ({ inputTokens: 0, outputTokens: 0, requestCount: 0, costUsd: 0, -}; +}); -const requests: SessionRequest[] = []; +let context: vscode.ExtensionContext | undefined; +let session: SessionTokens = emptySession(); +let requests: SessionRequest[] = []; +let history: DailyTokens[] = []; +let date: string = today(); // Build pricing lookup from MODELS const const pricingMap = new Map(); @@ -40,6 +58,10 @@ for (const m of MODELS as ModelDefinition[]) { } } +function today(): string { + return new Date().toISOString().slice(0, 10); +} + function calcCost(inputTokens: number, outputTokens: number, modelId: string): number { const p = pricingMap.get(modelId); if (!p) return 0; @@ -51,7 +73,46 @@ function findModelName(modelId: string): string { return m?.name ?? modelId; } +function persist(): void { + if (!context) return; + const state: PersistedState = { date, tokens: { ...session }, requests: [...requests], history: [...history] }; + void context.globalState.update(STORAGE_KEY, state); +} + +export function initTracker(ctx: vscode.ExtensionContext): void { + context = ctx; + const saved = ctx.globalState.get(STORAGE_KEY); + if (saved?.date === today()) { + session = saved.tokens ?? emptySession(); + requests = saved.requests ?? []; + history = saved.history ?? []; + date = saved.date ?? today(); + } else { + // New day: save yesterday to history + if (saved && (saved.tokens?.requestCount ?? 0) > 0) { + history = [...(saved.history ?? []), { date: saved.date, ...saved.tokens }]; + } else if (saved?.history) { + history = saved.history; + } + session = emptySession(); + requests = []; + date = today(); + persist(); + } +} + export function recordUsage(usage: DeepSeekUsage, modelId?: string): void { + // Detect date change mid-session + const d = today(); + if (d !== date) { + if (session.requestCount > 0) { + history.push({ date, ...session }); + } + session = emptySession(); + requests = []; + date = d; + } + const input = usage.prompt_tokens ?? 0; const output = usage.completion_tokens ?? 0; const cost = modelId ? calcCost(input, output, modelId) : 0; @@ -69,6 +130,8 @@ export function recordUsage(usage: DeepSeekUsage, modelId?: string): void { outputTokens: output, costUsd: cost, }); + + persist(); } export function getSessionTokens(): Readonly { @@ -78,3 +141,12 @@ export function getSessionTokens(): Readonly { export function getSessionRequests(): readonly SessionRequest[] { return requests; } + +export function getDailyHistory(): readonly DailyTokens[] { + // Include today if there's activity + const all = [...history]; + if (session.requestCount > 0) { + all.push({ date, ...session }); + } + return all; +} From 28f4010f3a491d34ca3fe4e1072485db00a3969f Mon Sep 17 00:00:00 2001 From: rbinar Date: Sat, 13 Jun 2026 17:56:17 +0300 Subject: [PATCH 5/6] feat: session grouping with titles, UI color updates - Session tokens grouped by first user message title - 'sessions' toggle in Session Tokens card shows group breakdown - Balance color: rgb(96, 179, 254) blue - Est. cost color: rgb(255, 161, 10) amber --- src/provider/request.ts | 14 ++++++++ src/provider/stream.ts | 2 +- src/runtime/lifecycle.ts | 78 +++++++++++++++++++++++++++++++++++----- src/tracker.ts | 34 +++++++++++++++++- 4 files changed, 117 insertions(+), 11 deletions(-) diff --git a/src/provider/request.ts b/src/provider/request.ts index 5712686..786d977 100644 --- a/src/provider/request.ts +++ b/src/provider/request.ts @@ -19,6 +19,17 @@ import type { ConversationSegment } from './segment'; import { collectTrailingToolResultIds, prepareRequestTools } from './tools/request'; import { resolveImageMessages, type VisionDescriber } from './vision'; +function extractSessionTitle(messages: readonly vscode.LanguageModelChatRequestMessage[]): string { + for (const msg of messages) { + if (msg.role !== vscode.LanguageModelChatMessageRole.User) continue; + const text = typeof msg.content === 'string' ? msg.content : ''; + // Truncate to first 60 chars, strip newlines + const title = text.replace(/\n/g, ' ').trim().slice(0, 60); + return title || 'Untitled'; + } + return 'Untitled'; +} + export interface PreparedChatRequest { client: DeepSeekClient; request: DeepSeekRequest; @@ -35,6 +46,8 @@ export interface PreparedChatRequest { initialResponseNotice?: string; /** The context size selected via the model-picker dropdown (if any). */ configuredContextSize: ContextSize; + /** Truncated title from the first user message in this request. */ + sessionTitle: string; } export interface PrepareChatRequestOptions { @@ -154,5 +167,6 @@ export async function prepareChatRequest({ visionMarkerTextChars: visionResolution.stats.markerVisionTextChars || undefined, initialResponseNotice: visionResolution.initialResponseNotice, configuredContextSize, + sessionTitle: extractSessionTitle(messages), }; } diff --git a/src/provider/stream.ts b/src/provider/stream.ts index 76f3628..6b0c39c 100644 --- a/src/provider/stream.ts +++ b/src/provider/stream.ts @@ -83,7 +83,7 @@ export function streamChatCompletion({ }, onUsage: (usage) => { - recordUsage(usage, prepared.modelId); + recordUsage(usage, prepared.modelId, prepared.sessionTitle); const charsPerToken = updateCharsPerToken( prepared.totalRequestChars, usage, diff --git a/src/runtime/lifecycle.ts b/src/runtime/lifecycle.ts index 169e8f9..4446a6a 100644 --- a/src/runtime/lifecycle.ts +++ b/src/runtime/lifecycle.ts @@ -5,7 +5,7 @@ import { EXTERNAL_URLS } from '../consts'; import { t } from '../i18n'; import { logger } from '../logger'; import { DeepSeekChatProvider } from '../provider'; -import { getSessionRequests, getSessionTokens, getDailyHistory, initTracker } from '../tracker'; +import { getSessionRequests, getSessionTokens, getDailyHistory, getSessionGroups, initTracker } from '../tracker'; import { registerActionUrls } from './actions'; import { registerCommands } from './commands'; import { initializeDiagnostics } from './diagnostics'; @@ -65,6 +65,10 @@ export async function activate(context: vscode.ExtensionContext): Promise const hist = getDailyHistory(); webviewView.webview.postMessage({ type: 'history', data: hist }); } + if (msg === 'getSessions') { + const groups = getSessionGroups(); + webviewView.webview.postMessage({ type: 'sessions', data: groups }); + } if (msg.type === 'openUrl' && msg.url) { void vscode.env.openExternal(vscode.Uri.parse(msg.url)); } @@ -84,6 +88,7 @@ export async function activate(context: vscode.ExtensionContext): Promise webviewView.webview.postMessage({ type: 'tokens', data: getSessionTokens() }); webviewView.webview.postMessage({ type: 'requests', data: getSessionRequests() }); webviewView.webview.postMessage({ type: 'history', data: getDailyHistory() }); + webviewView.webview.postMessage({ type: 'sessions', data: getSessionGroups() }); })(); } })(); @@ -147,7 +152,7 @@ function getPanelHtml(version: string): string { } .card label { display: block; font-size: 11px; color: var(--vscode-descriptionForeground); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; } .card .value { font-size: 13px; } - .card .value.balance { font-size: 16px; font-weight: 600; color: var(--vscode-charts-green); } + .card .value.balance { font-size: 16px; font-weight: 600; color: rgb(96, 179, 254); } .card .value.balance.low { color: var(--vscode-charts-orange); } .card .value.balance.empty { color: var(--vscode-errorForeground); } button { @@ -174,7 +179,7 @@ function getPanelHtml(version: string): string { #balanceError { color: var(--vscode-errorForeground); font-size: 12px; margin-top: 4px; } #tokenValue { font-family: var(--vscode-editor-font-family); font-size: 12px; cursor: pointer; } #tokenValue:hover { color: var(--vscode-textLink-foreground); } - #costValue { font-size: 12px; color: var(--vscode-charts-orange); margin-top: 4px; } + #costValue { font-size: 12px; color: rgb(255, 161, 10); margin-top: 4px; } #requestsTable { display: none; margin-top: 8px; } #requestsTable table { width: 100%; border-collapse: collapse; font-size: 11px; } #requestsTable th { text-align: left; padding: 3px 6px; border-bottom: 1px solid var(--vscode-panel-border); color: var(--vscode-descriptionForeground); font-weight: 500; } @@ -190,11 +195,12 @@ function getPanelHtml(version: string): string {
- +
Loading...
+
@@ -209,21 +215,32 @@ function getPanelHtml(version: string): string { const vscode = acquireVsCodeApi(); let requestsExpanded = false; let historyExpanded = false; + let sessionsExpanded = false; let lastRequests = []; let lastHistory = []; + let lastSessions = []; function toggleRequests() { if (historyExpanded) { toggleHistory(); } + if (sessionsExpanded) { toggleSessions(); } requestsExpanded = !requestsExpanded; renderRequests(lastRequests); } function toggleHistory() { if (requestsExpanded) { toggleRequests(); } + if (sessionsExpanded) { toggleSessions(); } historyExpanded = !historyExpanded; renderHistory(lastHistory); } + function toggleSessions() { + if (requestsExpanded) { toggleRequests(); } + if (historyExpanded) { toggleHistory(); } + sessionsExpanded = !sessionsExpanded; + renderSessions(lastSessions); + } + function refresh() { document.getElementById('balanceValue').textContent = 'Loading...'; document.getElementById('balanceError').textContent = ''; @@ -231,6 +248,7 @@ function getPanelHtml(version: string): string { vscode.postMessage('getTokens'); vscode.postMessage('getRequests'); vscode.postMessage('getHistory'); + vscode.postMessage('getSessions'); } function openUrl(url) { vscode.postMessage({ type: 'openUrl', url }); } @@ -262,23 +280,30 @@ function getPanelHtml(version: string): string { function renderHistory(hist) { lastHistory = hist; const table = document.getElementById('historyTable'); - if (!hist || hist.length <= 1 || !historyExpanded) { + if (!historyExpanded) { table.style.display = 'none'; table.innerHTML = ''; return; } - table.style.display = 'block'; + if (!hist || hist.length === 0) { + table.style.display = 'block'; + table.innerHTML = '
No history yet
'; + return; + } // Show past days (exclude today) - const past = hist.filter(h => h.date !== hist[hist.length-1]?.date || hist.length === 1); + const allPast = hist; // getDailyHistory includes today if active + const past = allPast.filter(h => h.date !== todayStr()); if (past.length === 0) { - table.style.display = 'none'; - table.innerHTML = ''; + table.style.display = 'block'; + table.innerHTML = '
History will appear after midnight
'; return; } + table.style.display = 'block'; let html = ''; for (let i = past.length - 1; i >= 0; i--) { const h = past[i]; const total = h.inputTokens + h.outputTokens; + if (h.requestCount === 0) continue; html += ''; html += ''; html += ''; @@ -289,6 +314,37 @@ function getPanelHtml(version: string): string { table.innerHTML = html; } + function todayStr() { + return new Date().toISOString().slice(0, 10); + } + + function renderSessions(groups) { + lastSessions = groups; + const table = document.getElementById('sessionsTable'); + if (!sessionsExpanded) { + table.style.display = 'none'; + table.innerHTML = ''; + return; + } + if (!groups || groups.length === 0) { + table.style.display = 'block'; + table.innerHTML = '
No sessions yet
'; + return; + } + table.style.display = 'block'; + let html = ''; + for (const g of groups) { + const total = g.inputTokens + g.outputTokens; + html += '
'; + html += '
' + esc(g.title) + '
'; + html += '
'; + html += total.toLocaleString() + ' tokens · ' + g.requests.length + ' requests · $' + g.costUsd.toFixed(4) + ''; + html += '
'; + html += '
'; + } + table.innerHTML = html; + } + window.addEventListener('message', e => { const msg = e.data; if (msg.type === 'balance') { @@ -347,10 +403,14 @@ function getPanelHtml(version: string): string { if (msg.type === 'history') { renderHistory(msg.data); } + if (msg.type === 'sessions') { + renderSessions(msg.data); + } }); vscode.postMessage('getTokens'); vscode.postMessage('getRequests'); vscode.postMessage('getHistory'); + vscode.postMessage('getSessions'); `; diff --git a/src/tracker.ts b/src/tracker.ts index 1164503..e04e35d 100644 --- a/src/tracker.ts +++ b/src/tracker.ts @@ -18,6 +18,15 @@ export interface SessionRequest { timestamp: number; modelId: string; modelName: string; + sessionTitle: string; + inputTokens: number; + outputTokens: number; + costUsd: number; +} + +export interface SessionGroup { + title: string; + requests: SessionRequest[]; inputTokens: number; outputTokens: number; costUsd: number; @@ -101,7 +110,7 @@ export function initTracker(ctx: vscode.ExtensionContext): void { } } -export function recordUsage(usage: DeepSeekUsage, modelId?: string): void { +export function recordUsage(usage: DeepSeekUsage, modelId?: string, sessionTitle?: string): void { // Detect date change mid-session const d = today(); if (d !== date) { @@ -126,6 +135,7 @@ export function recordUsage(usage: DeepSeekUsage, modelId?: string): void { timestamp: Date.now(), modelId: modelId ?? 'unknown', modelName: findModelName(modelId ?? 'unknown'), + sessionTitle: sessionTitle ?? 'Untitled', inputTokens: input, outputTokens: output, costUsd: cost, @@ -150,3 +160,25 @@ export function getDailyHistory(): readonly DailyTokens[] { } return all; } + +export function getSessionGroups(): readonly SessionGroup[] { + const groups = new Map(); + for (const req of requests) { + const existing = groups.get(req.sessionTitle); + if (existing) { + existing.requests.push(req); + existing.inputTokens += req.inputTokens; + existing.outputTokens += req.outputTokens; + existing.costUsd += req.costUsd; + } else { + groups.set(req.sessionTitle, { + title: req.sessionTitle, + requests: [req], + inputTokens: req.inputTokens, + outputTokens: req.outputTokens, + costUsd: req.costUsd, + }); + } + } + return [...groups.values()]; +} From 6f908a9de193b6f06030e38b1249c68c3619e36e Mon Sep 17 00:00:00 2001 From: rbinar Date: Sat, 13 Jun 2026 22:59:01 +0300 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20session=20bazl=C4=B1=20token=20taki?= =?UTF-8?q?bi,=20Copilot=20session=20ba=C5=9Fl=C4=B1=C4=9F=C4=B1=20entegra?= =?UTF-8?q?syonu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Status bar butonu ve webview panel eklendi (DeepSeek paneli) - Balance, token kullanımı, maliyet takibi yapıldı - Session (oturum) bazlı gruplama: aynı Copilot sohbetindeki tüm istekler tek grupta toplanıyor - Copilot Chat session başlıkları state.vscdb üzerinden okunuyor ( fallback ile) - Günlük geçmiş (daily history) globalState üzerinde kalıcı - Session kartına tıklayınca per-request detay gösterimi - Panelde reset butonu ile veri temizleme - maxInputTokens 655360 → 1000000 artırıldı - contextSize konfigürasyonu package.json'a eklendi --- package.json | 8 +- src/provider/request.ts | 96 ++++++++++++++++++++++-- src/provider/stream.ts | 2 +- src/runtime/lifecycle.ts | 102 ++++++++++++++++++++----- src/session.ts | 157 +++++++++++++++++++++++++++++++++++++++ src/tracker.ts | 27 ++++++- 6 files changed, 361 insertions(+), 31 deletions(-) create mode 100644 src/session.ts diff --git a/package.json b/package.json index 48edb77..0cb84d2 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "deepseek-copilot": [ { "id": "deepseek-copilot.panelView", - "name": "Overview", + "name": "DeepSeek", "type": "webview" } ] @@ -185,6 +185,12 @@ "default": "Describe all image attachments in this message.\n\nIf there is one image, describe it directly.\nIf there are multiple images:\n1. Describe each image separately, preserving their order.\n2. Then provide a combined description explaining the overall context and relationships across the images.\n\nReturn one concise factual description suitable for inserting into a text-only chat prompt. Include visible text, objects, UI elements, people, and relevant context. Do not invent details.", "markdownDescription": "%deepseek-copilot.config.visionPrompt.description%" }, + "deepseek-copilot.contextSize": { + "type": "number", + "default": 1000000, + "enum": [200000, 1000000], + "description": "Context window size (tokens). 200K or 1M." + }, "deepseek-copilot.debugMode": { "type": "string", "default": "minimal", diff --git a/src/provider/request.ts b/src/provider/request.ts index 786d977..7ce7a81 100644 --- a/src/provider/request.ts +++ b/src/provider/request.ts @@ -5,6 +5,7 @@ import { getApiModelId, getBaseUrl, getMaxTokens } from '../config'; import { MODELS } from '../consts'; import { isOfficialDeepSeekBaseUrl } from '../endpoint'; import { t } from '../i18n'; +import { getCurrentCopilotSession } from '../session'; import type { DeepSeekRequest } from '../types'; import { convertMessages, countMessageChars } from './convert'; import { @@ -19,14 +20,80 @@ import type { ConversationSegment } from './segment'; import { collectTrailingToolResultIds, prepareRequestTools } from './tools/request'; import { resolveImageMessages, type VisionDescriber } from './vision'; +function extractRawText(messages: readonly vscode.LanguageModelChatRequestMessage[]): string { + // Concatenate all user message text for debug display (first 500 chars). + let raw = ''; + for (const msg of messages) { + if (msg.role !== vscode.LanguageModelChatMessageRole.User) continue; + if (typeof msg.content === 'string') { + raw += msg.content + '\n---\n'; + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part && typeof (part as { value?: unknown }).value === 'string') { + raw += (part as { value: string }).value + '\n---\n'; + } + } + } + } + return raw; +} + function extractSessionTitle(messages: readonly vscode.LanguageModelChatRequestMessage[]): string { + // Collect text from user messages, joining all text parts per message. + let fullText = ''; for (const msg of messages) { if (msg.role !== vscode.LanguageModelChatMessageRole.User) continue; - const text = typeof msg.content === 'string' ? msg.content : ''; - // Truncate to first 60 chars, strip newlines - const title = text.replace(/\n/g, ' ').trim().slice(0, 60); - return title || 'Untitled'; + if (typeof msg.content === 'string') { + fullText += msg.content + '\n'; + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part && typeof (part as { value?: unknown }).value === 'string') { + fullText += (part as { value: string }).value + '\n'; + } + } + } + } + + // 1) is THE user message — always prefer it. + const urMatch = fullText.match(//gi); + if (urMatch) { + for (const block of urMatch) { + const inner = block.replace(/<\/?userRequest[^>]*>/gi, '').trim(); + if (inner.length >= 3) { + const clean = inner.replace(/[#*_`~]/g, '').trim().slice(0, 60); + if (clean) return clean; + } + } + } + + // 2) Fallback: search the rest of the text after stripping system blocks. + let cleaned = fullText + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(/[\s\S]*?<\/context>/gi, '') + .replace(//gi, '') + .replace(/[\s\S]*?<\/userMemory>/gi, '') + .replace(/[\s\S]*?<\/repoMemory>/gi, '') + .replace(/[\s\S]*?<\/sessionMemory>/gi, '') + .replace(/<[^>]*>/g, ' '); + + const lines = cleaned.split('\n').map(l => l.trim()).filter(l => l.length >= 3); + // Take the last non-system line (usually closest to the user's actual words). + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (/^(You are |The user|The current|Information about|#{1,6}\s)/.test(line)) continue; + const title = line.replace(/[#*_`~]/g, '').trim().slice(0, 60); + if (title) return title; } + return 'Untitled'; } @@ -46,8 +113,12 @@ export interface PreparedChatRequest { initialResponseNotice?: string; /** The context size selected via the model-picker dropdown (if any). */ configuredContextSize: ContextSize; - /** Truncated title from the first user message in this request. */ + /** Stable Copilot Chat session ID used for grouping (falls back to title). */ + sessionId: string; + /** Title from Copilot Chat session, or extracted from user message. */ sessionTitle: string; + /** Raw text from user messages (for debug). */ + sessionRawText: string; } export interface PrepareChatRequestOptions { @@ -167,6 +238,19 @@ export async function prepareChatRequest({ visionMarkerTextChars: visionResolution.stats.markerVisionTextChars || undefined, initialResponseNotice: visionResolution.initialResponseNotice, configuredContextSize, - sessionTitle: extractSessionTitle(messages), + ...resolveSessionIdentity(messages), + sessionRawText: extractRawText(messages), }; } + +function resolveSessionIdentity( + messages: readonly vscode.LanguageModelChatRequestMessage[], +): { sessionId: string; sessionTitle: string } { + const copilot = getCurrentCopilotSession(); + if (copilot) { + const title = copilot.title && copilot.title !== 'New Chat' ? copilot.title : extractSessionTitle(messages); + return { sessionId: copilot.id, sessionTitle: title }; + } + const fallbackTitle = extractSessionTitle(messages); + return { sessionId: fallbackTitle, sessionTitle: fallbackTitle }; +} diff --git a/src/provider/stream.ts b/src/provider/stream.ts index 6b0c39c..bd430a5 100644 --- a/src/provider/stream.ts +++ b/src/provider/stream.ts @@ -83,7 +83,7 @@ export function streamChatCompletion({ }, onUsage: (usage) => { - recordUsage(usage, prepared.modelId, prepared.sessionTitle); + recordUsage(usage, prepared.modelId, prepared.sessionTitle, prepared.sessionRawText, prepared.sessionId); const charsPerToken = updateCharsPerToken( prepared.totalRequestChars, usage, diff --git a/src/runtime/lifecycle.ts b/src/runtime/lifecycle.ts index 4446a6a..1154e6c 100644 --- a/src/runtime/lifecycle.ts +++ b/src/runtime/lifecycle.ts @@ -5,7 +5,8 @@ import { EXTERNAL_URLS } from '../consts'; import { t } from '../i18n'; import { logger } from '../logger'; import { DeepSeekChatProvider } from '../provider'; -import { getSessionRequests, getSessionTokens, getDailyHistory, getSessionGroups, initTracker } from '../tracker'; +import { initSession } from '../session'; +import { clearTracker, getDailyHistory, getSessionGroups, getSessionRequests, getSessionTokens, initTracker } from '../tracker'; import { registerActionUrls } from './actions'; import { registerCommands } from './commands'; import { initializeDiagnostics } from './diagnostics'; @@ -17,6 +18,7 @@ let activeProvider: DeepSeekChatProvider | undefined; export async function activate(context: vscode.ExtensionContext): Promise { await initializeDiagnostics(context); initTracker(context); + initSession(context); registerCommands(context); registerActionUrls(context); @@ -72,6 +74,13 @@ export async function activate(context: vscode.ExtensionContext): Promise if (msg.type === 'openUrl' && msg.url) { void vscode.env.openExternal(vscode.Uri.parse(msg.url)); } + if (msg === 'clearData') { + clearTracker(); + webviewView.webview.postMessage({ type: 'tokens', data: getSessionTokens() }); + webviewView.webview.postMessage({ type: 'requests', data: getSessionRequests() }); + webviewView.webview.postMessage({ type: 'history', data: getDailyHistory() }); + webviewView.webview.postMessage({ type: 'sessions', data: getSessionGroups() }); + } }); webviewView.webview.html = getPanelHtml(context.extension.packageJSON.version); @@ -152,7 +161,7 @@ function getPanelHtml(version: string): string { } .card label { display: block; font-size: 11px; color: var(--vscode-descriptionForeground); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; } .card .value { font-size: 13px; } - .card .value.balance { font-size: 16px; font-weight: 600; color: rgb(96, 179, 254); } + .card .value.balance { font-size: 16px; font-weight: 600; color: rgb(247, 173, 49); } .card .value.balance.low { color: var(--vscode-charts-orange); } .card .value.balance.empty { color: var(--vscode-errorForeground); } button { @@ -185,6 +194,17 @@ function getPanelHtml(version: string): string { #requestsTable th { text-align: left; padding: 3px 6px; border-bottom: 1px solid var(--vscode-panel-border); color: var(--vscode-descriptionForeground); font-weight: 500; } #requestsTable td { padding: 3px 6px; border-bottom: 1px solid var(--vscode-panel-border); font-family: var(--vscode-editor-font-family); } #requestsTable tr:hover td { background: var(--vscode-list-hoverBackground); } + .req-card { + background: var(--vscode-input-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + padding: 6px 8px; + margin-bottom: 4px; + font-size: 11px; + } + .req-card .req-model { font-weight: 500; margin-bottom: 2px; } + .req-card .req-detail { font-family: var(--vscode-editor-font-family); color: var(--vscode-descriptionForeground); } + .req-card .req-cost { color: rgb(255, 161, 10); } .footer { font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 12px; text-align: center; } @@ -195,8 +215,8 @@ function getPanelHtml(version: string): string {
- -
Loading...
+ +
Loading...
@@ -251,28 +271,43 @@ function getPanelHtml(version: string): string { vscode.postMessage('getSessions'); } function openUrl(url) { vscode.postMessage({ type: 'openUrl', url }); } + function clearAll() { + vscode.postMessage('clearData'); + // Reset local UI state + requestsExpanded = false; + historyExpanded = false; + sessionsExpanded = false; + document.getElementById('requestsTable').style.display = 'none'; + document.getElementById('historyTable').style.display = 'none'; + document.getElementById('sessionsTable').style.display = 'none'; + lastRequests = []; + lastHistory = []; + lastSessions = []; + } function renderRequests(reqs) { lastRequests = reqs; - const table = document.getElementById('requestsTable'); + const container = document.getElementById('requestsTable'); if (!reqs || reqs.length === 0 || !requestsExpanded) { - table.style.display = 'none'; - table.innerHTML = ''; + container.style.display = 'none'; + container.innerHTML = ''; return; } - table.style.display = 'block'; - let html = '
DateTokensCost
' + esc(h.date) + '' + total.toLocaleString() + ' (' + h.requestCount + ' req)
'; + container.style.display = 'block'; + let html = ''; + // Show newest first, vertically stacked as cards for (let i = reqs.length - 1; i >= 0; i--) { const r = reqs[i]; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; + html += '
'; + html += '
' + esc(r.modelName) + '
'; + html += '
'; + html += r.inputTokens.toLocaleString() + ' in · '; + html += r.outputTokens.toLocaleString() + ' out'; + html += ' $' + r.costUsd.toFixed(4) + ''; + html += '
'; + html += '
'; } - html += '
ModelInputOutputCost
' + esc(r.modelName) + '' + r.inputTokens.toLocaleString() + '' + r.outputTokens.toLocaleString() + '$' + r.costUsd.toFixed(4) + '
'; - table.innerHTML = html; + container.innerHTML = html; } function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } @@ -333,16 +368,43 @@ function getPanelHtml(version: string): string { } table.style.display = 'block'; let html = ''; - for (const g of groups) { + for (let gi = 0; gi < groups.length; gi++) { + const g = groups[gi]; const total = g.inputTokens + g.outputTokens; - html += '
'; - html += '
' + esc(g.title) + '
'; + const detailId = 'sessDetail' + gi; + html += '
'; + html += '
' + esc(g.title || 'Untitled') + '
'; html += '
'; html += total.toLocaleString() + ' tokens · ' + g.requests.length + ' requests · $' + g.costUsd.toFixed(4) + ''; html += '
'; + // Hidden per-request table + html += ''; html += '
'; } table.innerHTML = html; + // Click-to-expand per-session requests + const cards = table.querySelectorAll('.sessCard'); + for (let i = 0; i < cards.length; i++) { + cards[i].addEventListener('click', function(e) { + // Don't toggle if user clicked a link/button inside + if (e.target.tagName === 'A' || e.target.tagName === 'BUTTON') return; + const id = this.getAttribute('data-target'); + const el = document.getElementById(id); + if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none'; + }); + } } window.addEventListener('message', e => { diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..75f88cc --- /dev/null +++ b/src/session.ts @@ -0,0 +1,157 @@ +/** + * Reads Copilot Chat session titles from VS Code's globalState database. + * This is a lightweight approach — we shell out to sqlite3 (available on macOS). + * Falls back to undefined if the DB can't be read. + */ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import vscode from 'vscode'; + +const SESSION_STORE_KEY = 'chat.ChatSessionStore.index'; + +let cachedDbPath: string | undefined; +let dbPathResolved = false; +let globalStorageUri: vscode.Uri | undefined; +let cachedTitles: Map | undefined; +let cacheTime = 0; +const CACHE_TTL = 5000; // 5 seconds + +/** + * Must be called once during activation with the extension context, so we can + * locate `state.vscdb` (sibling of our own globalStorage folder). + */ +export function initSession(context: vscode.ExtensionContext): void { + globalStorageUri = context.globalStorageUri; + // Reset cache so the path is recomputed with the new context. + cachedDbPath = undefined; + dbPathResolved = false; +} + +function getStateDbPath(): string | undefined { + if (dbPathResolved) return cachedDbPath; + dbPathResolved = true; + + // Preferred: our own globalStorageUri is `.../User/globalStorage//`. + // `state.vscdb` lives one level up in `.../User/globalStorage/`. + if (globalStorageUri) { + try { + const candidate = vscode.Uri.joinPath(globalStorageUri, '..', 'state.vscdb').fsPath; + if (fs.existsSync(candidate)) { + cachedDbPath = candidate; + return cachedDbPath; + } + } catch { + // fall through to other strategies + } + } + + // Fallback: try common absolute paths for both VS Code variants. + try { + const home = process.env.HOME || '~'; + const paths = [ + `${home}/Library/Application Support/Code - Insiders/User/globalStorage/state.vscdb`, + `${home}/Library/Application Support/Code/User/globalStorage/state.vscdb`, + ]; + for (const p of paths) { + if (fs.existsSync(p)) { + cachedDbPath = p; + return p; + } + } + } catch { + // ignore + } + + cachedDbPath = undefined; + return undefined; +} + +function loadSessionTitles(): Map { + const now = Date.now(); + if (cachedTitles && now - cacheTime < CACHE_TTL) { + return cachedTitles; + } + + const dbPath = getStateDbPath(); + if (!dbPath) { + cachedTitles = new Map(); + return cachedTitles; + } + + try { + const raw = execSync( + `sqlite3 "${dbPath}" "SELECT value FROM ItemTable WHERE key = '${SESSION_STORE_KEY}';"`, + { timeout: 2000, maxBuffer: 10 * 1024 * 1024, encoding: 'utf-8' }, + ); + if (!raw) { + cachedTitles = new Map(); + return cachedTitles; + } + + const data = JSON.parse(raw) as { entries?: Record }; + const entries = data.entries ?? {}; + const map = new Map(); + for (const [, v] of Object.entries(entries)) { + if (v.title && v.title !== 'New Chat') { + map.set(v.sessionId, v.title); + } + } + cachedTitles = map; + cacheTime = now; + return map; + } catch { + cachedTitles = new Map(); + cacheTime = now; + return new Map(); + } +} + +/** + * Returns the Copilot Chat session title for the most recently active session, + * or undefined if unavailable. + */ +export function getCurrentCopilotSessionTitle(): string | undefined { + return getCurrentCopilotSession()?.title; +} + +/** + * Returns the most recently active Copilot Chat session + * (both id and title), or undefined if unavailable. + * Does NOT filter by isEmpty — the freshest session by lastMessageDate wins. + */ +export function getCurrentCopilotSession(): { id: string; title: string } | undefined { + const dbPath = getStateDbPath(); + if (!dbPath) return undefined; + + try { + const raw = execSync( + `sqlite3 "${dbPath}" "SELECT value FROM ItemTable WHERE key = '${SESSION_STORE_KEY}';"`, + { timeout: 2000, maxBuffer: 10 * 1024 * 1024, encoding: 'utf-8' }, + ); + if (!raw) return undefined; + + const data = JSON.parse(raw) as { entries?: Record }; + const entries = Object.values(data.entries ?? {}); + // Find most recent session by lastMessageDate (don't filter isEmpty — the + // current session may not be marked non-empty yet when our request arrives). + let best: { sessionId: string; title: string; lastMessageDate: number } | undefined; + for (const e of entries) { + if (!best || e.lastMessageDate > best.lastMessageDate) { + best = e; + } + } + if (best) { + return { id: best.sessionId, title: best.title || 'Untitled' }; + } + } catch { + // ignore + } + return undefined; +} + +/** + * Returns all known Copilot session titles mapped by session ID. + */ +export function getAllCopilotSessionTitles(): ReadonlyMap { + return loadSessionTitles(); +} diff --git a/src/tracker.ts b/src/tracker.ts index e04e35d..4e9a1cc 100644 --- a/src/tracker.ts +++ b/src/tracker.ts @@ -18,7 +18,11 @@ export interface SessionRequest { timestamp: number; modelId: string; modelName: string; + /** Stable session identifier used for grouping. */ + sessionId: string; sessionTitle: string; + /** Raw text used for title extraction (debug). */ + rawTitleText: string; inputTokens: number; outputTokens: number; costUsd: number; @@ -110,7 +114,17 @@ export function initTracker(ctx: vscode.ExtensionContext): void { } } -export function recordUsage(usage: DeepSeekUsage, modelId?: string, sessionTitle?: string): void { +export function clearTracker(): void { + session = emptySession(); + requests = []; + history = []; + date = today(); + if (context) { + void context.globalState.update(STORAGE_KEY, undefined); + } +} + +export function recordUsage(usage: DeepSeekUsage, modelId?: string, sessionTitle?: string, rawTitleText?: string, sessionId?: string): void { // Detect date change mid-session const d = today(); if (d !== date) { @@ -135,7 +149,9 @@ export function recordUsage(usage: DeepSeekUsage, modelId?: string, sessionTitle timestamp: Date.now(), modelId: modelId ?? 'unknown', modelName: findModelName(modelId ?? 'unknown'), + sessionId: sessionId ?? sessionTitle ?? 'Untitled', sessionTitle: sessionTitle ?? 'Untitled', + rawTitleText: rawTitleText ?? '', inputTokens: input, outputTokens: output, costUsd: cost, @@ -164,14 +180,19 @@ export function getDailyHistory(): readonly DailyTokens[] { export function getSessionGroups(): readonly SessionGroup[] { const groups = new Map(); for (const req of requests) { - const existing = groups.get(req.sessionTitle); + const key = req.sessionId || req.sessionTitle; + const existing = groups.get(key); if (existing) { existing.requests.push(req); existing.inputTokens += req.inputTokens; existing.outputTokens += req.outputTokens; existing.costUsd += req.costUsd; + // Use the most informative (latest non-"Untitled") title for the group. + if (req.sessionTitle && req.sessionTitle !== 'Untitled') { + existing.title = req.sessionTitle; + } } else { - groups.set(req.sessionTitle, { + groups.set(key, { title: req.sessionTitle, requests: [req], inputTokens: req.inputTokens,