Merge main into model-registry
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user