From a518a0bd11e7dc4c3c1aa70aabec83a120805848 Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Mon, 16 Feb 2026 01:14:06 -0500 Subject: [PATCH] feat: add Copilot usage display to Activity Panel with auto-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Copilot Usage section to right sidebar Activity Panel - Display Premium Requests, Chat, and Completions quotas with color-coded progress bars - Colors: Green (>40% remaining), Yellow (5-40%), Red (≤5%) - Auto-refresh every 60 seconds when provider is GitHub Copilot - Fetch immediately on session start (not after 60 seconds) - Refresh after task completion (debounced to 2 seconds) - Add usage refresh manager singleton for lifecycle management - Update /usage command to use same color system - Fix: Use COPILOT_DISPLAY_NAME for provider comparisons - Fix: Resume link shows actual session ID instead of 'unknown' Files added: - src/constants/quota-colors.ts - Color thresholds and status helpers - src/services/copilot/usage-refresh-manager.ts - Auto-refresh manager - src/tui-solid/components/panels/copilot-usage.tsx - UI components Files modified: - src/services/chat-tui/usage.ts - Add colored progress bars - src/tui-solid/app.tsx - Lifecycle hooks, fix resume link - src/tui-solid/components/panels/activity-panel.tsx - Integrate usage section - src/tui-solid/context/app.tsx - Add usage state and fetch logic --- src/constants/quota-colors.ts | 65 +++++ src/services/chat-tui/usage.ts | 41 +++- src/services/copilot/usage-refresh-manager.ts | 103 ++++++++ src/tui-solid/app.tsx | 24 +- .../components/panels/activity-panel.tsx | 4 + .../components/panels/copilot-usage.tsx | 231 ++++++++++++++++++ src/tui-solid/context/app.tsx | 109 ++++++++- 7 files changed, 565 insertions(+), 12 deletions(-) create mode 100644 src/constants/quota-colors.ts create mode 100644 src/services/copilot/usage-refresh-manager.ts create mode 100644 src/tui-solid/components/panels/copilot-usage.tsx diff --git a/src/constants/quota-colors.ts b/src/constants/quota-colors.ts new file mode 100644 index 0000000..b99d78f --- /dev/null +++ b/src/constants/quota-colors.ts @@ -0,0 +1,65 @@ +/** + * Quota Color System + * + * Defines color thresholds and status helpers for Copilot usage display + * + * Thresholds: + * - Green (healthy): 0-60% usage + * - Yellow (warning): 60-95% usage + * - Red (critical): 95-100% usage + */ + +export type QuotaStatus = "healthy" | "warning" | "critical"; + +export interface QuotaStatusInfo { + status: QuotaStatus; + color: "success" | "warning" | "error"; +} + +/** + * Get quota status based on percentage remaining + * @param percentRemaining - Percentage of quota remaining (0-100) + * @returns Status info with status level and color + */ +export function getQuotaStatus(percentRemaining: number): QuotaStatusInfo { + // Handle unlimited quotas (shown as 100%) + if (percentRemaining >= 100) { + return { status: "healthy", color: "success" }; + } + + // Critical: 5% or less remaining (95%+ used) + if (percentRemaining <= 5) { + return { status: "critical", color: "error" }; + } + + // Warning: 40% or less remaining (60%+ used) + if (percentRemaining <= 40) { + return { status: "warning", color: "warning" }; + } + + // Healthy: more than 40% remaining (less than 60% used) + return { status: "healthy", color: "success" }; +} + +/** + * Calculate percentage remaining from quota detail + * @param remaining - Number of requests remaining + * @param entitlement - Total entitlement + * @param unlimited - Whether quota is unlimited + * @returns Percentage remaining (0-100), or 100 for unlimited + */ +export function calculatePercentRemaining( + remaining: number, + entitlement: number, + unlimited: boolean, +): number { + if (unlimited) { + return 100; + } + + if (entitlement === 0) { + return 0; + } + + return Math.max(0, Math.min(100, (remaining / entitlement) * 100)); +} diff --git a/src/services/chat-tui/usage.ts b/src/services/chat-tui/usage.ts index 86cb8ef..3827f22 100644 --- a/src/services/chat-tui/usage.ts +++ b/src/services/chat-tui/usage.ts @@ -6,6 +6,10 @@ import { usageStore } from "@stores/core/usage-store"; import { getUserInfo } from "@providers/copilot/auth/credentials"; import { getCopilotUsage } from "@providers/copilot/usage"; import { PROGRESS_BAR } from "@constants/ui"; +import { + getQuotaStatus, + calculatePercentRemaining, +} from "@constants/quota-colors"; import type { ChatServiceState, ChatServiceCallbacks, @@ -30,14 +34,20 @@ const formatDuration = (ms: number): string => { return `${seconds}s`; }; -const renderBar = (percent: number): string => { +const renderBar = (percent: number, color?: string): string => { const clampedPercent = Math.max(0, Math.min(100, percent)); const filledWidth = Math.round((clampedPercent / 100) * PROGRESS_BAR.WIDTH); const emptyWidth = PROGRESS_BAR.WIDTH - filledWidth; - return ( - PROGRESS_BAR.FILLED_CHAR.repeat(filledWidth) + - PROGRESS_BAR.EMPTY_CHAR.repeat(emptyWidth) - ); + + const filled = PROGRESS_BAR.FILLED_CHAR.repeat(filledWidth); + const empty = PROGRESS_BAR.EMPTY_CHAR.repeat(emptyWidth); + + // If color is provided, wrap with ANSI color codes + if (color) { + return `${color}${filled}\x1b[2m${empty}\x1b[0m`; + } + + return filled + empty; }; const formatQuotaBar = ( @@ -55,8 +65,9 @@ const formatQuotaBar = ( if (quota.unlimited) { lines.push(name); + const greenColor = "\x1b[32m"; // Green ANSI color lines.push( - PROGRESS_BAR.FILLED_CHAR.repeat(PROGRESS_BAR.WIDTH) + " Unlimited", + `${greenColor}${PROGRESS_BAR.FILLED_CHAR.repeat(PROGRESS_BAR.WIDTH)}\x1b[0m Unlimited`, ); return lines; } @@ -64,9 +75,25 @@ const formatQuotaBar = ( const used = quota.entitlement - quota.remaining; const percentUsed = quota.entitlement > 0 ? (used / quota.entitlement) * 100 : 0; + const percentRemaining = calculatePercentRemaining( + quota.remaining, + quota.entitlement, + quota.unlimited, + ); + + // Get color based on percentage remaining + const statusInfo = getQuotaStatus(percentRemaining); + let ansiColor = "\x1b[32m"; // Green + if (statusInfo.color === "warning") { + ansiColor = "\x1b[33m"; // Yellow + } else if (statusInfo.color === "error") { + ansiColor = "\x1b[31m"; // Red + } lines.push(name); - lines.push(`${renderBar(percentUsed)} ${percentUsed.toFixed(0)}% used`); + lines.push( + `${renderBar(percentUsed, ansiColor)} ${percentUsed.toFixed(0)}% used`, + ); if (resetInfo) { lines.push(resetInfo); } diff --git a/src/services/copilot/usage-refresh-manager.ts b/src/services/copilot/usage-refresh-manager.ts new file mode 100644 index 0000000..f3e0d09 --- /dev/null +++ b/src/services/copilot/usage-refresh-manager.ts @@ -0,0 +1,103 @@ +/** + * Copilot Usage Refresh Manager + * + * Manages automatic refresh of Copilot usage data with: + * - 60-second interval timer + * - 5-second cache to prevent duplicate fetches + * - 2-second debouncing for manual refresh triggers + * - Lifecycle management (start/stop) + */ + +import type { AppContextValue } from "@/tui-solid/context/app"; + +const REFRESH_INTERVAL_MS = 60000; // 60 seconds +const DEBOUNCE_MS = 2000; // 2 seconds + +export class UsageRefreshManager { + private static instance: UsageRefreshManager | null = null; + + private intervalId: NodeJS.Timeout | null = null; + private lastManualRefreshTime = 0; + private appStore: AppContextValue | null = null; + + private constructor() {} + + public static getInstance(): UsageRefreshManager { + if (!UsageRefreshManager.instance) { + UsageRefreshManager.instance = new UsageRefreshManager(); + } + return UsageRefreshManager.instance; + } + + /** + * Start automatic refresh with 60-second intervals + */ + public start(appStore: AppContextValue): void { + this.appStore = appStore; + + // Clear any existing interval + this.stop(); + + // Only start if provider is copilot + if (appStore.provider() !== "copilot") { + return; + } + + // Set up 60-second interval + this.intervalId = setInterval(() => { + if (this.appStore && this.appStore.provider() === "copilot") { + this.appStore.fetchCopilotUsage(); + } else { + // Provider changed, stop refreshing + this.stop(); + } + }, REFRESH_INTERVAL_MS); + } + + /** + * Stop automatic refresh + */ + public stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + /** + * Manually trigger a refresh (debounced to prevent spam) + */ + public manualRefresh(): void { + const now = Date.now(); + + // Debounce: only allow manual refresh if 2 seconds have passed + if (now - this.lastManualRefreshTime < DEBOUNCE_MS) { + return; + } + + this.lastManualRefreshTime = now; + + if (this.appStore) { + this.appStore.fetchCopilotUsage(); + } + } + + /** + * Check if manager is currently running + */ + public isRunning(): boolean { + return this.intervalId !== null; + } + + /** + * Get the app store reference + */ + public getAppStore(): AppContextValue | null { + return this.appStore; + } +} + +// Export singleton instance getter +export const getUsageRefreshManager = (): UsageRefreshManager => { + return UsageRefreshManager.getInstance(); +}; diff --git a/src/tui-solid/app.tsx b/src/tui-solid/app.tsx index 18bb5df..3c8295d 100644 --- a/src/tui-solid/app.tsx +++ b/src/tui-solid/app.tsx @@ -6,6 +6,7 @@ import { Switch, createSignal, createEffect, + onCleanup, } from "solid-js"; import { batch } from "solid-js"; import { getFiles } from "@services/file-picker/files"; @@ -34,6 +35,8 @@ import { DialogProvider } from "@tui-solid/context/dialog"; import { ToastProvider, Toast, useToast } from "@tui-solid/ui/toast"; import { Home } from "@tui-solid/routes/home"; import { Session } from "@tui-solid/routes/session"; +import { getUsageRefreshManager } from "@services/copilot/usage-refresh-manager"; +import { COPILOT_DISPLAY_NAME } from "@constants/copilot"; import type { TuiInput, TuiOutput } from "@interfaces/index"; import type { MCPServerDisplay } from "@/types/tui"; import type { @@ -122,7 +125,7 @@ function AppContent(props: AppProps) { createEffect(() => { const state = appStore.getState(); const summary = generateSessionSummary({ - sessionId: props.sessionId ?? "unknown", + sessionId: state.sessionId ?? "unknown", sessionStats: state.sessionStats, modifiedFiles: state.modifiedFiles, modelName: state.model, @@ -197,6 +200,25 @@ function AppContent(props: AppProps) { }, 100); } + // Lifecycle: Start/stop Copilot usage refresh manager based on provider + createEffect(() => { + const refreshManager = getUsageRefreshManager(); + const currentProvider = app.provider(); + + if (currentProvider === COPILOT_DISPLAY_NAME) { + // Start refresh manager when provider is copilot (display name) + refreshManager.start(app); + } else { + // Stop refresh manager when provider changes away from copilot + refreshManager.stop(); + } + + // Cleanup on unmount + onCleanup(() => { + refreshManager.stop(); + }); + }); + useKeyboard((evt) => { // Clipboard: copy selected text if (matchesAction(evt, "clipboard_copy")) { diff --git a/src/tui-solid/components/panels/activity-panel.tsx b/src/tui-solid/components/panels/activity-panel.tsx index 393f40b..4e1e67c 100644 --- a/src/tui-solid/components/panels/activity-panel.tsx +++ b/src/tui-solid/components/panels/activity-panel.tsx @@ -12,6 +12,7 @@ import { TOKEN_WARNING_THRESHOLD, TOKEN_CRITICAL_THRESHOLD, } from "@constants/token"; +import { CopilotUsageSection } from "./copilot-usage"; /** Extract filename from a path without importing node:path */ const getFileName = (filePath: string): string => { @@ -82,6 +83,9 @@ export function ActivityPanel() { paddingTop={1} flexShrink={0} > + {/* Copilot Usage Section (when provider is copilot) */} + + {/* Context Section */} diff --git a/src/tui-solid/components/panels/copilot-usage.tsx b/src/tui-solid/components/panels/copilot-usage.tsx new file mode 100644 index 0000000..992a430 --- /dev/null +++ b/src/tui-solid/components/panels/copilot-usage.tsx @@ -0,0 +1,231 @@ +/** + * Copilot Usage Components + * + * Display Copilot quota usage in the Activity Panel + * with color-coded progress bars + */ + +import { Show, createMemo, type JSX } from "solid-js"; +import { TextAttributes } from "@opentui/core"; +import { useTheme } from "@tui-solid/context/theme"; +import { useAppStore } from "@tui-solid/context/app"; +import { PROGRESS_BAR } from "@constants/ui"; +import { + getQuotaStatus, + calculatePercentRemaining, +} from "@constants/quota-colors"; +import { COPILOT_DISPLAY_NAME } from "@constants/copilot"; +import type { CopilotQuotaDetail } from "@/types/copilot-usage"; + +const QUOTA_BAR_WIDTH = 30; // Fits within 36-char panel width + +/** + * Format date for reset display (e.g., "Feb 16" or "2026-02-16") + */ +const formatResetDate = (dateStr: string): string => { + try { + const date = new Date(dateStr); + const month = date.toLocaleString("en-US", { month: "short" }); + const day = date.getDate(); + return `${month} ${day}`; + } catch { + return dateStr; + } +}; + +/** + * Render a colored progress bar + */ +const renderProgressBar = (percent: number, color: string): JSX.Element => { + const clampedPercent = Math.max(0, Math.min(100, percent)); + const filledWidth = Math.round((clampedPercent / 100) * QUOTA_BAR_WIDTH); + const emptyWidth = QUOTA_BAR_WIDTH - filledWidth; + + return ( + <> + {PROGRESS_BAR.FILLED_CHAR.repeat(filledWidth)} + + {PROGRESS_BAR.EMPTY_CHAR.repeat(emptyWidth)} + + + ); +}; + +interface CopilotQuotaBarProps { + label: string; + quota: CopilotQuotaDetail | undefined; + showPercentage?: boolean; +} + +/** + * Display a single quota bar with label and usage + */ +function CopilotQuotaBar(props: CopilotQuotaBarProps) { + const theme = useTheme(); + + const quotaInfo = createMemo(() => { + if (!props.quota) { + return { + percentRemaining: 0, + percentUsed: 0, + status: getQuotaStatus(0), + unlimited: false, + remaining: 0, + total: 0, + }; + } + + if (props.quota.unlimited) { + return { + percentRemaining: 100, + percentUsed: 0, + status: getQuotaStatus(100), + unlimited: true, + remaining: 0, + total: 0, + }; + } + + const percentRemaining = calculatePercentRemaining( + props.quota.remaining, + props.quota.entitlement, + props.quota.unlimited, + ); + const percentUsed = 100 - percentRemaining; + + return { + percentRemaining, + percentUsed, + status: getQuotaStatus(percentRemaining), + unlimited: false, + remaining: props.quota.remaining, + total: props.quota.entitlement, + }; + }); + + const barColor = createMemo(() => { + const status = quotaInfo().status.color; + if (status === "error") return theme.colors.error; + if (status === "warning") return theme.colors.warning; + return theme.colors.success; + }); + + return ( + + {/* Label */} + {props.label} + + {/* Progress bar */} + + + + {PROGRESS_BAR.FILLED_CHAR.repeat(QUOTA_BAR_WIDTH)} + + + } + > + {renderProgressBar(quotaInfo().percentUsed, barColor())} + + + + {/* Usage info */} + + + + {Math.round(quotaInfo().percentRemaining)}% left + + + + {" "} + ({quotaInfo().remaining}/{quotaInfo().total}) + + + + } + > + Unlimited + + + + ); +} + +/** + * Main Copilot Usage Section displayed in Activity Panel + */ +export function CopilotUsageSection() { + const theme = useTheme(); + const app = useAppStore(); + + const usage = createMemo(() => app.copilotUsage()); + const loading = createMemo(() => app.copilotUsageLoading()); + + const resetDate = createMemo(() => { + const u = usage(); + return u ? formatResetDate(u.quota_reset_date) : ""; + }); + + return ( + + + {/* Header */} + + + Copilot Usage + + + ... + + + + {/* Loading state */} + + + {loading() ? "Loading quota..." : "Unable to fetch quota"} + + + } + > + + {/* Premium Requests */} + + + {/* Chat */} + + + {/* Completions */} + + + {/* Reset date */} + + Resets {resetDate()} + + + + + + {/* Separator */} + + {"─".repeat(34)} + + + ); +} diff --git a/src/tui-solid/context/app.tsx b/src/tui-solid/context/app.tsx index 756dfb6..2920732 100644 --- a/src/tui-solid/context/app.tsx +++ b/src/tui-solid/context/app.tsx @@ -21,7 +21,11 @@ import type { import type { ProviderModel } from "@/types/providers"; import type { BrainConnectionStatus, BrainUser } from "@/types/brain"; import type { PastedImage } from "@/types/image"; +import type { CopilotUsageResponse } from "@/types/copilot-usage"; import { stripMarkdown } from "@/utils/markdown/strip"; +import { getCopilotUsage } from "@/providers/copilot/usage"; +import { getUsageRefreshManager } from "@/services/copilot/usage-refresh-manager"; +import { COPILOT_DISPLAY_NAME } from "@constants/copilot"; interface AppStore { mode: AppMode; @@ -62,9 +66,12 @@ interface AppStore { memoryCount: number; showBanner: boolean; }; + copilotUsage: CopilotUsageResponse | null; + copilotUsageLoading: boolean; + copilotUsageLastFetch: number | null; } -interface AppContextValue { +export interface AppContextValue { // State accessors mode: Accessor; screenMode: Accessor; @@ -110,6 +117,9 @@ interface AppContextValue { memoryCount: number; showBanner: boolean; }>; + copilotUsage: Accessor; + copilotUsageLoading: Accessor; + copilotUsageLastFetch: Accessor; // Mode actions setMode: (mode: AppMode) => void; @@ -171,7 +181,12 @@ interface AppContextValue { addTokens: (input: number, output: number) => void; startApiCall: () => void; stopApiCall: () => void; - addTokensWithModel: (modelId: string, input: number, output: number, cached?: number) => void; + addTokensWithModel: ( + modelId: string, + input: number, + output: number, + cached?: number, + ) => void; resetSessionStats: () => void; setContextMaxTokens: (maxTokens: number) => void; @@ -205,6 +220,11 @@ interface AppContextValue { setBrainShowBanner: (show: boolean) => void; dismissBrainBanner: () => void; + // Copilot usage actions + setCopilotUsage: (usage: CopilotUsageResponse | null) => void; + setCopilotUsageLoading: (loading: boolean) => void; + fetchCopilotUsage: () => Promise; + // MCP actions setMcpServers: (servers: MCPServerDisplay[]) => void; addMcpServer: (server: MCPServerDisplay) => void; @@ -303,6 +323,9 @@ export const { provider: AppStoreProvider, use: useAppStore } = memoryCount: 0, showBanner: true, }, + copilotUsage: null, + copilotUsageLoading: false, + copilotUsageLastFetch: null, }); // Input insert function (set by InputArea) @@ -357,6 +380,11 @@ export const { provider: AppStoreProvider, use: useAppStore } = const mcpServers = (): MCPServerDisplay[] => store.mcpServers; const modifiedFiles = (): ModifiedFileEntry[] => store.modifiedFiles; const brain = () => store.brain; + const copilotUsage = (): CopilotUsageResponse | null => + store.copilotUsage; + const copilotUsageLoading = (): boolean => store.copilotUsageLoading; + const copilotUsageLastFetch = (): number | null => + store.copilotUsageLastFetch; // Mode actions const setMode = (newMode: AppMode): void => { @@ -486,6 +514,11 @@ export const { provider: AppStoreProvider, use: useAppStore } = setStore("provider", newProvider); setStore("model", newModel); }); + + // Fetch Copilot usage immediately if provider is copilot (display name) + if (newProvider === COPILOT_DISPLAY_NAME) { + fetchCopilotUsage(); + } }; const setVersion = (newVersion: string): void => { @@ -585,6 +618,48 @@ export const { provider: AppStoreProvider, use: useAppStore } = setStore("brain", { ...store.brain, showBanner: false }); }; + // Copilot usage actions + const setCopilotUsage = (usage: CopilotUsageResponse | null): void => { + batch(() => { + setStore("copilotUsage", usage); + setStore("copilotUsageLastFetch", Date.now()); + }); + }; + + const setCopilotUsageLoading = (loading: boolean): void => { + setStore("copilotUsageLoading", loading); + }; + + const fetchCopilotUsage = async (): Promise => { + // Only fetch if provider is copilot (display name) + if (store.provider !== COPILOT_DISPLAY_NAME) { + return; + } + + // Check cache - don't fetch if we fetched within last 5 seconds + const now = Date.now(); + if ( + store.copilotUsageLastFetch && + now - store.copilotUsageLastFetch < 5000 + ) { + return; + } + + setCopilotUsageLoading(true); + try { + const usage = await getCopilotUsage(); + setCopilotUsage(usage); + } catch (error) { + // Silently fail - usage display is non-critical + console.error( + "[fetchCopilotUsage] Failed to fetch Copilot usage:", + error, + ); + } finally { + setCopilotUsageLoading(false); + } + }; + // MCP actions const setMcpServers = (servers: MCPServerDisplay[]): void => { setStore("mcpServers", servers); @@ -727,7 +802,8 @@ export const { provider: AppStoreProvider, use: useAppStore } = if (existing) { existing.inputTokens += input; existing.outputTokens += output; - if (cached) existing.cachedTokens = (existing.cachedTokens ?? 0) + cached; + if (cached) + existing.cachedTokens = (existing.cachedTokens ?? 0) + cached; } else { s.sessionStats.modelUsage.push({ modelId, @@ -847,6 +923,12 @@ export const { provider: AppStoreProvider, use: useAppStore } = }); } }); + + // Trigger Copilot usage refresh after streaming completes (debounced) + if (store.provider === "copilot") { + const refreshManager = getUsageRefreshManager(); + refreshManager.manualRefresh(); + } }; const cancelStreaming = (): void => { @@ -965,6 +1047,9 @@ export const { provider: AppStoreProvider, use: useAppStore } = mcpServers, modifiedFiles, brain, + copilotUsage, + copilotUsageLoading, + copilotUsageLastFetch, // Mode actions setMode, @@ -1029,6 +1114,11 @@ export const { provider: AppStoreProvider, use: useAppStore } = setBrainShowBanner, dismissBrainBanner, + // Copilot usage actions + setCopilotUsage, + setCopilotUsageLoading, + fetchCopilotUsage, + // MCP actions setMcpServers, addMcpServer, @@ -1126,6 +1216,9 @@ const defaultAppState = { memoryCount: 0, showBanner: true, }, + copilotUsage: null, + copilotUsageLoading: false, + copilotUsageLastFetch: null, }; export const appStore = { @@ -1164,6 +1257,9 @@ export const appStore = { pastedImages: storeRef.pastedImages(), modifiedFiles: storeRef.modifiedFiles(), brain: storeRef.brain(), + copilotUsage: storeRef.copilotUsage(), + copilotUsageLoading: storeRef.copilotUsageLoading(), + copilotUsageLastFetch: storeRef.copilotUsageLastFetch(), }; }, @@ -1282,7 +1378,12 @@ export const appStore = { storeRef.stopApiCall(); }, - addTokensWithModel: (modelId: string, input: number, output: number, cached?: number): void => { + addTokensWithModel: ( + modelId: string, + input: number, + output: number, + cached?: number, + ): void => { if (!storeRef) return; storeRef.addTokensWithModel(modelId, input, output, cached); },