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