Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0587dd2
SL 1 RED: specify ambient UI visibility preference
caseneuve Sep 3, 2026
bc41fd8
SL 1 GREEN: add ambient UI visibility preference
caseneuve Sep 3, 2026
65931a3
SL 2 RED: specify provider-aware startup UI
caseneuve Sep 3, 2026
48fdc67
SL 2 GREEN: hide ambient UI outside Kilo at startup
caseneuve Sep 3, 2026
852dd3a
SL 3 RED: specify ambient UI provider transitions
caseneuve Sep 3, 2026
6c77574
SL 3 GREEN: reconcile ambient UI on model selection
caseneuve Sep 3, 2026
bb1fbae
SL 4 RED: suppress hidden turn-related API traffic
caseneuve Sep 3, 2026
3e23ef5
SL 4 GREEN: suppress hidden turn-related API traffic
caseneuve Sep 3, 2026
1f9cdd2
SL 5 RED: reject stale in-flight status publication
caseneuve Sep 3, 2026
8c655a9
SL 5 GREEN: invalidate hidden in-flight usage refreshes
caseneuve Sep 3, 2026
49008bf
SL 6 RED: reject stale in-flight balance publication
caseneuve Sep 3, 2026
494ac48
SL 6 GREEN: guard asynchronous credit publication
caseneuve Sep 3, 2026
35dbcfe
SL 2 REFACTOR: keep model catalog refresh independent
caseneuve Sep 3, 2026
e159a5a
docs: explain provider-aware ambient Kilo UI
caseneuve Sep 3, 2026
d250828
SL 7 RED: restore usage status on Kilo selection
caseneuve Sep 3, 2026
67c415d
SL 7 GREEN: refresh usage status on Kilo selection
caseneuve Sep 3, 2026
5346455
SL 8 RED: suppress balance after catalog race
caseneuve Sep 3, 2026
09a5244
SL 8 GREEN: recheck visibility after catalog refresh
caseneuve Sep 3, 2026
58a7a3b
test: cover ambient display config precedence
caseneuve Sep 3, 2026
71c683c
SL 8 REFACTOR: narrow test assertion boundaries
caseneuve Sep 3, 2026
781b797
release: v2026.09.0
caseneuve Sep 3, 2026
7e84b82
docs: link v2026.09.0 changes to pull requests
caseneuve Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,20 @@ Release versions use calendar versioning: `YYYY.MM.PATCH`. `PATCH` starts at `0`

## [Unreleased]

## [2026.09.0] - 2026-09-03

### Breaking Changes

- Hide Kilo's custom footer, credit balance, and usage statuses by default when the selected model uses another provider ([#30](https://github.com/Kilo-Org/kilo-pi-provider/pull/30)). Set `display.showForOtherProviders` to `true` or `KILO_PI_SHOW_FOR_OTHER_PROVIDERS=1` to preserve the previous always-visible behavior.

### Added

- Restore Kilo's custom footer and refresh configured credit and usage statuses immediately when switching back to a Kilo model ([#30](https://github.com/Kilo-Org/kilo-pi-provider/pull/30)).

### Fixed

- Classify malformed Kilo Responses failures that provide `error.type` without the required `error.code`.
- Avoid presentation-related balance and usage requests while ambient Kilo UI is hidden, including provider changes during in-flight requests ([#30](https://github.com/Kilo-Org/kilo-pi-provider/pull/30)).
- Classify malformed Kilo Responses failures that provide `error.type` without the required `error.code` ([#29](https://github.com/Kilo-Org/kilo-pi-provider/pull/29)).

## [2026.08.2] - 2026-08-30

Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,15 @@ You can also set `KILO_API_KEY` directly instead of using the login flow. Set `K

### Footer

Kilo replaces Pi's footer by default to show usage and credits. To keep another extension's custom footer, disable Kilo's footer:
When a Kilo model is selected, Kilo replaces Pi's footer by default to show usage and credits. Selecting a model from another provider restores Pi's footer and hides Kilo's ambient statuses. To keep Kilo's footer and statuses visible for models from other providers, set:

```bash
KILO_PI_SHOW_FOR_OTHER_PROVIDERS=1 pi
```

This setting controls only automatic footer and status presentation. It does not disable Kilo API access or explicitly invoked extension UI.

To keep another extension's custom footer, disable Kilo's footer:

```bash
KILO_PI_CUSTOM_FOOTER=0 pi
Expand All @@ -69,6 +77,7 @@ Set non-secret preferences globally in `~/.pi/agent/extensions/kilo-pi-provider/

```json
{
"display": { "showForOtherProviders": false },
"footer": { "custom": false },
"credits": { "enabled": true },
"usage": { "periods": ["day", "week"] }
Expand All @@ -77,6 +86,8 @@ Set non-secret preferences globally in `~/.pi/agent/extensions/kilo-pi-provider/

A trusted project file overrides the global file, and environment variables override both. Project preferences are ignored until Pi trusts the project. Keep API keys and OAuth credentials out of these files.

The `display.showForOtherProviders` preference defaults to `false` and is overridden by `KILO_PI_SHOW_FOR_OTHER_PROVIDERS`.

Environment overrides are useful for one-off sessions:

```bash
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-pi-provider",
"version": "2026.08.2",
"version": "2026.09.0",
"type": "module",
"keywords": [
"pi-package"
Expand Down
20 changes: 20 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Value from "typebox/value";
export const KILO_USAGE_PERIODS = ["day", "week", "month", "year"] as const;
export type KiloUsageDisplayPeriod = (typeof KILO_USAGE_PERIODS)[number];

const DisplayPreferences = Type.Object({ showForOtherProviders: Type.Optional(Type.Boolean()) });
const FooterPreferences = Type.Object({ custom: Type.Optional(Type.Boolean()) });
const CreditsPreferences = Type.Object({ enabled: Type.Optional(Type.Boolean()) });
const UsagePreferences = Type.Object({
Expand All @@ -16,19 +17,22 @@ const UsagePreferences = Type.Object({
const PreferencesRoot = Type.Object({});

export type KiloPreferences = {
display?: Type.Static<typeof DisplayPreferences>;
footer?: Type.Static<typeof FooterPreferences>;
credits?: Type.Static<typeof CreditsPreferences>;
usage?: Type.Static<typeof UsagePreferences>;
};

export type ResolvedKiloPreferences = {
display: { showForOtherProviders: boolean };
footer: { custom: boolean };
credits: { enabled: boolean };
usage: { periods: KiloUsageDisplayPeriod[] };
};

const CONFIG_DIRECTORY = "kilo-pi-provider";
const DEFAULT_PREFERENCES: ResolvedKiloPreferences = {
display: { showForOtherProviders: false },
footer: { custom: true },
credits: { enabled: true },
usage: { periods: [] },
Expand All @@ -44,12 +48,18 @@ function readKiloPreferences(path: string): KiloPreferences {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!Value.Check(PreferencesRoot, parsed)) return {};

const display =
"display" in parsed && Value.Check(DisplayPreferences, parsed.display) ? parsed.display : undefined;
const footer = "footer" in parsed && Value.Check(FooterPreferences, parsed.footer) ? parsed.footer : undefined;
const credits =
"credits" in parsed && Value.Check(CreditsPreferences, parsed.credits) ? parsed.credits : undefined;
const usage = "usage" in parsed && Value.Check(UsagePreferences, parsed.usage) ? parsed.usage : undefined;

return {
display:
display?.showForOtherProviders === undefined
? undefined
: { showForOtherProviders: display.showForOtherProviders },
footer: footer?.custom === undefined ? undefined : { custom: footer.custom },
credits: credits?.enabled === undefined ? undefined : { enabled: credits.enabled },
usage: usage?.periods === undefined ? undefined : { periods: usage.periods },
Expand Down Expand Up @@ -87,6 +97,9 @@ function environmentUsagePeriods(value: string | undefined): KiloUsageDisplayPer

export function getEnvironmentPreferences(environment: NodeJS.ProcessEnv = process.env): KiloPreferences {
return {
display: {
showForOtherProviders: environmentBoolean(environment.KILO_PI_SHOW_FOR_OTHER_PROVIDERS, false),
},
footer: { custom: environmentBoolean(environment.KILO_PI_CUSTOM_FOOTER, true) },
credits: { enabled: environmentBoolean(environment.KILO_PI_SHOW_CREDITS, true) },
usage: { periods: environmentUsagePeriods(environment.KILO_PI_USAGE) },
Expand All @@ -99,6 +112,13 @@ export function mergeKiloPreferences(
environment: KiloPreferences,
): ResolvedKiloPreferences {
return {
display: {
showForOtherProviders:
environment.display?.showForOtherProviders ??
project?.display?.showForOtherProviders ??
global.display?.showForOtherProviders ??
DEFAULT_PREFERENCES.display.showForOtherProviders,
},
footer: {
custom:
environment.footer?.custom ??
Expand Down
96 changes: 72 additions & 24 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { isFreeModel, mapOpenRouterModel, type OpenRouterModel, parsePrice } fro

export { parsePrice };

import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
import type { ExtensionAPI, ExtensionContext, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
import {
fetchKiloBalance,
fetchKiloUsageEntries,
Expand Down Expand Up @@ -42,6 +42,13 @@ import { createUsageRefresher } from "./usage.ts";
const KILO_GATEWAY_BASE = `${KILO_API_BASE}/api/gateway`;
const MODELS_FETCH_TIMEOUT_MS = 10_000;
const KILO_TOS_URL = "https://kilo.ai/terms";
const KILO_STATUS_KEYS = [
"kilo-credits",
"kilo-usage-day",
"kilo-usage-week",
"kilo-usage-month",
"kilo-usage-year",
] as const;

function formatCredits(balance: number): string {
if (balance >= 1000) {
Expand Down Expand Up @@ -127,10 +134,46 @@ function makeProviderConfig(organizationId?: string) {
// Extension Entry Point
// =============================================================================

export default async function (pi: ExtensionAPI) {
export type KiloExtensionApi = Pick<ExtensionAPI, "getThinkingLevel" | "on" | "registerProvider">;

export default async function (pi: KiloExtensionApi) {
const startupAccess = getKiloAccess();
let preferences = loadKiloPreferences({ cwd: process.cwd(), projectTrusted: false });

let kiloFooterInstalled = false;
let ambientUiRevision = 0;
const shouldShowAmbientKiloUi = (provider: string | undefined): boolean =>
provider === "kilo" || preferences.display.showForOtherProviders;

const clearAmbientKiloStatuses = (ctx: ExtensionContext): void => {
for (const key of KILO_STATUS_KEYS) ctx.ui.setStatus(key, undefined);
};

const reconcileAmbientKiloUi = (ctx: ExtensionContext, provider: string | undefined): boolean => {
ambientUiRevision += 1;
const visible = shouldShowAmbientKiloUi(provider);
if (!ctx.hasUI) return visible;

if (visible && preferences.footer.custom && !kiloFooterInstalled) {
installCustomFooter(pi, ctx, preferences.credits.enabled);
kiloFooterInstalled = true;
} else if ((!visible || !preferences.footer.custom) && kiloFooterInstalled) {
ctx.ui.setFooter(undefined);
kiloFooterInstalled = false;
}

if (!visible) {
usageRefresher.invalidate();
clearAmbientKiloStatuses(ctx);
}
return visible;
};

const publishCredits = (ctx: ExtensionContext, balance: number, revision: number): void => {
if (revision !== ambientUiRevision || !shouldShowAmbientKiloUi(ctx.model?.provider)) return;
ctx.ui.setStatus("kilo-credits", ctx.ui.theme.fg("accent", `💰 ${formatCredits(balance)}`));
};

// Fetch models at load time so the provider is immediately usable for
// --list-models, --model selection, and print mode before session_start fires.
let freeModels: ProviderModelConfig[] = [];
Expand Down Expand Up @@ -223,14 +266,16 @@ export default async function (pi: ExtensionAPI) {
});
const access = getKiloAccess();
const usagePeriods = preferences.usage.periods;
const showAmbientUi = reconcileAmbientKiloUi(ctx, ctx.model?.provider);
const sessionStartRevision = ambientUiRevision;

// Clear a stale credit status after logout when an interactive UI is available.
if (!access) {
if (ctx.hasUI) ctx.ui.setStatus("kilo-credits", undefined);
return;
}

if (ctx.hasUI && usagePeriods.length > 0) {
if (showAmbientUi && ctx.hasUI && usagePeriods.length > 0) {
usageRefresher.refresh(access, usagePeriods, {
setStatus: (key, value) => ctx.ui.setStatus(key, value),
accent: (text) => ctx.ui.theme.fg("accent", text),
Expand Down Expand Up @@ -259,34 +304,41 @@ export default async function (pi: ExtensionAPI) {
}

// Fetch and display credits balance when enabled and an interactive UI is available.
if (ctx.hasUI && preferences.credits.enabled) {
const ambientUiIsCurrent =
sessionStartRevision === ambientUiRevision && shouldShowAmbientKiloUi(ctx.model?.provider);
if (showAmbientUi && ambientUiIsCurrent && ctx.hasUI && preferences.credits.enabled) {
const revision = ambientUiRevision;
try {
const balance = await fetchKiloBalance(access.token, access.organizationId);
if (balance !== null) {
const theme = ctx.ui.theme;
ctx.ui.setStatus("kilo-credits", theme.fg("accent", `💰 ${formatCredits(balance)}`));
}
if (balance !== null) publishCredits(ctx, balance, revision);
} catch (error) {
console.warn("[kilo] Failed to fetch balance:", error instanceof Error ? error.message : error);
}
}
});

// Update credits display when model changes to a Kilo model
// Reconcile and refresh ambient Kilo UI when the selected model changes.
pi.on("model_select", async (event, ctx) => {
if (event.model?.provider !== "kilo") return;
if (!reconcileAmbientKiloUi(ctx, event.model.provider)) return;
if (event.model.provider !== "kilo" && !preferences.display.showForOtherProviders) return;

const access = getKiloAccess();
if (!access) return;
if (!access || !ctx.hasUI) return;

const usagePeriods = preferences.usage.periods;
if (usagePeriods.length > 0) {
usageRefresher.refresh(access, usagePeriods, {
setStatus: (key, value) => ctx.ui.setStatus(key, value),
accent: (text) => ctx.ui.theme.fg("accent", text),
});
}

if (!ctx.hasUI || !preferences.credits.enabled) return;
if (!preferences.credits.enabled) return;

const revision = ambientUiRevision;
try {
const balance = await fetchKiloBalance(access.token, access.organizationId);
if (balance !== null) {
const theme = ctx.ui.theme;
ctx.ui.setStatus("kilo-credits", theme.fg("accent", `💰 ${formatCredits(balance)}`));
}
if (balance !== null) publishCredits(ctx, balance, revision);
} catch (error) {
console.warn(
"[kilo] Failed to fetch balance on model select:",
Expand All @@ -297,6 +349,8 @@ export default async function (pi: ExtensionAPI) {

// Refresh credits and opt-in usage after each turn.
pi.on("turn_end", async (_event, ctx) => {
if (!shouldShowAmbientKiloUi(ctx.model?.provider)) return;

const access = getKiloAccess();
const usagePeriods = preferences.usage.periods;
if (!access || !ctx.hasUI) return;
Expand All @@ -310,12 +364,10 @@ export default async function (pi: ExtensionAPI) {

if (!preferences.credits.enabled) return;

const revision = ambientUiRevision;
try {
const balance = await fetchKiloBalance(access.token, access.organizationId);
if (balance !== null) {
const theme = ctx.ui.theme;
ctx.ui.setStatus("kilo-credits", theme.fg("accent", `💰 ${formatCredits(balance)}`));
}
if (balance !== null) publishCredits(ctx, balance, revision);
} catch (error) {
console.warn("[kilo] Failed to fetch balance on turn end:", error instanceof Error ? error.message : error);
}
Expand Down Expand Up @@ -343,8 +395,4 @@ export default async function (pi: ExtensionAPI) {
},
};
});

pi.on("session_start", async (_event, ctx) => {
if (preferences.footer.custom) installCustomFooter(pi, ctx, preferences.credits.enabled);
});
}
4 changes: 4 additions & 0 deletions src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ export function createUsageRefresher(options: UsageRefresherOptions) {
}

return {
invalidate(): void {
revision += 1;
queued = undefined;
},
refresh(access: KiloAccess, periods: KiloUsageDisplayPeriod[], presentation: UsageStatusPresentation): void {
if (periods.length === 0) return;

Expand Down
Loading
Loading