diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ec490..e987adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ version with its date and start a fresh empty `[Unreleased]` above it. ### Added +- Click the context usage reading beside the message input to open a context + window panel: what fills the window (system prompt, tools, skills, + messages), how full it is, and a Compact context button. - The agent now learns which external context directories are attached: the message you send right after adding or removing one carries the current list, so the agent can use those folders without you referencing every file. @@ -20,6 +23,19 @@ version with its date and start a fresh empty `[Unreleased]` above it. release; click it to open Qoderian's plugin page, where Obsidian's update button lives. +### Changed + +- The context usage meter is now a complete ring that fills from the top + instead of an arc with a gap at the bottom. +- The resize grip above the message input stays hidden until the pointer is + inside the input area or the composer has focus, so the input's top edge + reads as a plain border while you are reading or scrolling. + +### Removed + +- The hover tooltip on the message input's resize grip; the resize cursor and + the accent highlight while dragging already signal what the grip does. + ## [1.0.11] - 2026-09-16 ### Added diff --git a/src/core/runtime/chat-runtime.ts b/src/core/runtime/chat-runtime.ts index 5f03914..1155722 100644 --- a/src/core/runtime/chat-runtime.ts +++ b/src/core/runtime/chat-runtime.ts @@ -1,4 +1,4 @@ -import type { ChatMessage, Conversation, SlashCommand, StreamChunk, ToolCallInfo } from '../types'; +import type { ChatMessage, ContextUsageBreakdown, Conversation, SlashCommand, StreamChunk, ToolCallInfo } from '../types'; import type { ApprovalCallback, AskUserQuestionCallback, @@ -41,6 +41,8 @@ export interface ChatRuntime { getSessionId(): string | null; consumeSessionInvalidation(): boolean; isReady(): boolean; + /** Context breakdown for the toolbar panel; null when unavailable. */ + requestContextUsage?(): Promise; getSupportedCommands(): Promise; getAuxiliaryModel?(): string | null; cleanup(): Promise; diff --git a/src/core/types/chat.ts b/src/core/types/chat.ts index e77d35d..0c7d314 100644 --- a/src/core/types/chat.ts +++ b/src/core/types/chat.ts @@ -183,3 +183,30 @@ export interface UsageInfo { contextTokens: number; percentage: number; } + +/** Slots the CLI reports in its `/context` breakdown. */ +export type ContextUsageCategoryType = + | 'system_prompt' + | 'system_tools' + | 'skills' + | 'messages' + | 'other' + | 'free_space' + | 'auto_compact'; + +export interface ContextUsageCategory { + type: ContextUsageCategoryType; + /** Share of the full context window, 0-100 and possibly fractional. */ + percentage: number; +} + +/** What currently fills the context window, as reported by the CLI. */ +export interface ContextUsageBreakdown { + usedPercentage: number; + categories: ContextUsageCategory[]; + skills: { + count: number; + /** Aggregate skill frontmatter as a percentage of the full context. */ + percentageOfContext: number; + }; +} diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 9ac967d..6f6cd16 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -2,6 +2,9 @@ export { type ChatMessage, type ContentBlock, + type ContextUsageBreakdown, + type ContextUsageCategory, + type ContextUsageCategoryType, type Conversation, type ConversationMeta, type ErrorContentBlock, diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index 7ac8ed6..2865c5a 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -417,9 +417,6 @@ export class QoderianView extends ItemView { ); } this.creditsUsageButton?.refreshLocale(); - for (const tab of this.tabManager?.getAllTabs() ?? []) { - tab.ui.composerResize?.refreshLocale(); - } this.updateTabBar(); } diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index 8b61de8..e2eb627 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -11,6 +11,7 @@ import { t } from '../../../i18n/i18n'; import type QoderianPlugin from '../../../main'; import { isHiddenCommand } from '../../../qoder/commands/command-visibility-policy'; import { getQoderSettings, updateQoderSettings } from '../../../qoder/config/settings'; +import { fetchStoredSessionContextUsage } from '../../../qoder/services/context-usage'; import { SlashCommandDropdown, toSlashCommandDropdownEntries, @@ -429,6 +430,22 @@ function initializeInputToolbar( await plugin.qoderServices.agentCatalog.refresh(); }, loginService: plugin.qoderServices.loginService, + requestContextUsage: async () => { + const runtime = tab.service; + if (runtime?.requestContextUsage) { + return runtime.requestContextUsage(); + } + // A restored tab keeps its runtime unstarted until the first send, so + // read the stored session directly through the idle probe instead. + const conversation = tab.conversationId + ? await plugin.getConversationById(tab.conversationId) + : null; + const sessionId = conversation?.sessionId; + return sessionId ? fetchStoredSessionContextUsage(plugin, sessionId) : null; + }, + onCompactContext: () => { + void tab.controllers.inputController?.sendMessage({ content: '/compact' }); + }, onModelChange: async (model: string) => { // Blank tabs keep their model choice until the first message binds them. if (tab.lifecycleState === 'blank') { diff --git a/src/features/chat/ui/composer-resize.ts b/src/features/chat/ui/composer-resize.ts index 0eda291..5e14fdf 100644 --- a/src/features/chat/ui/composer-resize.ts +++ b/src/features/chat/ui/composer-resize.ts @@ -1,13 +1,9 @@ -import { t } from '../../../i18n/i18n'; -import { setButtonTooltip } from '../../../shared/dom/tooltip'; - export const COMPOSER_MIN_HEIGHT = 140; export const COMPOSER_MAX_HEIGHT_PERCENT = 0.75; export const COMPOSER_KEYBOARD_RESIZE_STEP = 24; export interface ComposerResizeController { destroy(): void; - refreshLocale(): void; reset(): void; } @@ -113,15 +109,10 @@ export function attachComposerResize( applyHeight(currentHeight() + delta); }; - const refreshLocale = (): void => { - setButtonTooltip(handleEl, t('composer.resize')); - }; - handleEl.setAttribute('role', 'separator'); handleEl.setAttribute('aria-orientation', 'horizontal'); handleEl.setAttribute('tabindex', '0'); updateAriaBounds(currentHeight()); - refreshLocale(); handleEl.addEventListener('pointerdown', handlePointerDown); handleEl.addEventListener('dblclick', reset); handleEl.addEventListener('keydown', handleKeydown); @@ -148,7 +139,6 @@ export function attachComposerResize( handleEl.removeEventListener('keydown', handleKeydown); viewWindow?.removeEventListener('blur', stopDragging); }, - refreshLocale, reset, }; } diff --git a/src/features/chat/ui/input-toolbar.ts b/src/features/chat/ui/input-toolbar.ts index abd51e4..33ad0a5 100644 --- a/src/features/chat/ui/input-toolbar.ts +++ b/src/features/chat/ui/input-toolbar.ts @@ -5,11 +5,17 @@ import * as path from 'path'; import { filterValidContextPaths, findConflictingPath, isDuplicatePath, validateContextPath, validateDirectoryPath } from '../../../core/context/external-context'; import { expandHomePath, normalizePathForFilesystem } from '../../../core/fs/path'; import type { + ContextUsageBreakdown, + ContextUsageCategory, + ContextUsageCategoryType, ManagedMcpServer, UsageInfo, } from '../../../core/types'; +import { t } from '../../../i18n/i18n'; +import type { TranslationKey } from '../../../i18n/types'; import type { McpServerManager } from '../../../qoder/mcp/mcp-server-manager'; import { appendCheckIcon, appendMcpIcon } from '../../../shared/icons'; +import { ClickPopover } from './toolbar/click-popover'; import { placeHoverDropdown } from './toolbar/hover-dropdown-placement'; import { ModelSelector, @@ -679,13 +685,45 @@ export class McpServerSelector { } } +export interface ContextUsageMeterCallbacks { + /** Fresh `/context` read; the runtime answers from its last snapshot while busy. */ + requestContextUsage?: () => Promise; + /** Compacts the conversation (sends `/compact` through the composer). */ + onCompactContext?: () => void; +} + +/** Skills also report how many are loaded; their label is formatted separately. */ +const CONTEXT_CATEGORY_LABELS: Record, TranslationKey> = { + system_prompt: 'contextUsage.categorySystemPrompt', + system_tools: 'contextUsage.categorySystemTools', + messages: 'contextUsage.categoryMessages', + other: 'contextUsage.categoryOther', + free_space: 'contextUsage.categoryFreeSpace', + auto_compact: 'contextUsage.categoryAutoCompact', +}; + +/** Buckets hidden from the panel: they describe headroom, not occupancy. */ +const HIDDEN_CATEGORY_TYPES = new Set(['free_space', 'auto_compact']); + +function roundPercent(value: number): number { + return Math.min(100, Math.max(0, Math.round(value))); +} + export class ContextUsageMeter { private container: HTMLElement; - private fillPath: SVGPathElement | null = null; + private triggerEl: HTMLElement | null = null; + private panelEl: HTMLElement | null = null; + private popover: ClickPopover | null = null; + private fillPath: SVGCircleElement | null = null; private percentEl: HTMLElement | null = null; private circumference: number = 0; + private breakdown: ContextUsageBreakdown | null = null; + private refreshToken = 0; - constructor(parentEl: HTMLElement) { + constructor( + parentEl: HTMLElement, + private readonly callbacks: ContextUsageMeterCallbacks = {}, + ) { this.container = parentEl.createDiv({ cls: 'qoderian-context-meter' }); this.render(); // Initially hidden @@ -697,27 +735,19 @@ export class ContextUsageMeter { } private render() { - const size = 16; + const size = 18; const strokeWidth = 2; - const radius = (size - strokeWidth) / 2; + const radius = (size - strokeWidth * 2) / 2; const cx = size / 2; const cy = size / 2; + this.circumference = 2 * Math.PI * radius; - // 240° arc: from 150° to 390° (upper-left through bottom to upper-right) - const startAngle = 150; - const endAngle = 390; - const arcDegrees = endAngle - startAngle; - const arcRadians = (arcDegrees * Math.PI) / 180; - this.circumference = radius * arcRadians; - - const startRad = (startAngle * Math.PI) / 180; - const endRad = (endAngle * Math.PI) / 180; - const x1 = cx + radius * Math.cos(startRad); - const y1 = cy + radius * Math.sin(startRad); - const x2 = cx + radius * Math.cos(endRad); - const y2 = cy + radius * Math.sin(endRad); - - const gaugeEl = this.container.createDiv({ cls: 'qoderian-context-meter-gauge' }); + // Gauge and percentage form the popover trigger; the panel is its sibling + // so clicks inside the panel cannot toggle it closed. + this.panelEl = this.container.createDiv({ cls: 'qoderian-context-panel' }); + this.triggerEl = this.container.createDiv({ cls: 'qoderian-context-meter-trigger' }); + + const gaugeEl = this.triggerEl.createDiv({ cls: 'qoderian-context-meter-gauge' }); const svg = gaugeEl.createSvg('svg', { attr: { width: String(size), @@ -726,31 +756,138 @@ export class ContextUsageMeter { }, }); - const pathData = `M ${x1} ${y1} A ${radius} ${radius} 0 1 1 ${x2} ${y2}`; - svg.createSvg('path', { + svg.createSvg('circle', { cls: 'qoderian-meter-bg', attr: { - d: pathData, + cx: String(cx), + cy: String(cy), + r: String(radius), fill: 'none', 'stroke-width': String(strokeWidth), - 'stroke-linecap': 'round', }, }); - const fillPath = svg.createSvg('path', { + const fillPath = svg.createSvg('circle', { cls: 'qoderian-meter-fill', attr: { - d: pathData, + cx: String(cx), + cy: String(cy), + r: String(radius), fill: 'none', 'stroke-width': String(strokeWidth), 'stroke-linecap': 'round', 'stroke-dasharray': String(this.circumference), 'stroke-dashoffset': String(this.circumference), + transform: `rotate(-90 ${cx} ${cy})`, }, }); this.fillPath = fillPath; - this.percentEl = this.container.createSpan({ cls: 'qoderian-context-meter-percent' }); + this.percentEl = this.triggerEl.createSpan({ cls: 'qoderian-context-meter-percent' }); + + this.popover = new ClickPopover( + this.container, + this.triggerEl, + this.panelEl, + 'qoderian-context-meter--open', + ); + this.triggerEl.addEventListener('click', this.handleTriggerClick); + } + + private readonly handleTriggerClick = (): void => { + // ClickPopover's own handler runs first and flips aria-expanded. + if (this.triggerEl?.getAttribute('aria-expanded') !== 'true') return; + this.renderPanel(); + void this.refresh(); + }; + + private async refresh(): Promise { + const request = this.callbacks.requestContextUsage; + if (!request) return; + + const token = ++this.refreshToken; + try { + const breakdown = await request(); + if (token !== this.refreshToken || !breakdown) return; + this.breakdown = breakdown; + this.renderPanel(); + } catch { + // The panel already shows the last known breakdown. + } + } + + private renderPanel(): void { + const panel = this.panelEl; + if (!panel) return; + panel.empty(); + + const header = panel.createDiv({ cls: 'qoderian-context-panel-header' }); + header.createSpan({ cls: 'qoderian-context-panel-title', text: t('contextUsage.title') }); + header.createSpan({ + cls: 'qoderian-context-panel-percent', + text: this.breakdown ? this.formatPercent(this.breakdown.usedPercentage) : '', + }); + + const breakdown = this.breakdown; + if (!breakdown) { + panel.createDiv({ cls: 'qoderian-context-panel-empty', text: t('contextUsage.empty') }); + return; + } + + panel.createDiv({ cls: 'qoderian-context-panel-desc', text: t('contextUsage.description') }); + + const bar = panel.createDiv({ cls: 'qoderian-context-panel-bar' }); + const fill = bar.createDiv({ cls: 'qoderian-context-panel-bar-fill' }); + fill.style.width = `${roundPercent(breakdown.usedPercentage)}%`; + + const list = panel.createDiv({ cls: 'qoderian-context-panel-list' }); + for (const category of breakdown.categories) { + if (HIDDEN_CATEGORY_TYPES.has(category.type)) continue; + this.renderCategory(list, category); + } + + const compactBtn = panel.createEl('button', { + cls: 'qoderian-context-compact-btn', + attr: { type: 'button' }, + }); + setIcon(compactBtn.createSpan({ cls: 'qoderian-context-compact-icon' }), 'minimize-2'); + compactBtn.createSpan({ text: t('contextUsage.compact') }); + compactBtn.addEventListener('click', (event) => { + event.stopPropagation(); + this.callbacks.onCompactContext?.(); + this.popover?.close(); + }); + + this.positionPanel(); + } + + private renderCategory(parentEl: HTMLElement, category: ContextUsageCategory): void { + const row = parentEl.createDiv({ cls: 'qoderian-context-row' }); + const dot = row.createSpan({ cls: 'qoderian-context-dot' }); + // Faint dots keep near-empty buckets visible without competing with the + // occupied ones. + dot.style.opacity = String(Math.max(0.25, Math.min(1, category.percentage / 25))); + + const label = category.type === 'skills' + ? t('contextUsage.categorySkills', { count: this.breakdown?.skills.count ?? 0 }) + : t(CONTEXT_CATEGORY_LABELS[category.type]); + row.createSpan({ cls: 'qoderian-context-row-label', text: label }); + row.createSpan({ + cls: 'qoderian-context-row-percent', + text: this.formatPercent(category.percentage), + }); + } + + private positionPanel(): void { + if (!this.triggerEl || !this.panelEl) return; + positionHoverDropdown(this.container, this.triggerEl, this.panelEl); + } + + /** Sub-1% buckets read as "<1%" instead of rounding down to a bare 0%. */ + private formatPercent(value: number): string { + if (!Number.isFinite(value) || value <= 0) return '0%'; + if (value < 1) return '<1%'; + return `${roundPercent(value)}%`; } update(usage: UsageInfo | null): void { @@ -808,7 +945,7 @@ export function createInputToolbar( permissionToggle: PermissionToggle; } { const modelSelector = new ModelSelector(parentEl, callbacks); - const contextUsageMeter = new ContextUsageMeter(parentEl); + const contextUsageMeter = new ContextUsageMeter(parentEl, callbacks); const externalContextSelector = new ExternalContextSelector(parentEl, callbacks); const mcpServerSelector = new McpServerSelector(parentEl); const permissionToggle = new PermissionToggle(parentEl, callbacks); diff --git a/src/features/chat/ui/toolbar/toolbar-selectors.ts b/src/features/chat/ui/toolbar/toolbar-selectors.ts index 2a44675..23329b6 100644 --- a/src/features/chat/ui/toolbar/toolbar-selectors.ts +++ b/src/features/chat/ui/toolbar/toolbar-selectors.ts @@ -1,5 +1,6 @@ import { Notice } from 'obsidian'; +import type { ContextUsageBreakdown } from '../../../../core/types'; import type { QoderRuntimeStatus } from '../../../../core/types/services'; import type { PermissionMode, QoderModelOverride } from '../../../../core/types/settings'; import { t } from '../../../../i18n/i18n'; @@ -47,6 +48,10 @@ export interface ToolbarCallbacks { getRuntimeStatus?: () => QoderRuntimeStatus; retryRuntimeCatalog?: () => Promise; subscribeRuntimeStatus?: (listener: (status: QoderRuntimeStatus) => void) => () => void; + /** Context breakdown for the context-usage popover. */ + requestContextUsage?: () => Promise; + /** Compacts the conversation from the context-usage popover. */ + onCompactContext?: () => void; loginService?: QoderLoginController; } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index b9ac998..0c27869 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -23,8 +23,7 @@ }, "composer": { "send": "Nachricht senden", - "stop": "Generierung stoppen", - "resize": "Ziehen, um die Eingabehöhe anzupassen; doppelklicken zum Zurücksetzen" + "stop": "Generierung stoppen" }, "restore": { "failed": "Einige Tabs oder Unterhaltungen konnten nicht wiederhergestellt werden ({count} Problem(e)). Details in der Entwicklerkonsole." @@ -393,6 +392,19 @@ "trigger": "Nutzung", "unavailable": "Nutzung nicht verfügbar" }, + "contextUsage": { + "title": "Kontextfenster", + "description": "Zeigt, was das Kontextfenster dieser Unterhaltung belegt. Verdichten fasst ältere Inhalte zusammen und kostet kurz Zeit sowie wenige Credits.", + "empty": "Kontextdetails erscheinen nach der ersten Antwort.", + "compact": "Kontext verdichten", + "categorySystemPrompt": "System-Prompt", + "categorySystemTools": "System-Tools", + "categorySkills": "Skills ({count})", + "categoryMessages": "Nachrichten", + "categoryOther": "Sonstiges", + "categoryFreeSpace": "Freier Platz", + "categoryAutoCompact": "Auto-Verdichtung" + }, "model": { "edit": "Bearbeiten", "contextWindow": "Kontextfenster", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e08640c..2f9bcd5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -23,8 +23,7 @@ }, "composer": { "send": "Send message", - "stop": "Stop generation", - "resize": "Drag to resize the message input; double-click to reset" + "stop": "Stop generation" }, "restore": { "failed": "Some of your previous tabs or conversations could not be restored ({count} issue(s)). Details are in the developer console." @@ -393,6 +392,19 @@ "trigger": "Usage", "unavailable": "Usage unavailable" }, + "contextUsage": { + "title": "Context window", + "description": "What currently fills this conversation's context window.", + "empty": "Context details appear after the first response.", + "compact": "Compact context", + "categorySystemPrompt": "System prompt", + "categorySystemTools": "System tools", + "categorySkills": "Skills ({count})", + "categoryMessages": "Messages", + "categoryOther": "Other", + "categoryFreeSpace": "Free space", + "categoryAutoCompact": "Auto-compact" + }, "model": { "edit": "Edit", "contextWindow": "Context", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 92e82a8..0c954ed 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -23,8 +23,7 @@ }, "composer": { "send": "Enviar mensaje", - "stop": "Detener generación", - "resize": "Arrastra para ajustar la altura del campo; haz doble clic para restablecer" + "stop": "Detener generación" }, "restore": { "failed": "No se pudieron restaurar algunas pestañas o conversaciones ({count} problema(s)). Detalles en la consola de desarrollador." @@ -393,6 +392,19 @@ "trigger": "Uso", "unavailable": "Uso no disponible" }, + "contextUsage": { + "title": "Ventana de contexto", + "description": "Muestra qué ocupa la ventana de contexto de esta conversación. Compactar resume el contenido anterior, con una breve espera y algunos créditos.", + "empty": "Los detalles del contexto aparecen tras la primera respuesta.", + "compact": "Compactar contexto", + "categorySystemPrompt": "Prompt del sistema", + "categorySystemTools": "Herramientas del sistema", + "categorySkills": "Habilidades ({count})", + "categoryMessages": "Mensajes", + "categoryOther": "Otros", + "categoryFreeSpace": "Espacio libre", + "categoryAutoCompact": "Compactación automática" + }, "model": { "edit": "Editar", "contextWindow": "Ventana de contexto", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2d54d69..bab18a2 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -23,8 +23,7 @@ }, "composer": { "send": "Envoyer le message", - "stop": "Arrêter la génération", - "resize": "Faites glisser pour redimensionner la saisie ; double-cliquez pour réinitialiser" + "stop": "Arrêter la génération" }, "restore": { "failed": "Certains onglets ou conversations n'ont pas pu être restaurés ({count} problème(s)). Détails dans la console développeur." @@ -393,6 +392,19 @@ "trigger": "Utilisation", "unavailable": "Utilisation indisponible" }, + "contextUsage": { + "title": "Fenêtre de contexte", + "description": "Montre ce qui occupe la fenêtre de contexte de cette conversation. Compacter résume les échanges antérieurs, avec un court délai et quelques crédits.", + "empty": "Les détails du contexte apparaissent après la première réponse.", + "compact": "Compacter le contexte", + "categorySystemPrompt": "Prompt système", + "categorySystemTools": "Outils système", + "categorySkills": "Compétences ({count})", + "categoryMessages": "Messages", + "categoryOther": "Autre", + "categoryFreeSpace": "Espace libre", + "categoryAutoCompact": "Compactage auto" + }, "model": { "edit": "Modifier", "contextWindow": "Fenêtre de contexte", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index b13f374..def32d6 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -23,8 +23,7 @@ }, "composer": { "send": "メッセージを送信", - "stop": "生成を停止", - "resize": "ドラッグして入力欄の高さを調整。ダブルクリックで自動サイズに戻す" + "stop": "生成を停止" }, "restore": { "failed": "一部のタブまたは会話を復元できませんでした({count} 件の問題)。詳細は開発者コンソールを確認してください。" @@ -393,6 +392,19 @@ "trigger": "利用量", "unavailable": "利用量情報を取得できません" }, + "contextUsage": { + "title": "コンテキストウィンドウ", + "description": "この会話でコンテキストを占めている内容を表示します。圧縮すると以前の内容が要約され、少し時間とクレジットを消費します。", + "empty": "最初の応答後にコンテキストの内訳が表示されます。", + "compact": "コンテキストを圧縮", + "categorySystemPrompt": "システムプロンプト", + "categorySystemTools": "システムツール", + "categorySkills": "スキル ({count})", + "categoryMessages": "メッセージ", + "categoryOther": "その他", + "categoryFreeSpace": "空き領域", + "categoryAutoCompact": "自動圧縮" + }, "model": { "edit": "編集", "contextWindow": "コンテキストウィンドウ", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index c5957b9..d8c92fe 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -23,8 +23,7 @@ }, "composer": { "send": "메시지 보내기", - "stop": "생성 중지", - "resize": "드래그하여 입력 높이 조절; 두 번 클릭하여 자동 높이로 재설정" + "stop": "생성 중지" }, "restore": { "failed": "이전 탭 또는 대화를 완전히 복원하지 못했습니다(문제 {count}건). 자세한 내용은 개발자 콘솔을 확인하세요." @@ -393,6 +392,19 @@ "trigger": "사용량", "unavailable": "사용량을 확인할 수 없음" }, + "contextUsage": { + "title": "컨텍스트 창", + "description": "이 대화에서 컨텍스트를 차지하는 항목을 보여 줍니다. 압축하면 이전 내용이 요약되며 시간과 크레딧이 조금 소모됩니다.", + "empty": "첫 응답 후 컨텍스트 내역이 표시됩니다.", + "compact": "컨텍스트 압축", + "categorySystemPrompt": "시스템 프롬프트", + "categorySystemTools": "시스템 도구", + "categorySkills": "스킬 ({count})", + "categoryMessages": "메시지", + "categoryOther": "기타", + "categoryFreeSpace": "여유 공간", + "categoryAutoCompact": "자동 압축" + }, "model": { "edit": "편집", "contextWindow": "컨텍스트 윈도우", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 9260a2f..2e6c542 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -23,8 +23,7 @@ }, "composer": { "send": "Enviar mensagem", - "stop": "Parar geração", - "resize": "Arraste para ajustar a altura da entrada; clique duas vezes para redefinir" + "stop": "Parar geração" }, "restore": { "failed": "Algumas abas ou conversas não puderam ser restauradas ({count} problema(s)). Detalhes no console do desenvolvedor." @@ -393,6 +392,19 @@ "trigger": "Uso", "unavailable": "Uso indisponível" }, + "contextUsage": { + "title": "Janela de contexto", + "description": "Mostra o que ocupa a janela de contexto desta conversa. Compactar resume o conteúdo anterior, com uma breve espera e alguns créditos.", + "empty": "Os detalhes do contexto aparecem após a primeira resposta.", + "compact": "Compactar contexto", + "categorySystemPrompt": "Prompt do sistema", + "categorySystemTools": "Ferramentas do sistema", + "categorySkills": "Habilidades ({count})", + "categoryMessages": "Mensagens", + "categoryOther": "Outros", + "categoryFreeSpace": "Espaço livre", + "categoryAutoCompact": "Compactação automática" + }, "model": { "edit": "Editar", "contextWindow": "Janela de contexto", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 77f93f6..0214d07 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -23,8 +23,7 @@ }, "composer": { "send": "Отправить сообщение", - "stop": "Остановить генерацию", - "resize": "Перетащите, чтобы изменить высоту поля; двойной щелчок — сброс" + "stop": "Остановить генерацию" }, "restore": { "failed": "Не удалось восстановить часть вкладок или бесед (проблем: {count}). Подробности — в консоли разработчика." @@ -393,6 +392,19 @@ "trigger": "Использование", "unavailable": "Использование недоступно" }, + "contextUsage": { + "title": "Контекстное окно", + "description": "Показывает, что занимает контекст этого диалога. Сжатие суммирует ранние сообщения — это занимает время и немного кредитов.", + "empty": "Детали контекста появятся после первого ответа.", + "compact": "Сжать контекст", + "categorySystemPrompt": "Системный промпт", + "categorySystemTools": "Системные инструменты", + "categorySkills": "Навыки ({count})", + "categoryMessages": "Сообщения", + "categoryOther": "Прочее", + "categoryFreeSpace": "Свободное место", + "categoryAutoCompact": "Автосжатие" + }, "model": { "edit": "Редактировать", "contextWindow": "Контекстное окно", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 08892fd..66f388d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -23,8 +23,7 @@ }, "composer": { "send": "发送消息", - "stop": "停止生成", - "resize": "拖动调整输入框高度;双击恢复自动高度" + "stop": "停止生成" }, "restore": { "failed": "部分标签或会话未能恢复({count} 个问题)。详情已输出到开发者控制台。" @@ -393,6 +392,19 @@ "trigger": "用量", "unavailable": "用量暂不可用" }, + "contextUsage": { + "title": "上下文窗口", + "description": "展示当前任务的上下文占用情况。压缩会摘要早期内容,需等待片刻并消耗少量积分。", + "empty": "首次回复后显示上下文明细。", + "compact": "压缩上下文", + "categorySystemPrompt": "系统提示词", + "categorySystemTools": "系统工具", + "categorySkills": "Skill ({count} 个)", + "categoryMessages": "消息", + "categoryOther": "其他", + "categoryFreeSpace": "可用空间", + "categoryAutoCompact": "自动压缩" + }, "model": { "edit": "编辑", "contextWindow": "上下文窗口", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index efcbd48..e562dc1 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -23,8 +23,7 @@ }, "composer": { "send": "傳送訊息", - "stop": "停止生成", - "resize": "拖曳調整輸入框高度;按兩下恢復自動高度" + "stop": "停止生成" }, "restore": { "failed": "部分分頁或工作階段未能恢復({count} 個問題)。詳情已輸出到開發者主控台。" @@ -393,6 +392,19 @@ "trigger": "用量", "unavailable": "用量暫不可用" }, + "contextUsage": { + "title": "上下文視窗", + "description": "顯示目前任務的上下文佔用情況。壓縮會摘要較早的內容,需等待片刻並消耗少量點數。", + "empty": "首次回覆後顯示上下文明細。", + "compact": "壓縮上下文", + "categorySystemPrompt": "系統提示詞", + "categorySystemTools": "系統工具", + "categorySkills": "Skill ({count} 個)", + "categoryMessages": "訊息", + "categoryOther": "其他", + "categoryFreeSpace": "可用空間", + "categoryAutoCompact": "自動壓縮" + }, "model": { "edit": "編輯", "contextWindow": "上下文視窗", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 2b396e4..e33fd7e 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -65,7 +65,6 @@ export type TranslationKey = // Composer - send/stop action button | 'composer.send' | 'composer.stop' - | 'composer.resize' | 'restore.failed' // Chat - Rewind @@ -127,6 +126,19 @@ export type TranslationKey = | 'credits.trigger' | 'credits.unavailable' + // Context window panel + | 'contextUsage.title' + | 'contextUsage.description' + | 'contextUsage.empty' + | 'contextUsage.compact' + | 'contextUsage.categorySystemPrompt' + | 'contextUsage.categorySystemTools' + | 'contextUsage.categorySkills' + | 'contextUsage.categoryMessages' + | 'contextUsage.categoryOther' + | 'contextUsage.categoryFreeSpace' + | 'contextUsage.categoryAutoCompact' + // Model editor panel | 'model.edit' | 'model.contextWindow' diff --git a/src/qoder/runtime/qoder-chat-runtime.ts b/src/qoder/runtime/qoder-chat-runtime.ts index 14072fe..141004e 100644 --- a/src/qoder/runtime/qoder-chat-runtime.ts +++ b/src/qoder/runtime/qoder-chat-runtime.ts @@ -42,6 +42,7 @@ import type { import type { ApprovalDecision, ChatMessage, + ContextUsageBreakdown, Conversation, ExitPlanModeCallback, ImageAttachment, @@ -63,6 +64,7 @@ import { qoderModelConfig } from '../models/qoder-model-config'; import { stripCurrentNoteContext } from '../prompt/context/prompt-context'; import { encodeQoderTurn } from '../prompt/qoder-turn-encoder'; import type { QoderHostContext } from '../qoder-host-context'; +import { fetchStoredSessionContextUsage } from '../services/context-usage'; import { createTransformStreamState, createTransformUsageState, @@ -72,6 +74,7 @@ import { isContextWindowEvent, isSessionInitEvent, isStreamChunk } from '../stre import { TOOL_SKILL } from '../tools/tool-names'; import { streamPersistentTurn } from './persistent-turn-stream'; import { createQoderApprovalCallback } from './qoder-approval-handler'; +import { mapContextUsageBreakdown } from './qoder-context-usage'; import { buildConversationSessionUpdates, buildHistoryRebuildRequest, @@ -1237,6 +1240,36 @@ export class QoderChatRuntime implements ChatRuntime { return this.isPersistentQueryActive(); } + /** + * Context breakdown for the toolbar panel: a live `/context` read when the + * CLI is idle, the snapshot the last turn end collected while a turn runs, + * and a read of the stored session when no query is running yet. + */ + async requestContextUsage(): Promise { + const query = this.persistentQuery; + const cached = () => this.turnTracker.getContextBreakdown(); + if (query && typeof query.getContextUsage === 'function') { + // The control request queues until the CLI is idle again, so querying + // it mid-turn would only delay an answer the cached snapshot can give. + if (this.messageChannel?.isTurnActive()) return cached(); + + try { + return mapContextUsageBreakdown(await query.getContextUsage()); + } catch { + // Supplemental read: older or closing queries may reject the request. + return cached(); + } + } + + return await this.readStoredSessionContextUsage() ?? cached(); + } + + private async readStoredSessionContextUsage(): Promise { + const sessionId = this.sessionManager.getSessionId(); + if (!sessionId) return null; + return fetchStoredSessionContextUsage(this.plugin, sessionId); + } + /** * Get supported commands (SDK skills). * Returns cached commands populated on system/init. Falls back to a fresh @@ -1296,6 +1329,7 @@ export class QoderChatRuntime implements ChatRuntime { this.crashRecoveryAttempted = false; // A restored session never saw the current directories; announce them. this.announcedExternalContextPaths = []; + this.turnTracker.clearContextBreakdown(); } this.sessionManager.setSessionId(id, this.getScopedSettings().model); diff --git a/src/qoder/runtime/qoder-context-usage.ts b/src/qoder/runtime/qoder-context-usage.ts new file mode 100644 index 0000000..19c659e --- /dev/null +++ b/src/qoder/runtime/qoder-context-usage.ts @@ -0,0 +1,50 @@ +import type { SDKControlGetContextUsageResponse } from '@qoder-ai/qoder-agent-sdk'; + +import type { + ContextUsageBreakdown, + ContextUsageCategory, + ContextUsageCategoryType, +} from '../../core/types'; + +const CATEGORY_TYPES = new Set([ + 'system_prompt', + 'system_tools', + 'skills', + 'messages', + 'other', + 'free_space', + 'auto_compact', +]); + +function toPercentage(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? Math.min(100, value) + : 0; +} + +function toCategory(entry: { type: string; percentage?: number }): ContextUsageCategory | null { + return CATEGORY_TYPES.has(entry.type) + ? { type: entry.type as ContextUsageCategoryType, percentage: toPercentage(entry.percentage) } + : null; +} + +/** Normalizes the CLI's `/context` control response into display data. */ +export function mapContextUsageBreakdown( + payload: SDKControlGetContextUsageResponse, +): ContextUsageBreakdown { + const categories = Array.isArray(payload.categories) + ? payload.categories.flatMap((entry) => { + const category = toCategory(entry); + return category ? [category] : []; + }) + : []; + + return { + usedPercentage: toPercentage(payload.contextWindow?.usedPercentage), + categories, + skills: { + count: Math.max(0, Math.floor(payload.skills?.count ?? 0)), + percentageOfContext: toPercentage(payload.skills?.percentageOfContext), + }, + }; +} diff --git a/src/qoder/runtime/qoder-turn-tracker.ts b/src/qoder/runtime/qoder-turn-tracker.ts index 43811a0..0ab9193 100644 --- a/src/qoder/runtime/qoder-turn-tracker.ts +++ b/src/qoder/runtime/qoder-turn-tracker.ts @@ -1,7 +1,7 @@ import type { Query, SDKMessage } from '@qoder-ai/qoder-agent-sdk'; import type { ChatTurnMetadata } from '../../core/runtime/types'; -import type { StreamChunk } from '../../core/types'; +import type { ContextUsageBreakdown, StreamChunk } from '../../core/types'; import { getContextWindowSize } from '../models/model-catalog'; import { toQoderRuntimeModelId } from '../models/model-selection'; import { @@ -9,6 +9,7 @@ import { createTransformUsageState, } from '../stream/transform-qoder-message'; import type { TransformEvent } from '../stream/types'; +import { mapContextUsageBreakdown } from './qoder-context-usage'; type UsageChunk = Extract; @@ -25,6 +26,8 @@ interface ContextUsageRequest { export class QoderTurnTracker { private metadata: ChatTurnMetadata = {}; private bufferedUsageChunk: UsageChunk | null = null; + /** Last `/context` breakdown; survives turn resets so the panel can open instantly. */ + private contextBreakdown: ContextUsageBreakdown | null = null; private readonly streamState = createTransformStreamState(); private readonly usageState = createTransformUsageState(); @@ -94,6 +97,16 @@ export class QoderTurnTracker { }; } + /** Last breakdown the CLI reported, or null before the first successful read. */ + getContextBreakdown(): ContextUsageBreakdown | null { + return this.contextBreakdown; + } + + /** Drops the cached breakdown (session switch or new conversation). */ + clearContextBreakdown(): void { + this.contextBreakdown = null; + } + /** Reads the CLI's public context-usage control API when available. */ async fetchContextUsage(request: ContextUsageRequest): Promise { const activeQuery = request.query; @@ -108,6 +121,7 @@ export class QoderTurnTracker { if (!request.isCurrentQuery(activeQuery)) { return null; } + this.contextBreakdown = mapContextUsageBreakdown(payload); const previousUsage = this.bufferedUsageChunk?.usage; const model = toQoderRuntimeModelId( diff --git a/src/qoder/services/context-usage.ts b/src/qoder/services/context-usage.ts new file mode 100644 index 0000000..f692931 --- /dev/null +++ b/src/qoder/services/context-usage.ts @@ -0,0 +1,61 @@ +import { query as agentQuery } from '@qoder-ai/qoder-agent-sdk'; + +import { getVaultPath } from '../../core/fs/path'; +import type { ContextUsageBreakdown } from '../../core/types'; +import { buildProbeOptions, ProbeInput } from '../commands/probe-runtime-commands'; +import type { QoderHostContext } from '../qoder-host-context'; +import { mapContextUsageBreakdown } from '../runtime/qoder-context-usage'; + +/** Stored-session reads spawn a CLI; repeated panel opens reuse the answer. */ +const PROBE_TTL_MS = 60_000; + +const probeCache = new Map(); + +/** + * Reads the `/context` breakdown of a stored session through a short-lived + * idle query. Used when no live query holds the session — a fresh plugin + * start or a restored conversation — so the panel can show numbers before the + * next turn. The probe never sends a prompt and does not persist, so the + * session transcript stays untouched; resuming may still fail (missing or + * foreign session), in which case callers keep their previous state. + */ +export async function fetchStoredSessionContextUsage( + plugin: QoderHostContext, + sessionId: string, + options?: { timeoutMs?: number; maxAgeMs?: number }, +): Promise { + const maxAgeMs = options?.maxAgeMs ?? PROBE_TTL_MS; + const cached = probeCache.get(sessionId); + if (cached && Date.now() - cached.fetchedAt < maxAgeMs) { + return cached.breakdown; + } + + const vaultPath = getVaultPath(plugin.app); + const cliPath = plugin.getResolvedQoderCliPath(); + if (!cliPath || !vaultPath) return null; + + const abortController = new AbortController(); + const input = new ProbeInput(); + const timeout = window.setTimeout(() => { + abortController.abort(); + }, options?.timeoutMs ?? 15_000); + const conversation = agentQuery({ + prompt: input, + options: { + ...buildProbeOptions(plugin, vaultPath, cliPath, abortController), + resume: sessionId, + }, + }); + try { + await conversation.initializationResult(); + const breakdown = mapContextUsageBreakdown(await conversation.getContextUsage()); + probeCache.set(sessionId, { fetchedAt: Date.now(), breakdown }); + return breakdown; + } catch { + return null; + } finally { + window.clearTimeout(timeout); + input.end(); + await conversation.close().catch(() => {}); + } +} diff --git a/src/style/components/context-footer.css b/src/style/components/context-footer.css index a1e2c06..97c8606 100644 --- a/src/style/components/context-footer.css +++ b/src/style/components/context-footer.css @@ -5,17 +5,29 @@ display: flex; align-items: center; gap: 4px; - margin-inline-start: 8px; + margin-inline-start: 4px; + flex: 0 0 auto; cursor: default; } +.qoderian-context-meter-trigger { + display: flex; + align-items: center; + gap: 4px; + border-radius: 4px; + cursor: pointer; +} + +.qoderian-context-meter-trigger:hover .qoderian-context-meter-percent { + color: var(--text-normal); +} + /* Custom tooltip */ .qoderian-context-meter::after { content: attr(data-tooltip); position: absolute; bottom: 100%; - left: 50%; - transform: translateX(-50%); + left: 0; margin-bottom: 6px; padding: 4px 8px; font-size: 11px; @@ -23,7 +35,10 @@ background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 4px; - white-space: nowrap; + width: max-content; + max-width: min(240px, calc(100vw - 24px)); + white-space: normal; + overflow-wrap: anywhere; opacity: 0; visibility: hidden; transition: opacity 0.15s ease, visibility 0.15s ease; @@ -36,17 +51,25 @@ visibility: visible; } +/* The panel carries the same readings; a hovering tooltip would only overlap. */ +.qoderian-context-meter--open::after { + opacity: 0; + visibility: hidden; +} + .qoderian-context-meter-gauge { display: flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 28px; + height: 28px; } .qoderian-context-meter-gauge svg { - width: 16px; - height: 16px; + display: block; + width: 18px; + height: 18px; + overflow: visible; } .qoderian-meter-bg { @@ -60,7 +83,7 @@ .qoderian-context-meter-percent { font-size: 11px; - color: var(--qoderian-brand); + color: var(--text-muted); min-width: 24px; text-align: end; transition: color 0.3s ease; @@ -74,3 +97,149 @@ .qoderian-context-meter.warning .qoderian-context-meter-percent { color: #E57373; } + +/* Context window panel */ + +.qoderian-context-panel { + position: absolute; + /* JS clamps the position and width to the toolbar bounds + (positionHoverDropdown); the fallbacks keep the icon-centered look + before the first measurement. */ + left: var(--qoderian-hover-dropdown-left, 50%); + transform: translateX(-50%); + bottom: 100%; + margin-bottom: 6px; + min-width: var(--qoderian-hover-dropdown-min-width, 260px); + max-width: var(--qoderian-hover-dropdown-max-width, 300px); + padding: 10px 12px 12px; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; + z-index: 100; +} + +.qoderian-context-meter--open .qoderian-context-panel { + opacity: 1; + visibility: visible; +} + +.qoderian-context-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.qoderian-context-panel-title { + font-size: 12px; + font-weight: 600; + color: var(--text-normal); +} + +.qoderian-context-panel-percent { + font-size: 12px; + font-weight: 600; + color: var(--qoderian-brand); + font-variant-numeric: tabular-nums; +} + +.qoderian-context-meter.warning .qoderian-context-panel-percent { + color: #E57373; +} + +.qoderian-context-panel-desc { + margin-top: 4px; + font-size: 11px; + line-height: 1.4; + color: var(--text-muted); +} + +.qoderian-context-panel-bar { + height: 4px; + margin: 10px 0; + border-radius: 2px; + background: var(--background-modifier-border); + overflow: hidden; +} + +.qoderian-context-panel-bar-fill { + height: 100%; + border-radius: 2px; + background: var(--qoderian-brand); + transition: width 0.3s ease; +} + +.qoderian-context-meter.warning .qoderian-context-panel-bar-fill { + background: #E57373; +} + +.qoderian-context-row { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 0; + font-size: 12px; +} + +.qoderian-context-dot { + flex: 0 0 auto; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--qoderian-brand); +} + +.qoderian-context-row-label { + flex: 1; + min-width: 0; + overflow: hidden; + color: var(--text-muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +.qoderian-context-row-percent { + color: var(--text-normal); + font-variant-numeric: tabular-nums; +} + +.qoderian-context-compact-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + margin-top: 10px; + padding: 6px 10px; + font-size: 12px; + color: var(--text-normal); + background: var(--background-modifier-hover); + border: none; + border-radius: 6px; + box-shadow: none; + cursor: pointer; +} + +.qoderian-context-compact-btn:hover { + background: var(--background-modifier-active-hover, var(--background-modifier-hover)); +} + +.qoderian-context-compact-icon { + display: flex; + align-items: center; +} + +.qoderian-context-compact-icon svg { + width: 14px; + height: 14px; +} + +.qoderian-context-panel-empty { + padding: 8px 0 2px; + font-size: 11px; + color: var(--text-muted); +} diff --git a/src/style/components/input.css b/src/style/components/input.css index 688d20f..33f759a 100644 --- a/src/style/components/input.css +++ b/src/style/components/input.css @@ -56,8 +56,16 @@ border-radius: 999px; background: var(--background-primary); transform: translateX(-50%); + opacity: 0; + transition: opacity 0.12s ease, border-color 0.12s ease, background 0.12s ease; +} + +/* The grip stays out of the way until the pointer is inside the composer or it has focus. */ +.qoderian-input-wrapper:hover .qoderian-composer-resize-handle::after, +.qoderian-input-wrapper:focus-within .qoderian-composer-resize-handle::after, +.qoderian-composer-resize-handle:focus-visible::after, +.qoderian-composer-resizing .qoderian-composer-resize-handle::after { opacity: 1; - transition: border-color 0.12s ease, background 0.12s ease; } .qoderian-composer-resize-handle:hover::after, diff --git a/tests/__mocks__/qoder-agent-sdk.ts b/tests/__mocks__/qoder-agent-sdk.ts index 23dc8a0..c8ad1b2 100644 --- a/tests/__mocks__/qoder-agent-sdk.ts +++ b/tests/__mocks__/qoder-agent-sdk.ts @@ -127,6 +127,7 @@ let mockSupportedCommands: Array<{ name: string; description: string; argumentHi let mockAvailableModels: any[] = []; let mockInitializationModels: any[] | undefined; let mockUsageInfo: any = undefined; +let mockContextUsage: any = undefined; let lastResponse: (AsyncGenerator & { interrupt: jest.Mock; setModel: jest.Mock; @@ -138,6 +139,7 @@ let lastResponse: (AsyncGenerator & { getAvailableModels: jest.Mock; initializationResult: jest.Mock; getUsageInfo: jest.Mock; + getContextUsage: jest.Mock; close: jest.Mock; }) | null = null; @@ -167,6 +169,7 @@ export function resetMockMessages() { mockAvailableModels = []; mockInitializationModels = undefined; mockUsageInfo = undefined; + mockContextUsage = undefined; lastResponse = null; shouldThrowOnIteration = false; throwAfterChunks = 0; @@ -194,6 +197,11 @@ export function setMockUsageInfo(usageInfo: any) { mockUsageInfo = usageInfo; } +/** Sets the `/context` payload returned by getContextUsage(); pass an Error to make it reject. */ +export function setMockContextUsage(contextUsage: any) { + mockContextUsage = contextUsage; +} + /** * Configure the mock to throw an error during iteration. * @param afterChunks - Number of chunks to emit before throwing (0 = throw immediately) @@ -370,6 +378,7 @@ export function query({ prompt, options }: { prompt: any; options: Options }): A getAvailableModels: jest.Mock; initializationResult: jest.Mock; getUsageInfo: jest.Mock; + getContextUsage: jest.Mock; close: jest.Mock; }; gen.interrupt = jest.fn().mockResolvedValue(undefined); @@ -412,6 +421,10 @@ export function query({ prompt, options }: { prompt: any; options: Options }): A if (mockUsageInfo instanceof Error) throw mockUsageInfo; return mockUsageInfo; }); + gen.getContextUsage = jest.fn().mockImplementation(async () => { + if (mockContextUsage instanceof Error) throw mockContextUsage; + return mockContextUsage; + }); lastResponse = gen; return gen; diff --git a/tests/unit/features/chat/ui/context-usage-meter.test.ts b/tests/unit/features/chat/ui/context-usage-meter.test.ts new file mode 100644 index 0000000..f93ece2 --- /dev/null +++ b/tests/unit/features/chat/ui/context-usage-meter.test.ts @@ -0,0 +1,158 @@ +import { createMockEl } from '@test/helpers/mock-element'; + +import type { ContextUsageBreakdown, UsageInfo } from '@/core/types'; +import { ContextUsageMeter } from '@/features/chat/ui/input-toolbar'; + +const BREAKDOWN: ContextUsageBreakdown = { + usedPercentage: 37.4, + categories: [ + { type: 'system_prompt', percentage: 0.4 }, + { type: 'system_tools', percentage: 1 }, + { type: 'skills', percentage: 0.2 }, + { type: 'messages', percentage: 35.8 }, + { type: 'other', percentage: 0 }, + { type: 'free_space', percentage: 62.6 }, + { type: 'auto_compact', percentage: 0 }, + ], + skills: { count: 7, percentageOfContext: 0.2 }, +}; + +const USAGE: UsageInfo = { + model: 'sonnet', + inputTokens: 0, + contextWindow: 200_000, + contextTokens: 74_000, + percentage: 37, +}; + +function flushPromises(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +interface MeterHarness { + parentEl: ReturnType; + meter: ContextUsageMeter; + requestContextUsage: jest.Mock; + onCompactContext: jest.Mock; +} + +function createMeter( + requestContextUsage: jest.Mock = jest.fn().mockResolvedValue(BREAKDOWN), +): MeterHarness { + const parentEl = createMockEl(); + const onCompactContext = jest.fn(); + const meter = new ContextUsageMeter(parentEl, { requestContextUsage, onCompactContext }); + return { parentEl, meter, requestContextUsage, onCompactContext }; +} + +function getRoot(parentEl: ReturnType) { + return parentEl.querySelector('.qoderian-context-meter'); +} + +async function openPanel(harness: MeterHarness) { + harness.parentEl.querySelector('.qoderian-context-meter-trigger')?.click(); + await flushPromises(); +} + +describe('ContextUsageMeter', () => { + it('draws the gauge as a full ring that fills from the top', () => { + const { parentEl } = createMeter(); + + const fill = parentEl.querySelector('.qoderian-meter-fill'); + expect(fill?.tagName).toBe('CIRCLE'); + const circumference = 2 * Math.PI * ((18 - 2 * 2) / 2); + expect(Number(fill?.getAttribute('stroke-dasharray'))).toBeCloseTo(circumference); + expect(Number(fill?.getAttribute('stroke-dashoffset'))).toBeCloseTo(circumference); + expect(fill?.getAttribute('transform')).toBe('rotate(-90 9 9)'); + }); + + it('fills the ring and reveals the panel trigger for a reading', () => { + const { parentEl, meter } = createMeter(); + meter.update(USAGE); + + const circumference = 2 * Math.PI * ((18 - 2 * 2) / 2); + const fill = parentEl.querySelector('.qoderian-meter-fill'); + expect(Number(fill?.getAttribute('stroke-dashoffset'))) + .toBeCloseTo(circumference * (1 - 0.37)); + expect(parentEl.querySelector('.qoderian-context-meter-percent')?.textContent).toBe('37%'); + expect(getRoot(parentEl)?.hasClass('qoderian-hidden')).toBe(false); + }); + + it('renders the breakdown after the panel opens', async () => { + const harness = createMeter(); + + await openPanel(harness); + + expect(harness.requestContextUsage).toHaveBeenCalledTimes(1); + expect(getRoot(harness.parentEl)?.hasClass('qoderian-context-meter--open')).toBe(true); + expect(harness.parentEl.querySelector('.qoderian-context-panel-title')?.textContent) + .toBe('Context window'); + expect(harness.parentEl.querySelector('.qoderian-context-panel-percent')?.textContent) + .toBe('37%'); + + const barFill = harness.parentEl.querySelector('.qoderian-context-panel-bar-fill'); + expect(barFill?.style.width).toBe('37%'); + + const labels = harness.parentEl.querySelectorAll('.qoderian-context-row-label') + .map((el: { textContent: string }) => el.textContent); + expect(labels).toEqual([ + 'System prompt', + 'System tools', + 'Skills (7)', + 'Messages', + 'Other', + ]); + + const percents = harness.parentEl.querySelectorAll('.qoderian-context-row-percent') + .map((el: { textContent: string }) => el.textContent); + expect(percents).toEqual(['<1%', '1%', '<1%', '36%', '0%']); + + // Near-empty buckets keep a visible dot; occupied ones are fully opaque. + const dots = harness.parentEl.querySelectorAll('.qoderian-context-dot'); + expect(dots[0]?.style.opacity).toBe('0.25'); + expect(dots[3]?.style.opacity).toBe('1'); + }); + + it('shows the empty state until the CLI reports a breakdown', async () => { + const harness = createMeter(jest.fn().mockResolvedValue(null)); + + await openPanel(harness); + + expect(harness.parentEl.querySelector('.qoderian-context-panel-empty')?.textContent) + .toBe('Context details appear after the first response.'); + expect(harness.parentEl.querySelector('.qoderian-context-row')).toBeNull(); + }); + + it('keeps the last breakdown when a refresh returns nothing', async () => { + const requestContextUsage = jest.fn() + .mockResolvedValueOnce(BREAKDOWN) + .mockResolvedValueOnce(null); + const harness = createMeter(requestContextUsage); + + await openPanel(harness); + harness.parentEl.querySelector('.qoderian-context-meter-trigger')?.click(); + harness.parentEl.querySelector('.qoderian-context-meter-trigger')?.click(); + await flushPromises(); + + // One read per open; the second returns nothing and the panel keeps its data. + expect(requestContextUsage).toHaveBeenCalledTimes(2); + expect(harness.parentEl.querySelectorAll('.qoderian-context-row')).toHaveLength(5); + }); + + it('compacts the conversation from the panel button and closes it', async () => { + const harness = createMeter(); + await openPanel(harness); + + harness.parentEl.querySelector('.qoderian-context-compact-btn')?.click(); + + expect(harness.onCompactContext).toHaveBeenCalledTimes(1); + expect(getRoot(harness.parentEl)?.hasClass('qoderian-context-meter--open')).toBe(false); + }); + + it('does not read the CLI while the panel stays closed', () => { + const harness = createMeter(); + + expect(harness.requestContextUsage).not.toHaveBeenCalled(); + expect(harness.parentEl.querySelector('.qoderian-context-panel-header')).toBeNull(); + }); +}); diff --git a/tests/unit/i18n/locales.test.ts b/tests/unit/i18n/locales.test.ts index 576b0e8..424ca47 100644 --- a/tests/unit/i18n/locales.test.ts +++ b/tests/unit/i18n/locales.test.ts @@ -91,7 +91,6 @@ const localizedKeys = [ 'chat.permissionMode.plan.desc', 'chat.permissionMode.changeFailed', 'chat.slashCommand.requiresInteractiveTerminal', - 'composer.resize', 'commands.submitFeedback', 'feedback.title', 'feedback.placeholder', @@ -111,6 +110,10 @@ const localizedKeys = [ 'feedback.noticeSuccessWithId', 'feedback.noticeSuccessCopied', 'feedback.noticeFailed', + 'contextUsage.title', + 'contextUsage.description', + 'contextUsage.empty', + 'contextUsage.compact', ] as const; const staleBangBashDesc = diff --git a/tests/unit/qoder/runtime/qoder-context-usage.test.ts b/tests/unit/qoder/runtime/qoder-context-usage.test.ts new file mode 100644 index 0000000..eca36b7 --- /dev/null +++ b/tests/unit/qoder/runtime/qoder-context-usage.test.ts @@ -0,0 +1,105 @@ +import type { SDKControlGetContextUsageResponse } from '@qoder-ai/qoder-agent-sdk'; + +import { mapContextUsageBreakdown } from '@/qoder/runtime/qoder-context-usage'; +import { QoderTurnTracker } from '@/qoder/runtime/qoder-turn-tracker'; + +function createPayload( + overrides: Partial = {}, +): SDKControlGetContextUsageResponse { + return { + model: 'qoder-sonnet-4-5', + contextWindow: { usedPercentage: 37.4 }, + categories: [ + { type: 'system_prompt', percentage: 0.4 }, + { type: 'system_tools', percentage: 1 }, + { type: 'skills', percentage: 0.2 }, + { type: 'messages', percentage: 35.8 }, + { type: 'other', percentage: 0 }, + { type: 'free_space', percentage: 62.6 }, + ], + autoCompact: { enabled: true, thresholdPercentage: 95 }, + skills: { count: 7, percentageOfContext: 0.2, items: [] }, + duplicateFileReads: [], + session: { + messageCount: 12, + promptCount: 6, + toolCalls: { total: 30, succeeded: 29, failed: 1 }, + linesChanged: { added: 120, removed: 4 }, + }, + ...overrides, + } as SDKControlGetContextUsageResponse; +} + +describe('mapContextUsageBreakdown', () => { + it('keeps category and skill data from the CLI payload', () => { + const breakdown = mapContextUsageBreakdown(createPayload()); + + expect(breakdown.usedPercentage).toBe(37.4); + expect(breakdown.categories).toEqual([ + { type: 'system_prompt', percentage: 0.4 }, + { type: 'system_tools', percentage: 1 }, + { type: 'skills', percentage: 0.2 }, + { type: 'messages', percentage: 35.8 }, + { type: 'other', percentage: 0 }, + { type: 'free_space', percentage: 62.6 }, + ]); + expect(breakdown.skills).toEqual({ count: 7, percentageOfContext: 0.2 }); + }); + + it('drops unknown categories and normalizes missing numbers', () => { + const payload = createPayload({ + contextWindow: { usedPercentage: Number.NaN }, + categories: [ + { type: 'tool_output_summary' as never, percentage: 5 }, + { type: 'messages', percentage: Number.POSITIVE_INFINITY }, + ], + skills: { count: 2.7, percentageOfContext: -1, items: [] }, + }); + + const breakdown = mapContextUsageBreakdown(payload); + + expect(breakdown.usedPercentage).toBe(0); + expect(breakdown.categories).toEqual([{ type: 'messages', percentage: 0 }]); + expect(breakdown.skills).toEqual({ count: 2, percentageOfContext: 0 }); + }); + + it('survives a payload without categories', () => { + const payload = createPayload(); + delete (payload as { categories?: unknown }).categories; + + expect(mapContextUsageBreakdown(payload).categories).toEqual([]); + }); +}); + +describe('QoderTurnTracker context breakdown cache', () => { + const request = (query: unknown) => ({ + query: query as never, + isCurrentQuery: () => true, + configuredModel: 'sonnet', + sessionId: null, + }); + + it('caches the breakdown reported by the CLI until the session changes', async () => { + const tracker = new QoderTurnTracker(); + const query = { getContextUsage: jest.fn().mockResolvedValue(createPayload()) }; + + const chunk = await tracker.fetchContextUsage(request(query)); + + expect(chunk).not.toBeNull(); + expect(tracker.getContextBreakdown()?.usedPercentage).toBe(37.4); + + tracker.clearContextBreakdown(); + expect(tracker.getContextBreakdown()).toBeNull(); + }); + + it('keeps the last breakdown when a later read fails', async () => { + const tracker = new QoderTurnTracker(); + const working = { getContextUsage: jest.fn().mockResolvedValue(createPayload()) }; + const failing = { getContextUsage: jest.fn().mockRejectedValue(new Error('gone')) }; + + await tracker.fetchContextUsage(request(working)); + await tracker.fetchContextUsage(request(failing)); + + expect(tracker.getContextBreakdown()?.usedPercentage).toBe(37.4); + }); +}); diff --git a/tests/unit/qoder/services/context-usage.test.ts b/tests/unit/qoder/services/context-usage.test.ts new file mode 100644 index 0000000..f01d408 --- /dev/null +++ b/tests/unit/qoder/services/context-usage.test.ts @@ -0,0 +1,105 @@ +import * as sdkModule from '@qoder-ai/qoder-agent-sdk'; + +import type QoderianPlugin from '@/main'; +import { fetchStoredSessionContextUsage } from '@/qoder/services/context-usage'; + +const sdkMock = sdkModule as unknown as { + setMockMessages: (messages: any[], options?: { appendResult?: boolean }) => void; + setMockContextUsage: (payload: unknown) => void; + resetMockMessages: () => void; + getLastResponse: () => { + initializationResult: jest.Mock; + getContextUsage: jest.Mock; + close: jest.Mock; + } | null; + getLastOptions: () => { resume?: string } | undefined; +}; + +jest.mock('@/core/fs/path', () => ({ + getVaultPath: jest.fn().mockReturnValue('/test/vault'), +})); + +jest.mock('@/core/env/environment', () => ({ + getEnhancedPath: jest.fn().mockReturnValue('/usr/bin:/mock/bin'), + getMissingNodeError: jest.fn().mockReturnValue(null), + findNodeExecutable: jest.fn().mockReturnValue('/usr/bin/node'), +})); + +function createMockPlugin(cliPath: string | null = '/mock/qoder'): QoderianPlugin { + return { + app: {}, + settings: {}, + getResolvedQoderCliPath: jest.fn().mockReturnValue(cliPath), + } as unknown as QoderianPlugin; +} + +const PAYLOAD = { + model: 'qoder-sonnet-4-5', + contextWindow: { usedPercentage: 12.5 }, + categories: [ + { type: 'system_tools', percentage: 6.1 }, + { type: 'messages', percentage: 6.4 }, + { type: 'free_space', percentage: 87.5 }, + ], + skills: { count: 3, percentageOfContext: 0.1, items: [] }, +}; + +describe('fetchStoredSessionContextUsage', () => { + beforeEach(() => { + sdkMock.resetMockMessages(); + sdkMock.setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'stored-session' }, + ], { appendResult: false }); + }); + + it('resumes the stored session and returns its breakdown', async () => { + sdkMock.setMockContextUsage(PAYLOAD); + + const breakdown = await fetchStoredSessionContextUsage( + createMockPlugin(), + 'stored-session', + ); + + expect(breakdown?.usedPercentage).toBe(12.5); + expect(breakdown?.categories).toHaveLength(3); + expect(breakdown?.skills.count).toBe(3); + expect(sdkMock.getLastOptions()?.resume).toBe('stored-session'); + expect(sdkMock.getLastResponse()?.getContextUsage).toHaveBeenCalled(); + expect(sdkMock.getLastResponse()?.close).toHaveBeenCalled(); + }); + + it('returns null when no CLI path is resolved', async () => { + const breakdown = await fetchStoredSessionContextUsage( + createMockPlugin(null), + 'stored-session', + { maxAgeMs: 0 }, + ); + + expect(breakdown).toBeNull(); + expect(sdkMock.getLastResponse()).toBeNull(); + }); + + it('returns null when the resume or read fails', async () => { + sdkMock.setMockContextUsage(new Error('Invalid session identifier')); + + const breakdown = await fetchStoredSessionContextUsage( + createMockPlugin(), + 'stored-session', + { maxAgeMs: 0 }, + ); + + expect(breakdown).toBeNull(); + expect(sdkMock.getLastResponse()?.close).toHaveBeenCalled(); + }); + + it('reuses a fresh read instead of spawning the CLI again', async () => { + sdkMock.setMockContextUsage(PAYLOAD); + + await fetchStoredSessionContextUsage(createMockPlugin(), 'reuse-session'); + const response = sdkMock.getLastResponse(); + const breakdown = await fetchStoredSessionContextUsage(createMockPlugin(), 'reuse-session'); + + expect(breakdown?.usedPercentage).toBe(12.5); + expect(response?.getContextUsage).toHaveBeenCalledTimes(1); + }); +});