Merge main into model-registry

This commit is contained in:
Mario Zechner
2026-06-22 14:00:18 +02:00
220 changed files with 10488 additions and 4354 deletions
@@ -4,6 +4,10 @@
* Bun compiled binaries have an empty `process.env` when running inside
* sandbox environments (e.g. nono on Linux/macOS). On Linux we can recover
* the environment from `/proc/self/environ`.
*
* Keep this in sync with getBunSandboxEnvValue() in
* packages/ai/src/utils/provider-env.ts. The ai package duplicates the lookup
* for direct consumers that do not go through this coding-agent entrypoint.
*/
import { readFileSync } from "node:fs";
+1 -1
View File
@@ -229,7 +229,7 @@ ${chalk.bold("Commands:")}
${APP_NAME} install <source> [-l] Install extension source and add to settings
${APP_NAME} remove <source> [-l] Remove extension source from settings
${APP_NAME} uninstall <source> [-l] Alias for remove
${APP_NAME} update [source|self|pi] Update pi and installed extensions
${APP_NAME} update [source|self|pi] Update pi (use --all for pi and extensions)
${APP_NAME} list List installed extensions from settings
${APP_NAME} config Open TUI to enable/disable package resources
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list
+49 -15
View File
@@ -1,6 +1,6 @@
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
import { existsSync } from "fs";
import { ENV_AGENT_DIR, getSettingsPath } from "../config.ts";
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getSettingsPath, PACKAGE_NAME } from "../config.ts";
import { areExperimentalFeaturesEnabled } from "../core/experimental.ts";
import { KeybindingsManager } from "../core/keybindings.ts";
import type { SettingsManager } from "../core/settings-manager.ts";
@@ -10,7 +10,25 @@ import {
FirstTimeSetupComponent,
type FirstTimeSetupResult,
} from "../modes/interactive/components/first-time-setup.ts";
import { detectTerminalBackground, initTheme, setTheme } from "../modes/interactive/theme/theme.ts";
import { detectTerminalBackgroundTheme, initTheme, setTheme } from "../modes/interactive/theme/theme.ts";
const OFFICIAL_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
const OFFICIAL_APP_NAME = "pi";
const OFFICIAL_CONFIG_DIR_NAME = ".pi";
interface DistributionMetadata {
packageName: string;
appName: string;
configDirName: string;
}
function isOfficialDistribution({ packageName, appName, configDirName }: DistributionMetadata): boolean {
return (
packageName === OFFICIAL_PACKAGE_NAME &&
appName === OFFICIAL_APP_NAME &&
configDirName === OFFICIAL_CONFIG_DIR_NAME
);
}
function createStartupTui(settingsManager: SettingsManager): TUI {
initTheme(settingsManager.getTheme());
@@ -28,11 +46,21 @@ async function clearStartupTui(ui: TUI): Promise<void> {
/**
* First-time setup runs when all of these hold:
* - this is the official Pi distribution (not a fork/rebrand)
* - experimental features are enabled (PI_EXPERIMENTAL=1)
* - the default agent directory is used (no custom agent dir override)
* - setup was not completed before (settings.json does not exist)
*/
export function shouldRunFirstTimeSetup(settingsPath: string = getSettingsPath()): boolean {
if (
!isOfficialDistribution({
packageName: PACKAGE_NAME,
appName: APP_NAME,
configDirName: CONFIG_DIR_NAME,
})
) {
return false;
}
if (!areExperimentalFeaturesEnabled()) {
return false;
}
@@ -95,19 +123,25 @@ export async function showFirstTimeSetup(settingsManager: SettingsManager): Prom
resolve();
};
const component = new FirstTimeSetupComponent({
detectedTheme: detectTerminalBackground().theme,
onThemePreview: (themeName) => {
setTheme(themeName);
ui.invalidate();
ui.requestRender();
},
onSubmit: (result) => void finish(result),
onCancel: () => void finish(undefined),
});
ui.addChild(component);
ui.setFocus(component);
ui.start();
const showSetup = async () => {
ui.start();
const detection = await detectTerminalBackgroundTheme({ ui, timeoutMs: 100 });
setTheme(detection.theme);
const component = new FirstTimeSetupComponent({
detectedTheme: detection.theme,
onThemePreview: (themeName) => {
setTheme(themeName);
ui.requestRender();
},
onSubmit: (result) => void finish(result),
onCancel: () => void finish(undefined),
});
ui.addChild(component);
ui.setFocus(component);
ui.requestRender();
};
void showSetup();
});
}
+41 -17
View File
@@ -38,6 +38,18 @@ export interface SelfUpdateCommand extends SelfUpdateCommandStep {
steps?: SelfUpdateCommandStep[];
}
export type SelfUpdatePackageTarget = string | { packageName: string; installSpec?: string };
function normalizeSelfUpdatePackageTarget(target: SelfUpdatePackageTarget): {
packageName: string;
installSpec: string;
} {
if (typeof target === "string") {
return { packageName: target, installSpec: target };
}
return { packageName: target.packageName, installSpec: target.installSpec ?? target.packageName };
}
function makeSelfUpdateCommand(
installStep: SelfUpdateCommandStep,
uninstallStep?: SelfUpdateCommandStep,
@@ -103,29 +115,38 @@ function getInferredNpmInstall(): { root: string; prefix: string } | undefined {
function getSelfUpdateCommandForMethod(
method: InstallMethod,
installedPackageName: string,
updatePackageName = installedPackageName,
updatePackageTarget: SelfUpdatePackageTarget = installedPackageName,
npmCommand?: string[],
): SelfUpdateCommand | undefined {
const target = normalizeSelfUpdatePackageTarget(updatePackageTarget);
switch (method) {
case "bun-binary":
return undefined;
case "pnpm":
case "pnpm": {
const match = readCommandOutput("pnpm", ["root", "-g"])
? undefined
: /^(.*[\\/]global[\\/][^\\/]+)[\\/]\.pnpm[\\/]/.exec(getPackageDir());
const binDirArgs = match
? [`--config.global-bin-dir=${process.env.PNPM_HOME || dirname(dirname(match[1]))}`]
: [];
return makeSelfUpdateCommand(
makeSelfUpdateCommandStep("pnpm", [
"install",
"-g",
"--ignore-scripts",
"--config.minimumReleaseAge=0",
updatePackageName,
...binDirArgs,
target.installSpec,
]),
updatePackageName === installedPackageName
target.packageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("pnpm", ["remove", "-g", installedPackageName]),
: makeSelfUpdateCommandStep("pnpm", ["remove", "-g", ...binDirArgs, installedPackageName]),
);
}
case "yarn":
return makeSelfUpdateCommand(
makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", updatePackageName]),
updatePackageName === installedPackageName
makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", target.installSpec]),
target.packageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("yarn", ["global", "remove", installedPackageName]),
);
@@ -136,9 +157,9 @@ function getSelfUpdateCommandForMethod(
"-g",
"--ignore-scripts",
"--minimum-release-age=0",
updatePackageName,
target.installSpec,
]),
updatePackageName === installedPackageName
target.packageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep("bun", ["uninstall", "-g", installedPackageName]),
);
@@ -152,10 +173,10 @@ function getSelfUpdateCommandForMethod(
"-g",
"--ignore-scripts",
"--min-release-age=0",
updatePackageName,
target.installSpec,
]);
const uninstallStep =
updatePackageName === installedPackageName
target.packageName === installedPackageName
? undefined
: makeSelfUpdateCommandStep(command, [...prefixArgs, "uninstall", "-g", installedPackageName]);
return makeSelfUpdateCommand(installStep, uninstallStep);
@@ -205,7 +226,9 @@ function getGlobalPackageRoots(method: InstallMethod, _packageName: string, npmC
}
case "pnpm": {
const root = readCommandOutput("pnpm", ["root", "-g"]);
return root ? [root, dirname(root)] : [];
if (root) return [root, dirname(root)];
const match = /^(.*[\\/]global[\\/][^\\/]+)[\\/]\.pnpm[\\/]/.exec(getPackageDir());
return match ? [match[1]] : [];
}
case "yarn": {
const dir = readCommandOutput("yarn", ["global", "dir"]);
@@ -292,10 +315,10 @@ function isManagedByGlobalPackageManager(method: InstallMethod, packageName: str
export function getSelfUpdateCommand(
packageName: string,
npmCommand?: string[],
updatePackageName = packageName,
updatePackageTarget: SelfUpdatePackageTarget = packageName,
): SelfUpdateCommand | undefined {
const method = detectInstallMethod();
const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageName, npmCommand);
const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageTarget, npmCommand);
if (!command || !isManagedByGlobalPackageManager(method, packageName, npmCommand) || !isSelfUpdatePathWritable()) {
return undefined;
}
@@ -305,20 +328,21 @@ export function getSelfUpdateCommand(
export function getSelfUpdateUnavailableInstruction(
packageName: string,
npmCommand?: string[],
updatePackageName = packageName,
updatePackageTarget: SelfUpdatePackageTarget = packageName,
): string {
const method = detectInstallMethod();
const target = normalizeSelfUpdatePackageTarget(updatePackageTarget);
if (method === "bun-binary") {
return `Download from: https://github.com/earendil-works/pi-mono/releases/latest`;
}
const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageName, npmCommand);
const command = getSelfUpdateCommandForMethod(method, packageName, target, npmCommand);
if (command) {
if (isManagedByGlobalPackageManager(method, packageName, npmCommand) && !isSelfUpdatePathWritable()) {
return `This installation is managed by a global ${method} install, but the install path is not writable. Update it yourself with: ${command.display}`;
}
return `This installation is not managed by a global ${method} install. Update it with the package manager, wrapper, or source checkout that provides it.`;
}
return `Update ${updatePackageName} using the package manager, wrapper, or source checkout that provides this installation.`;
return `Update ${target.installSpec} using the package manager, wrapper, or source checkout that provides this installation.`;
}
export function getUpdateInstruction(packageName: string): string {
+68 -46
View File
@@ -33,7 +33,7 @@ import {
resetApiProviders,
streamSimple,
} from "@earendil-works/pi-ai/compat";
import { theme } from "../modes/interactive/theme/theme.ts";
import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts";
import { stripFrontmatter } from "../utils/frontmatter.ts";
import { resolvePath } from "../utils/paths.ts";
import { sleep } from "../utils/sleep.ts";
@@ -45,6 +45,7 @@ import {
collectEntriesForBranchSummary,
compact,
estimateContextTokens,
estimateTokens,
generateBranchSummary,
prepareCompaction,
shouldCompact,
@@ -242,6 +243,14 @@ interface ToolDefinitionEntry {
sourceInfo: SourceInfo;
}
function estimateMessagesTokens(messages: AgentMessage[]): number {
let tokens = 0;
for (const message of messages) {
tokens += estimateTokens(message);
}
return tokens;
}
// ============================================================================
// Constants
// ============================================================================
@@ -357,6 +366,7 @@ export class AgentSession {
private async _getRequiredRequestAuth(model: Model<any>): Promise<{
apiKey: string;
headers?: Record<string, string>;
env?: Record<string, string>;
}> {
const result = await this._modelRegistry.getApiKeyAndHeaders(model);
if (!result.ok) {
@@ -366,7 +376,7 @@ export class AgentSession {
throw new Error(result.error);
}
if (result.apiKey) {
return { apiKey: result.apiKey, headers: result.headers };
return { apiKey: result.apiKey, headers: result.headers, env: result.env };
}
const isOAuth = this._modelRegistry.isUsingOAuth(model);
@@ -383,13 +393,14 @@ export class AgentSession {
private async _getCompactionRequestAuth(model: Model<any>): Promise<{
apiKey?: string;
headers?: Record<string, string>;
env?: Record<string, string>;
}> {
if (this.agent.streamFn === streamSimple) {
return this._getRequiredRequestAuth(model);
}
const result = await this._modelRegistry.getApiKeyAndHeaders(model);
return result.ok ? { apiKey: result.apiKey, headers: result.headers } : {};
return result.ok ? { apiKey: result.apiKey, headers: result.headers, env: result.env } : {};
}
/**
@@ -1649,7 +1660,7 @@ export class AgentSession {
throw new Error(formatNoModelSelectedMessage());
}
const { apiKey, headers } = await this._getCompactionRequestAuth(this.model);
const { apiKey, headers, env } = await this._getCompactionRequestAuth(this.model);
const pathEntries = this.sessionManager.getBranch();
const settings = this.settingsManager.getCompactionSettings();
@@ -1673,6 +1684,8 @@ export class AgentSession {
preparation,
branchEntries: pathEntries,
customInstructions,
reason: "manual",
willRetry: false,
signal: this._compactionAbortController.signal,
})) as SessionBeforeCompactResult | undefined;
@@ -1708,6 +1721,7 @@ export class AgentSession {
this._compactionAbortController.signal,
this.thinkingLevel,
this.agent.streamFn,
env,
);
summary = result.summary;
firstKeptEntryId = result.firstKeptEntryId;
@@ -1723,6 +1737,7 @@ export class AgentSession {
const newEntries = this.sessionManager.getEntries();
const sessionContext = this.sessionManager.buildSessionContext();
this.agent.state.messages = sessionContext.messages;
const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
// Get the saved compaction entry for the extension event
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
@@ -1734,13 +1749,16 @@ export class AgentSession {
type: "session_compact",
compactionEntry: savedCompactionEntry,
fromExtension,
reason: "manual",
willRetry: false,
});
}
const compactionResult = {
const compactionResult: CompactionResult = {
summary,
firstKeptEntryId,
tokensBefore,
estimatedTokensAfter,
details,
};
this._emit({
@@ -1821,8 +1839,17 @@ export class AgentSession {
return false;
}
// Case 1: Overflow - LLM returned context overflow error
// Case 1: Overflow - LLM returned context overflow error, or reported usage exceeded
// the configured window. A successful response over the configured window should compact
// but must not retry: the assistant answer already completed and agent.continue() cannot
// continue from an assistant message.
if (sameModel && isContextOverflow(assistantMessage, contextWindow)) {
const willRetry = assistantMessage.stopReason !== "stop";
if (!willRetry) {
return await this._runAutoCompaction("overflow", false);
}
if (this._overflowRecoveryAttempted) {
this._emit({
type: "compaction_end",
@@ -1843,7 +1870,7 @@ export class AgentSession {
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
this.agent.state.messages = messages.slice(0, -1);
}
return await this._runAutoCompaction("overflow", true);
return await this._runAutoCompaction("overflow", willRetry);
}
// Case 2: Threshold - context is getting large
@@ -1880,56 +1907,39 @@ export class AgentSession {
*/
private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<boolean> {
const settings = this.settingsManager.getCompactionSettings();
this._emit({ type: "compaction_start", reason });
this._autoCompactionAbortController = new AbortController();
let started = false;
try {
if (!this.model) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
});
return false;
}
let apiKey: string | undefined;
let headers: Record<string, string> | undefined;
let env: Record<string, string> | undefined;
if (this.agent.streamFn === streamSimple) {
const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model);
if (!authResult.ok || !authResult.apiKey) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
});
return false;
}
apiKey = authResult.apiKey;
headers = authResult.headers;
env = authResult.env;
} else {
({ apiKey, headers } = await this._getCompactionRequestAuth(this.model));
({ apiKey, headers, env } = await this._getCompactionRequestAuth(this.model));
}
const pathEntries = this.sessionManager.getBranch();
const preparation = prepareCompaction(pathEntries, settings);
if (!preparation) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
});
return false;
}
this._emit({ type: "compaction_start", reason });
this._autoCompactionAbortController = new AbortController();
started = true;
let extensionCompaction: CompactionResult | undefined;
let fromExtension = false;
@@ -1939,6 +1949,8 @@ export class AgentSession {
preparation,
branchEntries: pathEntries,
customInstructions: undefined,
reason,
willRetry,
signal: this._autoCompactionAbortController.signal,
})) as SessionBeforeCompactResult | undefined;
@@ -1981,6 +1993,7 @@ export class AgentSession {
this._autoCompactionAbortController.signal,
this.thinkingLevel,
this.agent.streamFn,
env,
);
summary = compactResult.summary;
firstKeptEntryId = compactResult.firstKeptEntryId;
@@ -2003,6 +2016,7 @@ export class AgentSession {
const newEntries = this.sessionManager.getEntries();
const sessionContext = this.sessionManager.buildSessionContext();
this.agent.state.messages = sessionContext.messages;
const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
// Get the saved compaction entry for the extension event
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as
@@ -2014,6 +2028,8 @@ export class AgentSession {
type: "session_compact",
compactionEntry: savedCompactionEntry,
fromExtension,
reason,
willRetry,
});
}
@@ -2021,6 +2037,7 @@ export class AgentSession {
summary,
firstKeptEntryId,
tokensBefore,
estimatedTokensAfter,
details,
};
this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry });
@@ -2039,17 +2056,19 @@ export class AgentSession {
return this.agent.hasQueuedMessages();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "compaction failed";
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
errorMessage:
reason === "overflow"
? `Context overflow recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`,
});
if (started) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
errorMessage:
reason === "overflow"
? `Context overflow recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`,
});
}
return false;
} finally {
this._autoCompactionAbortController = undefined;
@@ -2432,7 +2451,7 @@ export class AgentSession {
});
}
async reload(): Promise<void> {
async reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void> {
const previousFlagValues = this._extensionRunner.getFlagValues();
await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });
await this.settingsManager.reload();
@@ -2451,6 +2470,7 @@ export class AgentSession {
this._extensionShutdownHandler ||
this._extensionErrorListener;
if (hasBindings) {
await options?.beforeSessionStart?.();
await this._extensionRunner.emit({ type: "session_start", reason: "reload" });
await this.extendResourcesFromExtensions("reload");
}
@@ -2784,12 +2804,13 @@ export class AgentSession {
let summaryDetails: unknown;
if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
const model = this.model!;
const { apiKey, headers } = await this._getRequiredRequestAuth(model);
const { apiKey, headers, env } = await this._getRequiredRequestAuth(model);
const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
const result = await generateBranchSummary(entriesToSummarize, {
model,
apiKey,
headers,
env,
signal: this._branchSummaryAbortController.signal,
customInstructions,
replaceInstructions,
@@ -3017,7 +3038,8 @@ export class AgentSession {
* @returns Path to exported file
*/
async exportToHtml(outputPath?: string): Promise<string> {
const themeName = this.settingsManager.getTheme();
const configuredThemeName = this.settingsManager.getTheme();
const themeName = configuredThemeName && getThemeByName(configuredThemeName) ? configuredThemeName : undefined;
// Create tool renderer if we have an extension runner (for custom tool HTML rendering)
const toolRenderer: ToolHtmlRenderer = createToolHtmlRenderer({
+17 -2
View File
@@ -24,6 +24,7 @@ import { resolveConfigValue } from "./resolve-config-value.ts";
export type ApiKeyCredential = {
type: "api_key";
key: string;
env?: Record<string, string>;
};
export type OAuthCredential = {
@@ -40,6 +41,10 @@ export type AuthStatus = {
label?: string;
};
export interface GetApiKeyOptions {
includeFallback?: boolean;
}
type LockResult<T> = {
result: T;
next?: string;
@@ -294,6 +299,14 @@ export class AuthStorage {
return this.data[provider] ?? undefined;
}
/**
* Get provider-scoped environment values for an API key credential.
*/
getProviderEnv(provider: string): Record<string, string> | undefined {
const cred = this.data[provider];
return cred?.type === "api_key" && cred.env ? { ...cred.env } : undefined;
}
/**
* Set credential for a provider.
*/
@@ -446,7 +459,7 @@ export class AuthStorage {
* 3. OAuth token from auth.json (auto-refreshed with locking)
* 4. Environment variable
*/
async getApiKey(providerId: string): Promise<string | undefined> {
async getApiKey(providerId: string, options: GetApiKeyOptions = {}): Promise<string | undefined> {
// Runtime override takes highest priority
const runtimeKey = this.runtimeOverrides.get(providerId);
if (runtimeKey) {
@@ -456,7 +469,7 @@ export class AuthStorage {
const cred = this.data[providerId];
if (cred?.type === "api_key") {
return resolveConfigValue(cred.key);
return resolveConfigValue(cred.key, cred.env);
}
if (cred?.type === "oauth") {
@@ -497,6 +510,8 @@ export class AuthStorage {
}
}
if (options.includeFallback === false) return undefined;
// Fall back to environment variable
const envKey = getEnvApiKey(providerId);
if (envKey) return envKey;
@@ -69,6 +69,8 @@ export interface GenerateBranchSummaryOptions {
apiKey: string;
/** Request headers for the model */
headers?: Record<string, string>;
/** Provider-scoped environment values for the model */
env?: Record<string, string>;
/** Abort signal for cancellation */
signal: AbortSignal;
/** Optional custom instructions for summarization */
@@ -290,6 +292,7 @@ export async function generateBranchSummary(
model,
apiKey,
headers,
env,
signal,
customInstructions,
replaceInstructions,
@@ -335,7 +338,7 @@ export async function generateBranchSummary(
// request behavior (timeouts, retries, attribution headers) stays consistent
// without running through agent state/events.
const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages };
const requestOptions: SimpleStreamOptions = { apiKey, headers, signal, maxTokens: 2048 };
const requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 };
const response = streamFn
? await (await streamFn(model, context, requestOptions)).result()
: await completeSimple(model, context, requestOptions);
@@ -104,6 +104,7 @@ export interface CompactionResult<T = unknown> {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
estimatedTokensAfter?: number;
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
details?: T;
}
@@ -528,10 +529,11 @@ function createSummarizationOptions(
maxTokens: number,
apiKey: string | undefined,
headers: Record<string, string> | undefined,
env: Record<string, string> | undefined,
signal: AbortSignal | undefined,
thinkingLevel: ThinkingLevel | undefined,
): SimpleStreamOptions {
const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers };
const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env };
if (model.reasoning && thinkingLevel && thinkingLevel !== "off") {
options.reasoning = thinkingLevel;
}
@@ -566,6 +568,7 @@ export async function generateSummary(
previousSummary?: string,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
env?: Record<string, string>,
): Promise<string> {
const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens),
@@ -598,7 +601,7 @@ export async function generateSummary(
},
];
const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel);
const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel);
const response = await completeSummarization(
model,
@@ -696,6 +699,10 @@ export function prepareCompaction(
}
}
if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
return undefined;
}
// Extract file operations from messages and previous compaction
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
@@ -753,6 +760,7 @@ export async function compact(
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
env?: Record<string, string>,
): Promise<CompactionResult> {
const {
firstKeptEntryId,
@@ -783,6 +791,7 @@ export async function compact(
previousSummary,
thinkingLevel,
streamFn,
env,
)
: Promise.resolve("No prior history."),
generateTurnPrefixSummary(
@@ -791,6 +800,7 @@ export async function compact(
settings.reserveTokens,
apiKey,
headers,
env,
signal,
thinkingLevel,
streamFn,
@@ -811,6 +821,7 @@ export async function compact(
previousSummary,
thinkingLevel,
streamFn,
env,
);
}
@@ -839,6 +850,7 @@ async function generateTurnPrefixSummary(
reserveTokens: number,
apiKey: string | undefined,
headers?: Record<string, string>,
env?: Record<string, string>,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
@@ -861,7 +873,7 @@ async function generateTurnPrefixSummary(
const response = await completeSummarization(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel),
createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel),
streamFn,
);
File diff suppressed because one or more lines are too long
@@ -127,6 +127,30 @@ function getAliases(): Record<string, string> {
type HandlerFn = (...args: unknown[]) => Promise<unknown>;
let extensionCacheCwd: string | undefined;
let extensionCacheGeneration = 0;
const extensionCache = new Map<string, ExtensionFactory>();
interface ExtensionCacheToken {
cwd: string;
generation: number;
}
export function clearExtensionCache(): void {
extensionCache.clear();
extensionCacheCwd = undefined;
extensionCacheGeneration++;
}
function useExtensionCacheCwd(cwd: string): ExtensionCacheToken {
const resolvedCwd = resolvePath(cwd);
if (extensionCacheCwd !== undefined && extensionCacheCwd !== resolvedCwd) {
clearExtensionCache();
}
extensionCacheCwd = resolvedCwd;
return { cwd: resolvedCwd, generation: extensionCacheGeneration };
}
/**
* Create a runtime with throwing stubs for action methods.
* Runner.bindCore() replaces these with real implementations.
@@ -338,7 +362,22 @@ function createExtensionAPI(
return api;
}
async function loadExtensionModule(extensionPath: string) {
function isCurrentCacheToken(cacheToken: ExtensionCacheToken | undefined): cacheToken is ExtensionCacheToken {
return (
cacheToken !== undefined &&
extensionCacheCwd === cacheToken.cwd &&
extensionCacheGeneration === cacheToken.generation
);
}
async function loadExtensionModule(extensionPath: string, cacheToken?: ExtensionCacheToken) {
if (isCurrentCacheToken(cacheToken)) {
const cachedFactory = extensionCache.get(extensionPath);
if (cachedFactory) {
return cachedFactory;
}
}
const jiti = createJiti(import.meta.url, {
moduleCache: false,
// In Bun binary: use virtualModules for bundled packages (no filesystem resolution)
@@ -349,7 +388,13 @@ async function loadExtensionModule(extensionPath: string) {
const module = await jiti.import(extensionPath, { default: true });
const factory = module as ExtensionFactory;
return typeof factory !== "function" ? undefined : factory;
if (typeof factory !== "function") {
return undefined;
}
if (isCurrentCacheToken(cacheToken)) {
extensionCache.set(extensionPath, factory);
}
return factory;
}
/**
@@ -380,11 +425,12 @@ async function loadExtension(
cwd: string,
eventBus: EventBus,
runtime: ExtensionRuntime,
cacheToken?: ExtensionCacheToken,
): Promise<{ extension: Extension | null; error: string | null }> {
const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true });
try {
const factory = await loadExtensionModule(resolvedPath);
const factory = await loadExtensionModule(resolvedPath, cacheToken);
if (!factory) {
return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` };
}
@@ -420,20 +466,28 @@ export async function loadExtensionFromFactory(
/**
* Load extensions from paths.
*/
export async function loadExtensions(
async function loadExtensionsInternal(
paths: string[],
cwd: string,
eventBus?: EventBus,
runtime?: ExtensionRuntime,
useCache = false,
): Promise<LoadExtensionsResult> {
const extensions: Extension[] = [];
const errors: Array<{ path: string; error: string }> = [];
const resolvedCwd = resolvePath(cwd);
const cacheToken = useCache ? useExtensionCacheCwd(cwd) : undefined;
const resolvedCwd = cacheToken?.cwd ?? resolvePath(cwd);
const resolvedEventBus = eventBus ?? createEventBus();
const resolvedRuntime = runtime ?? createExtensionRuntime();
for (const extPath of paths) {
const { extension, error } = await loadExtension(extPath, resolvedCwd, resolvedEventBus, resolvedRuntime);
const { extension, error } = await loadExtension(
extPath,
resolvedCwd,
resolvedEventBus,
resolvedRuntime,
cacheToken,
);
if (error) {
errors.push({ path: extPath, error });
@@ -452,6 +506,24 @@ export async function loadExtensions(
};
}
export async function loadExtensions(
paths: string[],
cwd: string,
eventBus?: EventBus,
runtime?: ExtensionRuntime,
): Promise<LoadExtensionsResult> {
return loadExtensionsInternal(paths, cwd, eventBus, runtime);
}
export async function loadExtensionsCached(
paths: string[],
cwd: string,
eventBus?: EventBus,
runtime?: ExtensionRuntime,
): Promise<LoadExtensionsResult> {
return loadExtensionsInternal(paths, cwd, eventBus, runtime, true);
}
interface PiManifest {
extensions?: string[];
themes?: string[];
@@ -571,6 +571,10 @@ export interface SessionBeforeCompactEvent {
preparation: CompactionPreparation;
branchEntries: SessionEntry[];
customInstructions?: string;
/** What triggered the compaction: manual /compact, the context threshold, or context overflow recovery */
reason: "manual" | "threshold" | "overflow";
/** True when the aborted turn is retried after this compaction (overflow recovery) */
willRetry: boolean;
signal: AbortSignal;
}
@@ -579,6 +583,10 @@ export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
fromExtension: boolean;
/** What triggered the compaction: manual /compact, the context threshold, or context overflow recovery */
reason: "manual" | "threshold" | "overflow";
/** True when the aborted turn is retried after this compaction (overflow recovery) */
willRetry: boolean;
}
/** Fired before an extension runtime is torn down due to quit, reload, or session replacement. */
@@ -10,6 +10,9 @@ export const HTTP_IDLE_TIMEOUT_CHOICES = [
{ label: "disabled", timeoutMs: 0 },
] as const;
const originalGlobalFetch = globalThis.fetch;
let installedGlobalFetch: typeof globalThis.fetch | undefined;
export function parseHttpIdleTimeoutMs(value: unknown): number | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
@@ -36,6 +39,13 @@ export function formatHttpIdleTimeoutMs(timeoutMs: number): string {
return `${timeoutMs / 1000} sec`;
}
export function applyHttpProxySettings(httpProxy: string | undefined): void {
const proxy = httpProxy?.trim();
if (!proxy) return;
process.env.HTTP_PROXY ??= proxy;
process.env.HTTPS_PROXY ??= proxy;
}
export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void {
const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs);
if (normalizedTimeoutMs === undefined) {
@@ -51,5 +61,13 @@ export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TI
// Keep fetch and the dispatcher on the same undici implementation. Node 26.0's
// bundled fetch can otherwise consume compressed responses through npm undici's
// dispatcher without decompressing them, causing response.json() failures.
undici.install?.();
// If a caller replaced fetch after module load, preserve that deliberate override.
const shouldInstallGlobals =
installedGlobalFetch === undefined
? globalThis.fetch === originalGlobalFetch
: globalThis.fetch === installedGlobalFetch;
if (shouldInstallGlobals) {
undici.install?.();
installedGlobalFetch = globalThis.fetch;
}
}
@@ -25,7 +25,6 @@ import { type Static, Type } from "typebox";
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { getAgentDir } from "../config.ts";
import { warnDeprecation } from "../utils/deprecation.ts";
import { stripJsonComments } from "../utils/json.ts";
import { normalizePath } from "../utils/paths.ts";
import type { AuthStatus, AuthStorage } from "./auth-storage.ts";
@@ -35,7 +34,6 @@ import {
getConfigValueEnvVarNames,
isCommandConfigValue,
isConfigValueConfigured,
isLegacyEnvVarNameConfigValue,
resolveConfigValueOrThrow,
resolveConfigValueUncached,
resolveHeadersOrThrow,
@@ -98,6 +96,13 @@ const ThinkingLevelMapSchema = Type.Object({
xhigh: Type.Optional(ThinkingLevelMapValueSchema),
});
const ChatTemplateKwargScalarSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]);
const ChatTemplateKwargVariableSchema = Type.Object({
$var: Type.Union([Type.Literal("thinking.enabled"), Type.Literal("thinking.effort")]),
omitWhenOff: Type.Optional(Type.Boolean()),
});
const ChatTemplateKwargSchema = Type.Union([ChatTemplateKwargScalarSchema, ChatTemplateKwargVariableSchema]);
const OpenAICompletionsCompatSchema = Type.Object({
supportsStore: Type.Optional(Type.Boolean()),
supportsDeveloperRole: Type.Optional(Type.Boolean()),
@@ -116,9 +121,13 @@ const OpenAICompletionsCompatSchema = Type.Object({
Type.Literal("deepseek"),
Type.Literal("zai"),
Type.Literal("qwen"),
Type.Literal("chat-template"),
Type.Literal("qwen-chat-template"),
Type.Literal("string-thinking"),
Type.Literal("ant-ling"),
]),
),
chatTemplateKwargs: Type.Optional(Type.Record(Type.String(), ChatTemplateKwargSchema)),
cacheControlFormat: Type.Optional(Type.Literal("anthropic")),
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
@@ -237,82 +246,12 @@ interface ProviderRequestConfig {
authHeader?: boolean;
}
function migrateLegacyRegisterProviderConfigValue(providerName: string, field: string, value: string): string {
if (!isLegacyEnvVarNameConfigValue(value)) return value;
warnDeprecation(
`registerProvider("${providerName}") ${field} value "${value}" is treated as a legacy environment variable reference. This will no longer be detected as an environment variable reference in a future release. Pass "$${value}" instead.`,
);
return `$${value}`;
}
function migrateLegacyRegisterProviderHeaders(
providerName: string,
field: string,
headers: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!headers) return undefined;
let migratedHeaders: Record<string, string> | undefined;
for (const [key, value] of Object.entries(headers)) {
const migratedValue = migrateLegacyRegisterProviderConfigValue(providerName, `${field} header "${key}"`, value);
if (migratedValue === value) continue;
migratedHeaders ??= { ...headers };
migratedHeaders[key] = migratedValue;
}
return migratedHeaders ?? headers;
}
function migrateLegacyRegisterProviderConfigValues(
providerName: string,
config: ProviderConfigInput,
): ProviderConfigInput {
let migratedConfig: ProviderConfigInput | undefined;
const setMigratedConfigValue = <TKey extends keyof ProviderConfigInput>(
key: TKey,
value: ProviderConfigInput[TKey],
) => {
migratedConfig ??= { ...config };
migratedConfig[key] = value;
};
if (config.apiKey) {
const apiKey = migrateLegacyRegisterProviderConfigValue(providerName, "apiKey", config.apiKey);
if (apiKey !== config.apiKey) {
setMigratedConfigValue("apiKey", apiKey);
}
}
const headers = migrateLegacyRegisterProviderHeaders(providerName, "headers", config.headers);
if (headers !== config.headers) {
setMigratedConfigValue("headers", headers);
}
if (config.models) {
let models: ProviderConfigInput["models"] | undefined;
for (let index = 0; index < config.models.length; index++) {
const model = config.models[index];
const modelHeaders = migrateLegacyRegisterProviderHeaders(
providerName,
`model "${model.id}" headers`,
model.headers,
);
if (modelHeaders === model.headers) continue;
models ??= [...config.models];
models[index] = { ...model, headers: modelHeaders };
}
if (models) {
setMigratedConfigValue("models", models);
}
}
return migratedConfig ?? config;
}
export type ResolvedRequestAuth =
| {
ok: true;
apiKey?: string;
headers?: Record<string, string>;
env?: Record<string, string>;
}
| {
ok: false;
@@ -361,6 +300,13 @@ function mergeCompat(
};
}
if (baseCompletions?.chatTemplateKwargs || overrideCompletions.chatTemplateKwargs) {
mergedCompletions.chatTemplateKwargs = {
...baseCompletions?.chatTemplateKwargs,
...overrideCompletions.chatTemplateKwargs,
};
}
return merged as Model<Api>["compat"];
}
@@ -757,17 +703,27 @@ export class ModelRegistry {
async getApiKeyAndHeaders(model: Model<Api>): Promise<ResolvedRequestAuth> {
try {
const providerConfig = this.providerRequestConfigs.get(model.provider);
const apiKeyFromAuthStorage = await this.authStorage.getApiKey(model.provider);
const providerEnv = this.authStorage.getProviderEnv(model.provider);
const apiKeyFromAuthStorage = await this.authStorage.getApiKey(model.provider, { includeFallback: false });
const apiKey =
apiKeyFromAuthStorage ??
(providerConfig?.apiKey
? resolveConfigValueOrThrow(providerConfig.apiKey, `API key for provider "${model.provider}"`)
? resolveConfigValueOrThrow(
providerConfig.apiKey,
`API key for provider "${model.provider}"`,
providerEnv,
)
: undefined);
const providerHeaders = resolveHeadersOrThrow(providerConfig?.headers, `provider "${model.provider}"`);
const providerHeaders = resolveHeadersOrThrow(
providerConfig?.headers,
`provider "${model.provider}"`,
providerEnv,
);
const modelHeaders = resolveHeadersOrThrow(
this.modelRequestHeaders.get(this.getModelRequestKey(model.provider, model.id)),
`model "${model.provider}/${model.id}"`,
providerEnv,
);
let headers =
@@ -786,6 +742,7 @@ export class ModelRegistry {
ok: true,
apiKey,
headers: headers && Object.keys(headers).length > 0 ? headers : undefined,
env: providerEnv && Object.keys(providerEnv).length > 0 ? providerEnv : undefined,
};
} catch (error) {
return {
@@ -850,7 +807,9 @@ export class ModelRegistry {
}
const providerApiKey = this.providerRequestConfigs.get(provider)?.apiKey;
return providerApiKey ? resolveConfigValueUncached(providerApiKey) : undefined;
return providerApiKey
? resolveConfigValueUncached(providerApiKey, this.authStorage.getProviderEnv(provider))
: undefined;
}
/**
@@ -869,10 +828,9 @@ export class ModelRegistry {
* If provider has oauth: registers OAuth provider for /login support.
*/
registerProvider(providerName: string, config: ProviderConfigInput): void {
const migratedConfig = migrateLegacyRegisterProviderConfigValues(providerName, config);
this.validateProviderConfig(providerName, migratedConfig);
this.applyProviderConfig(providerName, migratedConfig);
this.upsertRegisteredProvider(providerName, migratedConfig);
this.validateProviderConfig(providerName, config);
this.applyProviderConfig(providerName, config);
this.upsertRegisteredProvider(providerName, config);
}
/**
@@ -340,7 +340,7 @@ export interface ResolveCliModelResult {
export function resolveCliModel(options: {
cliProvider?: string;
cliModel?: string;
cliThinking?: string;
cliThinking?: ThinkingLevel;
modelRegistry: ModelRegistry;
}): ResolveCliModelResult {
const { cliProvider, cliModel, cliThinking, modelRegistry } = options;
@@ -422,6 +422,27 @@ export function resolveCliModel(options: {
});
if (model) {
// If provider inference matched an unauthenticated provider/model pair, prefer
// one exact raw model-id match that is authenticated. This keeps
// "provider/model" syntax preferred when usable, but handles models whose
// literal id starts with a known provider name (for example
// commandcode model id "xiaomi/mimo-v2.5-pro").
if (inferredProvider) {
const rawExactMatches = availableModels.filter(
(m) => m.id.toLowerCase() === cliModel.toLowerCase() && !modelsAreEqual(m, model),
);
if (rawExactMatches.length > 0 && !modelRegistry.hasConfiguredAuth(model)) {
const authenticatedRawMatches = rawExactMatches.filter((m) => modelRegistry.hasConfiguredAuth(m));
if (authenticatedRawMatches.length === 1) {
return {
model: authenticatedRawMatches[0],
thinkingLevel: undefined,
warning: undefined,
error: undefined,
};
}
}
}
return { model, thinkingLevel, warning, error: undefined };
}
@@ -470,10 +491,13 @@ export function resolveCliModel(options: {
const fallbackModel = buildFallbackModel(provider, fallbackPattern, availableModels);
if (fallbackModel) {
const requestedThinking = cliThinking ?? fallbackThinking;
const model =
requestedThinking && requestedThinking !== "off" ? { ...fallbackModel, reasoning: true } : fallbackModel;
const fallbackWarning = warning
? `${warning} Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`
: `Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`;
return { model: fallbackModel, thinkingLevel: fallbackThinking, warning: fallbackWarning, error: undefined };
return { model, thinkingLevel: fallbackThinking, warning: fallbackWarning, error: undefined };
}
}
@@ -27,6 +27,7 @@ import type { Readable } from "node:stream";
import { globSync } from "glob";
import ignore from "ignore";
import { minimatch } from "minimatch";
import { maxSatisfying, rcompare, satisfies, valid, validRange } from "semver";
import { CONFIG_DIR_NAME } from "../config.ts";
import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts";
import { type GitSource, parseGitUrl } from "../utils/git.ts";
@@ -44,6 +45,14 @@ function isOfflineModeEnabled(): boolean {
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
function isExactNpmVersion(version: string | undefined): boolean {
return valid(version ?? "") !== null;
}
function getNpmVersionRange(version: string | undefined): string | undefined {
return version ? (validRange(version) ?? undefined) : undefined;
}
export interface PathMetadata {
source: string;
scope: SourceScope;
@@ -119,6 +128,8 @@ type NpmSource = {
type: "npm";
spec: string;
name: string;
version?: string;
range?: string;
pinned: boolean;
};
@@ -1113,8 +1124,8 @@ export class DefaultPackageManager implements PackageManager {
}
try {
const latestVersion = await this.getLatestNpmVersion(source.name);
return latestVersion !== installedVersion;
const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range);
return targetVersion !== installedVersion;
} catch {
// Preserve existing update behavior when version lookup fails.
return true;
@@ -1128,7 +1139,7 @@ export class DefaultPackageManager implements PackageManager {
const sourceLabel = sources.length === 1 ? sources[0].source : `${scope} npm packages`;
const message = sources.length === 1 ? `Updating ${sources[0].source}...` : `Updating ${scope} npm packages...`;
const specs = sources.map((entry) => `${entry.parsed.name}@latest`);
const specs = sources.map((entry) => (entry.parsed.version ? entry.parsed.spec : `${entry.parsed.name}@latest`));
await this.withProgress("update", sourceLabel, message, async () => {
await this.installNpmBatch(specs, scope);
@@ -1241,8 +1252,7 @@ export class DefaultPackageManager implements PackageManager {
if (parsed.type === "npm") {
let installedPath = this.getNpmInstallPath(parsed, scope);
const needsInstall =
!existsSync(installedPath) ||
(parsed.pinned && !(await this.installedNpmMatchesPinnedVersion(parsed, installedPath)));
!existsSync(installedPath) || !(await this.installedNpmMatchesConfiguredVersion(parsed, installedPath));
if (needsInstall) {
const installed = await installMissing();
if (!installed) continue;
@@ -1394,7 +1404,9 @@ export class DefaultPackageManager implements PackageManager {
type: "npm",
spec,
name,
pinned: Boolean(version),
version,
range: getNpmVersionRange(version),
pinned: isExactNpmVersion(version),
};
}
@@ -1411,18 +1423,12 @@ export class DefaultPackageManager implements PackageManager {
return { type: "local", path: source };
}
private async installedNpmMatchesPinnedVersion(source: NpmSource, installedPath: string): Promise<boolean> {
private async installedNpmMatchesConfiguredVersion(source: NpmSource, installedPath: string): Promise<boolean> {
const installedVersion = this.getInstalledNpmVersion(installedPath);
if (!installedVersion) {
return false;
}
const { version: pinnedVersion } = this.parseNpmSpec(source.spec);
if (!pinnedVersion) {
return true;
}
return installedVersion === pinnedVersion;
return source.range ? satisfies(installedVersion, source.range) : true;
}
private async npmHasAvailableUpdate(source: NpmSource, installedPath: string): Promise<boolean> {
@@ -1436,8 +1442,8 @@ export class DefaultPackageManager implements PackageManager {
}
try {
const latestVersion = await this.getLatestNpmVersion(source.name);
return latestVersion !== installedVersion;
const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range);
return targetVersion !== installedVersion;
} catch {
return false;
}
@@ -1455,16 +1461,25 @@ export class DefaultPackageManager implements PackageManager {
}
}
private async getLatestNpmVersion(packageName: string): Promise<string> {
private async getLatestNpmVersion(packageSpec: string, range?: string): Promise<string> {
const npmCommand = this.getNpmCommand();
const stdout = await this.runCommandCapture(
npmCommand.command,
[...npmCommand.args, "view", packageName, "version", "--json"],
[...npmCommand.args, "view", packageSpec, "version", "--json"],
{ cwd: this.cwd, timeoutMs: NETWORK_TIMEOUT_MS },
);
const raw = stdout.trim();
if (!raw) throw new Error("Empty response from npm view");
return JSON.parse(raw);
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed === "string") {
return parsed;
}
if (Array.isArray(parsed)) {
const versions = parsed.filter((value): value is string => typeof value === "string" && value.length > 0);
const latest = range ? maxSatisfying(versions, range) : [...versions].sort(rcompare)[0];
if (latest) return latest;
}
throw new Error("Unexpected response from npm view");
}
private async gitHasAvailableUpdate(installedPath: string): Promise<boolean> {
@@ -1,9 +1,10 @@
import { CONFIG_DIR_NAME } from "../config.ts";
import { emitProjectTrustEvent } from "./extensions/runner.ts";
import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/types.ts";
import type { DefaultProjectTrust } from "./settings-manager.ts";
import {
getProjectTrustOptions,
hasProjectTrustInputs,
hasTrustRequiringProjectResources,
type ProjectTrustOption,
type ProjectTrustStore,
} from "./trust-manager.ts";
@@ -21,7 +22,7 @@ export interface ResolveProjectTrustedOptions {
}
function formatProjectTrustPrompt(cwd: string): string {
return `Trust project folder?\n${cwd}\n\nThis allows pi to load .pi settings and resources, install missing project packages, and execute project extensions.`;
return `Trust project folder?\n${cwd}\n\nThis allows pi to load ${CONFIG_DIR_NAME} settings and resources, install missing project packages, and execute project extensions.`;
}
async function selectProjectTrustOption(
@@ -46,7 +47,7 @@ export async function resolveProjectTrusted(options: ResolveProjectTrustedOption
if (options.trustOverride !== undefined) {
return options.trustOverride;
}
if (!hasProjectTrustInputs(options.cwd)) {
if (!hasTrustRequiringProjectResources(options.cwd)) {
return true;
}
@@ -7,6 +7,7 @@ const NVIDIA_NIM_HOST = "integrate.api.nvidia.com";
const CLOUDFLARE_API_HOST = "api.cloudflare.com";
const CLOUDFLARE_AI_GATEWAY_HOST = "gateway.ai.cloudflare.com";
const OPENCODE_HOST = "opencode.ai";
const VERCEL_GATEWAY_HOST = "ai-gateway.vercel.sh";
function matchesHost(baseUrl: string, expectedHost: string): boolean {
try {
@@ -33,6 +34,10 @@ function isCloudflareModel(model: Model<Api>): boolean {
);
}
function isVercelGatewayModel(model: Model<Api>): boolean {
return model.provider === "vercel-ai-gateway" || matchesHost(model.baseUrl, VERCEL_GATEWAY_HOST);
}
function getDefaultAttributionHeaders(
model: Model<Api>,
settingsManager: SettingsManager,
@@ -61,6 +66,13 @@ function getDefaultAttributionHeaders(
};
}
if (isVercelGatewayModel(model)) {
return {
"http-referer": "https://pi.dev",
"x-title": "pi",
};
}
return undefined;
}
@@ -10,7 +10,6 @@ import { getShellConfig } from "../utils/shell.ts";
const commandResultCache = new Map<string, string | undefined>();
const ENV_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const ENV_VAR_NAME_PREFIX_RE = /^[A-Za-z_][A-Za-z0-9_]*/;
const LEGACY_ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
type TemplatePart = { type: "literal"; value: string } | { type: "env"; name: string };
@@ -86,8 +85,8 @@ function parseConfigValueReference(config: string): ConfigValueReference {
return { type: "template", parts: parseConfigValueTemplate(config) };
}
function resolveEnvConfigValue(name: string): string | undefined {
return process.env[name] || undefined;
function resolveEnvConfigValue(name: string, env?: Record<string, string>): string | undefined {
return env?.[name] || process.env[name] || undefined;
}
function getTemplateEnvVarNames(parts: TemplatePart[]): string[] {
@@ -99,14 +98,14 @@ function getTemplateEnvVarNames(parts: TemplatePart[]): string[] {
return names;
}
function resolveTemplate(parts: TemplatePart[]): string | undefined {
function resolveTemplate(parts: TemplatePart[], env?: Record<string, string>): string | undefined {
let resolved = "";
for (const part of parts) {
if (part.type === "literal") {
resolved += part.value;
continue;
}
const envValue = resolveEnvConfigValue(part.name);
const envValue = resolveEnvConfigValue(part.name, env);
if (envValue === undefined) return undefined;
resolved += envValue;
}
@@ -124,20 +123,16 @@ export function getConfigValueEnvVarNames(config: string): string[] {
return reference.type === "template" ? getTemplateEnvVarNames(reference.parts) : [];
}
export function getMissingConfigValueEnvVarNames(config: string): string[] {
return getConfigValueEnvVarNames(config).filter((name) => resolveEnvConfigValue(name) === undefined);
export function getMissingConfigValueEnvVarNames(config: string, env?: Record<string, string>): string[] {
return getConfigValueEnvVarNames(config).filter((name) => resolveEnvConfigValue(name, env) === undefined);
}
export function isCommandConfigValue(config: string): boolean {
return parseConfigValueReference(config).type === "command";
}
export function isConfigValueConfigured(config: string): boolean {
return getMissingConfigValueEnvVarNames(config).length === 0;
}
export function isLegacyEnvVarNameConfigValue(config: string): boolean {
return LEGACY_ENV_VAR_NAME_RE.test(config);
export function isConfigValueConfigured(config: string, env?: Record<string, string>): boolean {
return getMissingConfigValueEnvVarNames(config, env).length === 0;
}
/**
@@ -147,21 +142,23 @@ export function isLegacyEnvVarNameConfigValue(config: string): boolean {
* - In non-command values, "$$" escapes a literal "$" and "$!" escapes a literal "!"
* - Otherwise treats the value as a literal
*/
export function resolveConfigValue(config: string): string | undefined {
export function resolveConfigValue(config: string, env?: Record<string, string>): string | undefined {
const reference = parseConfigValueReference(config);
if (reference.type === "command") {
return executeCommand(reference.config);
}
return resolveTemplate(reference.parts);
return resolveTemplate(reference.parts, env);
}
function executeWithConfiguredShell(command: string): { executed: boolean; value: string | undefined } {
try {
const { shell, args } = getShellConfig();
const result = spawnSync(shell, [...args, command], {
const { shell, args, commandTransport } = getShellConfig();
const commandFromStdin = commandTransport === "stdin";
const result = spawnSync(shell, commandFromStdin ? args : [...args, command], {
encoding: "utf-8",
input: commandFromStdin ? command : undefined,
timeout: 10000,
stdio: ["ignore", "pipe", "ignore"],
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "ignore"],
shell: false,
windowsHide: true,
});
@@ -221,16 +218,16 @@ function executeCommand(commandConfig: string): string | undefined {
/**
* Resolve all header values using the same resolution logic as API keys.
*/
export function resolveConfigValueUncached(config: string): string | undefined {
export function resolveConfigValueUncached(config: string, env?: Record<string, string>): string | undefined {
const reference = parseConfigValueReference(config);
if (reference.type === "command") {
return executeCommandUncached(reference.config);
}
return resolveTemplate(reference.parts);
return resolveTemplate(reference.parts, env);
}
export function resolveConfigValueOrThrow(config: string, description: string): string {
const resolvedValue = resolveConfigValueUncached(config);
export function resolveConfigValueOrThrow(config: string, description: string, env?: Record<string, string>): string {
const resolvedValue = resolveConfigValueUncached(config, env);
if (resolvedValue !== undefined) {
return resolvedValue;
}
@@ -241,7 +238,7 @@ export function resolveConfigValueOrThrow(config: string, description: string):
}
if (reference.type === "template") {
const missingEnvVars = getMissingConfigValueEnvVarNames(config);
const missingEnvVars = getMissingConfigValueEnvVarNames(config, env);
if (missingEnvVars.length === 1) {
throw new Error(`Failed to resolve ${description} from environment variable: ${missingEnvVars[0]}`);
}
@@ -256,11 +253,14 @@ export function resolveConfigValueOrThrow(config: string, description: string):
/**
* Resolve all header values using the same resolution logic as API keys.
*/
export function resolveHeaders(headers: Record<string, string> | undefined): Record<string, string> | undefined {
export function resolveHeaders(
headers: Record<string, string> | undefined,
env?: Record<string, string>,
): Record<string, string> | undefined {
if (!headers) return undefined;
const resolved: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
const resolvedValue = resolveConfigValue(value);
const resolvedValue = resolveConfigValue(value, env);
if (resolvedValue) {
resolved[key] = resolvedValue;
}
@@ -271,11 +271,12 @@ export function resolveHeaders(headers: Record<string, string> | undefined): Rec
export function resolveHeadersOrThrow(
headers: Record<string, string> | undefined,
description: string,
env?: Record<string, string>,
): Record<string, string> | undefined {
if (!headers) return undefined;
const resolved: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
resolved[key] = resolveConfigValueOrThrow(value, `${description} header "${key}"`);
resolved[key] = resolveConfigValueOrThrow(value, `${description} header "${key}"`, env);
}
return Object.keys(resolved).length > 0 ? resolved : undefined;
}
@@ -9,7 +9,12 @@ export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";
import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
import { createEventBus, type EventBus } from "./event-bus.ts";
import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.ts";
import {
clearExtensionCache,
createExtensionRuntime,
loadExtensionFromFactory,
loadExtensionsCached,
} from "./extensions/loader.ts";
import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts";
import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts";
import type { PromptTemplate } from "./prompt-templates.ts";
@@ -206,6 +211,7 @@ export class DefaultResourceLoader implements ResourceLoader {
private extensionThemeSourceInfos: Map<string, SourceInfo>;
private lastPromptPaths: string[];
private lastThemePaths: string[];
private loaded: boolean;
constructor(options: DefaultResourceLoaderOptions) {
this.cwd = resolvePath(options.cwd);
@@ -252,6 +258,7 @@ export class DefaultResourceLoader implements ResourceLoader {
this.extensionThemeSourceInfos = new Map();
this.lastPromptPaths = [];
this.lastThemePaths = [];
this.loaded = false;
}
getExtensions(): LoadExtensionsResult {
@@ -331,6 +338,10 @@ export class DefaultResourceLoader implements ResourceLoader {
}
async reload(options?: ResourceLoaderReloadOptions): Promise<void> {
if (this.loaded) {
clearExtensionCache();
}
let preTrustExtensions: LoadExtensionsResult | undefined;
if (options?.resolveProjectTrust) {
preTrustExtensions = await this.loadProjectTrustExtensions();
@@ -475,6 +486,7 @@ export class DefaultResourceLoader implements ResourceLoader {
this.appendSystemPrompt = this.appendSystemPromptOverride
? this.appendSystemPromptOverride(baseAppend)
: baseAppend;
this.loaded = true;
}
private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise<LoadExtensionsResult> {
@@ -487,7 +499,7 @@ export class DefaultResourceLoader implements ResourceLoader {
const extensionPaths = this.noExtensions
? cliEnabledExtensions
: this.mergePaths(cliEnabledExtensions, enabledExtensions);
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus);
if (!options.includeInlineFactories) {
return extensionsResult;
}
@@ -507,7 +519,7 @@ export class DefaultResourceLoader implements ResourceLoader {
preTrustExtensions: LoadExtensionsResult | undefined,
): Promise<LoadExtensionsResult> {
if (!preTrustExtensions) {
const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus);
const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus);
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
extensionsResult.extensions.push(...inlineExtensions.extensions);
extensionsResult.errors.push(...inlineExtensions.errors);
@@ -527,7 +539,7 @@ export class DefaultResourceLoader implements ResourceLoader {
const resolvedPath = this.resolveExtensionLoadPath(path);
return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath);
});
const remainingExtensions = await loadExtensions(
const remainingExtensions = await loadExtensionsCached(
remainingPaths,
this.cwd,
this.eventBus,
+2
View File
@@ -303,6 +303,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
if (!auth.ok) {
throw new Error(auth.error);
}
const env = auth.env || options?.env ? { ...(auth.env ?? {}), ...(options?.env ?? {}) } : undefined;
const providerRetrySettings = settingsManager.getProviderRetrySettings();
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
// SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout".
@@ -314,6 +315,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return streamSimple(model, context, {
...options,
apiKey: auth.apiKey,
env,
timeoutMs,
websocketConnectTimeoutMs,
maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
@@ -357,9 +357,10 @@ export function buildSessionContext(
const path: SessionEntry[] = [];
let current: SessionEntry | undefined = leaf;
while (current) {
path.unshift(current);
path.push(current);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
path.reverse();
// Extract settings and find compaction
let thinkingLevel = "off";
@@ -1152,9 +1153,10 @@ export class SessionManager {
const startId = fromId ?? this.leafId;
let current = startId ? this.byId.get(startId) : undefined;
while (current) {
path.unshift(current);
path.push(current);
current = current.parentId ? this.byId.get(current.parentId) : undefined;
}
path.reverse();
return path;
}
@@ -1290,8 +1292,16 @@ export class SessionManager {
throw new Error(`Entry ${leafId} not found`);
}
// Filter out LabelEntry from path - we'll recreate them from the resolved map
const pathWithoutLabels = path.filter((e) => e.type !== "label");
// Filter out LabelEntry from path - we'll recreate them from the resolved map.
// Because labels are real tree entries, later entries can be children of labels;
// removing labels requires re-chaining the retained path to avoid orphaned subtrees.
const pathWithoutLabels: SessionEntry[] = [];
let pathParentId: string | null = null;
for (const entry of path) {
if (entry.type === "label") continue;
pathWithoutLabels.push({ ...entry, parentId: pathParentId });
pathParentId = entry.id;
}
const newSessionId = createSessionId();
const timestamp = new Date().toISOString();
@@ -117,6 +117,7 @@ export interface Settings {
markdown?: MarkdownSettings;
warnings?: WarningSettings;
sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag)
httpProxy?: string; // Proxy URL applied as HTTP_PROXY and HTTPS_PROXY for Pi-managed HTTP clients
httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it
websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it
}
@@ -713,8 +714,15 @@ export class SettingsManager {
this.save();
}
getThemeSetting(): string | undefined {
const value = this.settings.theme;
if (typeof value === "string") return value;
return undefined;
}
getTheme(): string | undefined {
return this.settings.theme;
const theme = this.getThemeSetting();
return theme?.includes("/") ? undefined : theme;
}
setTheme(theme: string): void {
+11 -3
View File
@@ -66,7 +66,7 @@ export interface BashOperations {
export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations {
return {
exec: async (command, cwd, { onData, signal, timeout, env }) => {
const { shell, args } = getShellConfig(options?.shellPath);
const shellConfig = getShellConfig(options?.shellPath);
try {
await fsAccess(cwd, constants.F_OK);
} catch {
@@ -76,13 +76,18 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas
throw new Error("aborted");
}
const child = spawn(shell, [...args, command], {
const commandFromStdin = shellConfig.commandTransport === "stdin";
const child = spawn(shellConfig.shell, commandFromStdin ? shellConfig.args : [...shellConfig.args, command], {
cwd,
detached: process.platform !== "win32",
env: env ?? getShellEnv(),
stdio: ["ignore", "pipe", "pipe"],
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
windowsHide: true,
});
if (commandFromStdin) {
child.stdin?.on("error", () => {});
child.stdin?.end(command);
}
if (child.pid) trackDetachedChildPid(child.pid);
let timedOut = false;
let timeoutHandle: NodeJS.Timeout | undefined;
@@ -289,6 +294,7 @@ export function createBashToolDefinition(
const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook);
const output = new OutputAccumulator({ tempFilePrefix: "pi-bash" });
let acceptingOutput = true;
let updateTimer: NodeJS.Timeout | undefined;
let updateDirty = false;
let lastUpdateAt = 0;
@@ -334,11 +340,13 @@ export function createBashToolDefinition(
}
const handleData = (data: Buffer) => {
if (!acceptingOutput) return;
output.append(data);
scheduleOutputUpdate();
};
const finishOutput = async () => {
acceptingOutput = false;
output.finish();
clearUpdateTimer();
emitOutputUpdate();
+133 -27
View File
@@ -1,6 +1,5 @@
/**
* Shared diff computation utilities for the edit tool.
* Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering).
* Shared diff computation utilities for the edit and similar tools.
*/
import * as Diff from "diff";
@@ -54,6 +53,124 @@ export function normalizeForFuzzyMatch(text: string): string {
);
}
function splitLinesWithEndings(content: string): string[] {
return content.match(/[^\n]*\n|[^\n]+/g) ?? [];
}
interface LineSpan {
start: number;
end: number;
}
interface MatchedEdit {
editIndex: number;
matchIndex: number;
matchLength: number;
newText: string;
}
type TextReplacement = Pick<MatchedEdit, "matchIndex" | "matchLength" | "newText">;
function getLineSpans(content: string): LineSpan[] {
let offset = 0;
return splitLinesWithEndings(content).map((line) => {
const span = { start: offset, end: offset + line.length };
offset = span.end;
return span;
});
}
function getReplacementLineRange(lines: LineSpan[], replacement: TextReplacement) {
const replacementStart = replacement.matchIndex;
const replacementEnd = replacement.matchIndex + replacement.matchLength;
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (replacementStart >= line.start && replacementStart < line.end) {
startLine = i;
break;
}
}
if (startLine === -1) {
throw new Error("Replacement range is outside the base content.");
}
let endLine = startLine;
while (endLine < lines.length && lines[endLine].end < replacementEnd) {
endLine++;
}
if (endLine >= lines.length) {
throw new Error("Replacement range is outside the base content.");
}
return { startLine, endLine: endLine + 1 };
}
function applyReplacements(content: string, replacements: TextReplacement[], offset = 0): string {
let result = content;
for (let i = replacements.length - 1; i >= 0; i--) {
const replacement = replacements[i];
const matchIndex = replacement.matchIndex - offset;
result =
result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength);
}
return result;
}
/**
* Apply replacements matched against `baseContent` to `originalContent` while
* preserving unchanged line blocks from the original.
*
* This is useful when `baseContent` is a normalized view of the original. Each
* replacement is widened to the lines it actually touches, those touched lines
* are rewritten from the normalized base, and all other lines are copied back
* from `originalContent`. The actual replacement ranges drive preservation so
* duplicate normalized lines cannot be aligned to the wrong occurrence.
*/
export function applyReplacementsPreservingUnchangedLines(
originalContent: string,
baseContent: string,
replacements: TextReplacement[],
): string {
const originalLines = splitLinesWithEndings(originalContent);
const baseLines = getLineSpans(baseContent);
if (originalLines.length !== baseLines.length) {
throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
}
const groups: Array<{ startLine: number; endLine: number; replacements: TextReplacement[] }> = [];
const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex);
for (const replacement of sortedReplacements) {
const range = getReplacementLineRange(baseLines, replacement);
const current = groups[groups.length - 1];
if (current && range.startLine < current.endLine) {
current.endLine = Math.max(current.endLine, range.endLine);
current.replacements.push(replacement);
continue;
}
groups.push({ ...range, replacements: [replacement] });
}
let originalLineIndex = 0;
let result = "";
for (const group of groups) {
result += originalLines.slice(originalLineIndex, group.startLine).join("");
const groupStartOffset = baseLines[group.startLine].start;
const groupEndOffset = baseLines[group.endLine - 1].end;
result += applyReplacements(
baseContent.slice(groupStartOffset, groupEndOffset),
group.replacements,
groupStartOffset,
);
originalLineIndex = group.endLine;
}
result += originalLines.slice(originalLineIndex).join("");
return result;
}
export interface FuzzyMatchResult {
/** Whether a match was found */
found: boolean;
@@ -75,13 +192,6 @@ export interface Edit {
newText: string;
}
interface MatchedEdit {
editIndex: number;
matchIndex: number;
matchLength: number;
newText: string;
}
export interface AppliedEditsResult {
baseContent: string;
newContent: string;
@@ -121,9 +231,9 @@ export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResul
};
}
// When fuzzy matching, we work in the normalized space for replacement.
// This means the output will have normalized whitespace/quotes/dashes,
// which is acceptable since we're fixing minor formatting differences anyway.
// When fuzzy matching, return offsets in normalized space. Callers can use
// the normalized content to compute replacements, then decide how much of
// that normalized output should be written back.
return {
found: true,
index: fuzzyIndex,
@@ -187,8 +297,9 @@ function getNoChangeError(path: string, totalEdits: number): Error {
*
* All edits are matched against the same original content. Replacements are
* then applied in reverse order so offsets remain stable. If any edit needs
* fuzzy matching, the operation runs in fuzzy-normalized content space to
* preserve current single-edit behavior.
* fuzzy matching, the operation runs in fuzzy-normalized content space and then
* overlays those line-level changes onto the original content so unchanged line
* blocks keep their original bytes.
*/
export function applyEditsToNormalizedContent(
normalizedContent: string,
@@ -207,19 +318,18 @@ export function applyEditsToNormalizedContent(
}
const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
const baseContent = initialMatches.some((match) => match.usedFuzzyMatch)
? normalizeForFuzzyMatch(normalizedContent)
: normalizedContent;
const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch);
const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
const matchedEdits: MatchedEdit[] = [];
for (let i = 0; i < normalizedEdits.length; i++) {
const edit = normalizedEdits[i];
const matchResult = fuzzyFindText(baseContent, edit.oldText);
const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);
if (!matchResult.found) {
throw getNotFoundError(path, i, normalizedEdits.length);
}
const occurrences = countOccurrences(baseContent, edit.oldText);
const occurrences = countOccurrences(replacementBaseContent, edit.oldText);
if (occurrences > 1) {
throw getDuplicateError(path, i, normalizedEdits.length, occurrences);
}
@@ -243,14 +353,10 @@ export function applyEditsToNormalizedContent(
}
}
let newContent = baseContent;
for (let i = matchedEdits.length - 1; i >= 0; i--) {
const edit = matchedEdits[i];
newContent =
newContent.substring(0, edit.matchIndex) +
edit.newText +
newContent.substring(edit.matchIndex + edit.matchLength);
}
const baseContent = normalizedContent;
const newContent = usedFuzzyMatch
? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits)
: applyReplacements(replacementBaseContent, matchedEdits);
if (baseContent === newContent) {
throw getNoChangeError(path, normalizedEdits.length);
+18 -11
View File
@@ -221,17 +221,24 @@ export function createFindToolDefinition(
return;
}
// Build fd arguments. --no-require-git makes fd apply hierarchical .gitignore
// semantics whether or not the search path is inside a git repository, without
// leaking sibling-directory rules the way --ignore-file (a global source) would.
const args: string[] = [
"--glob",
"--color=never",
"--hidden",
"--no-require-git",
"--max-results",
String(effectiveLimit),
];
const args: string[] = ["--glob", "--color=never", "--hidden"];
// fd normally ignores .gitignore outside git repos, so keep --no-require-git
// there. Inside repos, use fd's default git-aware behavior so parent
// .gitignore rules stop at nested repo boundaries:
// https://github.com/earendil-works/pi/issues/5960
let insideGitRepo = false;
for (let current = searchPath; ; ) {
if (await pathExists(path.join(current, ".git"))) {
insideGitRepo = true;
break;
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
if (!insideGitRepo) args.push("--no-require-git");
args.push("--max-results", String(effectiveLimit));
// fd --glob matches against the basename unless --full-path is set; in --full-path
// mode it matches against the absolute candidate path, so a path-containing
+28 -13
View File
@@ -1,4 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME } from "../config.ts";
@@ -25,6 +26,16 @@ export interface ProjectTrustOption {
type TrustFile = Record<string, boolean | null | undefined>;
const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [
"settings.json",
"extensions",
"skills",
"prompts",
"themes",
"SYSTEM.md",
"APPEND_SYSTEM.md",
] as const;
function normalizeCwd(cwd: string): string {
return canonicalizePath(resolvePath(cwd));
}
@@ -45,18 +56,14 @@ function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreE
}
}
export function getProjectTrustPath(cwd: string): string {
return normalizeCwd(cwd);
}
export function getProjectTrustParentPath(cwd: string): string | undefined {
const trustPath = getProjectTrustPath(cwd);
const trustPath = normalizeCwd(cwd);
const parentDir = dirname(trustPath);
return parentDir === trustPath ? undefined : parentDir;
}
export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] {
const trustPath = getProjectTrustPath(cwd);
const trustPath = normalizeCwd(cwd);
const trustOptions: ProjectTrustOption[] = [
{ label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath },
];
@@ -167,18 +174,26 @@ function withTrustFileLock<T>(path: string, fn: () => T): T {
}
}
export function hasProjectConfigDir(cwd: string): boolean {
return existsSync(join(canonicalizePath(resolvePath(cwd)), CONFIG_DIR_NAME));
}
export function hasProjectTrustInputs(cwd: string): boolean {
/**
* Returns true when cwd has project-local resources that must be gated by
* project trust: trust-requiring entries under cwd/.pi, or .agents/skills in
* cwd or one of its ancestors. Returns false when no such project resources
* exist. The user/global ~/.agents/skills directory is always treated as a
* trusted user resource and is ignored here, even when cwd is $HOME.
*/
export function hasTrustRequiringProjectResources(cwd: string): boolean {
const homeDir = canonicalizePath(resolvePath(process.env.HOME || homedir()));
const userAgentsSkillsDir = join(homeDir, ".agents", "skills");
let currentDir = canonicalizePath(resolvePath(cwd));
if (hasProjectConfigDir(currentDir)) {
const configDir = join(currentDir, CONFIG_DIR_NAME);
if (TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES.some((entry) => existsSync(join(configDir, entry)))) {
return true;
}
while (true) {
if (existsSync(join(currentDir, ".agents", "skills"))) {
const agentsSkillsDir = join(currentDir, ".agents", "skills");
if (agentsSkillsDir !== userAgentsSkillsDir && existsSync(agentsSkillsDir)) {
return true;
}
+11 -2
View File
@@ -3,7 +3,15 @@
export { type Args, parseArgs } from "./cli/args.ts";
// Config paths
export { getAgentDir, getDocsPath, getExamplesPath, getPackageDir, getReadmePath, VERSION } from "./config.ts";
export {
CONFIG_DIR_NAME,
getAgentDir,
getDocsPath,
getExamplesPath,
getPackageDir,
getReadmePath,
VERSION,
} from "./config.ts";
export {
AgentSession,
type AgentSessionConfig,
@@ -238,6 +246,7 @@ export {
type SkillFrontmatter,
} from "./core/skills.ts";
export { createSyntheticSourceInfo } from "./core/source-info.ts";
export { type EditDiffResult, generateDiffString, generateUnifiedPatch } from "./core/tools/edit-diff.ts";
// Tools
export {
type BashOperations,
@@ -289,7 +298,7 @@ export {
withFileMutationQueue,
} from "./core/tools/index.ts";
export {
hasProjectTrustInputs,
hasTrustRequiringProjectResources,
type ProjectTrustDecision,
ProjectTrustStore,
type ProjectTrustStoreEntry,
+27 -9
View File
@@ -26,7 +26,7 @@ import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
import { AuthStorage } from "./core/auth-storage.ts";
import { exportFromFile } from "./core/export-html/index.ts";
import type { ExtensionFactory } from "./core/extensions/types.ts";
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts";
import type { ModelRegistry } from "./core/model-registry.ts";
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts";
import { restoreStdout, takeOverStdout } from "./core/output-guard.ts";
@@ -41,7 +41,7 @@ import {
import { assertValidSessionId, SessionManager } from "./core/session-manager.ts";
import { SettingsManager } from "./core/settings-manager.ts";
import { printTimings, resetTimings, time } from "./core/timings.ts";
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts";
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
@@ -466,7 +466,22 @@ export async function main(args: string[], options?: MainOptions) {
cleanupWindowsSelfUpdateQuarantine(getPackageDir());
}
const cwd = process.cwd();
const agentDir = getAgentDir();
const bootstrapSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
applyHttpProxySettings(bootstrapSettingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher();
if (await handlePackageCommand(args, { extensionFactories: options?.extensionFactories })) {
const exitCode = process.exitCode ?? 0;
if (process.platform === "win32" && exitCode === 0 && args[0] === "update") {
// We normally prefer process.exit(0) for package commands so bad extensions cannot keep
// one-shot commands alive. On Windows, Node can assert after fetch() if process.exit(0)
// runs during teardown; let successful `pi update` drain naturally instead.
// https://github.com/nodejs/node/issues/56645
return;
}
process.exit(exitCode);
return;
}
@@ -520,11 +535,9 @@ export async function main(args: string[], options?: MainOptions) {
validateSessionIdFlags(parsed);
// Run migrations (pass cwd for project-local migrations)
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(cwd);
time("runMigrations");
const cwd = process.cwd();
const agentDir = getAgentDir();
const startupSettingsManager = SettingsManager.create(cwd, agentDir);
reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup"));
@@ -572,7 +585,9 @@ export async function main(args: string[], options?: MainOptions) {
const trustStore = new ProjectTrustStore(agentDir);
const sessionCwd = sessionManager.getCwd();
const autoTrustOnReloadCwd =
parsed.projectTrustOverride === undefined && !hasProjectTrustInputs(sessionCwd) ? sessionCwd : undefined;
parsed.projectTrustOverride === undefined && !hasTrustRequiringProjectResources(sessionCwd)
? sessionCwd
: undefined;
const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode;
const projectTrustByCwd = new Map<string, boolean>();
@@ -591,12 +606,14 @@ export async function main(args: string[], options?: MainOptions) {
const isInitialRuntime = sessionStartEvent === undefined;
const projectTrustDiagnostics: AgentSessionRuntimeDiagnostic[] = [];
const cachedProjectTrust = projectTrustByCwd.get(cwd);
const hasTrustInputs = hasProjectTrustInputs(cwd);
const hasTrustRequiringResources = hasTrustRequiringProjectResources(cwd);
const shouldResolveProjectTrust =
parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustInputs;
parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustRequiringResources;
const projectTrusted = shouldResolveProjectTrust
? false
: (cachedProjectTrust ?? parsed.projectTrustOverride ?? (!hasTrustInputs || trustStore.get(cwd) === true));
: (cachedProjectTrust ??
parsed.projectTrustOverride ??
(!hasTrustRequiringResources || trustStore.get(cwd) === true));
const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
const services = await createAgentSessionServices({
cwd,
@@ -713,6 +730,7 @@ export async function main(args: string[], options?: MainOptions) {
time("createAgentSessionRuntime");
const { services, session, modelFallbackMessage } = runtime;
const { settingsManager, modelRegistry, resourceLoader } = services;
applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);
configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());
if (parsed.help) {
+1 -138
View File
@@ -3,12 +3,10 @@
*/
import chalk from "chalk";
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import { CONFIG_DIR_NAME, getAgentDir, getBinDir } from "./config.ts";
import { migrateKeybindingsConfig } from "./core/keybindings.ts";
import { isLegacyEnvVarNameConfigValue } from "./core/resolve-config-value.ts";
import { stripJsonComments } from "./utils/json.ts";
const MIGRATION_GUIDE_URL =
"https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md#extensions-migration";
@@ -74,140 +72,6 @@ export function migrateAuthToAuthJson(): string[] {
return providers;
}
interface ConfigValueMigration {
location: string;
from: string;
to: string;
}
function migrateLegacyEnvVarString(value: string): string | undefined {
return isLegacyEnvVarNameConfigValue(value) ? `$${value}` : undefined;
}
function migrateStringProperty(
record: Record<string, unknown>,
key: string,
location: string,
migrations: ConfigValueMigration[],
): boolean {
const value = record[key];
if (typeof value !== "string") return false;
const migrated = migrateLegacyEnvVarString(value);
if (migrated === undefined) return false;
record[key] = migrated;
migrations.push({ location, from: value, to: migrated });
return true;
}
function migrateHeadersConfig(headers: unknown, location: string, migrations: ConfigValueMigration[]): boolean {
if (typeof headers !== "object" || headers === null || Array.isArray(headers)) return false;
const headerRecord = headers as Record<string, unknown>;
let migrated = false;
for (const [key, value] of Object.entries(headerRecord)) {
if (typeof value !== "string") continue;
const migratedValue = migrateLegacyEnvVarString(value);
if (migratedValue === undefined) continue;
headerRecord[key] = migratedValue;
migrations.push({ location: `${location}[${JSON.stringify(key)}]`, from: value, to: migratedValue });
migrated = true;
}
return migrated;
}
function migrateAuthJsonConfigValues(agentDir: string): ConfigValueMigration[] {
const authPath = join(agentDir, "auth.json");
if (!existsSync(authPath)) return [];
try {
const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
const authData = parsed as Record<string, unknown>;
const migrations: ConfigValueMigration[] = [];
for (const [provider, credential] of Object.entries(authData)) {
if (typeof credential !== "object" || credential === null || Array.isArray(credential)) continue;
const credentialRecord = credential as Record<string, unknown>;
if (credentialRecord.type !== "api_key") continue;
migrateStringProperty(credentialRecord, "key", `auth.json[${JSON.stringify(provider)}].key`, migrations);
}
if (migrations.length === 0) return [];
writeFileSync(authPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8");
chmodSync(authPath, 0o600);
return migrations;
} catch {
return [];
}
}
function migrateModelsJsonConfigValues(agentDir: string): ConfigValueMigration[] {
const modelsPath = join(agentDir, "models.json");
if (!existsSync(modelsPath)) return [];
try {
const parsed = JSON.parse(stripJsonComments(readFileSync(modelsPath, "utf-8"))) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
const modelsData = parsed as Record<string, unknown>;
const providers = modelsData.providers;
if (typeof providers !== "object" || providers === null || Array.isArray(providers)) return [];
const migrations: ConfigValueMigration[] = [];
for (const [provider, providerConfig] of Object.entries(providers)) {
if (typeof providerConfig !== "object" || providerConfig === null || Array.isArray(providerConfig)) continue;
const providerRecord = providerConfig as Record<string, unknown>;
const providerLocation = `models.json.providers[${JSON.stringify(provider)}]`;
migrateStringProperty(providerRecord, "apiKey", `${providerLocation}.apiKey`, migrations);
migrateHeadersConfig(providerRecord.headers, `${providerLocation}.headers`, migrations);
if (Array.isArray(providerRecord.models)) {
for (let index = 0; index < providerRecord.models.length; index++) {
const modelConfig = providerRecord.models[index];
if (typeof modelConfig !== "object" || modelConfig === null || Array.isArray(modelConfig)) continue;
const modelRecord = modelConfig as Record<string, unknown>;
const modelKey = typeof modelRecord.id === "string" ? JSON.stringify(modelRecord.id) : String(index);
migrateHeadersConfig(modelRecord.headers, `${providerLocation}.models[${modelKey}].headers`, migrations);
}
}
const modelOverrides = providerRecord.modelOverrides;
if (typeof modelOverrides === "object" && modelOverrides !== null && !Array.isArray(modelOverrides)) {
for (const [modelId, modelOverride] of Object.entries(modelOverrides)) {
if (typeof modelOverride !== "object" || modelOverride === null || Array.isArray(modelOverride))
continue;
const modelOverrideRecord = modelOverride as Record<string, unknown>;
migrateHeadersConfig(
modelOverrideRecord.headers,
`${providerLocation}.modelOverrides[${JSON.stringify(modelId)}].headers`,
migrations,
);
}
}
}
if (migrations.length === 0) return [];
writeFileSync(modelsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8");
return migrations;
} catch {
return [];
}
}
function migrateExplicitEnvVarConfigValues(): void {
const agentDir = getAgentDir();
const migrations = [...migrateAuthJsonConfigValues(agentDir), ...migrateModelsJsonConfigValues(agentDir)];
if (migrations.length === 0) return;
const details = migrations.map((migration) => ` - ${migration.location}: ${migration.from} -> ${migration.to}`);
console.log(
chalk.yellow(
[
"Warning: Migrated API key/header environment references to explicit $ENV_VAR syntax. Plain strings will be treated as literals.",
...details,
].join("\n"),
),
);
}
/**
* Migrate sessions from ~/.pi/agent/*.jsonl to proper session directories.
*
@@ -443,7 +307,6 @@ export function runMigrations(cwd: string): {
deprecationWarnings: string[];
} {
const migratedAuthProviders = migrateAuthToAuthJson();
migrateExplicitEnvVarConfigValues();
migrateSessionsFromAgentRoot();
migrateToolsToBin();
migrateKeybindingsConfigFile();
@@ -73,7 +73,7 @@ function formatBaseDir(baseDir: string): string {
return displayPath.endsWith("/") ? displayPath : `${displayPath}/`;
}
function getGroupLabel(metadata: PathMetadata): string {
function getGroupLabel(metadata: PathMetadata, agentDir: string): string {
if (metadata.origin === "package") {
return `${metadata.source} (${metadata.scope})`;
}
@@ -84,12 +84,12 @@ function getGroupLabel(metadata: PathMetadata): string {
? `User (${formatBaseDir(metadata.baseDir)})`
: `Project (${formatBaseDir(metadata.baseDir)})`;
}
return metadata.scope === "user" ? "User (~/.pi/agent/)" : "Project (.pi/)";
return metadata.scope === "user" ? `User (${formatBaseDir(agentDir)})` : `Project (${CONFIG_DIR_NAME}/)`;
}
return metadata.scope === "user" ? "User settings" : "Project settings";
}
function buildGroups(resolved: ResolvedPaths): ResourceGroup[] {
function buildGroups(resolved: ResolvedPaths, agentDir: string): ResourceGroup[] {
const groupMap = new Map<string, ResourceGroup>();
const addToGroup = (resources: ResolvedResource[], resourceType: ResourceType) => {
@@ -100,7 +100,7 @@ function buildGroups(resolved: ResolvedPaths): ResourceGroup[] {
if (!groupMap.has(groupKey)) {
groupMap.set(groupKey, {
key: groupKey,
label: getGroupLabel(metadata),
label: getGroupLabel(metadata, agentDir),
scope: metadata.scope,
origin: metadata.origin,
source: metadata.source,
@@ -601,7 +601,7 @@ export class ConfigSelectorComponent extends Container implements Focusable {
) {
super();
const groups = buildGroups(resolvedPaths);
const groups = buildGroups(resolvedPaths, agentDir);
// Add header
this.addChild(new Spacer(1));
@@ -1,6 +1,7 @@
import { isAbsolute, relative, resolve, sep } from "node:path";
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import type { AgentSession } from "../../../core/agent-session.ts";
import { areExperimentalFeaturesEnabled } from "../../../core/experimental.ts";
import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts";
import { theme } from "../theme/theme.ts";
@@ -159,6 +160,9 @@ export class FooterComponent implements Component {
contextPercentStr = contextPercentDisplay;
}
statsParts.push(contextPercentStr);
if (areExperimentalFeaturesEnabled()) {
statsParts.push(`${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`);
}
let statsLeft = statsParts.join(" ");
@@ -128,7 +128,6 @@ export class LoginDialogComponent extends Container implements Focusable {
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0));
openBrowser(info.verificationUri);
this.tui.requestRender();
}
@@ -11,6 +11,7 @@ import {
} from "@earendil-works/pi-tui";
import type { ModelRegistry } from "../../../core/model-registry.ts";
import type { SettingsManager } from "../../../core/settings-manager.ts";
import { getModelSelectorSearchText } from "../model-search.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
@@ -217,10 +218,8 @@ export class ModelSelectorComponent extends Container implements Focusable {
private filterModels(query: string): void {
this.filteredModels = query
? fuzzyFilter(
this.activeModels,
query,
({ id, provider }) => `${id} ${provider} ${provider}/${id} ${provider} ${id}`,
? fuzzyFilter(this.activeModels, query, ({ id, provider, model }) =>
getModelSelectorSearchText({ id, provider, name: model.name }),
)
: this.activeModels;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
@@ -10,6 +10,7 @@ import {
Spacer,
Text,
} from "@earendil-works/pi-tui";
import { getModelSearchText } from "../model-search.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyText } from "./keybinding-hints.ts";
@@ -182,7 +183,11 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
private refresh(): void {
const query = this.searchInput.getValue();
const items = this.buildItems();
this.filteredItems = query ? fuzzyFilter(items, query, (i) => `${i.model.id} ${i.model.provider}`) : items;
this.filteredItems = query
? fuzzyFilter(items, query, (i) =>
getModelSearchText({ id: i.model.id, provider: i.model.provider, name: i.model.name }),
)
: items;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
this.updateList();
this.footerText.setText(this.getFooterText());
@@ -694,7 +694,6 @@ export class SessionSelectorComponent extends Container implements Focusable {
private allSessions: SessionInfo[] | null = null;
private currentSessionsLoader: SessionsLoader;
private allSessionsLoader: SessionsLoader;
private onCancel: () => void;
private requestRender: () => void;
private renameSession?: (sessionPath: string, currentName: string | undefined) => Promise<void>;
private currentLoading = false;
@@ -751,7 +750,6 @@ export class SessionSelectorComponent extends Container implements Focusable {
this.keybindings = options?.keybindings ?? KeybindingsManager.create();
this.currentSessionsLoader = currentSessionsLoader;
this.allSessionsLoader = allSessionsLoader;
this.onCancel = onCancel;
this.requestRender = requestRender;
this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.nameFilter, this.requestRender);
const renameSession = options?.renameSession;
@@ -948,10 +946,6 @@ export class SessionSelectorComponent extends Container implements Focusable {
this.header.setLoading(false);
this.sessionList.setSessions(sessions, showCwd);
this.requestRender();
if (scope === "all" && sessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) {
this.onCancel();
}
} catch (err) {
if (scope === "current") {
this.currentLoading = false;
@@ -1,6 +1,7 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Transport } from "@earendil-works/pi-ai";
import {
type Component,
Container,
getCapabilities,
type SelectItem,
@@ -13,7 +14,13 @@ import {
} from "@earendil-works/pi-tui";
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts";
import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts";
import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts";
import {
getSelectListTheme,
getSettingsListTheme,
parseAutoThemeSetting,
type TerminalTheme,
theme,
} from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyDisplayText } from "./keybinding-hints.ts";
@@ -55,6 +62,7 @@ export interface SettingsConfig {
thinkingLevel: ThinkingLevel;
availableThinkingLevels: ThinkingLevel[];
currentTheme: string;
terminalTheme: TerminalTheme;
availableThemes: string[];
hideThinkingBlock: boolean;
collapseChangelog: boolean;
@@ -210,6 +218,249 @@ class SelectSubmenu extends Container {
}
}
function themeItems(availableThemes: string[]): SelectItem[] {
return availableThemes.map((name) => ({ value: name, label: name }));
}
const AUTOMATIC_THEME_VALUE = "/";
function singleModeThemeItems(availableThemes: string[]): SelectItem[] {
return [
{
value: AUTOMATIC_THEME_VALUE,
label: "Automatic",
description: "Use separate themes for light and dark terminal appearance",
},
...themeItems(availableThemes),
];
}
function preferredTheme(availableThemes: string[], preferred: string | undefined, fallback: string): string {
if (preferred && availableThemes.includes(preferred)) return preferred;
if (availableThemes.includes(fallback)) return fallback;
return availableThemes[0] ?? fallback;
}
function defaultAutomaticThemes(
currentThemeSetting: string,
availableThemes: string[],
): { lightTheme: string; darkTheme: string } {
const autoTheme = parseAutoThemeSetting(currentThemeSetting);
if (autoTheme) return autoTheme;
const currentFixedTheme = currentThemeSetting.includes("/") ? undefined : currentThemeSetting;
const themeName = preferredTheme(availableThemes, currentFixedTheme, "dark");
return { lightTheme: themeName, darkTheme: themeName };
}
class ThemeSubmenu extends Container {
private inputComponent: Component | undefined;
private readonly callbacks: SettingsCallbacks;
private readonly availableThemes: string[];
private readonly terminalTheme: TerminalTheme;
private readonly onDone: (selectedValue?: string) => void;
private readonly originalThemeSetting: string;
private mode: "single" | "automatic";
private singleTheme: string;
private lightTheme: string;
private darkTheme: string;
constructor(
currentThemeSetting: string,
terminalTheme: TerminalTheme,
availableThemes: string[],
callbacks: SettingsCallbacks,
onDone: (selectedValue?: string) => void,
) {
super();
this.callbacks = callbacks;
this.availableThemes = availableThemes;
this.terminalTheme = terminalTheme;
this.onDone = onDone;
this.originalThemeSetting = currentThemeSetting;
const autoTheme = parseAutoThemeSetting(currentThemeSetting);
const automaticThemes = defaultAutomaticThemes(currentThemeSetting, availableThemes);
const fixedTheme = autoTheme || currentThemeSetting.includes("/") ? undefined : currentThemeSetting;
this.mode = autoTheme ? "automatic" : "single";
this.lightTheme = automaticThemes.lightTheme;
this.darkTheme = automaticThemes.darkTheme;
this.singleTheme = preferredTheme(
availableThemes,
fixedTheme ?? (autoTheme ? this.getActiveAutomaticTheme() : undefined),
"dark",
);
if (this.mode === "automatic") {
this.showAutomaticMenu();
} else {
this.showSingleMenu();
}
}
handleInput(data: string): void {
this.inputComponent?.handleInput?.(data);
}
private setContent(renderComponent: Component, inputComponent: Component = renderComponent): void {
this.clear();
this.addChild(renderComponent);
this.inputComponent = inputComponent;
}
private showSingleMenu(): void {
this.mode = "single";
const menu = new SelectSubmenu(
"Theme",
"Select a theme, or choose Automatic to follow terminal appearance.",
singleModeThemeItems(this.availableThemes),
this.singleTheme,
(value) => {
if (value === AUTOMATIC_THEME_VALUE) {
this.mode = "automatic";
this.callbacks.onThemePreview?.(this.getThemeSetting());
this.showAutomaticMenu();
return;
}
this.singleTheme = value;
this.apply(value);
},
() => this.cancel(),
(value) => {
this.callbacks.onThemePreview?.(value === AUTOMATIC_THEME_VALUE ? this.getAutomaticThemeSetting() : value);
},
);
this.setContent(menu);
}
private showAutomaticMenu(): void {
this.mode = "automatic";
const content = new Container();
content.addChild(new Text(theme.bold(theme.fg("accent", "Automatic Theme")), 0, 0));
content.addChild(new Spacer(1));
content.addChild(new Text(theme.fg("muted", "Choose themes for terminal light and dark appearance."), 0, 0));
content.addChild(new Text(theme.fg("muted", "Light/dark detection requires terminal support."), 0, 0));
content.addChild(new Spacer(1));
const items: SettingItem[] = [
{
id: "light-theme",
label: "Light theme",
description: "Theme to use in automatic mode when the terminal is light",
currentValue: this.lightTheme,
submenu: (currentValue, done) =>
this.createThemeSelect(
"Light Theme",
"Select the theme to use for light terminal appearance",
currentValue,
done,
(value) => {
this.lightTheme = value;
this.callbacks.onThemePreview?.(this.getThemeSetting());
done(value);
},
),
},
{
id: "dark-theme",
label: "Dark theme",
description: "Theme to use in automatic mode when the terminal is dark",
currentValue: this.darkTheme,
submenu: (currentValue, done) =>
this.createThemeSelect(
"Dark Theme",
"Select the theme to use for dark terminal appearance",
currentValue,
done,
(value) => {
this.darkTheme = value;
this.callbacks.onThemePreview?.(this.getThemeSetting());
done(value);
},
),
},
{
id: "apply",
label: "Apply",
description: "Save and go back",
currentValue: "save and go back",
values: ["save and go back"],
},
{
id: "single-mode",
label: "Change mode",
description: "Switch to one theme for light and dark",
currentValue: "switch to single theme",
values: ["switch to single theme"],
},
];
const settingsList = new SettingsList(
items,
Math.min(items.length, 10),
getSettingsListTheme(),
(id) => {
switch (id) {
case "single-mode":
this.mode = "single";
this.singleTheme = this.getActiveAutomaticTheme();
this.callbacks.onThemePreview?.(this.singleTheme);
this.showSingleMenu();
break;
case "apply":
this.apply(this.getAutomaticThemeSetting());
break;
}
},
() => this.cancel(),
);
content.addChild(settingsList);
this.setContent(content, settingsList);
}
private createThemeSelect(
title: string,
description: string,
currentValue: string,
done: (selectedValue?: string) => void,
onSelect: (value: string) => void,
): SelectSubmenu {
return new SelectSubmenu(
title,
description,
themeItems(this.availableThemes),
currentValue,
onSelect,
() => {
this.callbacks.onThemePreview?.(this.getThemeSetting());
done();
},
(value) => this.callbacks.onThemePreview?.(value),
);
}
private getThemeSetting(): string {
return this.mode === "automatic" ? this.getAutomaticThemeSetting() : this.singleTheme;
}
private getActiveAutomaticTheme(): string {
return this.terminalTheme === "light" ? this.lightTheme : this.darkTheme;
}
private getAutomaticThemeSetting(): string {
return `${this.lightTheme}/${this.darkTheme}`;
}
private apply(themeSetting: string): void {
this.onDone(themeSetting);
}
private cancel(): void {
this.callbacks.onThemePreview?.(this.originalThemeSetting);
this.onDone();
}
}
/**
* Main settings selector component.
*/
@@ -353,28 +604,7 @@ export class SettingsSelectorComponent extends Container {
description: "Color theme for the interface",
currentValue: config.currentTheme,
submenu: (currentValue, done) =>
new SelectSubmenu(
"Theme",
"Select color theme",
config.availableThemes.map((t) => ({
value: t,
label: t,
})),
currentValue,
(value) => {
callbacks.onThemeChange(value);
done(value);
},
() => {
// Restore original theme on cancel
callbacks.onThemePreview?.(currentValue);
done();
},
(value) => {
// Preview theme on selection change
callbacks.onThemePreview?.(value);
},
),
new ThemeSubmenu(currentValue, config.terminalTheme, config.availableThemes, callbacks, done),
},
];
@@ -561,6 +791,9 @@ export class SettingsSelectorComponent extends Container {
case "terminal-progress":
callbacks.onShowTerminalProgressChange(newValue === "true");
break;
case "theme":
callbacks.onThemeChange(newValue);
break;
}
},
callbacks.onCancel,
@@ -4,15 +4,18 @@ import {
type Focusable,
getKeybindings,
Input,
type Keybinding,
Spacer,
sliceByColumn,
Text,
TruncatedText,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import type { SessionTreeNode } from "../../../core/session-manager.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, keyText } from "./keybinding-hints.ts";
import { formatKeyText, keyHint } from "./keybinding-hints.ts";
/** Gutter info: position (displayIndent where connector was) and whether to show │ */
interface GutterInfo {
@@ -35,6 +38,59 @@ interface FlatNode {
isVirtualRootChild: boolean;
}
interface HorizontalViewportRow {
gutter: string;
body: string;
anchorCol: number;
bodyWidth: number;
isSelected: boolean;
}
const TREE_GUTTER_WIDTH = 2;
const MIN_VISIBLE_ANCHOR_CONTENT_WIDTH = 4;
const MAX_VISIBLE_ANCHOR_CONTENT_WIDTH = 20;
const MIN_ANCHOR_CONTEXT_WIDTH = 2;
const MAX_ANCHOR_CONTEXT_WIDTH = 12;
/**
* Render tree rows into a horizontally clipped viewport.
*
* The tree gutter is always kept visible. The row bodies are shifted left only
* when the selected row's anchor (the start of its entry text after tree
* indentation/markers) would otherwise be too far right to see useful content.
*/
function renderHorizontalViewport(rows: HorizontalViewportRow[], width: number): string[] {
const viewportWidth = Math.max(0, width - TREE_GUTTER_WIDTH);
const maxBodyWidth = rows.reduce((max, row) => Math.max(max, row.bodyWidth), 0);
const maxHorizontalScroll = Math.max(0, maxBodyWidth - viewportWidth);
const selectedRow = rows.find((row) => row.isSelected);
// Only pan horizontally when needed to keep enough selected-row content visible after its anchor.
let horizontalScroll = 0;
if (selectedRow && maxHorizontalScroll > 0) {
const minVisibleAnchorContentWidth = Math.min(
MAX_VISIBLE_ANCHOR_CONTENT_WIDTH,
Math.max(MIN_VISIBLE_ANCHOR_CONTENT_WIDTH, Math.floor(viewportWidth / 3)),
);
if (selectedRow.anchorCol > viewportWidth - minVisibleAnchorContentWidth) {
const anchorContextWidth = Math.min(
MAX_ANCHOR_CONTEXT_WIDTH,
Math.max(MIN_ANCHOR_CONTEXT_WIDTH, Math.floor(viewportWidth / 4)),
);
horizontalScroll = Math.min(maxHorizontalScroll, selectedRow.anchorCol - anchorContextWidth);
}
}
// Clip only the body; the fixed-width gutter remains visible as navigation context.
return rows.map((row) => {
const line =
horizontalScroll > 0
? `${row.gutter}${sliceByColumn(row.body, horizontalScroll, viewportWidth, true)}\x1b[0m`
: row.gutter + row.body;
return truncateToWidth(line, width, "");
});
}
/** Filter mode for tree display */
export type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
@@ -617,6 +673,7 @@ class TreeList implements Component {
);
const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length);
const renderedRows: HorizontalViewportRow[] = [];
for (let i = startIndex; i < endIndex; i++) {
const flatNode = this.filteredNodes[i];
const entry = flatNode.node.entry;
@@ -680,14 +737,18 @@ class TreeList implements Component {
? theme.fg("muted", `${this.formatLabelTimestamp(flatNode.node.labelTimestamp)} `)
: "";
const content = this.getEntryDisplayText(flatNode.node, isSelected);
let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + labelTimestamp + content;
const prefixPart = theme.fg("dim", prefix) + foldMarker + pathMarker;
const anchorCol = visibleWidth(prefixPart);
let gutter = cursor;
let body = prefixPart + label + labelTimestamp + content;
if (isSelected) {
line = theme.bg("selectedBg", line);
gutter = theme.bg("selectedBg", gutter);
body = theme.bg("selectedBg", body);
}
lines.push(truncateToWidth(line, width));
renderedRows.push({ gutter, body, anchorCol, bodyWidth: visibleWidth(body), isSelected });
}
lines.push(...renderHorizontalViewport(renderedRows, width));
lines.push(
truncateToWidth(
theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getStatusLabels()}`),
@@ -1075,6 +1136,98 @@ class SearchLine implements Component {
handleInput(_keyData: string): void {}
}
/** Component that renders tree help as semantic rows with chunk-aware wrapping */
class TreeHelp implements Component {
invalidate(): void {}
render(width: number): string[] {
const items = TREE_HELP_ITEMS.map(({ keys, label, labelFirst }) => {
const text = formatHelpKeys(keys);
if (!text) return label;
return labelFirst ? `${label} ${text}` : `${text} ${label}`;
});
const availableWidth = Math.max(1, width);
const indent = " ";
const separator = " · ";
const lines: string[] = [];
let currentLine = "";
for (const item of items) {
const candidate = currentLine
? `${currentLine}${separator}${item}`
: visibleWidth(`${indent}${item}`) <= availableWidth
? `${indent}${item}`
: item;
if (!currentLine || visibleWidth(candidate) <= availableWidth) {
currentLine = candidate;
continue;
}
lines.push(...wrapTextWithAnsi(currentLine.trimEnd(), availableWidth));
currentLine = visibleWidth(`${indent}${item}`) <= availableWidth ? `${indent}${item}` : item;
}
if (currentLine) {
lines.push(...wrapTextWithAnsi(currentLine.trimEnd(), availableWidth));
}
return lines.map((line) => theme.fg("muted", line));
}
}
const TREE_HELP_ITEMS: Array<{ keys: Keybinding[]; label: string; labelFirst?: boolean }> = [
{ 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.tree.editLabel"], label: "label" },
{ keys: ["app.tree.toggleLabelTimestamp"], label: "label time" },
{
keys: [
"app.tree.filter.default",
"app.tree.filter.noTools",
"app.tree.filter.userOnly",
"app.tree.filter.labeledOnly",
"app.tree.filter.all",
],
label: "filters",
labelFirst: true,
},
{ keys: ["app.tree.filter.cycleForward", "app.tree.filter.cycleBackward"], label: "cycle", labelFirst: true },
];
function formatHelpKeys(keybindings: Keybinding[]): string {
const keys: string[] = [];
for (const keybinding of keybindings) {
const key = getKeybindings().getKeys(keybinding)[0];
if (key !== undefined) keys.push(key);
}
if (keys.length === 0) return "";
return formatKeyText(compactRawKeys(keys))
.replace(/\bpageUp\b/g, "pgup")
.replace(/\bpageDown\b/g, "pgdn")
.replace(/\bup\b/g, "↑")
.replace(/\bdown\b/g, "↓")
.replace(/\bleft\b/g, "←")
.replace(/\bright\b/g, "→");
}
function compactRawKeys(keys: string[]): string {
if (keys.length === 1) return keys[0]!;
const parts = keys.map((key) => {
const separatorIndex = key.lastIndexOf("+");
return separatorIndex === -1
? { prefix: "", suffix: key }
: { prefix: key.slice(0, separatorIndex + 1), suffix: key.slice(separatorIndex + 1) };
});
const prefix = parts[0]!.prefix;
return prefix && parts.every((part) => part.prefix === prefix)
? `${prefix}${parts.map((part) => part.suffix).join("/")}`
: keys.join("/");
}
/** Label input component shown when editing a label */
class LabelInput implements Component, Focusable {
private input: Input;
@@ -1181,25 +1334,7 @@ export class TreeSelectorComponent extends Container implements Focusable {
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Text(theme.bold(" Session Tree"), 1, 0));
const filterKeys = [
keyText("app.tree.filter.default"),
keyText("app.tree.filter.noTools"),
keyText("app.tree.filter.userOnly"),
keyText("app.tree.filter.labeledOnly"),
keyText("app.tree.filter.all"),
].join("/");
const cycleKeys = `${keyText("app.tree.filter.cycleForward")}/${keyText("app.tree.filter.cycleBackward")}`;
const branchKeys = `${keyText("app.tree.foldOrUp")}/${keyText("app.tree.unfoldOrDown")}`;
this.addChild(
new TruncatedText(
theme.fg(
"muted",
` ↑/↓: move. ←/→: page. ${branchKeys}: fold/branch. ${keyText("app.tree.editLabel")}: label. ${filterKeys}: filters (${cycleKeys} cycle). ${keyText("app.tree.toggleLabelTimestamp")}: label time`,
),
0,
0,
),
);
this.addChild(new TreeHelp());
this.addChild(new SearchLine(this.treeList));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
@@ -1,7 +1,6 @@
import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui";
import {
getProjectTrustOptions,
getProjectTrustPath,
type ProjectTrustOption,
type ProjectTrustStoreEntry,
} from "../../../core/trust-manager.ts";
@@ -19,12 +18,12 @@ export interface TrustSelectorOptions {
onCancel: () => void;
}
function formatDecision(cwd: string, decision: ProjectTrustStoreEntry | null): string {
function formatDecision(trustPath: string | undefined, decision: ProjectTrustStoreEntry | null): string {
if (decision === null) {
return "none";
}
const label = decision.decision ? "trusted" : "untrusted";
if (decision.path !== getProjectTrustPath(cwd)) {
if (trustPath !== undefined && decision.path !== trustPath) {
return `${label} (inherited from ${decision.path})`;
}
return `${label} (${decision.path})`;
@@ -56,7 +55,14 @@ export class TrustSelectorComponent extends Container {
this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0));
this.addChild(new Spacer(1));
this.addChild(
new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.cwd, options.savedDecision)}`), 1, 0),
new Text(
theme.fg(
"muted",
`Saved decision: ${formatDecision(this.trustOptions[0]?.savedPath, options.savedDecision)}`,
),
1,
0,
),
);
this.addChild(
new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0),
@@ -52,6 +52,7 @@ import { spawn, spawnSync } from "child_process";
import {
APP_NAME,
APP_TITLE,
CONFIG_DIR_NAME,
getAgentDir,
getAuthPath,
getDebugLogPath,
@@ -86,7 +87,7 @@ import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
import type { SourceInfo } from "../../core/source-info.ts";
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
import type { TruncationResult } from "../../core/tools/truncate.ts";
import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.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 { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
@@ -125,22 +126,21 @@ import { TreeSelectorComponent } from "./components/tree-selector.ts";
import { TrustSelectorComponent } from "./components/trust-selector.ts";
import { UserMessageComponent } from "./components/user-message.ts";
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
import { getModelSearchText } from "./model-search.ts";
import {
getAvailableThemes,
getAvailableThemesWithPaths,
getEditorTheme,
getMarkdownTheme,
getThemeByName,
initTheme,
onThemeChange,
setRegisteredThemes,
setTheme,
setThemeInstance,
stopThemeWatcher,
Theme,
type ThemeColor,
theme,
} from "./theme/theme.ts";
import { InteractiveThemeController } from "./theme/theme-controller.ts";
/** Interface for components that can be expanded/collapsed */
interface Expandable {
@@ -371,6 +371,7 @@ export class InteractiveMode {
private options: InteractiveModeOptions;
private autoTrustOnReloadCwd: string | undefined;
private themeController: InteractiveThemeController;
// Convenience accessors
private get session(): AgentSession {
@@ -394,7 +395,7 @@ export class InteractiveMode {
this.resetExtensionUI();
});
this.runtimeHost.setRebindSession(async () => {
await this.rebindCurrentSession();
await this.rebindCurrentSession({ renderBeforeBind: true });
});
this.version = VERSION;
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
@@ -425,7 +426,12 @@ export class InteractiveMode {
// Register themes from resource loader and initialize
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
initTheme(this.settingsManager.getTheme(), true);
this.themeController = new InteractiveThemeController(
this.ui,
this.settingsManager,
(message) => this.showError(message),
() => this.updateEditorBorderColor(),
);
}
private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined {
@@ -498,11 +504,12 @@ export class InteractiveMode {
const items = models.map((m) => ({
id: m.id,
provider: m.provider,
name: m.name,
label: `${m.provider}/${m.id}`,
}));
// Fuzzy filter by model ID + provider (allows "opus anthropic" to match)
const filtered = fuzzyFilter(items, prefix, (item) => `${item.id} ${item.provider}`);
// Fuzzy filter by model ID + provider in either order.
const filtered = fuzzyFilter(items, prefix, getModelSearchText);
if (filtered.length === 0) return null;
@@ -629,9 +636,28 @@ export class InteractiveMode {
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
}
// Add header container as first child
// Add header container as first child. Populate it after detectThemeIfUnset.
this.ui.addChild(this.headerContainer);
this.ui.addChild(this.chatContainer);
this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer);
this.renderWidgets(); // Initialize with default spacer
this.ui.addChild(this.widgetContainerAbove);
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.widgetContainerBelow);
this.ui.addChild(this.footer);
this.ui.setFocus(this.editor);
this.setupKeyHandlers();
this.setupEditorSubmitHandler();
// Start the UI before initializing extensions so session_start handlers can use interactive dialogs
this.ui.start();
this.isInitialized = true;
await this.themeController.applyFromSettings();
// Add header with keybindings from config (unless silenced)
if (this.options.verbose || !this.settingsManager.getQuietStartup()) {
const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
@@ -692,23 +718,7 @@ export class InteractiveMode {
this.builtInHeader = new Text("", 0, 0);
this.headerContainer.addChild(this.builtInHeader);
}
this.ui.addChild(this.chatContainer);
this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer);
this.renderWidgets(); // Initialize with default spacer
this.ui.addChild(this.widgetContainerAbove);
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.widgetContainerBelow);
this.ui.addChild(this.footer);
this.ui.setFocus(this.editor);
this.setupKeyHandlers();
this.setupEditorSubmitHandler();
// Start the UI before initializing extensions so session_start handlers can use interactive dialogs
this.ui.start();
this.isInitialized = true;
this.ui.requestRender();
// Initialize extensions first so resources are shown before messages
await this.rebindCurrentSession();
@@ -1533,12 +1543,7 @@ export class InteractiveMode {
}
this.statusContainer.clear();
try {
const result = await this.runtimeHost.newSession(options);
if (!result.cancelled) {
this.renderCurrentSessionState();
this.ui.requestRender();
}
return result;
return await this.runtimeHost.newSession(options);
} catch (error: unknown) {
return this.handleFatalRuntimeError("Failed to create session", error);
}
@@ -1547,7 +1552,6 @@ export class InteractiveMode {
try {
const result = await this.runtimeHost.fork(entryId, options);
if (!result.cancelled) {
this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? "");
this.showStatus("Forked to new session");
}
@@ -1621,12 +1625,18 @@ export class InteractiveMode {
}
}
private async rebindCurrentSession(): Promise<void> {
private async rebindCurrentSession(options: { renderBeforeBind?: boolean } = {}): Promise<void> {
this.unsubscribe?.();
this.unsubscribe = undefined;
this.applyRuntimeSettings();
await this.bindCurrentSessionExtensions();
this.subscribeToAgent();
if (options.renderBeforeBind) {
this.renderCurrentSessionState();
this.subscribeToAgent();
await this.bindCurrentSessionExtensions();
} else {
await this.bindCurrentSessionExtensions();
this.subscribeToAgent();
}
await this.updateAvailableProviderCount();
this.updateEditorBorderColor();
this.updateTerminalTitle();
@@ -2054,16 +2064,13 @@ export class InteractiveMode {
getTheme: (name) => getThemeByName(name),
setTheme: (themeOrName) => {
if (themeOrName instanceof Theme) {
setThemeInstance(themeOrName);
this.ui.requestRender();
return { success: true };
return this.themeController.setThemeInstance(themeOrName);
}
const result = setTheme(themeOrName, true);
const result = this.themeController.setThemeName(themeOrName);
if (result.success) {
if (this.settingsManager.getTheme() !== themeOrName) {
this.settingsManager.setTheme(themeOrName);
}
this.ui.requestRender();
}
return result;
},
@@ -3271,7 +3278,7 @@ export class InteractiveMode {
}
private renderProjectTrustWarningIfNeeded(): void {
if (this.settingsManager.isProjectTrusted() || !hasProjectTrustInputs(this.sessionManager.getCwd())) {
if (this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(this.sessionManager.getCwd())) {
return;
}
@@ -3282,7 +3289,7 @@ export class InteractiveMode {
new Text(
theme.fg(
"warning",
"This project is not trusted. Project .pi resources and packages are ignored. Use /trust to save a trust decision, then restart pi.",
`This project is not trusted. Project ${CONFIG_DIR_NAME} resources and packages are ignored. Use /trust to save a trust decision, then restart pi.`,
),
1,
0,
@@ -3339,7 +3346,9 @@ export class InteractiveMode {
private async shutdown(options?: { fromSignal?: boolean }): Promise<void> {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
this.unregisterSignalHandlers();
// Keep signal handlers registered until terminal cleanup has completed.
// `signal-exit` checks the listener list during the same SIGTERM/SIGHUP
// dispatch and re-sends the signal if only its own listeners remain.
if (options?.fromSignal) {
// Signal-triggered shutdown (SIGTERM/SIGHUP). Emit extension cleanup
@@ -3350,6 +3359,7 @@ export class InteractiveMode {
// which the stdout/stderr error handler turns into emergencyTerminalExit;
// the render loop is already idle, so this cannot hot-spin (see #4144).
await this.runtimeHost.dispose();
this.themeController.disableAutoSync();
await this.ui.terminal.drainInput(1000);
this.stop();
process.exit(0);
@@ -3360,6 +3370,7 @@ export class InteractiveMode {
// the final frame while the process is exiting.
// Drain any in-flight Kitty key release events before stopping.
// This prevents escape sequences from leaking to the parent shell over slow SSH.
this.themeController.disableAutoSync();
await this.ui.terminal.drainInput(1000);
this.stop();
@@ -3689,7 +3700,6 @@ export class InteractiveMode {
showError(errorMessage: string): void {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
this.chatContainer.addChild(new Spacer(1));
this.ui.requestRender();
}
@@ -3704,7 +3714,7 @@ export class InteractiveMode {
const updateInstruction = theme.fg("muted", `New version ${release.version} is available. Run `) + action;
const changelogUrl = "https://pi.dev/changelog";
const changelogLink = getCapabilities().hyperlinks
? hyperlink(theme.fg("accent", "open changelog"), changelogUrl)
? hyperlink(theme.fg("accent", changelogUrl), changelogUrl)
: theme.fg("accent", changelogUrl);
const changelogLine = theme.fg("muted", "Changelog: ") + changelogLink;
const note = release.note?.trim();
@@ -3729,7 +3739,7 @@ export class InteractiveMode {
}
showPackageUpdateNotification(packages: string[]): void {
const action = theme.fg("accent", `${APP_NAME} update`);
const action = theme.fg("accent", `${APP_NAME} update --extensions`);
const updateInstruction = theme.fg("muted", "Package updates are available. Run ") + action;
const packageLines = packages.map((pkg) => `- ${pkg}`).join("\n");
@@ -3963,7 +3973,8 @@ export class InteractiveMode {
httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(),
thinkingLevel: this.session.thinkingLevel,
availableThinkingLevels: this.session.getAvailableThinkingLevels(),
currentTheme: this.settingsManager.getTheme() || "dark",
currentTheme: this.settingsManager.getThemeSetting() || "dark",
terminalTheme: this.themeController.getTerminalTheme(),
availableThemes: getAvailableThemes(),
hideThinkingBlock: this.hideThinkingBlock,
collapseChangelog: this.settingsManager.getCollapseChangelog(),
@@ -4030,21 +4041,11 @@ export class InteractiveMode {
this.footer.invalidate();
this.updateEditorBorderColor();
},
onThemeChange: (themeName) => {
const result = setTheme(themeName, true);
this.settingsManager.setTheme(themeName);
this.ui.invalidate();
if (!result.success) {
this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
}
},
onThemePreview: (themeName) => {
const result = setTheme(themeName, true);
if (result.success) {
this.ui.invalidate();
this.ui.requestRender();
}
onThemeChange: (themeSetting) => {
this.settingsManager.setTheme(themeSetting);
void this.themeController.applyFromSettings();
},
onThemePreview: (themeName) => this.themeController.preview(themeName),
onHideThinkingBlockChange: (hidden) => {
this.hideThinkingBlock = hidden;
this.settingsManager.setHideThinkingBlock(hidden);
@@ -4198,7 +4199,7 @@ export class InteractiveMode {
if (this.autoTrustOnReloadCwd !== cwd) {
return false;
}
if (!this.settingsManager.isProjectTrusted() || !hasProjectConfigDir(cwd)) {
if (!this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(cwd)) {
return false;
}
@@ -4375,7 +4376,6 @@ export class InteractiveMode {
return;
}
this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? "");
done();
this.showStatus("Forked to new session");
@@ -4408,7 +4408,6 @@ export class InteractiveMode {
return;
}
this.renderCurrentSessionState();
this.editor.setText("");
this.showStatus("Cloned to new session");
} catch (error: unknown) {
@@ -4600,7 +4599,6 @@ export class InteractiveMode {
if (result.cancelled) {
return result;
}
this.renderCurrentSessionState();
this.showStatus("Resumed session");
return result;
} catch (error: unknown) {
@@ -4618,7 +4616,6 @@ export class InteractiveMode {
if (result.cancelled) {
return result;
}
this.renderCurrentSessionState();
this.showStatus("Resumed session in current cwd");
return result;
}
@@ -5071,8 +5068,20 @@ export class InteractiveMode {
this.ui.requestRender();
};
let chatRestoredBeforeSessionStart = false;
let reloadBoxDismissed = false;
const restoreChatBeforeSessionStart = () => {
if (chatRestoredBeforeSessionStart) {
return;
}
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
this.rebuildChatFromMessages();
chatRestoredBeforeSessionStart = true;
};
try {
await this.session.reload();
await this.session.reload({ beforeSessionStart: restoreChatBeforeSessionStart });
restoreChatBeforeSessionStart();
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
this.keybindings.reload();
const activeHeader = this.customHeader ?? this.builtInHeader;
@@ -5080,12 +5089,7 @@ export class InteractiveMode {
activeHeader.setExpanded(this.toolOutputExpanded);
}
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
const themeName = this.settingsManager.getTheme();
const themeResult = themeName ? setTheme(themeName, true) : { success: true };
if (!themeResult.success) {
this.showError(`Failed to load theme "${themeName}": ${themeResult.error}\nFell back to dark theme.`);
}
await this.themeController.applyFromSettings();
const editorPaddingX = this.settingsManager.getEditorPaddingX();
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
this.defaultEditor.setPaddingX(editorPaddingX);
@@ -5099,8 +5103,6 @@ export class InteractiveMode {
this.setupAutocompleteProvider();
const runner = this.session.extensionRunner;
this.setupExtensionShortcuts(runner);
this.rebuildChatFromMessages();
dismissReloadBox(this.editor as Component);
this.showLoadedResources({
force: false,
showDiagnosticsWhenQuiet: true,
@@ -5115,8 +5117,12 @@ export class InteractiveMode {
? "Reloaded keybindings, extensions, skills, prompts, themes; saved project trust"
: "Reloaded keybindings, extensions, skills, prompts, themes",
);
dismissReloadBox(this.editor as Component);
reloadBoxDismissed = true;
} catch (error) {
dismissReloadBox(previousEditor as Component);
if (!reloadBoxDismissed) {
dismissReloadBox(previousEditor as Component);
}
this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
@@ -5190,7 +5196,6 @@ export class InteractiveMode {
this.showStatus("Import cancelled");
return;
}
this.renderCurrentSessionState();
this.showStatus(`Session imported from: ${inputPath}`);
} catch (error: unknown) {
if (error instanceof MissingSessionCwdError) {
@@ -5204,7 +5209,6 @@ export class InteractiveMode {
this.showStatus("Import cancelled");
return;
}
this.renderCurrentSessionState();
this.showStatus(`Session imported from: ${inputPath}`);
return;
}
@@ -5543,7 +5547,6 @@ export class InteractiveMode {
if (result.cancelled) {
return;
}
this.renderCurrentSessionState();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
this.ui.requestRender();
@@ -5697,14 +5700,6 @@ export class InteractiveMode {
}
private async handleCompactCommand(customInstructions?: string): Promise<void> {
const entries = this.sessionManager.getEntries();
const messageCount = entries.filter((e) => e.type === "message").length;
if (messageCount < 2) {
this.showWarning("Nothing to compact (no messages yet)");
return;
}
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
@@ -5719,7 +5714,6 @@ export class InteractiveMode {
}
stop(): void {
this.unregisterSignalHandlers();
if (this.settingsManager.getShowTerminalProgress()) {
this.ui.terminal.setProgress(false);
}
@@ -5727,6 +5721,7 @@ export class InteractiveMode {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.themeController.disableAutoSync();
this.clearExtensionTerminalInputListeners();
this.footer.dispose();
this.footerDataProvider.dispose();
@@ -5737,5 +5732,6 @@ export class InteractiveMode {
this.ui.stop();
this.isInitialized = false;
}
this.unregisterSignalHandlers();
}
}
@@ -0,0 +1,21 @@
export interface ModelSearchItem {
id: string;
provider: string;
name?: string;
}
export function getModelSearchText(item: ModelSearchItem): string {
const { id, provider } = item;
const name = item.name ? ` ${item.name}` : "";
return `${id} ${provider} ${provider}/${id} ${provider} ${id}${name}`;
}
/**
* The /model selector search should rank exact provider-prefixed queries before proxy-provider IDs
* like openrouter/openai/gpt-5, so keep the bare model ID out of the leading position.
*/
export function getModelSelectorSearchText(item: ModelSearchItem): string {
const { id, provider } = item;
const name = item.name ? ` ${item.name}` : "";
return `${provider} ${provider}/${id} ${provider} ${id}${name}`;
}
@@ -0,0 +1,135 @@
import type { TUI } from "@earendil-works/pi-tui";
import type { SettingsManager } from "../../../core/settings-manager.ts";
import {
detectTerminalBackgroundFromEnv,
detectTerminalBackgroundTheme,
initTheme,
parseAutoThemeSetting,
resolveThemeSetting,
setTheme,
setThemeInstance,
type TerminalTheme,
type Theme,
} from "./theme.ts";
type ThemeResult = { success: boolean; error?: string };
export class InteractiveThemeController {
private readonly ui: TUI;
private readonly settingsManager: SettingsManager;
private readonly showError: (message: string) => void;
private readonly onChanged: () => void;
private terminalTheme: TerminalTheme = detectTerminalBackgroundFromEnv().theme;
private activeThemeName: string | undefined;
private autoSyncEnabled = false;
constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void) {
this.ui = ui;
this.settingsManager = settingsManager;
this.showError = showError;
this.onChanged = onChanged;
this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme);
initTheme(this.activeThemeName, true);
this.ui.onTerminalColorSchemeChange((terminalTheme) => this.applyTerminalTheme(terminalTheme));
}
async applyFromSettings(): Promise<void> {
const themeSetting = this.settingsManager.getThemeSetting();
const autoTheme = parseAutoThemeSetting(themeSetting);
if (autoTheme) {
this.terminalTheme = await this.detectTerminalThemeForAuto();
this.setAutoSync(true);
this.applyThemeName(this.terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme, true);
return;
}
this.setAutoSync(false);
if (themeSetting !== undefined) {
this.applyThemeName(themeSetting, true);
return;
}
const detection = await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 });
this.terminalTheme = detection.theme;
if (!this.applyThemeName(detection.theme).success) return;
if (detection.confidence === "high") {
this.settingsManager.setTheme(detection.theme);
await this.settingsManager.flush();
}
}
setThemeName(themeName: string, showError = false): ThemeResult {
this.setAutoSync(false);
return this.applyThemeName(themeName, showError);
}
setThemeInstance(themeInstance: Theme): ThemeResult {
this.setAutoSync(false);
setThemeInstance(themeInstance);
this.activeThemeName = "<in-memory>";
this.notifyChanged();
return { success: true };
}
preview(themeSettingOrName: string): void {
const themeName = resolveThemeSetting(themeSettingOrName, this.terminalTheme) ?? this.activeThemeName;
if (!themeName) return;
if (setTheme(themeName, true).success) {
this.ui.invalidate();
this.ui.requestRender();
}
}
disableAutoSync(): void {
this.setAutoSync(false);
}
getTerminalTheme(): TerminalTheme {
return this.terminalTheme;
}
private applyThemeName(themeName: string, showError = false): ThemeResult {
const result = setTheme(themeName, true);
this.activeThemeName = result.success ? themeName : "dark";
this.notifyChanged();
if (!result.success && showError) {
this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
}
return result;
}
private notifyChanged(): void {
this.ui.invalidate();
this.onChanged();
}
private setAutoSync(enabled: boolean): void {
if (this.autoSyncEnabled === enabled) return;
this.autoSyncEnabled = enabled;
this.ui.setTerminalColorSchemeNotifications(enabled);
}
private async detectTerminalThemeForAuto(): Promise<TerminalTheme> {
try {
const colorScheme = await this.ui.queryTerminalColorScheme({ timeoutMs: 100 });
if (colorScheme) return colorScheme;
} catch {
// Fall back to OSC 11 / COLORFGBG detection when color-scheme DSR is unsupported.
}
return (await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 })).theme;
}
private applyTerminalTheme(terminalTheme: TerminalTheme): void {
if (!this.autoSyncEnabled) return;
this.terminalTheme = terminalTheme;
const autoTheme = parseAutoThemeSetting(this.settingsManager.getThemeSetting());
if (!autoTheme) {
this.setAutoSync(false);
return;
}
const themeName = terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme;
if (themeName !== this.activeThemeName) {
this.applyThemeName(themeName);
}
}
}
@@ -11,7 +11,8 @@
},
"name": {
"type": "string",
"description": "Theme name"
"pattern": "^[^/]+$",
"description": "Theme name. Must not contain '/' because it is reserved for automatic light/dark theme settings."
},
"vars": {
"type": "object",
@@ -4,6 +4,7 @@ import {
type EditorTheme,
getCapabilities,
type MarkdownTheme,
type RgbColor,
type SelectListTheme,
type SettingsListTheme,
} from "@earendil-works/pi-tui";
@@ -502,6 +503,14 @@ function getCustomThemeInfos(): ThemeInfo[] {
return result;
}
function assertThemeNameIsValid(name: string): void {
if (name.includes("/")) {
throw new Error(
`Invalid theme name "${name}": theme names cannot contain "/" because it is reserved for automatic light/dark theme settings.`,
);
}
}
function parseThemeJson(label: string, json: unknown): ThemeJson {
if (!validateThemeJson.Check(json)) {
const errors = Array.from(validateThemeJson.Errors(json));
@@ -538,7 +547,9 @@ function parseThemeJson(label: string, json: unknown): ThemeJson {
throw new Error(errorMessage);
}
return json as ThemeJson;
const themeJson = json as ThemeJson;
assertThemeNameIsValid(themeJson.name);
return themeJson;
}
function parseThemeJsonContent(label: string, content: string): ThemeJson {
@@ -624,10 +635,34 @@ export function getThemeByName(name: string): Theme | undefined {
export type TerminalTheme = "dark" | "light";
export interface RgbColor {
r: number;
g: number;
b: number;
export function parseAutoThemeSetting(
themeSetting: string | undefined,
): { lightTheme: string; darkTheme: string } | undefined {
if (!themeSetting) return undefined;
const slashIndex = themeSetting.indexOf("/");
if (slashIndex === -1 || themeSetting.indexOf("/", slashIndex + 1) !== -1) {
return undefined;
}
const lightTheme = themeSetting.slice(0, slashIndex).trim();
const darkTheme = themeSetting.slice(slashIndex + 1).trim();
if (!lightTheme || !darkTheme) {
return undefined;
}
return { lightTheme, darkTheme };
}
export function resolveThemeSetting(
themeSetting: string | undefined,
terminalTheme: TerminalTheme,
): string | undefined {
const autoTheme = parseAutoThemeSetting(themeSetting);
if (autoTheme) {
return terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme;
}
if (themeSetting?.includes("/")) return undefined;
if (typeof themeSetting === "string") return themeSetting;
return undefined;
}
export interface TerminalThemeDetection {
@@ -641,6 +676,15 @@ export interface TerminalThemeDetectionOptions {
env?: NodeJS.ProcessEnv;
}
export interface TerminalBackgroundThemeDetector {
queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise<RgbColor | undefined>;
}
export interface TerminalBackgroundThemeDetectionOptions extends TerminalThemeDetectionOptions {
ui: TerminalBackgroundThemeDetector;
timeoutMs: number;
}
function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined {
const parts = colorfgbg.split(";");
for (let i = parts.length - 1; i >= 0; i--) {
@@ -668,50 +712,7 @@ export function getThemeForRgbColor(rgb: RgbColor): TerminalTheme {
return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark";
}
function parseOscHexChannel(channel: string): number | undefined {
if (!/^[0-9a-f]+$/i.test(channel)) {
return undefined;
}
const max = 16 ** channel.length - 1;
if (max <= 0) {
return undefined;
}
return Math.round((parseInt(channel, 16) / max) * 255);
}
export function parseOsc11BackgroundColor(data: string): RgbColor | undefined {
const match = data.match(/^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i);
if (!match) {
return undefined;
}
const value = match[1].trim();
if (value.startsWith("#")) {
const hex = value.slice(1);
if (/^[0-9a-f]{6}$/i.test(hex)) {
return hexToRgb(value);
}
if (/^[0-9a-f]{12}$/i.test(hex)) {
const r = parseOscHexChannel(hex.slice(0, 4));
const g = parseOscHexChannel(hex.slice(4, 8));
const b = parseOscHexChannel(hex.slice(8, 12));
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
}
return undefined;
}
const rgbValue = value.replace(/^rgba?:/i, "");
const [red, green, blue] = rgbValue.split("/");
if (red === undefined || green === undefined || blue === undefined) {
return undefined;
}
const r = parseOscHexChannel(red);
const g = parseOscHexChannel(green);
const b = parseOscHexChannel(blue);
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
}
export function detectTerminalBackground(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection {
export function detectTerminalBackgroundFromEnv(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection {
const env = options.env ?? process.env;
const colorfgbg = env.COLORFGBG || "";
const bg = getColorFgBgBackgroundIndex(colorfgbg);
@@ -732,8 +733,30 @@ export function detectTerminalBackground(options: TerminalThemeDetectionOptions
};
}
export async function detectTerminalBackgroundTheme({
ui,
timeoutMs,
env,
}: TerminalBackgroundThemeDetectionOptions): Promise<TerminalThemeDetection> {
try {
const rgb = await ui.queryTerminalBackgroundColor({ timeoutMs });
if (rgb) {
return {
theme: getThemeForRgbColor(rgb),
source: "terminal background",
detail: `OSC 11 background rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
confidence: "high",
};
}
} catch {
// Fall back to environment-based detection when the terminal query fails.
}
return detectTerminalBackgroundFromEnv({ env });
}
export function getDefaultTheme(): string {
return detectTerminalBackground().theme;
return detectTerminalBackgroundFromEnv().theme;
}
// ============================================================================
@@ -769,6 +792,7 @@ export function setRegisteredThemes(themes: Theme[]): void {
registeredThemes.clear();
for (const theme of themes) {
if (theme.name) {
assertThemeNameIsValid(theme.name);
registeredThemes.set(theme.name, theme);
}
}
@@ -667,7 +667,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
default: {
const unknownCommand = command as { type: string };
return error(undefined, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
return error(id, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
}
}
};
@@ -4,6 +4,7 @@ import { selectConfig } from "./cli/config-selector.ts";
import { createProjectTrustContext } from "./cli/project-trust.ts";
import {
APP_NAME,
CONFIG_DIR_NAME,
detectInstallMethod,
getAgentDir,
getPackageDir,
@@ -11,6 +12,7 @@ import {
getSelfUpdateUnavailableInstruction,
PACKAGE_NAME,
type SelfUpdateCommand,
type SelfUpdatePackageTarget,
VERSION,
} from "./config.ts";
import type { ExtensionFactory } from "./core/extensions/types.ts";
@@ -18,7 +20,7 @@ import { DefaultPackageManager } from "./core/package-manager.ts";
import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
import { DefaultResourceLoader } from "./core/resource-loader.ts";
import { SettingsManager } from "./core/settings-manager.ts";
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts";
import { spawnProcess } from "./utils/child-process.ts";
import { getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts";
import {
@@ -51,6 +53,7 @@ interface PackageCommandOptions {
command: PackageCommand;
source?: string;
updateTarget?: UpdateTarget;
showExtensionsSkippedNote: boolean;
local: boolean;
force: boolean;
projectTrustOverride?: boolean;
@@ -78,7 +81,7 @@ function getPackageCommandUsage(command: PackageCommand): string {
case "remove":
return `${APP_NAME} remove <source> [-l] [--approve|--no-approve]`;
case "update":
return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>] [--approve|--no-approve] [--force]`;
return `${APP_NAME} update [source|self|pi] [--self|--extensions|--all] [--extension <source>] [--approve|--no-approve] [--force]`;
case "list":
return `${APP_NAME} list [--approve|--no-approve]`;
}
@@ -93,7 +96,7 @@ function printPackageCommandHelp(command: PackageCommand): void {
Install a package and add it to settings.
Options:
-l, --local Install project-locally (.pi/settings.json)
-l, --local Install project-locally (${CONFIG_DIR_NAME}/settings.json)
-a, --approve Trust project-local files for this command
-na, --no-approve Ignore project-local files for this command
@@ -115,7 +118,7 @@ Remove a package and its source from settings.
Alias: ${APP_NAME} uninstall <source> [-l]
Options:
-l, --local Remove from project settings (.pi/settings.json)
-l, --local Remove from project settings (${CONFIG_DIR_NAME}/settings.json)
-a, --approve Trust project-local files for this command
-na, --no-approve Ignore project-local files for this command
@@ -132,15 +135,17 @@ Examples:
Update pi and installed packages.
Options:
--self Update pi only
--self Update pi only (default when no target is given)
--extensions Update installed packages only
--all Update pi and installed packages
--extension <source> Update one package only
-a, --approve Trust project-local files for this command
-na, --no-approve Ignore project-local files for this command
--force Reinstall pi even if the current version is latest
Short forms:
${APP_NAME} update Update pi and all extensions
${APP_NAME} update Update pi only
${APP_NAME} update --all Update pi and all extensions
${APP_NAME} update <source> Update one package
${APP_NAME} update pi Update pi only (self works as alias to pi)
`);
@@ -183,6 +188,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
let source: string | undefined;
let selfFlag = false;
let extensionsFlag = false;
let allFlag = false;
let extensionFlagSource: string | undefined;
for (let index = 0; index < rest.length; index++) {
@@ -219,6 +225,15 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
continue;
}
if (arg === "--all") {
if (command === "update") {
allFlag = true;
} else {
invalidOption = invalidOption ?? arg;
}
continue;
}
if (arg === "--approve" || arg === "-a") {
projectTrustOverride = true;
continue;
@@ -270,10 +285,20 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
}
let updateTarget: UpdateTarget | undefined;
let showExtensionsSkippedNote = false;
if (command === "update") {
if (allFlag && (selfFlag || extensionsFlag || extensionFlagSource)) {
conflictingOptions =
conflictingOptions ?? "--all cannot be combined with --self, --extensions, or --extension";
}
if (allFlag && source) {
conflictingOptions = conflictingOptions ?? "--all cannot be combined with a positional source";
}
if (extensionFlagSource) {
if (selfFlag || extensionsFlag) {
conflictingOptions = conflictingOptions ?? "--extension cannot be combined with --self or --extensions";
if (selfFlag || extensionsFlag || allFlag) {
conflictingOptions =
conflictingOptions ?? "--extension cannot be combined with --self, --extensions, or --all";
}
if (source) {
conflictingOptions = conflictingOptions ?? "--extension cannot be combined with a positional source";
@@ -284,12 +309,15 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
if (sourceIsSelf) {
updateTarget = extensionsFlag ? { type: "all" } : { type: "self" };
} else {
if (extensionsFlag || selfFlag) {
if (extensionsFlag || selfFlag || allFlag) {
conflictingOptions =
conflictingOptions ?? "positional update targets cannot be combined with --self or --extensions";
conflictingOptions ??
"positional update targets cannot be combined with --self, --extensions, or --all";
}
updateTarget = { type: "extensions", source };
}
} else if (allFlag) {
updateTarget = { type: "all" };
} else if (selfFlag && extensionsFlag) {
updateTarget = { type: "all" };
} else if (selfFlag) {
@@ -297,7 +325,8 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
} else if (extensionsFlag) {
updateTarget = { type: "extensions" };
} else {
updateTarget = { type: "all" };
updateTarget = { type: "self" };
showExtensionsSkippedNote = true;
}
}
@@ -305,6 +334,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined
command,
source,
updateTarget,
showExtensionsSkippedNote,
local,
force,
projectTrustOverride,
@@ -324,9 +354,12 @@ function updateTargetIncludesExtensions(target: UpdateTarget): boolean {
return target.type === "all" || target.type === "extensions";
}
function printSelfUpdateUnavailable(npmCommand?: string[], updatePackageName = PACKAGE_NAME): void {
function printSelfUpdateUnavailable(
npmCommand?: string[],
updatePackageTarget: SelfUpdatePackageTarget = PACKAGE_NAME,
): void {
console.error(`error: ${APP_NAME} cannot self-update this installation.`);
console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME, npmCommand, updatePackageName));
console.error(getSelfUpdateUnavailableInstruction(PACKAGE_NAME, npmCommand, updatePackageTarget));
const entrypoint = process.argv[1];
if (entrypoint) {
@@ -361,27 +394,38 @@ function printSelfUpdateNote(note: string): void {
interface SelfUpdatePlan {
packageName: string;
installSpec: string;
version: string;
shouldRun: boolean;
note?: string;
}
async function getSelfUpdatePlan(force: boolean): Promise<SelfUpdatePlan> {
if (force) {
return { packageName: PACKAGE_NAME, shouldRun: true };
let latestRelease: Awaited<ReturnType<typeof getLatestPiRelease>>;
try {
latestRelease = await getLatestPiRelease(VERSION);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Could not determine latest ${APP_NAME} version: ${message}`);
}
if (!latestRelease) {
throw new Error(`Could not determine latest ${APP_NAME} version.`);
}
try {
const latestRelease = await getLatestPiRelease(VERSION);
const packageName = latestRelease?.packageName ?? PACKAGE_NAME;
if (!latestRelease || packageName !== PACKAGE_NAME || isNewerPackageVersion(latestRelease.version, VERSION)) {
return { packageName, shouldRun: true, ...(latestRelease?.note ? { note: latestRelease.note } : {}) };
}
} catch {
return { packageName: PACKAGE_NAME, shouldRun: true };
const packageName = latestRelease.packageName ?? PACKAGE_NAME;
const installSpec = `${packageName}@${latestRelease.version}`;
if (force || packageName !== PACKAGE_NAME || isNewerPackageVersion(latestRelease.version, VERSION)) {
return {
packageName,
installSpec,
version: latestRelease.version,
...(latestRelease.note ? { note: latestRelease.note } : {}),
shouldRun: true,
};
}
console.log(chalk.green(`${APP_NAME} is already up to date (v${VERSION})`));
return { packageName: PACKAGE_NAME, shouldRun: false };
return { packageName, installSpec, version: latestRelease.version, shouldRun: false };
}
async function runSelfUpdate(command: SelfUpdateCommand): Promise<void> {
@@ -452,13 +496,21 @@ async function createCommandSettingsManager(options: {
cwd: string;
agentDir: string;
projectTrustOverride?: boolean;
useSavedProjectTrustOnly?: boolean;
extensionFactories?: ExtensionFactory[];
}): Promise<CommandSettingsResult> {
const settingsManager = SettingsManager.create(options.cwd, options.agentDir, { projectTrusted: false });
const projectTrustWarnings: string[] = [];
const trustStore = new ProjectTrustStore(options.agentDir);
if (options.useSavedProjectTrustOnly) {
const savedProjectTrusted = trustStore.get(options.cwd) === true;
settingsManager.setProjectTrusted(options.projectTrustOverride ?? savedProjectTrusted);
return { settingsManager, projectTrustWarnings };
}
const appMode = getCommandAppMode();
const extensionsResult =
options.projectTrustOverride === undefined && hasProjectTrustInputs(options.cwd)
options.projectTrustOverride === undefined && hasTrustRequiringProjectResources(options.cwd)
? await new DefaultResourceLoader({
cwd: options.cwd,
agentDir: options.agentDir,
@@ -472,7 +524,7 @@ async function createCommandSettingsManager(options: {
const projectTrusted = await resolveProjectTrusted({
cwd: options.cwd,
trustStore: new ProjectTrustStore(options.agentDir),
trustStore,
trustOverride: options.projectTrustOverride,
defaultProjectTrust: settingsManager.getDefaultProjectTrust(),
extensionsResult,
@@ -576,6 +628,7 @@ export async function handlePackageCommand(
cwd,
agentDir,
projectTrustOverride: options.projectTrustOverride,
useSavedProjectTrustOnly: options.command === "update",
extensionFactories: runtimeOptions.extensionFactories,
});
reportProjectTrustWarnings(projectTrustWarnings);
@@ -650,7 +703,12 @@ export async function handlePackageCommand(
}
case "update": {
const target = options.updateTarget ?? { type: "all" };
const target = options.updateTarget ?? { type: "self" };
if (options.showExtensionsSkippedNote) {
console.log(
chalk.dim(`Extensions are skipped. Run ${APP_NAME} update --extensions to update extensions.`),
);
}
if (updateTargetIncludesExtensions(target)) {
const updateSource = target.type === "extensions" ? target.source : undefined;
await packageManager.update(updateSource);
@@ -674,13 +732,13 @@ export async function handlePackageCommand(
process.exitCode = 1;
return true;
}
const selfUpdateCommand = getSelfUpdateCommand(
PACKAGE_NAME,
selfUpdateNpmCommand,
selfUpdatePlan.packageName,
);
const selfUpdateTarget = {
packageName: selfUpdatePlan.packageName,
installSpec: selfUpdatePlan.installSpec,
};
const selfUpdateCommand = getSelfUpdateCommand(PACKAGE_NAME, selfUpdateNpmCommand, selfUpdateTarget);
if (!selfUpdateCommand) {
printSelfUpdateUnavailable(selfUpdateNpmCommand, selfUpdatePlan.packageName);
printSelfUpdateUnavailable(selfUpdateNpmCommand, selfUpdateTarget);
process.exitCode = 1;
return true;
}
@@ -699,7 +757,7 @@ export async function handlePackageCommand(
process.exitCode = 1;
return true;
}
console.log(chalk.green(`Updated ${APP_NAME}`));
console.log(chalk.green(`Updated ${APP_NAME} from ${VERSION} to ${selfUpdatePlan.version}`));
}
return true;
}
@@ -38,10 +38,13 @@ export function spawnProcessSync(
/**
* Wait for a child process to terminate without hanging on inherited stdio handles.
*
* On Windows, daemonized descendants can inherit the child's stdout/stderr pipe
* handles. In that case the child emits `exit`, but `close` can hang forever even
* though the original process is already gone. We wait briefly for stdio to end,
* then forcibly stop tracking the inherited handles.
* A short-lived child can `exit` while a detached descendant keeps its stdout/stderr
* pipe open. We must not resolve and destroy the streams on a fixed deadline measured
* from `exit`, or output still being written past that deadline is silently lost
* (earendil-works/pi#5303). Instead, after `exit` we wait for the pipes to fall idle:
* the grace timer is re-armed on every chunk, so an actively writing descendant keeps
* us reading, while a quiet inherited handle (e.g. a Windows daemonized descendant
* that never lets `close` fire) still releases us after the grace elapses.
*/
export function waitForChildProcess(child: ChildProcess): Promise<number | null> {
return new Promise((resolve, reject) => {
@@ -62,6 +65,8 @@ export function waitForChildProcess(child: ChildProcess): Promise<number | null>
child.removeListener("close", onClose);
child.stdout?.removeListener("end", onStdoutEnd);
child.stderr?.removeListener("end", onStderrEnd);
child.stdout?.removeListener("data", onData);
child.stderr?.removeListener("data", onData);
};
const finalize = (code: number | null) => {
@@ -80,6 +85,17 @@ export function waitForChildProcess(child: ChildProcess): Promise<number | null>
}
};
const armIdleTimer = () => {
if (postExitTimer) clearTimeout(postExitTimer);
postExitTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_GRACE_MS);
};
const onData = () => {
// Output is still arriving after exit; defer finalizing so we don't
// destroy the stream mid-write and truncate the tail.
if (exited && !settled) armIdleTimer();
};
const onStdoutEnd = () => {
stdoutEnded = true;
maybeFinalizeAfterExit();
@@ -102,7 +118,7 @@ export function waitForChildProcess(child: ChildProcess): Promise<number | null>
exitCode = code;
maybeFinalizeAfterExit();
if (!settled) {
postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS);
armIdleTimer();
}
};
@@ -112,6 +128,8 @@ export function waitForChildProcess(child: ChildProcess): Promise<number | null>
child.stdout?.once("end", onStdoutEnd);
child.stderr?.once("end", onStderrEnd);
child.stdout?.on("data", onData);
child.stderr?.on("data", onData);
child.once("error", onError);
child.once("exit", onExit);
child.once("close", onClose);
+15 -5
View File
@@ -6,11 +6,21 @@ import { getBinDir } from "../config.ts";
export interface ShellConfig {
shell: string;
args: string[];
commandTransport?: "argv" | "stdin";
}
/**
* Find bash executable on PATH (cross-platform)
*/
function isLegacyWslBashPath(path: string): boolean {
const normalized = path.replace(/\//g, "\\").toLowerCase();
return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized);
}
function getBashShellConfig(shell: string): ShellConfig {
return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] };
}
function findBashOnPath(): string | null {
if (process.platform === "win32") {
// Windows: Use 'where' and verify file exists (where can return non-existent paths)
@@ -58,7 +68,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
// 1. Check user-specified shell path
if (customShellPath) {
if (existsSync(customShellPath)) {
return { shell: customShellPath, args: ["-c"] };
return getBashShellConfig(customShellPath);
}
throw new Error(`Custom shell path not found: ${customShellPath}`);
}
@@ -77,14 +87,14 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
for (const path of paths) {
if (existsSync(path)) {
return { shell: path, args: ["-c"] };
return getBashShellConfig(path);
}
}
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
const bashOnPath = findBashOnPath();
if (bashOnPath) {
return { shell: bashOnPath, args: ["-c"] };
return getBashShellConfig(bashOnPath);
}
throw new Error(
@@ -98,12 +108,12 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
// Unix: try /bin/bash, then bash on PATH, then fallback to sh
if (existsSync("/bin/bash")) {
return { shell: "/bin/bash", args: ["-c"] };
return getBashShellConfig("/bin/bash");
}
const bashOnPath = findBashOnPath();
if (bashOnPath) {
return { shell: bashOnPath, args: ["-c"] };
return getBashShellConfig(bashOnPath);
}
return { shell: "sh", args: ["-c"] };
@@ -1,3 +1,4 @@
import { compare, valid } from "semver";
import { getPiUserAgent } from "./pi-user-agent.ts";
const LATEST_VERSION_URL = "https://pi.dev/api/latest-version";
@@ -9,40 +10,13 @@ export interface LatestPiRelease {
note?: string;
}
interface ParsedVersion {
major: number;
minor: number;
patch: number;
prerelease?: string;
}
function parsePackageVersion(version: string): ParsedVersion | undefined {
const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/);
if (!match) {
return undefined;
}
return {
major: Number.parseInt(match[1], 10),
minor: Number.parseInt(match[2], 10),
patch: Number.parseInt(match[3], 10),
prerelease: match[4],
};
}
export function comparePackageVersions(leftVersion: string, rightVersion: string): number | undefined {
const left = parsePackageVersion(leftVersion);
const right = parsePackageVersion(rightVersion);
const left = valid(leftVersion.trim());
const right = valid(rightVersion.trim());
if (!left || !right) {
return undefined;
}
if (left.major !== right.major) return left.major - right.major;
if (left.minor !== right.minor) return left.minor - right.minor;
if (left.patch !== right.patch) return left.patch - right.patch;
if (left.prerelease === right.prerelease) return 0;
if (!left.prerelease) return 1;
if (!right.prerelease) return -1;
return left.prerelease.localeCompare(right.prerelease);
return compare(left, right);
}
export function isNewerPackageVersion(candidateVersion: string, currentVersion: string): boolean {