feat: add Copilot usage display to Activity Panel with auto-refresh
- 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
This commit is contained in:
65
src/constants/quota-colors.ts
Normal file
65
src/constants/quota-colors.ts
Normal file
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
103
src/services/copilot/usage-refresh-manager.ts
Normal file
103
src/services/copilot/usage-refresh-manager.ts
Normal file
@@ -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();
|
||||
};
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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) */}
|
||||
<CopilotUsageSection />
|
||||
|
||||
{/* Context Section */}
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
<text fg={theme.colors.text} attributes={TextAttributes.BOLD}>
|
||||
|
||||
231
src/tui-solid/components/panels/copilot-usage.tsx
Normal file
231
src/tui-solid/components/panels/copilot-usage.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<text fg={color}>{PROGRESS_BAR.FILLED_CHAR.repeat(filledWidth)}</text>
|
||||
<text fg={color} attributes={TextAttributes.DIM}>
|
||||
{PROGRESS_BAR.EMPTY_CHAR.repeat(emptyWidth)}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
{/* Label */}
|
||||
<text fg={theme.colors.textDim}>{props.label}</text>
|
||||
|
||||
{/* Progress bar */}
|
||||
<box flexDirection="row" marginTop={0}>
|
||||
<Show
|
||||
when={!quotaInfo().unlimited}
|
||||
fallback={
|
||||
<>
|
||||
<text fg={theme.colors.success}>
|
||||
{PROGRESS_BAR.FILLED_CHAR.repeat(QUOTA_BAR_WIDTH)}
|
||||
</text>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{renderProgressBar(quotaInfo().percentUsed, barColor())}
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* Usage info */}
|
||||
<box flexDirection="row" marginTop={0}>
|
||||
<Show
|
||||
when={quotaInfo().unlimited}
|
||||
fallback={
|
||||
<>
|
||||
<text fg={barColor()}>
|
||||
{Math.round(quotaInfo().percentRemaining)}% left
|
||||
</text>
|
||||
<Show when={props.showPercentage}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{" "}
|
||||
({quotaInfo().remaining}/{quotaInfo().total})
|
||||
</text>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<text fg={theme.colors.success}>Unlimited</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Show when={app.provider() === COPILOT_DISPLAY_NAME}>
|
||||
<box flexDirection="column" marginBottom={1}>
|
||||
{/* Header */}
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.colors.text} attributes={TextAttributes.BOLD}>
|
||||
Copilot Usage
|
||||
</text>
|
||||
<Show when={loading()}>
|
||||
<text fg={theme.colors.textDim}>...</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* Loading state */}
|
||||
<Show
|
||||
when={!loading() && usage()}
|
||||
fallback={
|
||||
<box marginTop={1}>
|
||||
<text fg={theme.colors.textDim}>
|
||||
{loading() ? "Loading quota..." : "Unable to fetch quota"}
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
{/* Premium Requests */}
|
||||
<CopilotQuotaBar
|
||||
label="Premium Requests"
|
||||
quota={usage()?.quota_snapshots.premium_interactions}
|
||||
/>
|
||||
|
||||
{/* Chat */}
|
||||
<CopilotQuotaBar
|
||||
label="Chat"
|
||||
quota={usage()?.quota_snapshots.chat}
|
||||
/>
|
||||
|
||||
{/* Completions */}
|
||||
<CopilotQuotaBar
|
||||
label="Completions"
|
||||
quota={usage()?.quota_snapshots.completions}
|
||||
/>
|
||||
|
||||
{/* Reset date */}
|
||||
<box marginTop={0}>
|
||||
<text fg={theme.colors.textDim}>Resets {resetDate()}</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* Separator */}
|
||||
<box marginBottom={1}>
|
||||
<text fg={theme.colors.border}>{"─".repeat(34)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -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<AppMode>;
|
||||
screenMode: Accessor<ScreenMode>;
|
||||
@@ -110,6 +117,9 @@ interface AppContextValue {
|
||||
memoryCount: number;
|
||||
showBanner: boolean;
|
||||
}>;
|
||||
copilotUsage: Accessor<CopilotUsageResponse | null>;
|
||||
copilotUsageLoading: Accessor<boolean>;
|
||||
copilotUsageLastFetch: Accessor<number | null>;
|
||||
|
||||
// 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<void>;
|
||||
|
||||
// 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<void> => {
|
||||
// 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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user