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

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