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:
2026-02-16 01:14:06 -05:00
parent 24f6083d61
commit a518a0bd11
7 changed files with 565 additions and 12 deletions

View 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));
}

View File

@@ -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);
}

View 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();
};

View File

@@ -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")) {

View File

@@ -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}>

View 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>
);
}

View File

@@ -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);
},