feat(coding-agent): merge origin/main into model runtime facade

This commit is contained in:
Mario Zechner
2026-07-15 12:25:36 +02:00
119 changed files with 4275 additions and 631 deletions
@@ -165,6 +165,7 @@ export async function createAgentSessionServices(
}
}
extensionsResult.runtime.pendingProviderRegistrations = [];
await modelRuntime.refresh({ allowNetwork: false });
diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues));
return {
@@ -419,7 +419,7 @@ export class AgentSession {
throw new Error(formatNoApiKeyFoundMessage(model.provider));
}
private async _getCompactionRequestAuth(model: Model<any>): Promise<{
private async _getSummarizationRequestAuth(model: Model<any>): Promise<{
apiKey?: string;
headers?: Record<string, string>;
env?: Record<string, string>;
@@ -1779,7 +1779,7 @@ export class AgentSession {
throw new Error(formatNoModelSelectedMessage());
}
const { apiKey, headers, env } = await this._getCompactionRequestAuth(this.model);
const { apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model);
const pathEntries = this.sessionManager.getBranch();
const settings = this.settingsManager.getCompactionSettings();
@@ -2045,7 +2045,7 @@ export class AgentSession {
headers = withoutDeletedHeaders(authResult.auth.headers);
env = authResult.env;
} else {
({ apiKey, headers, env } = await this._getCompactionRequestAuth(this.model));
({ apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model));
}
const pathEntries = this.sessionManager.getBranch();
@@ -2914,7 +2914,7 @@ export class AgentSession {
let summaryDetails: unknown;
if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
const model = this.model!;
const { apiKey, headers, env } = await this._getRequiredRequestAuth(model);
const { apiKey, headers, env } = await this._getSummarizationRequestAuth(model);
const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
const result = await generateBranchSummary(entriesToSummarize, {
model,
@@ -66,7 +66,7 @@ export interface GenerateBranchSummaryOptions {
/** Model to use for summarization */
model: Model<any>;
/** API key for the model */
apiKey: string;
apiKey?: string;
/** Request headers for the model */
headers?: Record<string, string>;
/** Provider-scoped environment values for the model */
@@ -78,6 +78,7 @@ const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [
"app.tools.expand",
"app.thinking.toggle",
"app.editor.external",
"app.message.copy",
"app.message.followUp",
"tui.input.submit",
"tui.select.confirm",
@@ -627,6 +628,11 @@ export class ExtensionRunner {
this.shutdownHandler();
}
getActiveTools(): string[] {
this.assertActive();
return this.runtime.getActiveTools();
}
/**
* Create an ExtensionContext for use in event handlers and tool execution.
* Context values are resolved at call time, so changes via bindCore/bindUI are reflected.
@@ -1432,6 +1432,8 @@ export interface ProviderConfig {
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
/** Convert credentials to API key string for the provider. */
getApiKey(credentials: OAuthCredentials): string;
/** Legacy synchronous credential-dependent model projection. */
modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
};
}
@@ -6,7 +6,7 @@
*/
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { wrapToolDefinition, wrapToolDefinitions } from "../tools/tool-definition-wrapper.ts";
import { wrapToolDefinition } from "../tools/tool-definition-wrapper.ts";
import type { ExtensionRunner } from "./runner.ts";
import type { RegisteredTool } from "./types.ts";
@@ -15,7 +15,25 @@ import type { RegisteredTool } from "./types.ts";
* Uses the runner's createContext() for consistent context across tools and event handlers.
*/
export function wrapRegisteredTool(registeredTool: RegisteredTool, runner: ExtensionRunner): AgentTool {
return wrapToolDefinition(registeredTool.definition, () => runner.createContext());
const tool = wrapToolDefinition(registeredTool.definition, () => runner.createContext());
const execute = tool.execute;
return {
...tool,
execute: async (toolCallId, params, signal, onUpdate) => {
const activeBefore = runner.getActiveTools();
const result = await execute(toolCallId, params, signal, onUpdate);
const activeAfter = runner.getActiveTools();
if (!activeBefore.every((name) => activeAfter.includes(name))) return result;
const beforeNames = new Set(activeBefore);
const addedToolNames = activeAfter.filter((name) => !beforeNames.has(name));
if (addedToolNames.length === 0) return result;
return {
...result,
addedToolNames: [...new Set([...(result.addedToolNames ?? []), ...addedToolNames])],
};
},
};
}
/**
@@ -23,8 +41,5 @@ export function wrapRegisteredTool(registeredTool: RegisteredTool, runner: Exten
* Uses the runner's createContext() for consistent context across tools and event handlers.
*/
export function wrapRegisteredTools(registeredTools: RegisteredTool[], runner: ExtensionRunner): AgentTool[] {
return wrapToolDefinitions(
registeredTools.map((registeredTool) => registeredTool.definition),
() => runner.createContext(),
);
return registeredTools.map((tool) => wrapRegisteredTool(tool, runner));
}
@@ -23,6 +23,7 @@ export interface AppKeybindings {
"app.thinking.toggle": true;
"app.session.toggleNamedFilter": true;
"app.editor.external": true;
"app.message.copy": true;
"app.message.followUp": true;
"app.message.dequeue": true;
"app.clipboard.pasteImage": true;
@@ -95,6 +96,10 @@ export const KEYBINDINGS = {
defaultKeys: "ctrl+g",
description: "Open external editor",
},
"app.message.copy": {
defaultKeys: "ctrl+x",
description: "Copy message to clipboard",
},
"app.message.followUp": {
defaultKeys: "alt+enter",
description: "Queue follow-up message",
@@ -105,18 +110,18 @@ export const KEYBINDINGS = {
},
"app.clipboard.pasteImage": {
defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
description: "Paste image from clipboard",
description: "Paste image from clipboard (text fallback)",
},
"app.session.new": { defaultKeys: [], description: "Start a new session" },
"app.session.tree": { defaultKeys: [], description: "Open session tree" },
"app.session.fork": { defaultKeys: [], description: "Fork current session" },
"app.session.resume": { defaultKeys: [], description: "Resume a session" },
"app.tree.foldOrUp": {
defaultKeys: ["ctrl+left", "alt+left"],
defaultKeys: process.platform === "darwin" ? ["alt+left", "ctrl+left"] : ["ctrl+left", "alt+left"],
description: "Fold tree branch or move up",
},
"app.tree.unfoldOrDown": {
defaultKeys: ["ctrl+right", "alt+right"],
defaultKeys: process.platform === "darwin" ? ["alt+right", "ctrl+right"] : ["ctrl+right", "alt+right"],
description: "Unfold tree branch or move down",
},
"app.tree.editLabel": {
+10 -1
View File
@@ -98,13 +98,20 @@ const OpenAICompletionsCompatSchema = Type.Object({
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
supportsStrictMode: Type.Optional(Type.Boolean()),
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
sessionAffinityFormat: Type.Optional(
Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]),
),
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
});
const OpenAIResponsesCompatSchema = Type.Object({
supportsDeveloperRole: Type.Optional(Type.Boolean()),
sendSessionIdHeader: Type.Optional(Type.Boolean()),
sessionAffinityFormat: Type.Optional(
Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]),
),
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
supportsToolSearch: Type.Optional(Type.Boolean()),
});
const AnthropicMessagesCompatSchema = Type.Object({
@@ -113,6 +120,7 @@ const AnthropicMessagesCompatSchema = Type.Object({
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
supportsCacheControlOnTools: Type.Optional(Type.Boolean()),
forceAdaptiveThinking: Type.Optional(Type.Boolean()),
supportsToolReferences: Type.Optional(Type.Boolean()),
});
const ProviderCompatSchema = Type.Union([
@@ -176,6 +184,7 @@ const ProviderConfigSchema = Type.Object({
baseUrl: Type.Optional(Type.String({ minLength: 1 })),
apiKey: Type.Optional(Type.String({ minLength: 1 })),
api: Type.Optional(Type.String({ minLength: 1 })),
oauth: Type.Optional(Type.Literal("radius")),
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
compat: Type.Optional(ProviderCompatSchema),
authHeader: Type.Optional(Type.Boolean()),
@@ -18,6 +18,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
openai: "gpt-5.5",
"azure-openai-responses": "gpt-5.4",
"openai-codex": "gpt-5.5",
radius: "auto",
nvidia: "nvidia/nemotron-3-super-120b-a12b",
deepseek: "deepseek-v4-pro",
google: "gemini-3.1-pro-preview",
+92 -19
View File
@@ -1,4 +1,4 @@
import { join } from "node:path";
import { dirname, join } from "node:path";
import {
type Api,
type ApiStreamOptions,
@@ -18,7 +18,10 @@ import {
type Models,
type ModelsApiStreamOptions,
ModelsError,
type ModelsRefreshOptions,
type ModelsRefreshResult,
type ModelsSimpleStreamOptions,
type ModelsStore,
type ModelsStreamTransforms,
type MutableModels,
type Provider,
@@ -26,10 +29,11 @@ import {
type SimpleStreamOptions,
type StreamOptions,
} from "@earendil-works/pi-ai";
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
import * as builtinProviderCatalog from "@earendil-works/pi-ai/providers/all";
import { getAgentDir } from "../config.ts";
import { AuthStorage as DefaultAuthStorage } from "./auth-storage.ts";
import { ModelConfig } from "./model-config.ts";
import { FileModelsStore, InMemoryCodingAgentModelsStore } from "./models-store.ts";
import {
type AuthStatus,
type CompatibilityRequestConfig,
@@ -40,6 +44,7 @@ import {
resolveConfiguredModelHeaders,
validateExtensionProvider,
} from "./provider-composer.ts";
import { withRemoteCatalog } from "./remote-catalog-provider.ts";
import { RuntimeCredentials } from "./runtime-credentials.ts";
interface ModelRuntimeSnapshot {
@@ -55,6 +60,11 @@ export interface CreateModelRuntimeOptions {
credentials?: CredentialStore;
authPath?: string;
modelsPath?: string | null;
modelsStore?: ModelsStore;
modelsStorePath?: string;
allowModelNetwork?: boolean;
modelRefreshTimeoutMs?: number;
catalogBaseUrl?: string;
}
export interface ModelRuntimeAuthOverrides {
@@ -82,10 +92,12 @@ function mergeHeaders(
export class ModelRuntime implements Models {
private readonly models: MutableModels;
private readonly credentials: RuntimeCredentials;
private readonly builtins: ReadonlyMap<string, Provider>;
private readonly defaultBuiltins: ReadonlyMap<string, Provider>;
private readonly builtins = new Map<string, Provider>();
private readonly extensionProviders = new Map<string, ProviderConfigInput>();
private readonly compositionErrors = new Map<string, string>();
private readonly modelsPath: string | undefined;
private readonly allowModelNetwork: boolean;
private config: ModelConfig;
private snapshot: ModelRuntimeSnapshot = {
all: [],
@@ -101,13 +113,17 @@ export class ModelRuntime implements Models {
credentials: RuntimeCredentials,
config: ModelConfig,
modelsPath: string | undefined,
modelsStore: ModelsStore,
providers: readonly Provider[],
allowModelNetwork: boolean,
) {
this.credentials = credentials;
this.config = config;
this.modelsPath = modelsPath;
this.builtins = new Map(providers.map((provider) => [provider.id, provider]));
this.models = createModels({ credentials });
this.allowModelNetwork = allowModelNetwork;
this.defaultBuiltins = new Map(providers.map((provider) => [provider.id, provider]));
for (const [providerId, provider] of this.defaultBuiltins) this.builtins.set(providerId, provider);
this.models = createModels({ credentials, modelsStore });
this.rebuildProviders();
}
@@ -116,11 +132,55 @@ export class ModelRuntime implements Models {
const modelsPath =
options.modelsPath === null ? undefined : (options.modelsPath ?? join(getAgentDir(), "models.json"));
const config = await ModelConfig.load(modelsPath);
const runtime = new ModelRuntime(credentials, config, modelsPath, builtinProviders());
await runtime.refreshAvailability();
const modelsStore =
options.modelsStore ??
(modelsPath
? new FileModelsStore(options.modelsStorePath ?? join(dirname(modelsPath), "models-store.json"))
: new InMemoryCodingAgentModelsStore());
const providers = builtinProviderCatalog
.builtinProviders()
.map((provider) =>
provider.id === "radius" ? provider : withRemoteCatalog(provider, options.catalogBaseUrl),
);
const runtime = new ModelRuntime(
credentials,
config,
modelsPath,
modelsStore,
providers,
options.allowModelNetwork ?? process.env.PI_OFFLINE === undefined,
);
runtime.configureRadiusProviders();
runtime.rebuildProviders();
const controller = new AbortController();
const timeout = runtime.allowModelNetwork
? setTimeout(() => controller.abort(), options.modelRefreshTimeoutMs ?? 15_000)
: undefined;
try {
await runtime.refresh({ allowNetwork: runtime.allowModelNetwork, signal: controller.signal });
} finally {
if (timeout) clearTimeout(timeout);
}
return runtime;
}
private configureRadiusProviders(): void {
this.builtins.clear();
for (const [providerId, provider] of this.defaultBuiltins) this.builtins.set(providerId, provider);
for (const providerId of this.config.getProviderIds()) {
const config = this.config.getProvider(providerId);
if (config?.oauth !== "radius" || !config.baseUrl) continue;
this.builtins.set(
providerId,
builtinProviderCatalog.radiusProvider({
id: providerId,
name: config.name ?? providerId,
gateway: config.baseUrl.replace(/\/v1\/?$/u, ""),
}),
);
}
}
private providerIds(): Set<string> {
return new Set([...this.builtins.keys(), ...this.config.getProviderIds(), ...this.extensionProviders.keys()]);
}
@@ -319,7 +379,7 @@ export class ModelRuntime implements Models {
};
}
setRuntimeApiKey(providerId: string, apiKey: string): void {
async setRuntimeApiKey(providerId: string, apiKey: string): Promise<void> {
this.credentials.setRuntimeApiKey(providerId, apiKey);
const auth = new Map(this.snapshot.auth).set(providerId, { type: "api_key", source: "runtime API key" });
const configuredProviders = new Set(this.snapshot.configuredProviders).add(providerId);
@@ -331,12 +391,12 @@ export class ModelRuntime implements Models {
storedProviders,
available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)),
};
void this.forceRefreshAvailability().catch(() => {});
await this.refresh({ allowNetwork: this.allowModelNetwork });
}
removeRuntimeApiKey(providerId: string): void {
async removeRuntimeApiKey(providerId: string): Promise<void> {
this.credentials.removeRuntimeApiKey(providerId);
void this.forceRefreshAvailability().catch(() => {});
await this.refresh({ allowNetwork: this.allowModelNetwork });
}
listCredentials(): Promise<readonly CredentialInfo[]> {
@@ -422,25 +482,38 @@ export class ModelRuntime implements Models {
async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
const credential = await this.models.login(providerId, type, interaction);
await this.forceRefreshAvailability();
await this.refresh({ allowNetwork: this.allowModelNetwork });
return credential;
}
async logout(providerId: string): Promise<void> {
await this.models.logout(providerId);
await this.forceRefreshAvailability();
// Reset credential-dependent compatibility projections before the unconfigured provider is skipped by refresh.
this.recomposeProvider(providerId);
await this.refresh({ allowNetwork: this.allowModelNetwork });
}
async reloadConfig(): Promise<void> {
this.config = await ModelConfig.load(this.modelsPath);
this.configureRadiusProviders();
this.rebuildProviders();
await this.forceRefreshAvailability();
await this.refresh({ allowNetwork: this.allowModelNetwork });
}
async refresh(providerId?: string): Promise<void> {
await this.models.refresh(providerId);
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
// Published pi-ai builds before ModelsStore returned void and accepted a provider ID.
// The fallback keeps source-mode CLI tests working without rebuilding workspace dependencies.
const result = ((await this.models.refresh(options)) as ModelsRefreshResult | undefined) ?? {
aborted: options.signal?.aborted ?? false,
errors: new Map(),
};
this.updateModelSnapshot();
await this.forceRefreshAvailability();
try {
await this.forceRefreshAvailability();
} catch {
// Availability errors are recorded by forceRefreshAvailability; refreshed models remain usable.
}
return result;
}
registerProvider(providerId: string, config: ProviderConfigInput): void {
@@ -477,13 +550,13 @@ export class ModelRuntime implements Models {
available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)),
};
}
void this.forceRefreshAvailability().catch(() => {});
void this.refresh({ allowNetwork: false });
}
unregisterProvider(providerId: string): void {
this.extensionProviders.delete(providerId);
this.recomposeProvider(providerId);
this.updateModelSnapshot();
void this.forceRefreshAvailability().catch(() => {});
void this.refresh({ allowNetwork: false });
}
}
@@ -0,0 +1,57 @@
import { join } from "node:path";
import type { Api, Model, ModelsStore } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.ts";
import { type AuthStorageBackend, FileAuthStorageBackend } from "./auth-storage.ts";
type StoredModels = Record<string, Model<Api>[]>;
export class InMemoryCodingAgentModelsStore implements ModelsStore {
private readonly models = new Map<string, readonly Model<Api>[]>();
async read(providerId: string): Promise<readonly Model<Api>[] | undefined> {
return this.models.get(providerId);
}
async write(providerId: string, models: readonly Model<Api>[]): Promise<void> {
this.models.set(providerId, models);
}
async delete(providerId: string): Promise<void> {
this.models.delete(providerId);
}
}
/** Locked JSON-backed storage for dynamically refreshed provider catalogs. */
export class FileModelsStore implements ModelsStore {
private readonly storage: AuthStorageBackend;
constructor(path: string = join(getAgentDir(), "models-store.json")) {
this.storage = new FileAuthStorageBackend(path);
}
private parse(content: string | undefined): StoredModels {
return content ? (JSON.parse(content) as StoredModels) : {};
}
async read(providerId: string): Promise<readonly Model<Api>[] | undefined> {
return this.storage.withLock((content) => ({
result: this.parse(content)[providerId]?.map((model) => structuredClone(model)),
}));
}
async write(providerId: string, models: readonly Model<Api>[]): Promise<void> {
await this.storage.withLockAsync(async (content) => {
const current = this.parse(content);
current[providerId] = models.map((model) => structuredClone(model));
return { result: undefined, next: JSON.stringify(current, null, 2) };
});
}
async delete(providerId: string): Promise<void> {
await this.storage.withLockAsync(async (content) => {
const current = this.parse(content);
delete current[providerId];
return { result: undefined, next: JSON.stringify(current, null, 2) };
});
}
}
@@ -1805,11 +1805,16 @@ export class DefaultPackageManager implements PackageManager {
if (!existsSync(installRoot)) {
return;
}
if (this.getPackageManagerName() === "bun") {
const packageManagerName = this.getPackageManagerName();
if (packageManagerName === "bun") {
await this.runNpmCommand(["uninstall", source.name, "--cwd", installRoot]);
return;
}
await this.runNpmCommand(["uninstall", source.name, "--prefix", installRoot]);
const args = ["uninstall", source.name, "--prefix", installRoot];
if (packageManagerName !== "pnpm") {
args.push("--legacy-peer-deps");
}
await this.runNpmCommand(args);
}
private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
@@ -36,6 +36,7 @@ export interface ExtensionOAuthConfig {
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
getApiKey(credentials: OAuthCredentials): string;
modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
}
/** Input type for the extension registerProvider API. */
@@ -161,6 +162,9 @@ function applyModelsJson(
config: ModelsJsonProvider | undefined,
): Model<Api>[] {
if (!config) return [...baseModels];
if (config.oauth && !config.baseUrl) {
throw new Error(`Provider ${providerId}: "baseUrl" is required when "oauth" is set.`);
}
const hasOverrides = config.modelOverrides && Object.keys(config.modelOverrides).length > 0;
if (
!config.models?.length &&
@@ -169,6 +173,7 @@ function applyModelsJson(
!config.compat &&
!hasOverrides &&
!config.apiKey &&
!config.oauth &&
config.authHeader === undefined
) {
throw new Error(
@@ -178,7 +183,7 @@ function applyModelsJson(
const models: Model<Api>[] = baseModels.map((model) => ({
...model,
baseUrl: config.baseUrl ?? model.baseUrl,
baseUrl: config.oauth === "radius" ? model.baseUrl : (config.baseUrl ?? model.baseUrl),
compat: mergeCompat(model.compat, config.compat),
}));
for (const definition of config.models ?? []) {
@@ -409,15 +414,19 @@ export function composeModelProvider(
extension: ProviderConfigInput | undefined,
): Provider {
const config = modelConfig.getProvider(providerId);
let extensionOAuthCredential: OAuthCredentials | undefined;
// models.json modelOverrides are the topmost user-config layer: they apply once,
// after custom-model upserts and extension model replacement.
const getModels = () =>
applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], config), extension).map(
(model) => {
const override = config?.modelOverrides?.[model.id];
return override ? applyModelOverride(model, override) : model;
},
);
// after custom-model upserts, extension model replacement, and legacy OAuth projection.
const getModels = () => {
let models = applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], config), extension);
if (extensionOAuthCredential && extension?.oauth?.modifyModels) {
models = extension.oauth.modifyModels(models, extensionOAuthCredential);
}
return models.map((model) => {
const override = config?.modelOverrides?.[model.id];
return override ? applyModelOverride(model, override) : model;
});
};
// Validate eagerly so registration/reload reports structural errors immediately.
getModels();
const apiKey = composeApiKeyAuth(providerId, base, config, extension);
@@ -454,7 +463,13 @@ export function composeModelProvider(
headers: base?.headers,
auth: { ...(apiKey ? { apiKey } : {}), ...(oauth ? { oauth } : {}) },
getModels,
refreshModels: base?.refreshModels ? () => base.refreshModels!() : undefined,
refreshModels:
base?.refreshModels || extension?.oauth?.modifyModels
? async (context) => {
await base?.refreshModels?.(context);
extensionOAuthCredential = context.credential?.type === "oauth" ? context.credential : undefined;
}
: undefined,
filterModels: base?.filterModels
? (models, credential: Credential | undefined) => base.filterModels!(models, credential)
: undefined,
+1
View File
@@ -0,0 +1 @@
export const RADIUS_PROVIDER_ID = "radius";
@@ -0,0 +1,61 @@
import type { Api, Model, Provider } from "@earendil-works/pi-ai";
const DEFAULT_CATALOG_BASE_URL = "https://pi.dev";
function mergeModels(baseline: readonly Model<Api>[], dynamic: readonly Model<Api>[]): Model<Api>[] {
const merged = [...baseline];
for (const model of dynamic) {
const index = merged.findIndex((entry) => entry.id === model.id);
if (index >= 0) merged[index] = model;
else merged.push(model);
}
return merged;
}
function parseCatalog(providerId: string, value: unknown): Model<Api>[] {
const entries = Array.isArray(value)
? value
: typeof value === "object" && value !== null && "models" in value && Array.isArray(value.models)
? value.models
: undefined;
if (!entries) throw new Error(`Invalid model catalog for provider "${providerId}"`);
return entries
.filter((entry): entry is Model<Api> => typeof entry === "object" && entry !== null && "id" in entry)
.map((model) => ({ ...model, provider: providerId }));
}
/** Add a persisted pi.dev catalog overlay to a static built-in provider. */
export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL): Provider {
let dynamicModels: readonly Model<Api>[] = [];
let inflightRefresh: Promise<void> | undefined;
return {
...provider,
getModels: () => mergeModels(provider.getModels(), dynamicModels),
refreshModels: (context) => {
inflightRefresh ??= (async () => {
try {
const stored = await context.store.read();
if (stored) dynamicModels = stored.filter((model) => model.provider === provider.id);
if (!context.allowNetwork || context.signal?.aborted) return;
const url = new URL(`/api/models/providers/${encodeURIComponent(provider.id)}`, catalogBaseUrl);
const response = await fetch(url, {
headers: { accept: "application/json" },
signal: context.signal,
});
if (!response.ok) {
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
}
const refreshed = parseCatalog(provider.id, await response.json());
if (context.signal?.aborted) return;
dynamicModels = refreshed;
await context.store.write(refreshed);
} finally {
inflightRefresh = undefined;
}
})();
return inflightRefresh;
},
};
}
@@ -36,14 +36,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
contextFiles: providedContextFiles,
skills: providedSkills,
} = options;
const resolvedCwd = cwd;
const promptCwd = resolvedCwd.replace(/\\/g, "/");
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const date = `${year}-${month}-${day}`;
const promptCwd = cwd.replace(/\\/g, "/");
const appendSection = appendSystemPrompt ? `\n\n${appendSystemPrompt}` : "";
@@ -73,8 +66,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
prompt += formatSkillsForPrompt(skills);
}
// Add date and working directory last
prompt += `\nCurrent date: ${date}`;
prompt += `\nCurrent working directory: ${promptCwd}`;
return prompt;
@@ -165,8 +156,6 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi
prompt += formatSkillsForPrompt(skills);
}
// Add date and working directory last
prompt += `\nCurrent date: ${date}`;
prompt += `\nCurrent working directory: ${promptCwd}`;
return prompt;
+1 -1
View File
@@ -707,7 +707,7 @@ export async function main(args: string[], options?: MainOptions) {
message: "--api-key requires a model to be specified via --model, --provider/--model, or --models",
});
} else {
modelRuntime.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
await modelRuntime.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
await services.modelRuntime.getAvailable();
}
}
@@ -33,7 +33,7 @@ export class CustomEditor extends Editor {
return;
}
// Check for paste image keybinding
// Check for clipboard paste keybinding
if (this.keybindings.matches(data, "app.clipboard.pasteImage")) {
this.onPasteImage?.();
return;
@@ -175,6 +175,16 @@ export class LoginDialogComponent extends Container implements Focusable {
});
}
/** Show informational text before another login step. */
showDetails(lines: string[]): void {
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
for (const line of lines) {
this.contentContainer.addChild(new Text(line, 1, 0));
}
this.tui.requestRender();
}
/** Show provider-owned information and links without starting an auth callback flow. */
showInfo(message: string, links: readonly AuthInfoLink[] = [], showCloseHint = false): void {
this.contentContainer.addChild(new Spacer(1));
@@ -61,6 +61,9 @@ export class ModelSelectorComponent extends Container implements Focusable {
private scope: ModelScope = "all";
private scopeText?: Text;
private scopeHintText?: Text;
private readonly refreshAbortController = new AbortController();
private refreshTimeout?: ReturnType<typeof setTimeout>;
private closed = false;
constructor(
tui: TUI,
@@ -123,47 +126,20 @@ export class ModelSelectorComponent extends Container implements Focusable {
// Add bottom border
this.addChild(new DynamicBorder());
// Load models and do initial render
this.loadModels().then(() => {
if (initialSearchInput) {
this.filterModels(initialSearchInput);
} else {
this.updateList();
}
// Request re-render after models are loaded
this.tui.requestRender();
});
// Render the current snapshot immediately, then refresh in the background.
this.loadModelsFromSnapshot();
if (initialSearchInput) this.filterModels(initialSearchInput);
else this.updateList();
this.tui.requestRender();
void this.refreshModels();
}
private async loadModels(): Promise<void> {
let models: ModelItem[];
// Refresh to pick up any changes to models.json
await this.modelRuntime.refresh();
// Check for models.json errors
const loadError = this.modelRuntime.getError();
if (loadError) {
this.errorMessage = loadError;
}
// Load available models (built-in models still work even if models.json failed)
try {
const availableModels = await this.modelRuntime.getAvailable();
models = availableModels.map((model: Model<any>) => ({
provider: model.provider,
id: model.id,
model,
}));
} catch (error) {
this.allModels = [];
this.scopedModelItems = [];
this.activeModels = [];
this.filteredModels = [];
this.errorMessage = error instanceof Error ? error.message : String(error);
return;
}
private loadModelsFromSnapshot(): void {
const models = this.modelRuntime.getAvailableSnapshot().map((model: Model<any>) => ({
provider: model.provider,
id: model.id,
model,
}));
this.allModels = this.sortModels(models);
this.scopedModels = this.scopedModels.map((scoped) => {
const refreshed = this.modelRuntime.getModel(scoped.model.provider, scoped.model.id);
@@ -181,6 +157,37 @@ export class ModelSelectorComponent extends Container implements Focusable {
currentIndex >= 0 ? currentIndex : Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
}
private async refreshModels(): Promise<void> {
const timeoutMs = 15_000;
let timedOut = false;
this.refreshTimeout = setTimeout(() => {
timedOut = true;
this.refreshAbortController.abort();
}, timeoutMs);
try {
const result = await this.modelRuntime.refresh({ signal: this.refreshAbortController.signal });
if (this.closed) return;
if (result.aborted && timedOut) {
this.errorMessage = "Model refresh timed out; showing cached models.";
} else if (result.errors.size > 0) {
this.errorMessage = `Model refresh failed for: ${[...result.errors.keys()].join(", ")}`;
} else {
this.errorMessage = this.modelRuntime.getError();
}
this.loadModelsFromSnapshot();
this.filterModels(this.searchInput.getValue());
this.tui.requestRender();
} finally {
if (this.refreshTimeout) clearTimeout(this.refreshTimeout);
}
}
private close(): void {
this.closed = true;
if (this.refreshTimeout) clearTimeout(this.refreshTimeout);
this.refreshAbortController.abort();
}
private sortModels(models: ModelItem[]): ModelItem[] {
const sorted = [...models];
// Sort: current model first, then by provider
@@ -316,6 +323,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
}
// Escape or Ctrl+C
else if (kb.matches(keyData, "tui.select.cancel")) {
this.close();
this.onCancelCallback();
}
// Pass everything else to search input
@@ -326,6 +334,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
}
private handleSelect(model: Model<any>): void {
this.close();
// Save as new default
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
this.onSelectCallback(model);
@@ -122,6 +122,7 @@ class TreeList implements Component {
public onSelect?: (entryId: string) => void;
public onCancel?: () => void;
public onCopy?: (text: string | undefined) => void;
public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void;
constructor(
@@ -623,6 +624,11 @@ class TreeList implements Component {
return this.filteredNodes[this.selectedIndex]?.node;
}
copySelected(): void {
const node = this.getSelectedNode();
this.onCopy?.(node ? this.getEntryCopyText(node) : undefined);
}
updateNodeLabel(entryId: string, label: string | undefined, labelTimestamp?: string): void {
for (const flatNode of this.flatNodes) {
if (flatNode.node.entry.id === entryId) {
@@ -871,19 +877,49 @@ class TreeList implements Component {
}
private extractContent(content: unknown): string {
const maxLen = 200;
if (typeof content === "string") return content.slice(0, maxLen);
if (Array.isArray(content)) {
let result = "";
for (const c of content) {
if (typeof c === "object" && c !== null && "type" in c && c.type === "text") {
result += (c as { text: string }).text;
if (result.length >= maxLen) return result.slice(0, maxLen);
}
return this.extractFullContent(content).slice(0, 200);
}
private extractFullContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
let result = "";
for (const block of content) {
if (typeof block === "object" && block !== null && "type" in block && block.type === "text") {
result += (block as { text: string }).text;
}
return result;
}
return "";
return result;
}
private getEntryCopyText(node: SessionTreeNode): string | undefined {
const entry = node.entry;
let text: string | undefined;
switch (entry.type) {
case "message":
if (entry.message.role === "bashExecution") {
text = entry.message.command;
} else if ("content" in entry.message) {
text = this.extractFullContent(entry.message.content);
if (!text && entry.message.role === "assistant") {
text = entry.message.errorMessage;
}
}
break;
case "custom_message":
text = this.extractFullContent(entry.content);
break;
case "compaction":
text = entry.summary;
break;
case "branch_summary":
text = entry.summary;
break;
}
return text?.trim() ? text : undefined;
}
private hasTextContent(content: unknown): boolean {
@@ -990,6 +1026,8 @@ class TreeList implements Component {
if (selected && this.onSelect) {
this.onSelect(selected.node.entry.id);
}
} else if (kb.matches(keyData, "app.message.copy")) {
this.copySelected();
} else if (kb.matches(keyData, "tui.select.cancel")) {
if (this.searchQuery) {
this.searchQuery = "";
@@ -1180,6 +1218,7 @@ const TREE_HELP_ITEMS: Array<{ keys: Keybinding[]; label: string; labelFirst?: b
{ keys: ["tui.select.up", "tui.select.down"], label: "move" },
{ keys: ["tui.editor.cursorLeft", "tui.editor.cursorRight"], label: "page" },
{ keys: ["app.tree.foldOrUp", "app.tree.unfoldOrDown"], label: "branch" },
{ keys: ["app.message.copy"], label: "copy" },
{ keys: ["app.tree.editLabel"], label: "label" },
{ keys: ["app.tree.toggleLabelTimestamp"], label: "label time" },
{
@@ -1292,6 +1331,7 @@ export class TreeSelectorComponent extends Container implements Focusable {
private labelInputContainer: Container;
private treeContainer: Container;
private onLabelChangeCallback?: (entryId: string, label: string | undefined) => void;
public onCopy?: (text: string | undefined) => void;
// Focusable implementation - propagate to labelInput when active for IME cursor positioning
private _focused = false;
@@ -1324,6 +1364,7 @@ export class TreeSelectorComponent extends Container implements Focusable {
this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines, initialSelectedId, initialFilterMode);
this.treeList.onSelect = onSelect;
this.treeList.onCancel = onCancel;
this.treeList.onCopy = (text) => this.onCopy?.(text);
this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel);
this.treeContainer = new Container();
@@ -47,6 +47,7 @@ import {
getAgentDir,
getAuthPath,
getDebugLogPath,
getDocsPath,
getShareViewerUrl,
VERSION,
} from "../../config.ts";
@@ -86,7 +87,7 @@ import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
import type { TruncationResult } from "../../core/tools/truncate.ts";
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
import { copyToClipboard } from "../../utils/clipboard.ts";
import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
import { parseGitUrl } from "../../utils/git.ts";
import { getCwdRelativePath } from "../../utils/paths.ts";
@@ -745,7 +746,7 @@ export class InteractiveMode {
rawKeyHint("!!", "to run bash (no context)"),
hint("app.message.followUp", "to queue follow-up"),
hint("app.message.dequeue", "to edit all queued messages"),
hint("app.clipboard.pasteImage", "to paste image"),
hint("app.clipboard.pasteImage", "to paste image (with text fallback)"),
rawKeyHint("drop files", "to attach"),
].join("\n");
const compactInstructions = [
@@ -2558,6 +2559,7 @@ export class InteractiveMode {
this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion());
this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility());
this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor());
this.defaultEditor.onAction("app.message.copy", () => void this.handleCopyCommand());
this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp());
this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue());
this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand());
@@ -2573,29 +2575,33 @@ export class InteractiveMode {
}
};
// Handle clipboard image paste (triggered on Ctrl+V)
// Handle clipboard paste (triggered on Ctrl+V). Images are attached by path;
// otherwise, paste plain text from the system clipboard.
this.defaultEditor.onPasteImage = () => {
this.handleClipboardImagePaste();
void this.handleClipboardPaste();
};
}
private async handleClipboardImagePaste(): Promise<void> {
private async handleClipboardPaste(): Promise<void> {
try {
const image = await readClipboardImage();
if (!image) {
if (image) {
const tmpDir = os.tmpdir();
const ext = extensionForImageMimeType(image.mimeType) ?? "png";
const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`;
const filePath = path.join(tmpDir, fileName);
fs.writeFileSync(filePath, Buffer.from(image.bytes));
this.editor.insertTextAtCursor?.(filePath);
this.ui.requestRender();
return;
}
// Write to temp file
const tmpDir = os.tmpdir();
const ext = extensionForImageMimeType(image.mimeType) ?? "png";
const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`;
const filePath = path.join(tmpDir, fileName);
fs.writeFileSync(filePath, Buffer.from(image.bytes));
// Insert file path directly
this.editor.insertTextAtCursor?.(filePath);
this.ui.requestRender();
const text = await readClipboardText();
if (text) {
this.editor.insertTextAtCursor?.(text);
this.ui.requestRender();
}
} catch {
// Silently ignore clipboard errors (may not have permission, etc.)
}
@@ -4681,6 +4687,18 @@ export class InteractiveMode {
initialSelectedId,
initialFilterMode,
);
selector.onCopy = async (text) => {
if (!text) {
this.showError("Selected entry has no text to copy");
return;
}
try {
await copyToClipboard(text);
this.showStatus("Copied selected message to clipboard");
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
}
};
return { component: selector, focus: selector };
});
}
@@ -4851,8 +4869,8 @@ export class InteractiveMode {
}
private showLoginAuthTypeSelector(providerOptions?: AuthSelectorProvider[]): void {
const subscriptionLabel = "Use a subscription";
const apiKeyLabel = "Use an API key";
const subscriptionLabel = "Sign in with an account";
const apiKeyLabel = "Sign in with an API key";
const availableAuthTypes = providerOptions
? new Set(providerOptions.map((provider) => provider.authType))
: new Set<AuthSelectorProvider["authType"]>(["oauth", "api_key"]);
@@ -5083,6 +5101,14 @@ export class InteractiveMode {
providerName,
);
if (providerId === "amazon-bedrock") {
dialog.showDetails([
theme.fg("text", "You can also use an AWS profile, IAM keys, or role-based credentials."),
theme.fg("muted", "See:"),
theme.fg("accent", ` ${path.join(getDocsPath(), "providers.md")}`),
]);
}
this.editorContainer.clear();
this.editorContainer.addChild(dialog);
this.ui.setFocus(dialog);
@@ -5698,6 +5724,7 @@ export class InteractiveMode {
const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle");
const externalEditor = this.getAppKeyDisplay("app.editor.external");
const cycleModelBackward = this.getAppKeyDisplay("app.model.cycleBackward");
const copyMessage = this.getAppKeyDisplay("app.message.copy");
const followUp = this.getAppKeyDisplay("app.message.followUp");
const dequeue = this.getAppKeyDisplay("app.message.dequeue");
const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage");
@@ -5741,9 +5768,10 @@ export class InteractiveMode {
| \`${expandTools}\` | Toggle tool output expansion |
| \`${toggleThinking}\` | Toggle thinking block visibility |
| \`${externalEditor}\` | Edit message in external editor |
| \`${copyMessage}\` | Copy last assistant message |
| \`${followUp}\` | Queue follow-up message |
| \`${dequeue}\` | Restore queued messages |
| \`${pasteImage}\` | Paste image from clipboard |
| \`${pasteImage}\` | Paste image or text from clipboard |
| \`/\` | Slash commands |
| \`!\` | Run bash command |
| \`!!\` | Run bash command (excluded from context) |
@@ -3,6 +3,7 @@ import { dirname, join } from "path";
import { pathToFileURL } from "url";
export type ClipboardModule = {
getText: () => Promise<string>;
setText: (text: string) => Promise<void>;
hasImage: () => boolean;
getImageBinary: () => Promise<Array<number>>;
@@ -32,6 +32,20 @@ function emitOsc52(text: string): boolean {
return true;
}
/** Read plain text from the system clipboard, if native clipboard access is available. */
export async function readClipboardText(): Promise<string | null> {
if (!clipboard) {
return null;
}
try {
const text = await clipboard.getText();
return text || null;
} catch {
return null;
}
}
export async function copyToClipboard(text: string): Promise<void> {
let copied = false;