feat(coding-agent): merge origin/main into model runtime facade
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user