Merge branch 'main' into prompt-template-defaults

This commit is contained in:
Mario Zechner
2026-06-09 14:23:00 +02:00
committed by GitHub
65 changed files with 2021 additions and 551 deletions

View File

@@ -230,10 +230,8 @@ ${chalk.bold("Commands:")}
${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} list [--approve|--no-approve]
List installed extensions from settings
${APP_NAME} config [--no-approve]
Open TUI to enable/disable package resources
${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
${chalk.bold("Options:")}

View File

@@ -0,0 +1,62 @@
import chalk from "chalk";
import type { ProjectTrustContext } from "../core/extensions/types.ts";
import type { AppMode } from "../core/project-trust.ts";
import type { SettingsManager } from "../core/settings-manager.ts";
import { showStartupInput, showStartupSelector } from "./startup-ui.ts";
export function createProjectTrustContext(options: {
cwd: string;
mode: AppMode;
settingsManager: SettingsManager;
hasUI: boolean;
}): ProjectTrustContext {
return {
cwd: options.cwd,
mode: options.mode === "interactive" ? "tui" : options.mode,
hasUI: options.hasUI,
ui: {
select: async (title, selectOptions) => {
if (!options.hasUI) {
return undefined;
}
if (options.mode !== "interactive") {
return undefined;
}
return showStartupSelector(
options.settingsManager,
title,
selectOptions.map((option) => ({ label: option, value: option })),
);
},
confirm: async (title, message) => {
if (!options.hasUI) {
return false;
}
if (options.mode !== "interactive") {
return false;
}
return (
(await showStartupSelector(options.settingsManager, `${title}\n${message}`, [
{ label: "Yes", value: true },
{ label: "No", value: false },
])) ?? false
);
},
input: async (title, placeholder) => {
if (!options.hasUI) {
return undefined;
}
if (options.mode !== "interactive") {
return undefined;
}
return showStartupInput(options.settingsManager, title, placeholder);
},
notify: (message, type = "info") => {
if (options.mode !== "interactive") {
const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan;
console.error(color(message));
}
},
},
};
}

View File

@@ -0,0 +1,87 @@
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
import { KeybindingsManager } from "../core/keybindings.ts";
import type { SettingsManager } from "../core/settings-manager.ts";
import { ExtensionInputComponent } from "../modes/interactive/components/extension-input.ts";
import { ExtensionSelectorComponent } from "../modes/interactive/components/extension-selector.ts";
import { initTheme } from "../modes/interactive/theme/theme.ts";
function createStartupTui(settingsManager: SettingsManager): TUI {
initTheme(settingsManager.getTheme());
setKeybindings(KeybindingsManager.create());
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
ui.setClearOnShrink(settingsManager.getClearOnShrink());
return ui;
}
async function clearStartupTui(ui: TUI): Promise<void> {
ui.clear();
ui.requestRender();
await new Promise((resolve) => setTimeout(resolve, 25));
}
export async function showStartupSelector<T>(
settingsManager: SettingsManager,
title: string,
options: Array<{ label: string; value: T }>,
): Promise<T | undefined> {
return new Promise((resolve) => {
const ui = createStartupTui(settingsManager);
let settled = false;
const finish = async (result: T | undefined) => {
if (settled) {
return;
}
settled = true;
await clearStartupTui(ui);
ui.stop();
resolve(result);
};
const selector = new ExtensionSelectorComponent(
title,
options.map((option) => option.label),
(option) => void finish(options.find((entry) => entry.label === option)?.value),
() => void finish(undefined),
{ tui: ui },
);
ui.addChild(selector);
ui.setFocus(selector);
ui.start();
});
}
export async function showStartupInput(
settingsManager: SettingsManager,
title: string,
placeholder?: string,
): Promise<string | undefined> {
return new Promise((resolve) => {
const ui = createStartupTui(settingsManager);
let settled = false;
const finish = async (result: string | undefined) => {
if (settled) {
return;
}
settled = true;
input.dispose();
await clearStartupTui(ui);
ui.stop();
resolve(result);
};
const input = new ExtensionInputComponent(
title,
placeholder,
(value) => void finish(value),
() => void finish(undefined),
{
tui: ui,
},
);
ui.addChild(input);
ui.setFocus(input);
ui.start();
});
}

View File

@@ -232,7 +232,9 @@ export class AgentSessionRuntime {
const previousSessionFile = this.session.sessionFile;
const sessionDir = this.session.sessionManager.getSessionDir();
const sessionManager = SessionManager.create(this.cwd, sessionDir);
const sessionManager = this.session.sessionManager.isPersisted()
? SessionManager.create(this.cwd, sessionDir)
: SessionManager.inMemory(this.cwd);
if (options?.parentSession) {
sessionManager.newSession({ parentSession: options.parentSession });
}

View File

@@ -1606,6 +1606,11 @@ export class AgentSession {
// Queue Mode Management
// =========================================================================
private syncQueueModesFromSettings(): void {
this.agent.steeringMode = this.settingsManager.getSteeringMode();
this.agent.followUpMode = this.settingsManager.getFollowUpMode();
}
/**
* Set steering message mode.
* Saves to settings.
@@ -2239,6 +2244,7 @@ export class AgentSession {
{
getModel: () => this.model,
isIdle: () => !this.isStreaming,
isProjectTrusted: () => this.settingsManager.isProjectTrusted(),
getSignal: () => this.agent.signal,
abort: () => {
if (this._extensionAbortHandler) {
@@ -2430,6 +2436,7 @@ export class AgentSession {
const previousFlagValues = this._extensionRunner.getFlagValues();
await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" });
await this.settingsManager.reload();
this.syncQueueModesFromSettings();
resetApiProviders();
await this._resourceLoader.reload();
this._buildRuntime({

View File

@@ -0,0 +1,3 @@
export function areExperimentalFeaturesEnabled(): boolean {
return process.env.PI_EXPERIMENTAL === "1";
}

View File

@@ -270,6 +270,7 @@ export class ExtensionRunner {
private errorListeners: Set<ExtensionErrorListener> = new Set();
private getModel: () => Model<any> | undefined = () => undefined;
private isIdleFn: () => boolean = () => true;
private isProjectTrustedFn: () => boolean = () => true;
private getSignalFn: () => AbortSignal | undefined = () => undefined;
private waitForIdleFn: () => Promise<void> = async () => {};
private abortFn: () => void = () => {};
@@ -330,6 +331,7 @@ export class ExtensionRunner {
// Context actions (required)
this.getModel = contextActions.getModel;
this.isIdleFn = contextActions.isIdle;
this.isProjectTrustedFn = contextActions.isProjectTrusted;
this.getSignalFn = contextActions.getSignal;
this.abortFn = contextActions.abort;
this.hasPendingMessagesFn = contextActions.hasPendingMessages;
@@ -648,6 +650,10 @@ export class ExtensionRunner {
runner.assertActive();
return runner.isIdleFn();
},
isProjectTrusted: () => {
runner.assertActive();
return runner.isProjectTrustedFn();
},
get signal() {
runner.assertActive();
return runner.getSignalFn();

View File

@@ -314,6 +314,8 @@ export interface ExtensionContext {
model: Model<any> | undefined;
/** Whether the agent is idle (not streaming) */
isIdle(): boolean;
/** Whether project-local trust is active for this context. */
isProjectTrusted(): boolean;
/** The current abort signal, or undefined when the agent is not streaming. */
signal: AbortSignal | undefined;
/** Abort the current agent operation */
@@ -1528,6 +1530,7 @@ export interface ExtensionActions {
export interface ExtensionContextActions {
getModel: () => Model<any> | undefined;
isIdle: () => boolean;
isProjectTrusted: () => boolean;
getSignal: () => AbortSignal | undefined;
abort: () => void;
hasPendingMessages: () => boolean;

View File

@@ -28,6 +28,7 @@ export {
export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.ts";
export type { CompactionResult } from "./compaction/index.ts";
export { createEventBus, type EventBus, type EventBusController } from "./event-bus.ts";
export { areExperimentalFeaturesEnabled } from "./experimental.ts";
// Extensions system
export {
type AgentEndEvent,

View File

@@ -0,0 +1,95 @@
import { emitProjectTrustEvent } from "./extensions/runner.ts";
import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/types.ts";
import type { DefaultProjectTrust } from "./settings-manager.ts";
import {
getProjectTrustOptions,
hasProjectTrustInputs,
type ProjectTrustOption,
type ProjectTrustStore,
} from "./trust-manager.ts";
export type AppMode = "interactive" | "print" | "json" | "rpc";
export interface ResolveProjectTrustedOptions {
cwd: string;
trustStore: ProjectTrustStore;
trustOverride?: boolean;
defaultProjectTrust?: DefaultProjectTrust;
extensionsResult?: LoadExtensionsResult;
projectTrustContext: ProjectTrustContext;
onExtensionError?: (message: string) => void;
}
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.`;
}
async function selectProjectTrustOption(
cwd: string,
ctx: ProjectTrustContext,
): Promise<ProjectTrustOption | undefined> {
const options = getProjectTrustOptions(cwd, { includeSessionOnly: true });
const selected = await ctx.ui.select(
formatProjectTrustPrompt(cwd),
options.map((option) => option.label),
);
return options.find((option) => option.label === selected);
}
function saveProjectTrustPromptResult(trustStore: ProjectTrustStore, result: ProjectTrustOption): void {
if (result.updates.length > 0) {
trustStore.setMany(result.updates);
}
}
export async function resolveProjectTrusted(options: ResolveProjectTrustedOptions): Promise<boolean> {
if (options.trustOverride !== undefined) {
return options.trustOverride;
}
if (!hasProjectTrustInputs(options.cwd)) {
return true;
}
if (options.extensionsResult) {
const { result, errors } = await emitProjectTrustEvent(
options.extensionsResult,
{ type: "project_trust", cwd: options.cwd },
options.projectTrustContext,
);
for (const error of errors) {
options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
}
if (result) {
const trusted = result.trusted === "yes";
if (result.remember === true) {
options.trustStore.set(options.cwd, trusted);
}
return trusted;
}
}
const decision = options.trustStore.get(options.cwd);
if (decision !== null) {
return decision;
}
switch (options.defaultProjectTrust ?? "ask") {
case "always":
return true;
case "never":
return false;
case "ask":
break;
}
if (!options.projectTrustContext.hasUI) {
return false;
}
const selected = await selectProjectTrustOption(options.cwd, options.projectTrustContext);
if (selected !== undefined) {
saveProjectTrustPromptResult(options.trustStore, selected);
return selected.trusted;
}
return false;
}

View File

@@ -79,7 +79,6 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
export function loadProjectContextFiles(options: {
cwd: string;
agentDir: string;
projectTrusted?: boolean;
}): Array<{ path: string; content: string }> {
const resolvedCwd = resolvePath(options.cwd);
const resolvedAgentDir = resolvePath(options.agentDir);
@@ -93,29 +92,27 @@ export function loadProjectContextFiles(options: {
seenPaths.add(globalContext.path);
}
if (options.projectTrusted !== false) {
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
let currentDir = resolvedCwd;
const root = resolve("/");
let currentDir = resolvedCwd;
const root = resolve("/");
while (true) {
const contextFile = loadContextFileFromDir(currentDir);
if (contextFile && !seenPaths.has(contextFile.path)) {
ancestorContextFiles.unshift(contextFile);
seenPaths.add(contextFile.path);
}
if (currentDir === root) break;
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) break;
currentDir = parentDir;
while (true) {
const contextFile = loadContextFileFromDir(currentDir);
if (contextFile && !seenPaths.has(contextFile.path)) {
ancestorContextFiles.unshift(contextFile);
seenPaths.add(contextFile.path);
}
contextFiles.push(...ancestorContextFiles);
if (currentDir === root) break;
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) break;
currentDir = parentDir;
}
contextFiles.push(...ancestorContextFiles);
return contextFiles;
}
@@ -325,14 +322,18 @@ export class DefaultResourceLoader implements ResourceLoader {
}
}
async loadProjectTrustExtensions(): Promise<LoadExtensionsResult> {
// Force untrusted project settings for the bootstrap pass. This keeps project-local
// extensions/packages out while still loading user/global and temporary CLI extensions.
this.settingsManager.setProjectTrusted(false);
await this.settingsManager.reload();
return this.loadCurrentExtensionSet({ includeInlineFactories: true });
}
async reload(options?: ResourceLoaderReloadOptions): Promise<void> {
let preTrustExtensions: LoadExtensionsResult | undefined;
if (options?.resolveProjectTrust) {
// Force untrusted project settings for the bootstrap pass. This keeps project-local
// extensions/packages out while still loading user/global and temporary CLI extensions.
this.settingsManager.setProjectTrusted(false);
await this.settingsManager.reload();
preTrustExtensions = await this.loadCurrentExtensionSet({ includeInlineFactories: true });
preTrustExtensions = await this.loadProjectTrustExtensions();
const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions });
this.settingsManager.setProjectTrusted(projectTrusted);
}
@@ -454,7 +455,6 @@ export class DefaultResourceLoader implements ResourceLoader {
: loadProjectContextFiles({
cwd: this.cwd,
agentDir: this.agentDir,
projectTrusted: this.settingsManager.isProjectTrusted(),
}),
};
const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;

View File

@@ -57,6 +57,8 @@ export interface WarningSettings {
anthropicExtraUsage?: boolean; // default: true
}
export type DefaultProjectTrust = "ask" | "always" | "never";
export type TransportSetting = Transport;
/**
@@ -89,6 +91,7 @@ export interface Settings {
hideThinkingBlock?: boolean;
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
quietStartup?: boolean;
defaultProjectTrust?: DefaultProjectTrust; // default: "ask"; global setting only
shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support)
npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"])
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
@@ -853,6 +856,17 @@ export class SettingsManager {
this.save();
}
getDefaultProjectTrust(): DefaultProjectTrust {
const value = this.globalSettings.defaultProjectTrust;
return value === "always" || value === "never" ? value : "ask";
}
setDefaultProjectTrust(defaultProjectTrust: DefaultProjectTrust): void {
this.globalSettings.defaultProjectTrust = defaultProjectTrust;
this.markModified("defaultProjectTrust");
this.save();
}
getShellCommandPrefix(): string | undefined {
return this.settings.shellCommandPrefix;
}

View File

@@ -6,14 +6,87 @@ import { canonicalizePath, resolvePath } from "../utils/paths.ts";
export type ProjectTrustDecision = boolean | null;
type TrustFile = Record<string, boolean | null | undefined>;
export interface ProjectTrustStoreEntry {
path: string;
decision: boolean;
}
const CONTEXT_FILE_NAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
export interface ProjectTrustUpdate {
path: string;
decision: ProjectTrustDecision;
}
export interface ProjectTrustOption {
label: string;
trusted: boolean;
updates: ProjectTrustUpdate[];
savedPath?: string;
}
type TrustFile = Record<string, boolean | null | undefined>;
function normalizeCwd(cwd: string): string {
return canonicalizePath(resolvePath(cwd));
}
function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreEntry | null {
let currentDir = normalizeCwd(cwd);
while (true) {
const value = data[currentDir];
if (value === true || value === false) {
return { path: currentDir, decision: value };
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
return null;
}
currentDir = parentDir;
}
}
export function getProjectTrustPath(cwd: string): string {
return normalizeCwd(cwd);
}
export function getProjectTrustParentPath(cwd: string): string | undefined {
const trustPath = getProjectTrustPath(cwd);
const parentDir = dirname(trustPath);
return parentDir === trustPath ? undefined : parentDir;
}
export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] {
const trustPath = getProjectTrustPath(cwd);
const trustOptions: ProjectTrustOption[] = [
{ label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath },
];
const parentPath = getProjectTrustParentPath(cwd);
if (parentPath !== undefined) {
trustOptions.push({
label: `Trust parent folder (${parentPath})`,
trusted: true,
updates: [
{ path: parentPath, decision: true },
{ path: trustPath, decision: null },
],
savedPath: parentPath,
});
}
if (options?.includeSessionOnly) {
trustOptions.push({ label: "Trust (this session only)", trusted: true, updates: [] });
}
trustOptions.push({
label: "Do not trust",
trusted: false,
updates: [{ path: trustPath, decision: false }],
savedPath: trustPath,
});
if (options?.includeSessionOnly) {
trustOptions.push({ label: "Do not trust (this session only)", trusted: false, updates: [] });
}
return trustOptions;
}
function readTrustFile(path: string): TrustFile {
if (!existsSync(path)) {
return {};
@@ -105,11 +178,6 @@ export function hasProjectTrustInputs(cwd: string): boolean {
}
while (true) {
for (const filename of CONTEXT_FILE_NAMES) {
if (existsSync(join(currentDir, filename))) {
return true;
}
}
if (existsSync(join(currentDir, ".agents", "skills"))) {
return true;
}
@@ -130,21 +198,30 @@ export class ProjectTrustStore {
}
get(cwd: string): ProjectTrustDecision {
return this.getEntry(cwd)?.decision ?? null;
}
getEntry(cwd: string): ProjectTrustStoreEntry | null {
return withTrustFileLock(this.trustPath, () => {
const data = readTrustFile(this.trustPath);
const value = data[normalizeCwd(cwd)];
return value === true || value === false ? value : null;
return findNearestTrustEntry(data, cwd);
});
}
set(cwd: string, decision: ProjectTrustDecision): void {
this.setMany([{ path: cwd, decision }]);
}
setMany(decisions: ProjectTrustUpdate[]): void {
withTrustFileLock(this.trustPath, () => {
const data = readTrustFile(this.trustPath);
const key = normalizeCwd(cwd);
if (decision === null) {
delete data[key];
} else {
data[key] = decision;
for (const { path, decision } of decisions) {
const key = normalizeCwd(path);
if (decision === null) {
delete data[key];
} else {
data[key] = decision;
}
}
writeTrustFile(this.trustPath, data);
});

View File

@@ -220,6 +220,7 @@ export {
} from "./core/session-manager.ts";
export {
type CompactionSettings,
type DefaultProjectTrust,
type ImageSettings,
type PackageSource,
type RetrySettings,
@@ -287,7 +288,13 @@ export {
type WriteToolOptions,
withFileMutationQueue,
} from "./core/tools/index.ts";
export { hasProjectTrustInputs, type ProjectTrustDecision, ProjectTrustStore } from "./core/trust-manager.ts";
export {
hasProjectTrustInputs,
type ProjectTrustDecision,
ProjectTrustStore,
type ProjectTrustStoreEntry,
type ProjectTrustUpdate,
} from "./core/trust-manager.ts";
// Main entry point
export { type MainOptions, main } from "./main.ts";
// Run modes for programmatic SDK usage

View File

@@ -7,13 +7,14 @@
import { createInterface } from "node:readline";
import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai";
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
import chalk from "chalk";
import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts";
import { processFileArguments } from "./cli/file-processor.ts";
import { buildInitialMessage } from "./cli/initial-message.ts";
import { listModels } from "./cli/list-models.ts";
import { createProjectTrustContext } from "./cli/project-trust.ts";
import { selectSession } from "./cli/session-picker.ts";
import { showStartupSelector } from "./cli/startup-ui.ts";
import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts";
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts";
import {
@@ -24,13 +25,12 @@ import {
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
import { AuthStorage } from "./core/auth-storage.ts";
import { exportFromFile } from "./core/export-html/index.ts";
import { emitProjectTrustEvent } from "./core/extensions/runner.ts";
import type { ExtensionFactory, LoadExtensionsResult, ProjectTrustContext } from "./core/extensions/types.ts";
import type { ExtensionFactory } from "./core/extensions/types.ts";
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
import { KeybindingsManager } from "./core/keybindings.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";
import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
import type { CreateAgentSessionOptions } from "./core/sdk.ts";
import {
formatMissingSessionCwdPrompt,
@@ -44,8 +44,6 @@ import { printTimings, resetTimings, time } from "./core/timings.ts";
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
import { ExtensionInputComponent } from "./modes/interactive/components/extension-input.ts";
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts";
import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts";
@@ -97,16 +95,14 @@ function isTruthyEnvFlag(value: string | undefined): boolean {
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
type AppMode = "interactive" | "print" | "json" | "rpc";
function resolveAppMode(parsed: Args, stdinIsTTY: boolean): AppMode {
function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode {
if (parsed.mode === "rpc") {
return "rpc";
}
if (parsed.mode === "json") {
return "json";
}
if (parsed.print || !stdinIsTTY) {
if (parsed.print || !stdinIsTTY || !stdoutIsTTY) {
return "print";
}
return "interactive";
@@ -116,6 +112,10 @@ function toPrintOutputMode(appMode: AppMode): Exclude<Mode, "rpc"> {
return appMode === "json" ? "json" : "text";
}
function isPlainRuntimeMetadataCommand(parsed: Args): boolean {
return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined);
}
async function prepareInitialMessage(
parsed: Args,
autoResizeImages: boolean,
@@ -439,87 +439,6 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u
return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value));
}
function createStartupTui(settingsManager: SettingsManager): TUI {
initTheme(settingsManager.getTheme());
setKeybindings(KeybindingsManager.create());
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
ui.setClearOnShrink(settingsManager.getClearOnShrink());
return ui;
}
async function clearStartupTui(ui: TUI): Promise<void> {
ui.clear();
ui.requestRender();
await new Promise((resolve) => setTimeout(resolve, 25));
}
async function showStartupSelector<T>(
settingsManager: SettingsManager,
title: string,
options: Array<{ label: string; value: T }>,
): Promise<T | undefined> {
return new Promise((resolve) => {
const ui = createStartupTui(settingsManager);
let settled = false;
const finish = async (result: T | undefined) => {
if (settled) {
return;
}
settled = true;
await clearStartupTui(ui);
ui.stop();
resolve(result);
};
const selector = new ExtensionSelectorComponent(
title,
options.map((option) => option.label),
(option) => void finish(options.find((entry) => entry.label === option)?.value),
() => void finish(undefined),
{ tui: ui },
);
ui.addChild(selector);
ui.setFocus(selector);
ui.start();
});
}
async function showStartupInput(
settingsManager: SettingsManager,
title: string,
placeholder?: string,
): Promise<string | undefined> {
return new Promise((resolve) => {
const ui = createStartupTui(settingsManager);
let settled = false;
const finish = async (result: string | undefined) => {
if (settled) {
return;
}
settled = true;
input.dispose();
await clearStartupTui(ui);
ui.stop();
resolve(result);
};
const input = new ExtensionInputComponent(
title,
placeholder,
(value) => void finish(value),
() => void finish(undefined),
{
tui: ui,
},
);
ui.addChild(input);
ui.setFocus(input);
ui.start();
});
}
async function promptForMissingSessionCwd(
issue: SessionCwdIssue,
settingsManager: SettingsManager,
@@ -530,160 +449,6 @@ async function promptForMissingSessionCwd(
]);
}
interface ProjectTrustPromptResult {
trusted: boolean;
remember: boolean;
}
const PROJECT_TRUST_PROMPT_OPTIONS: Array<{ label: string; value: ProjectTrustPromptResult }> = [
{ label: "Trust", value: { trusted: true, remember: true } },
{ label: "Trust (this session only)", value: { trusted: true, remember: false } },
{ label: "Do not trust", value: { trusted: false, remember: true } },
{ label: "Do not trust (this session only)", value: { trusted: false, remember: false } },
];
function formatProjectTrustPrompt(cwd: string): string {
return `Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`;
}
async function promptForProjectTrust(
cwd: string,
settingsManager: SettingsManager,
): Promise<ProjectTrustPromptResult | undefined> {
return showStartupSelector(settingsManager, formatProjectTrustPrompt(cwd), PROJECT_TRUST_PROMPT_OPTIONS);
}
async function promptForProjectTrustWithContext(
cwd: string,
ctx: ProjectTrustContext,
): Promise<ProjectTrustPromptResult | undefined> {
const selected = await ctx.ui.select(
formatProjectTrustPrompt(cwd),
PROJECT_TRUST_PROMPT_OPTIONS.map((option) => option.label),
);
return PROJECT_TRUST_PROMPT_OPTIONS.find((option) => option.label === selected)?.value;
}
function createProjectTrustContext(options: {
cwd: string;
mode: AppMode;
settingsManager: SettingsManager;
hasUI: boolean;
}): ProjectTrustContext {
return {
cwd: options.cwd,
mode: options.mode === "interactive" ? "tui" : options.mode,
hasUI: options.hasUI,
ui: {
select: async (title, selectOptions) => {
if (!options.hasUI) {
return undefined;
}
if (options.mode !== "interactive") {
return undefined;
}
return showStartupSelector(
options.settingsManager,
title,
selectOptions.map((option) => ({ label: option, value: option })),
);
},
confirm: async (title, message) => {
if (!options.hasUI) {
return false;
}
if (options.mode !== "interactive") {
return false;
}
return (
(await showStartupSelector(options.settingsManager, `${title}\n${message}`, [
{ label: "Yes", value: true },
{ label: "No", value: false },
])) ?? false
);
},
input: async (title, placeholder) => {
if (!options.hasUI) {
return undefined;
}
if (options.mode !== "interactive") {
return undefined;
}
return showStartupInput(options.settingsManager, title, placeholder);
},
notify: (message, type = "info") => {
if (options.mode !== "interactive") {
const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan;
console.error(color(message));
}
},
},
};
}
async function resolveProjectTrusted(options: {
cwd: string;
trustStore: ProjectTrustStore;
trustOverride?: boolean;
appMode: AppMode;
settingsManagerForPrompt: SettingsManager;
extensionsResult?: LoadExtensionsResult;
projectTrustContext?: ProjectTrustContext;
onExtensionError?: (message: string) => void;
}): Promise<boolean> {
if (options.trustOverride !== undefined) {
return options.trustOverride;
}
if (!hasProjectTrustInputs(options.cwd)) {
return true;
}
if (options.extensionsResult && options.projectTrustContext) {
const { result, errors } = await emitProjectTrustEvent(
options.extensionsResult,
{ type: "project_trust", cwd: options.cwd },
options.projectTrustContext,
);
for (const error of errors) {
options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
}
if (result) {
const trusted = result.trusted === "yes";
if (result.remember === true) {
options.trustStore.set(options.cwd, trusted);
}
return trusted;
}
}
const decision = options.trustStore.get(options.cwd);
if (decision !== null) {
return decision;
}
if (options.projectTrustContext?.hasUI) {
const selected = await promptForProjectTrustWithContext(options.cwd, options.projectTrustContext);
if (selected !== undefined) {
if (selected.remember) {
options.trustStore.set(options.cwd, selected.trusted);
}
return selected.trusted;
}
return false;
}
if (options.appMode !== "interactive") {
return false;
}
const selected = await promptForProjectTrust(options.cwd, options.settingsManagerForPrompt);
if (selected !== undefined) {
if (selected.remember) {
options.trustStore.set(options.cwd, selected.trusted);
}
return selected.trusted;
}
return false;
}
export interface MainOptions {
extensionFactories?: ExtensionFactory[];
}
@@ -700,11 +465,11 @@ export async function main(args: string[], options?: MainOptions) {
cleanupWindowsSelfUpdateQuarantine(getPackageDir());
}
if (await handlePackageCommand(args)) {
if (await handlePackageCommand(args, { extensionFactories: options?.extensionFactories })) {
return;
}
if (await handleConfigCommand(args)) {
if (await handleConfigCommand(args, { extensionFactories: options?.extensionFactories })) {
return;
}
@@ -719,11 +484,6 @@ export async function main(args: string[], options?: MainOptions) {
}
}
time("parseArgs");
let appMode = resolveAppMode(parsed, process.stdin.isTTY);
const shouldTakeOverStdout = appMode !== "interactive";
if (shouldTakeOverStdout) {
takeOverStdout();
}
if (parsed.version) {
console.log(VERSION);
@@ -744,6 +504,12 @@ export async function main(args: string[], options?: MainOptions) {
process.exit(0);
}
let appMode = resolveAppMode(parsed, process.stdin.isTTY, process.stdout.isTTY);
const shouldTakeOverStdout = appMode !== "interactive" && !isPlainRuntimeMetadataCommand(parsed);
if (shouldTakeOverStdout) {
takeOverStdout();
}
if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) {
console.error(chalk.red("Error: @file arguments are not supported in RPC mode"));
process.exit(1);
@@ -837,8 +603,7 @@ export async function main(args: string[], options?: MainOptions) {
cwd,
trustStore,
trustOverride: parsed.projectTrustOverride,
appMode: isInitialRuntime ? trustPromptMode : "print",
settingsManagerForPrompt: startupSettingsManager,
defaultProjectTrust: startupSettingsManager.getDefaultProjectTrust(),
extensionsResult,
projectTrustContext:
projectTrustContext ??

View File

@@ -144,47 +144,52 @@ function migrateModelsJsonConfigValues(agentDir: string): ConfigValueMigration[]
const modelsPath = join(agentDir, "models.json");
if (!existsSync(modelsPath)) return [];
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 [];
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);
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);
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,
);
}
}
}
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 [];
}
if (migrations.length === 0) return [];
writeFileSync(modelsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8");
return migrations;
}
function migrateExplicitEnvVarConfigValues(): void {

View File

@@ -56,7 +56,9 @@ export class LoginDialogComponent extends Container implements Focusable {
this.input = new Input();
this.input.onSubmit = () => {
if (this.inputResolver) {
this.inputResolver(this.input.getValue());
const value = this.input.getValue();
this.replaceInputWithSubmittedText(value);
this.inputResolver(value);
this.inputResolver = undefined;
this.inputRejecter = undefined;
}
@@ -73,6 +75,12 @@ export class LoginDialogComponent extends Container implements Focusable {
return this.abortController.signal;
}
private replaceInputWithSubmittedText(value: string): void {
this.contentContainer.children = this.contentContainer.children.map((child) =>
child === this.input ? new Text(`> ${value}`, 0, 0) : child,
);
}
private cancel(): void {
this.abortController.abort();
if (this.inputRejecter) {
@@ -128,6 +136,7 @@ export class LoginDialogComponent extends Container implements Focusable {
* Show input for manual code/URL entry (for callback server providers)
*/
showManualInput(prompt: string): Promise<string> {
this.input.setValue("");
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0));
this.contentContainer.addChild(this.input);

View File

@@ -12,7 +12,7 @@ import {
Text,
} from "@earendil-works/pi-tui";
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts";
import type { WarningSettings } from "../../../core/settings-manager.ts";
import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts";
import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyDisplayText } from "./keybinding-hints.ts";
@@ -31,6 +31,16 @@ const THINKING_DESCRIPTIONS: Record<ThinkingLevel, string> = {
xhigh: "Maximum reasoning (~32k tokens)",
};
const DEFAULT_PROJECT_TRUST_LABELS: Record<DefaultProjectTrust, string> = {
ask: "Ask",
always: "Always trust",
never: "Never trust",
};
const DEFAULT_PROJECT_TRUST_BY_LABEL = new Map(
Object.entries(DEFAULT_PROJECT_TRUST_LABELS).map(([value, label]) => [label, value as DefaultProjectTrust]),
);
export interface SettingsConfig {
autoCompact: boolean;
showImages: boolean;
@@ -55,6 +65,7 @@ export interface SettingsConfig {
editorPaddingX: number;
autocompleteMaxVisible: number;
quietStartup: boolean;
defaultProjectTrust: DefaultProjectTrust;
clearOnShrink: boolean;
showTerminalProgress: boolean;
warnings: WarningSettings;
@@ -83,6 +94,7 @@ export interface SettingsCallbacks {
onEditorPaddingXChange: (padding: number) => void;
onAutocompleteMaxVisibleChange: (maxVisible: number) => void;
onQuietStartupChange: (enabled: boolean) => void;
onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void;
onClearOnShrinkChange: (enabled: boolean) => void;
onShowTerminalProgressChange: (enabled: boolean) => void;
onWarningsChange: (warnings: WarningSettings) => void;
@@ -277,6 +289,13 @@ export class SettingsSelectorComponent extends Container {
currentValue: config.enableInstallTelemetry ? "true" : "false",
values: ["true", "false"],
},
{
id: "default-project-trust",
label: "Default project trust",
description: "Fallback behavior when no extension or saved trust decision decides project trust",
currentValue: DEFAULT_PROJECT_TRUST_LABELS[config.defaultProjectTrust],
values: Object.values(DEFAULT_PROJECT_TRUST_LABELS),
},
{
id: "double-escape-action",
label: "Double-escape action",
@@ -512,6 +531,13 @@ export class SettingsSelectorComponent extends Container {
case "install-telemetry":
callbacks.onEnableInstallTelemetryChange(newValue === "true");
break;
case "default-project-trust": {
const defaultProjectTrust = DEFAULT_PROJECT_TRUST_BY_LABEL.get(newValue);
if (defaultProjectTrust) {
callbacks.onDefaultProjectTrustChange(defaultProjectTrust);
}
break;
}
case "double-escape-action":
callbacks.onDoubleEscapeActionChange(newValue as "fork" | "tree");
break;

View File

@@ -1,51 +1,51 @@
import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui";
import type { ProjectTrustDecision } from "../../../core/trust-manager.ts";
import {
getProjectTrustOptions,
getProjectTrustPath,
type ProjectTrustOption,
type ProjectTrustStoreEntry,
} from "../../../core/trust-manager.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
interface TrustOption {
label: string;
trusted: boolean;
}
export type TrustSelection = Pick<ProjectTrustOption, "trusted" | "updates">;
export interface TrustSelectorOptions {
cwd: string;
savedDecision: ProjectTrustDecision;
savedDecision: ProjectTrustStoreEntry | null;
projectTrusted: boolean;
onSelect: (trusted: boolean) => void;
onSelect: (selection: TrustSelection) => void;
onCancel: () => void;
}
const TRUST_OPTIONS: TrustOption[] = [
{ label: "Trust", trusted: true },
{ label: "Do not trust", trusted: false },
];
function formatDecision(decision: ProjectTrustDecision): string {
if (decision === true) {
return "trusted";
function formatDecision(cwd: string, decision: ProjectTrustStoreEntry | null): string {
if (decision === null) {
return "none";
}
if (decision === false) {
return "untrusted";
const label = decision.decision ? "trusted" : "untrusted";
if (decision.path !== getProjectTrustPath(cwd)) {
return `${label} (inherited from ${decision.path})`;
}
return "none";
return `${label} (${decision.path})`;
}
export class TrustSelectorComponent extends Container {
private selectedIndex: number;
private readonly listContainer: Container;
private readonly savedDecision: ProjectTrustDecision;
private readonly onSelectCallback: (trusted: boolean) => void;
private readonly trustOptions: ProjectTrustOption[];
private readonly savedDecision: ProjectTrustStoreEntry | null;
private readonly onSelectCallback: (selection: TrustSelection) => void;
private readonly onCancelCallback: () => void;
constructor(options: TrustSelectorOptions) {
super();
this.savedDecision = options.savedDecision;
this.trustOptions = getProjectTrustOptions(options.cwd);
this.selectedIndex = Math.max(
0,
TRUST_OPTIONS.findIndex((option) => option.trusted === options.savedDecision),
this.trustOptions.findIndex((option) => this.isSavedOption(option)),
);
this.onSelectCallback = options.onSelect;
this.onCancelCallback = options.onCancel;
@@ -55,7 +55,9 @@ export class TrustSelectorComponent extends Container {
this.addChild(new Text(theme.fg("accent", theme.bold("Project trust")), 1, 0));
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.savedDecision)}`), 1, 0));
this.addChild(
new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.cwd, options.savedDecision)}`), 1, 0),
);
this.addChild(
new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0),
);
@@ -81,16 +83,24 @@ export class TrustSelectorComponent extends Container {
this.updateList();
}
private isSavedOption(option: ProjectTrustOption): boolean {
return (
option.savedPath !== undefined &&
this.savedDecision?.decision === option.trusted &&
this.savedDecision.path === option.savedPath
);
}
private updateList(): void {
this.listContainer.clear();
for (let i = 0; i < TRUST_OPTIONS.length; i++) {
const option = TRUST_OPTIONS[i];
for (let i = 0; i < this.trustOptions.length; i++) {
const option = this.trustOptions[i];
if (!option) {
continue;
}
const isSelected = i === this.selectedIndex;
const isCurrent = option.trusted === this.savedDecision;
const isCurrent = this.isSavedOption(option);
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
const label = isSelected ? theme.fg("accent", option.label) : theme.fg("text", option.label);
@@ -104,12 +114,12 @@ export class TrustSelectorComponent extends Container {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.updateList();
} else if (kb.matches(keyData, "tui.select.down") || keyData === "j") {
this.selectedIndex = Math.min(TRUST_OPTIONS.length - 1, this.selectedIndex + 1);
this.selectedIndex = Math.min(this.trustOptions.length - 1, this.selectedIndex + 1);
this.updateList();
} else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
const selected = TRUST_OPTIONS[this.selectedIndex];
const selected = this.trustOptions[this.selectedIndex];
if (selected) {
this.onSelectCallback(selected.trusted);
this.onSelectCallback({ trusted: selected.trusted, updates: selected.updates });
}
} else if (kb.matches(keyData, "tui.select.cancel")) {
this.onCancelCallback();

View File

@@ -87,7 +87,7 @@ 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 { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts";
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
import { copyToClipboard } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
import { parseGitUrl } from "../../utils/git.ts";
@@ -555,8 +555,13 @@ export class InteractiveMode {
private setupAutocompleteProvider(): void {
let provider = this.createBaseAutocompleteProvider();
const triggerCharacters: string[] = [];
for (const wrapProvider of this.autocompleteProviderWrappers) {
provider = wrapProvider(provider);
triggerCharacters.push(...(provider.triggerCharacters ?? []));
}
if (triggerCharacters.length > 0) {
provider.triggerCharacters = [...new Set(triggerCharacters)];
}
this.autocompleteProvider = provider;
@@ -909,7 +914,7 @@ export class InteractiveMode {
if (newEntries.length > 0) {
this.settingsManager.setLastChangelogVersion(VERSION);
this.reportInstallTelemetry(VERSION);
return newEntries.map((e) => e.content).join("\n\n");
return newEntries.map((e) => normalizeChangelogLinks(e.content, e)).join("\n\n");
}
return undefined;
@@ -1669,6 +1674,7 @@ export class InteractiveMode {
modelRegistry: this.session.modelRegistry,
model: this.session.model,
isIdle: () => !this.session.isStreaming,
isProjectTrusted: () => this.settingsManager.isProjectTrusted(),
signal: this.session.agent.signal,
abort: () => {
this.restoreQueuedMessagesToEditor({ abort: true });
@@ -3276,7 +3282,7 @@ export class InteractiveMode {
new Text(
theme.fg(
"warning",
"This project is not trusted. Project instructions (AGENTS.md/CLAUDE.md), .pi resources, and project packages are ignored. Use /trust to save a trust decision, then restart pi.",
"This project is not trusted. Project .pi resources and packages are ignored. Use /trust to save a trust decision, then restart pi.",
),
1,
0,
@@ -3965,6 +3971,7 @@ export class InteractiveMode {
doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(),
treeFilterMode: this.settingsManager.getTreeFilterMode(),
showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
defaultProjectTrust: this.settingsManager.getDefaultProjectTrust(),
editorPaddingX: this.settingsManager.getEditorPaddingX(),
autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(),
quietStartup: this.settingsManager.getQuietStartup(),
@@ -4058,6 +4065,9 @@ export class InteractiveMode {
onQuietStartupChange: (enabled) => {
this.settingsManager.setQuietStartup(enabled);
},
onDefaultProjectTrustChange: (defaultProjectTrust) => {
this.settingsManager.setDefaultProjectTrust(defaultProjectTrust);
},
onDoubleEscapeActionChange: (action) => {
this.settingsManager.setDoubleEscapeAction(action);
},
@@ -4212,17 +4222,17 @@ export class InteractiveMode {
private showTrustSelector(): void {
const cwd = this.sessionManager.getCwd();
const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir);
const savedDecision = trustStore.get(cwd);
const savedDecision = trustStore.getEntry(cwd);
this.showSelector((done) => {
const selector = new TrustSelectorComponent({
cwd,
savedDecision,
projectTrusted: this.settingsManager.isProjectTrusted(),
onSelect: (trusted) => {
trustStore.set(cwd, trusted);
onSelect: (selection) => {
trustStore.setMany(selection.updates);
done();
this.showStatus(
`Saved trust decision: ${trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`,
`Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`,
);
},
onCancel: () => {
@@ -5380,7 +5390,7 @@ export class InteractiveMode {
allEntries.length > 0
? allEntries
.reverse()
.map((e) => e.content)
.map((e) => normalizeChangelogLinks(e.content, e))
.join("\n\n")
: "No changelog entries found.";
@@ -5454,7 +5464,7 @@ export class InteractiveMode {
**Navigation**
| Key | Action |
|-----|--------|
| \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history (Up when empty) |
| \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history |
| \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word |
| \`${cursorLineStart}\` | Start of line |
| \`${cursorLineEnd}\` | End of line |

View File

@@ -1,6 +1,7 @@
import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
import chalk from "chalk";
import { selectConfig } from "./cli/config-selector.ts";
import { createProjectTrustContext } from "./cli/project-trust.ts";
import {
APP_NAME,
detectInstallMethod,
@@ -12,7 +13,10 @@ import {
type SelfUpdateCommand,
VERSION,
} from "./config.ts";
import type { ExtensionFactory } from "./core/extensions/types.ts";
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 { spawnProcess } from "./utils/child-process.ts";
@@ -425,22 +429,82 @@ function parseProjectTrustOverride(args: readonly string[]): boolean | undefined
return trustOverride;
}
function resolveProjectTrusted(cwd: string, agentDir: string, trustOverride: boolean | undefined): boolean {
if (trustOverride !== undefined) {
return trustOverride;
}
return !hasProjectTrustInputs(cwd) || new ProjectTrustStore(agentDir).get(cwd) === true;
export interface PackageCommandRuntimeOptions {
extensionFactories?: ExtensionFactory[];
}
export async function handleConfigCommand(args: string[]): Promise<boolean> {
interface CommandSettingsResult {
settingsManager: SettingsManager;
projectTrustWarnings: string[];
}
function getCommandAppMode(): AppMode {
return process.stdin.isTTY && process.stdout.isTTY ? "interactive" : "print";
}
function reportProjectTrustWarnings(warnings: readonly string[]): void {
for (const warning of warnings) {
console.error(chalk.yellow(`Warning: ${warning}`));
}
}
async function createCommandSettingsManager(options: {
cwd: string;
agentDir: string;
projectTrustOverride?: boolean;
extensionFactories?: ExtensionFactory[];
}): Promise<CommandSettingsResult> {
const settingsManager = SettingsManager.create(options.cwd, options.agentDir, { projectTrusted: false });
const projectTrustWarnings: string[] = [];
const appMode = getCommandAppMode();
const extensionsResult =
options.projectTrustOverride === undefined && hasProjectTrustInputs(options.cwd)
? await new DefaultResourceLoader({
cwd: options.cwd,
agentDir: options.agentDir,
settingsManager,
extensionFactories: options.extensionFactories,
}).loadProjectTrustExtensions()
: undefined;
for (const error of extensionsResult?.errors ?? []) {
projectTrustWarnings.push(`Failed to load extension "${error.path}": ${error.error}`);
}
const projectTrusted = await resolveProjectTrusted({
cwd: options.cwd,
trustStore: new ProjectTrustStore(options.agentDir),
trustOverride: options.projectTrustOverride,
defaultProjectTrust: settingsManager.getDefaultProjectTrust(),
extensionsResult,
projectTrustContext: createProjectTrustContext({
cwd: options.cwd,
mode: appMode,
settingsManager,
hasUI: appMode === "interactive",
}),
onExtensionError: (message) => projectTrustWarnings.push(message),
});
settingsManager.setProjectTrusted(projectTrusted);
return { settingsManager, projectTrustWarnings };
}
export async function handleConfigCommand(
args: string[],
runtimeOptions: PackageCommandRuntimeOptions = {},
): Promise<boolean> {
if (args[0] !== "config") {
return false;
}
const cwd = process.cwd();
const agentDir = getAgentDir();
const projectTrusted = parseProjectTrustOverride(args) ?? true;
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({
cwd,
agentDir,
projectTrustOverride: parseProjectTrustOverride(args),
extensionFactories: runtimeOptions.extensionFactories,
});
reportProjectTrustWarnings(projectTrustWarnings);
reportSettingsErrors(settingsManager, "config command");
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
const resolvedPaths = await packageManager.resolve();
@@ -455,7 +519,10 @@ export async function handleConfigCommand(args: string[]): Promise<boolean> {
process.exit(0);
}
export async function handlePackageCommand(args: string[]): Promise<boolean> {
export async function handlePackageCommand(
args: string[],
runtimeOptions: PackageCommandRuntimeOptions = {},
): Promise<boolean> {
const options = parsePackageCommand(args);
if (!options) {
return false;
@@ -505,13 +572,18 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
const cwd = process.cwd();
const agentDir = getAgentDir();
const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local;
const projectTrusted = resolveProjectTrusted(cwd, agentDir, options.projectTrustOverride);
if (!projectTrusted && writesProjectPackageConfig) {
const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({
cwd,
agentDir,
projectTrustOverride: options.projectTrustOverride,
extensionFactories: runtimeOptions.extensionFactories,
});
reportProjectTrustWarnings(projectTrustWarnings);
if (!settingsManager.isProjectTrusted() && writesProjectPackageConfig) {
console.error(chalk.red("Project is not trusted. Use --approve to modify local package config."));
process.exitCode = 1;
return true;
}
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
reportSettingsErrors(settingsManager, "package command");
const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand;

View File

@@ -1,3 +1,4 @@
import path from "node:path";
import { existsSync, readFileSync } from "fs";
export interface ChangelogEntry {
@@ -7,6 +8,102 @@ export interface ChangelogEntry {
content: string;
}
const GITHUB_REPO = "earendil-works/pi";
const CHANGELOG_LINK_BASE_PATH = "packages/coding-agent";
const LEGACY_REPO_RE = /^https:\/\/github\.com\/(?:badlogic|earendil-works)\/pi-mono(?=\/|$)/;
const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
const INLINE_MARKDOWN_LINK_RE = /(!?\[[^\]\n]+\]\()([^\s)]+)((?:\s+[^)]*)?\))/g;
function entryVersion(entry: ChangelogEntry): string {
return `${entry.major}.${entry.minor}.${entry.patch}`;
}
function normalizeTag(version: string | ChangelogEntry): string {
const versionString = typeof version === "string" ? version : entryVersion(version);
return versionString.startsWith("v") ? versionString : `v${versionString}`;
}
function splitLocalTarget(target: string): { fragment: string; pathPart: string; query: string } {
const hashIndex = target.indexOf("#");
const beforeHash = hashIndex === -1 ? target : target.slice(0, hashIndex);
const fragment = hashIndex === -1 ? "" : target.slice(hashIndex);
const queryIndex = beforeHash.indexOf("?");
if (queryIndex === -1) {
return { fragment, pathPart: beforeHash, query: "" };
}
return {
fragment,
pathPart: beforeHash.slice(0, queryIndex),
query: beforeHash.slice(queryIndex),
};
}
function normalizePathPart(value: string): string {
return value.replaceAll("\\", "/");
}
function resolveRepositoryPath(targetPath: string): string | undefined {
const normalizedTarget = normalizePathPart(targetPath);
const joined = normalizedTarget.startsWith("/")
? path.posix.normalize(normalizedTarget.replace(/^\/+/, ""))
: path.posix.normalize(path.posix.join(CHANGELOG_LINK_BASE_PATH, normalizedTarget));
if (joined === "." || joined.startsWith("../") || joined === "..") {
return undefined;
}
return joined;
}
function isDirectoryTarget(originalPath: string, repositoryPath: string): boolean {
if (originalPath.endsWith("/")) {
return true;
}
const basename = path.posix.basename(repositoryPath);
return !basename.includes(".");
}
function normalizeChangelogLinkTarget(target: string, tag: string): string {
let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${GITHUB_REPO}`);
const repoUrl = `https://github.com/${GITHUB_REPO}`;
for (const route of ["blob", "tree"]) {
for (const branch of ["main", "master"]) {
const floatingRefPrefix = `${repoUrl}/${route}/${branch}/`;
if (canonicalTarget.startsWith(floatingRefPrefix)) {
canonicalTarget = `${repoUrl}/${route}/${tag}/${canonicalTarget.slice(floatingRefPrefix.length)}`;
}
}
}
if (canonicalTarget.startsWith("#") || canonicalTarget.startsWith("//") || URL_SCHEME_RE.test(canonicalTarget)) {
return canonicalTarget;
}
const { fragment, pathPart, query } = splitLocalTarget(canonicalTarget);
if (!pathPart) {
return canonicalTarget;
}
const repositoryPath = resolveRepositoryPath(pathPart);
if (!repositoryPath) {
return canonicalTarget;
}
const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob";
return `https://github.com/${GITHUB_REPO}/${route}/${tag}/${encodeURI(repositoryPath)}${query}${fragment}`;
}
export function normalizeChangelogLinks(markdown: string, version: string | ChangelogEntry): string {
const tag = normalizeTag(version);
return markdown.replace(INLINE_MARKDOWN_LINK_RE, (_match, prefix, target, suffix) => {
return `${prefix}${normalizeChangelogLinkTarget(target, tag)}${suffix}`;
});
}
/**
* Parse changelog entries from CHANGELOG.md
* Scans for ## lines and collects content until next ## or EOF