@0xkahi/pi-qol 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -12
- package/assets/config.schema.json +190 -2
- package/package.json +1 -1
- package/src/config-loader.ts +8 -4
- package/src/constants.ts +20 -0
- package/src/extensions/auto-session-name/index.ts +1 -1
- package/src/extensions/custom-footer/constants.ts +4 -0
- package/src/extensions/custom-footer/footer-component.ts +179 -0
- package/src/extensions/custom-footer/index.ts +18 -0
- package/src/extensions/custom-footer/progress-bar.ts +16 -0
- package/src/extensions/custom-footer/subscription-usage-manager.ts +91 -0
- package/src/extensions/custom-footer/token-stats.ts +144 -0
- package/src/extensions/custom-footer/types.ts +40 -0
- package/src/extensions/model-select/constants.ts +8 -0
- package/src/extensions/model-select/index.ts +119 -0
- package/src/extensions/model-select/model-formatter.ts +60 -0
- package/src/extensions/model-select/model-lists.ts +63 -0
- package/src/extensions/model-select/model-select-dialog.ts +340 -0
- package/src/extensions/model-select/types.ts +31 -0
- package/src/index.ts +4 -0
- package/src/libs/subscription-usage/strategy/anthropic-oauth-usage.strategy.ts +41 -0
- package/src/libs/subscription-usage/strategy/openai-codex-usage.strategy.ts +75 -0
- package/src/libs/subscription-usage/subscription-usage-api.util.ts +78 -0
- package/src/schemas/auto-session-name.config.schema.ts +2 -2
- package/src/schemas/config.schema.ts +11 -0
- package/src/schemas/custom-footer-config.schema.ts +64 -0
- package/src/schemas/model-select.config.schema.ts +18 -0
- package/src/schemas/shared-config.schema.ts +5 -2
- package/src/types.ts +1 -0
- package/src/utils/crayon.util.ts +58 -0
- package/src/utils/model-resolver.util.ts +2 -0
- package/src/utils/path.util.ts +4 -0
- package/src/utils/raw-data-parser.util.ts +14 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { AnthropicOauthUsageStrategy } from '../../libs/subscription-usage/strategy/anthropic-oauth-usage.strategy';
|
|
2
|
+
import { OpenAiCodexUsageStrategy } from '../../libs/subscription-usage/strategy/openai-codex-usage.strategy';
|
|
3
|
+
import {
|
|
4
|
+
type FetchUsageResponse,
|
|
5
|
+
type RateWindow,
|
|
6
|
+
SubscriptionUsageApi,
|
|
7
|
+
type SubscriptionUsageStrategy,
|
|
8
|
+
} from '../../libs/subscription-usage/subscription-usage-api.util';
|
|
9
|
+
import { SUBSCRIPTION_USAGE_TTL_MS } from './constants';
|
|
10
|
+
import type { SupportedProvider } from './types';
|
|
11
|
+
|
|
12
|
+
export type SubscriptionUsageApiLike = Pick<SubscriptionUsageApi, 'fetchUsage' | 'formatResetDescription'>;
|
|
13
|
+
|
|
14
|
+
export function resolveSupportedProvider(provider: string | undefined): SupportedProvider | undefined {
|
|
15
|
+
switch (provider?.toLowerCase()) {
|
|
16
|
+
case 'anthropic':
|
|
17
|
+
return 'anthropic';
|
|
18
|
+
case 'openai-codex':
|
|
19
|
+
case 'codex':
|
|
20
|
+
case 'openai':
|
|
21
|
+
case 'chatgpt':
|
|
22
|
+
return 'openai-codex';
|
|
23
|
+
default:
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function pickSubscriptionUsageWindow(windows: readonly RateWindow[]): RateWindow | undefined {
|
|
29
|
+
let earliestWithReset: RateWindow | undefined;
|
|
30
|
+
|
|
31
|
+
for (const window of windows) {
|
|
32
|
+
if (!window.resetAt) continue;
|
|
33
|
+
if (!earliestWithReset?.resetAt || window.resetAt.getTime() <= earliestWithReset.resetAt.getTime()) {
|
|
34
|
+
earliestWithReset = window;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return earliestWithReset ?? windows[0];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class SubscriptionUsageManager {
|
|
42
|
+
private cache = new Map<SupportedProvider, FetchUsageResponse>();
|
|
43
|
+
private inFlight = new Set<SupportedProvider>();
|
|
44
|
+
private lastFetchAt = new Map<SupportedProvider, number>();
|
|
45
|
+
|
|
46
|
+
constructor(
|
|
47
|
+
private api: SubscriptionUsageApiLike = new SubscriptionUsageApi(),
|
|
48
|
+
private onUpdate?: () => void,
|
|
49
|
+
private ttlMs = SUBSCRIPTION_USAGE_TTL_MS,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
get(provider: SupportedProvider): FetchUsageResponse | undefined {
|
|
53
|
+
return this.cache.get(provider);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
ensureFresh(provider: SupportedProvider): FetchUsageResponse | undefined {
|
|
57
|
+
const last = this.lastFetchAt.get(provider) ?? 0;
|
|
58
|
+
if (!this.inFlight.has(provider) && Date.now() - last > this.ttlMs) {
|
|
59
|
+
this.refresh(provider);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return this.cache.get(provider);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
formatResetDescription(date: Date): string {
|
|
66
|
+
return this.api.formatResetDescription(date);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private refresh(provider: SupportedProvider): void {
|
|
70
|
+
this.inFlight.add(provider);
|
|
71
|
+
this.lastFetchAt.set(provider, Date.now());
|
|
72
|
+
|
|
73
|
+
void this.api
|
|
74
|
+
.fetchUsage(this.strategyFor(provider))
|
|
75
|
+
.then(response => {
|
|
76
|
+
if (!response) return;
|
|
77
|
+
this.cache.set(provider, response);
|
|
78
|
+
this.onUpdate?.();
|
|
79
|
+
})
|
|
80
|
+
.catch(() => {
|
|
81
|
+
// Best-effort status: auth/network failures simply hide the segment.
|
|
82
|
+
})
|
|
83
|
+
.finally(() => {
|
|
84
|
+
this.inFlight.delete(provider);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private strategyFor(provider: SupportedProvider): SubscriptionUsageStrategy {
|
|
89
|
+
return provider === 'anthropic' ? new AnthropicOauthUsageStrategy() : new OpenAiCodexUsageStrategy();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { ContextUsage, SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { crayon } from '../../utils/crayon.util';
|
|
3
|
+
import { clampPercent, renderProgressBar } from './progress-bar';
|
|
4
|
+
import type { CustomFooterColors, CustomFooterDisplay, CustomFooterIcons, FooterTheme, SupportedProvider, UsageTotals } from './types';
|
|
5
|
+
|
|
6
|
+
export type SubscriptionUsageSegmentInput = {
|
|
7
|
+
provider: SupportedProvider;
|
|
8
|
+
responseLabel: string;
|
|
9
|
+
windowLabel: string;
|
|
10
|
+
usedPercent: number;
|
|
11
|
+
resetDescription?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type BuildStatsLeftOptions = {
|
|
15
|
+
totals: UsageTotals;
|
|
16
|
+
display: CustomFooterDisplay;
|
|
17
|
+
icons: Pick<CustomFooterIcons, 'cache' | 'cacheRead' | 'cacheWrite'>;
|
|
18
|
+
contextUsage: ContextUsage | undefined;
|
|
19
|
+
contextWindow: number;
|
|
20
|
+
usingSubscription: boolean;
|
|
21
|
+
subscriptionUsageSegment?: string;
|
|
22
|
+
theme: FooterTheme;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function formatTokens(count: number): string {
|
|
26
|
+
if (count < 1000) return count.toString();
|
|
27
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
28
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
29
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
30
|
+
return `${Math.round(count / 1000000)}M`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function calculateUsageTotals(entries: readonly SessionEntry[]): UsageTotals {
|
|
34
|
+
const totals: UsageTotals = {
|
|
35
|
+
totalInput: 0,
|
|
36
|
+
totalOutput: 0,
|
|
37
|
+
totalCacheRead: 0,
|
|
38
|
+
totalCacheWrite: 0,
|
|
39
|
+
totalCost: 0,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (entry.type !== 'message' || entry.message.role !== 'assistant') continue;
|
|
44
|
+
|
|
45
|
+
const usage = entry.message.usage;
|
|
46
|
+
totals.totalInput += usage.input;
|
|
47
|
+
totals.totalOutput += usage.output;
|
|
48
|
+
totals.totalCacheRead += usage.cacheRead;
|
|
49
|
+
totals.totalCacheWrite += usage.cacheWrite;
|
|
50
|
+
totals.totalCost += usage.cost.total;
|
|
51
|
+
|
|
52
|
+
const latestPromptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
53
|
+
totals.latestCacheHitRate = latestPromptTokens > 0 ? (usage.cacheRead / latestPromptTokens) * 100 : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return totals;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildSubscriptionUsageSegment({
|
|
60
|
+
colors,
|
|
61
|
+
icons,
|
|
62
|
+
theme,
|
|
63
|
+
usage,
|
|
64
|
+
}: {
|
|
65
|
+
colors: Pick<CustomFooterColors, 'anthropicUsage' | 'codexUsage'>;
|
|
66
|
+
icons: Pick<CustomFooterIcons, 'refresh'>;
|
|
67
|
+
theme: FooterTheme;
|
|
68
|
+
usage: SubscriptionUsageSegmentInput;
|
|
69
|
+
}): string {
|
|
70
|
+
const providerColor = usage.provider === 'anthropic' ? colors.anthropicUsage : colors.codexUsage;
|
|
71
|
+
const pct = clampPercent(usage.usedPercent);
|
|
72
|
+
const roundedPercent = Math.round(pct).toString();
|
|
73
|
+
const bar = renderProgressBar(pct);
|
|
74
|
+
|
|
75
|
+
const resetParts = usage.resetDescription ? [icons.refresh, usage.resetDescription].filter(Boolean).join(' ') : '';
|
|
76
|
+
|
|
77
|
+
return [
|
|
78
|
+
crayon.colorize(usage.responseLabel, { fg: providerColor }),
|
|
79
|
+
crayon.colorize(usage.windowLabel, { fg: providerColor }),
|
|
80
|
+
`${crayon.colorize(bar.filled, { fg: providerColor })}${theme.fg('dim', bar.empty)}`,
|
|
81
|
+
crayon.colorize(`${roundedPercent}%`, { fg: providerColor }),
|
|
82
|
+
resetParts ? theme.fg('dim', resetParts) : undefined,
|
|
83
|
+
]
|
|
84
|
+
.filter((part): part is string => Boolean(part))
|
|
85
|
+
.join(' ');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildStatsLeft({
|
|
89
|
+
contextUsage,
|
|
90
|
+
contextWindow,
|
|
91
|
+
display,
|
|
92
|
+
icons,
|
|
93
|
+
subscriptionUsageSegment,
|
|
94
|
+
theme,
|
|
95
|
+
totals,
|
|
96
|
+
usingSubscription,
|
|
97
|
+
}: BuildStatsLeftOptions): string {
|
|
98
|
+
const parts: string[] = [];
|
|
99
|
+
|
|
100
|
+
if (display.tokens) {
|
|
101
|
+
if (totals.totalInput) parts.push(theme.fg('dim', `↑${formatTokens(totals.totalInput)}`));
|
|
102
|
+
if (totals.totalOutput) parts.push(theme.fg('dim', `↓${formatTokens(totals.totalOutput)}`));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (display.cache && (totals.totalCacheRead > 0 || totals.totalCacheWrite > 0)) {
|
|
106
|
+
const hitRate = totals.latestCacheHitRate !== undefined ? ` ${totals.latestCacheHitRate.toFixed(1)}%` : '';
|
|
107
|
+
parts.push(
|
|
108
|
+
theme.fg(
|
|
109
|
+
'dim',
|
|
110
|
+
`${icons.cache}[${icons.cacheRead}${formatTokens(totals.totalCacheRead)} ${icons.cacheWrite}${formatTokens(totals.totalCacheWrite)}${hitRate}]`,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (totals.totalCost || usingSubscription) {
|
|
116
|
+
parts.push(theme.fg('dim', `$${totals.totalCost.toFixed(3)}${usingSubscription ? ' (sub)' : ''}`));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
parts.push(buildContextUsageSegment({ contextUsage, contextWindow, theme }));
|
|
120
|
+
|
|
121
|
+
if (subscriptionUsageSegment) {
|
|
122
|
+
parts.push(theme.fg('dim', '•'), subscriptionUsageSegment);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return parts.join(' ');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildContextUsageSegment({
|
|
129
|
+
contextUsage,
|
|
130
|
+
contextWindow,
|
|
131
|
+
theme,
|
|
132
|
+
}: {
|
|
133
|
+
contextUsage: ContextUsage | undefined;
|
|
134
|
+
contextWindow: number;
|
|
135
|
+
theme: FooterTheme;
|
|
136
|
+
}): string {
|
|
137
|
+
const contextPercentValue = contextUsage?.percent ?? 0;
|
|
138
|
+
const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : '?';
|
|
139
|
+
const contextPercentDisplay = contextPercent === '?' ? `?/${formatTokens(contextWindow)}` : `${contextPercent}%/${formatTokens(contextWindow)}`;
|
|
140
|
+
|
|
141
|
+
if (contextPercentValue > 90) return theme.fg('error', contextPercentDisplay);
|
|
142
|
+
if (contextPercentValue > 70) return theme.fg('warning', contextPercentDisplay);
|
|
143
|
+
return theme.fg('dim', contextPercentDisplay);
|
|
144
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { TUI } from '@earendil-works/pi-tui';
|
|
3
|
+
import type { ConfigLoader } from '../../config-loader';
|
|
4
|
+
import type { Config } from '../../schemas/config.schema';
|
|
5
|
+
|
|
6
|
+
export type SupportedProvider = 'anthropic' | 'openai-codex';
|
|
7
|
+
|
|
8
|
+
export type CustomFooterConfig = Config['custom_footer'];
|
|
9
|
+
export type CustomFooterColors = CustomFooterConfig['colors'];
|
|
10
|
+
export type CustomFooterDisplay = CustomFooterConfig['display'];
|
|
11
|
+
export type CustomFooterIcons = CustomFooterConfig['icons'];
|
|
12
|
+
|
|
13
|
+
export type FooterTheme = {
|
|
14
|
+
fg(color: 'dim' | 'error' | 'warning', text: string): string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type FooterDataProvider = {
|
|
18
|
+
getGitBranch(): string | null;
|
|
19
|
+
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
20
|
+
getAvailableProviderCount(): number;
|
|
21
|
+
onBranchChange(cb: () => void): () => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type CustomFooterComponentDeps = {
|
|
25
|
+
tui: TUI;
|
|
26
|
+
theme: FooterTheme;
|
|
27
|
+
footerData: FooterDataProvider;
|
|
28
|
+
ctx: ExtensionContext;
|
|
29
|
+
config: ConfigLoader;
|
|
30
|
+
getThinkingLevel: () => string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type UsageTotals = {
|
|
34
|
+
totalInput: number;
|
|
35
|
+
totalOutput: number;
|
|
36
|
+
totalCacheRead: number;
|
|
37
|
+
totalCacheWrite: number;
|
|
38
|
+
totalCost: number;
|
|
39
|
+
latestCacheHitRate?: number;
|
|
40
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { piVimKeyEventId } from '../../constants';
|
|
2
|
+
|
|
3
|
+
export const COMMAND_NAME = 'select-model';
|
|
4
|
+
export const MAX_VISIBLE_MODELS = 10;
|
|
5
|
+
export const MAX_CONFIG_WARNING_LINES = 4;
|
|
6
|
+
|
|
7
|
+
// Cross-extension activation hook (for example, pi-vim-keys can emit this to open the picker).
|
|
8
|
+
export const PI_VIM_KEY_EVENT_ID = piVimKeyEventId('model_select');
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import type { ConfigLoader } from '../../config-loader';
|
|
4
|
+
import { COMMAND_NAME, PI_VIM_KEY_EVENT_ID } from './constants';
|
|
5
|
+
import { ModelFormatter } from './model-formatter';
|
|
6
|
+
import { buildModelLists, findExactModel } from './model-lists';
|
|
7
|
+
import { ModelSelectDialog } from './model-select-dialog';
|
|
8
|
+
import type { DialogResult } from './types';
|
|
9
|
+
|
|
10
|
+
async function applySelectedModel(pi: ExtensionAPI, ctx: ExtensionContext, model: Model<Api>): Promise<void> {
|
|
11
|
+
const success = await pi.setModel(model);
|
|
12
|
+
if (success) {
|
|
13
|
+
ctx.ui.notify(`Model set to ${ModelFormatter.modelLabel(model)}`, 'info');
|
|
14
|
+
} else {
|
|
15
|
+
ctx.ui.notify(`No configured auth for ${ModelFormatter.modelLabel(model)}`, 'error');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function showModelSelector(pi: ExtensionAPI, args: string, ctx: ExtensionContext, configLoader: ConfigLoader): Promise<void> {
|
|
20
|
+
// `waitForIdle` only exists on command contexts. When invoked from the event
|
|
21
|
+
// bus we get a plain ExtensionContext, so fall back to a best-effort guard.
|
|
22
|
+
if ('waitForIdle' in ctx && typeof ctx.waitForIdle === 'function') {
|
|
23
|
+
await (ctx as ExtensionCommandContext).waitForIdle();
|
|
24
|
+
}
|
|
25
|
+
ctx.modelRegistry.refresh();
|
|
26
|
+
|
|
27
|
+
const exactModel = findExactModel(ctx, args);
|
|
28
|
+
if (exactModel) {
|
|
29
|
+
await applySelectedModel(pi, ctx, exactModel);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!ctx.hasUI) {
|
|
34
|
+
ctx.ui.notify('The /select-model picker requires an interactive UI. Pass provider/modelId to select directly.', 'warning');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const config = configLoader.getModelSelect();
|
|
39
|
+
const modelLists = await buildModelLists(ctx, config);
|
|
40
|
+
const registryError = ctx.modelRegistry.getError();
|
|
41
|
+
|
|
42
|
+
const selected = await ctx.ui.custom<DialogResult>(
|
|
43
|
+
(tui, theme, keybindings, done) =>
|
|
44
|
+
new ModelSelectDialog(tui, theme, keybindings, {
|
|
45
|
+
currentModel: ctx.model,
|
|
46
|
+
favouriteItems: modelLists.favouriteItems,
|
|
47
|
+
favouriteWarnings: modelLists.favouriteWarnings,
|
|
48
|
+
hasFavouriteSection: config.favourite.length > 0,
|
|
49
|
+
searchItems: modelLists.searchItems,
|
|
50
|
+
providerFilter: config.provider_filter,
|
|
51
|
+
configWarnings: registryError ? [`models.json: ${registryError}`] : [],
|
|
52
|
+
initialSearch: args.trim(),
|
|
53
|
+
layout: config.layout,
|
|
54
|
+
onDone: done,
|
|
55
|
+
}),
|
|
56
|
+
config.layout === 'overlay'
|
|
57
|
+
? {
|
|
58
|
+
overlay: true,
|
|
59
|
+
overlayOptions: {
|
|
60
|
+
anchor: 'center',
|
|
61
|
+
width: '85%',
|
|
62
|
+
margin: 1,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
: undefined,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
if (selected) {
|
|
69
|
+
await applySelectedModel(pi, ctx, selected);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function activateModelSelect(pi: ExtensionAPI, deps: { config: ConfigLoader; initialCtx?: ExtensionContext }) {
|
|
74
|
+
// Keep a reference to the latest context so the event-bus handler (which gets
|
|
75
|
+
// no context of its own) can open the modal too. Lazy activation may happen
|
|
76
|
+
// during session_start, so seed this with the context that triggered it.
|
|
77
|
+
let latestCtx: ExtensionContext | undefined = deps.initialCtx;
|
|
78
|
+
|
|
79
|
+
pi.on('session_start', (_event, ctx) => {
|
|
80
|
+
latestCtx = ctx;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
pi.registerCommand(COMMAND_NAME, {
|
|
84
|
+
description: 'Select/search models with favourites and provider filtering',
|
|
85
|
+
handler: async (args, ctx) => {
|
|
86
|
+
latestCtx = ctx;
|
|
87
|
+
if (!deps.config.isEnabled('model_select')) {
|
|
88
|
+
ctx.ui.notify('(pi-qol) model_select is disabled', 'warning');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
await showModelSelector(pi, args, ctx, deps.config);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Cross-extension activation: another extension can open the modal via
|
|
96
|
+
// pi.events.emit(PI_VIM_KEY_EVENT_ID)
|
|
97
|
+
pi.events.on(PI_VIM_KEY_EVENT_ID, () => {
|
|
98
|
+
const ctx = latestCtx;
|
|
99
|
+
if (!ctx || !deps.config.isEnabled('model_select')) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
void showModelSelector(pi, '', ctx, deps.config).catch(error => {
|
|
103
|
+
ctx.ui.notify(`Failed to open model selector: ${error instanceof Error ? error.message : String(error)}`, 'error');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function registerModelSelect(pi: ExtensionAPI, deps: { config: ConfigLoader }) {
|
|
109
|
+
let modelSelectRegistered = false;
|
|
110
|
+
|
|
111
|
+
pi.on('session_start', (_event, ctx) => {
|
|
112
|
+
if (!deps.config.isEnabled('model_select') || modelSelectRegistered) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
activateModelSelect(pi, { config: deps.config, initialCtx: ctx });
|
|
117
|
+
modelSelectRegistered = true;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type Api, type Model, modelsAreEqual } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ModelItem } from './types';
|
|
3
|
+
|
|
4
|
+
export class ModelFormatter {
|
|
5
|
+
static formatTokenCount(count: number): string {
|
|
6
|
+
if (count >= 1_000_000) {
|
|
7
|
+
const millions = count / 1_000_000;
|
|
8
|
+
return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`;
|
|
9
|
+
}
|
|
10
|
+
if (count >= 1_000) {
|
|
11
|
+
const thousands = count / 1_000;
|
|
12
|
+
return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`;
|
|
13
|
+
}
|
|
14
|
+
return count.toString();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static modelLabel(model: Model<Api>): string {
|
|
18
|
+
return `${model.provider}/${model.id}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static describeModel(model: Model<Api>): string {
|
|
22
|
+
const capabilities: string[] = [];
|
|
23
|
+
if (model.reasoning) {
|
|
24
|
+
capabilities.push('thinking');
|
|
25
|
+
}
|
|
26
|
+
if (model.input.includes('image')) {
|
|
27
|
+
capabilities.push('images');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const capabilityText = capabilities.length > 0 ? ` • ${capabilities.join(', ')}` : '';
|
|
31
|
+
return `${model.name} • ctx ${ModelFormatter.formatTokenCount(model.contextWindow)} • max ${ModelFormatter.formatTokenCount(model.maxTokens)}${capabilityText}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static toModelItem(model: Model<Api>): ModelItem {
|
|
35
|
+
return {
|
|
36
|
+
model,
|
|
37
|
+
description: ModelFormatter.describeModel(model),
|
|
38
|
+
searchText: `${model.provider} ${model.id} ${model.provider}/${model.id} ${model.name}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static sortModels(models: Model<Api>[], currentModel: Model<Api> | undefined): Model<Api>[] {
|
|
43
|
+
return [...models].sort((a, b) => {
|
|
44
|
+
const aIsCurrent = modelsAreEqual(a, currentModel);
|
|
45
|
+
const bIsCurrent = modelsAreEqual(b, currentModel);
|
|
46
|
+
if (aIsCurrent && !bIsCurrent) {
|
|
47
|
+
return -1;
|
|
48
|
+
}
|
|
49
|
+
if (!aIsCurrent && bIsCurrent) {
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const provider = a.provider.localeCompare(b.provider);
|
|
54
|
+
if (provider !== 0) {
|
|
55
|
+
return provider;
|
|
56
|
+
}
|
|
57
|
+
return a.id.localeCompare(b.id);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import type { Config } from '../../schemas/config.schema';
|
|
4
|
+
import { ModelFormatter } from './model-formatter';
|
|
5
|
+
import type { ModelItem, ModelLists } from './types';
|
|
6
|
+
|
|
7
|
+
export async function buildModelLists(ctx: ExtensionContext, config: Config['model_select']): Promise<ModelLists> {
|
|
8
|
+
ctx.modelRegistry.refresh();
|
|
9
|
+
|
|
10
|
+
const availableModels = ctx.modelRegistry.getAvailable();
|
|
11
|
+
const providerFilterSet = new Set(config.provider_filter);
|
|
12
|
+
const searchModels = config.provider_filter.length > 0 ? availableModels.filter(model => providerFilterSet.has(model.provider)) : availableModels;
|
|
13
|
+
const sortedSearchModels = ModelFormatter.sortModels(searchModels, ctx.model).map(model => ModelFormatter.toModelItem(model));
|
|
14
|
+
|
|
15
|
+
const favouriteItems: ModelItem[] = [];
|
|
16
|
+
const favouriteWarnings: string[] = [];
|
|
17
|
+
const seenFavouriteModels = new Set<string>();
|
|
18
|
+
|
|
19
|
+
for (const favourite of config.favourite) {
|
|
20
|
+
const model = ctx.modelRegistry.find(favourite.provider, favourite.modelId);
|
|
21
|
+
const label = `${favourite.provider}/${favourite.modelId}`;
|
|
22
|
+
|
|
23
|
+
if (!model) {
|
|
24
|
+
favouriteWarnings.push(`${label} was not found`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
|
|
29
|
+
favouriteWarnings.push(`${label} has no configured auth`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const key = `${model.provider}\u0000${model.id}`;
|
|
34
|
+
if (seenFavouriteModels.has(key)) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
seenFavouriteModels.add(key);
|
|
38
|
+
favouriteItems.push(ModelFormatter.toModelItem(model));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { favouriteItems, favouriteWarnings, searchItems: sortedSearchModels };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function findExactModel(ctx: ExtensionContext, args: string): Model<Api> | undefined {
|
|
45
|
+
const trimmed = args.trim();
|
|
46
|
+
if (!trimmed) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const slashIndex = trimmed.indexOf('/');
|
|
51
|
+
if (slashIndex > 0 && slashIndex < trimmed.length - 1) {
|
|
52
|
+
const provider = trimmed.slice(0, slashIndex).trim();
|
|
53
|
+
const modelId = trimmed.slice(slashIndex + 1).trim();
|
|
54
|
+
return ctx.modelRegistry.find(provider, modelId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const [provider, modelId] = trimmed.split(/\s+/, 2);
|
|
58
|
+
if (provider && modelId) {
|
|
59
|
+
return ctx.modelRegistry.find(provider, modelId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|